Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,26 @@ docker compose exec app npm run db:seed
machine below uses `bun`, because `bun.lock` is the lockfile — `npm install` would ignore it and
resolve a different tree.)

The seed creates two idempotent cases:

- **Cicero Forum** — a fictional Roman-themed conference with 14 submissions mid-review, 7
historically inspired speakers, 4 tracks, 3 rooms, and a two-day agenda with gaps still in it.
- **The First Settlement** — a Roman Senate-themed programme inspired by the sessions of
13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and outstanding
speaker tasks.

Run it twice and you get the same two events, not four.
The seed creates four idempotent cases. The first three are one conference at three scales, so you
can see what a screen does under load without writing a fixture for it:

- **Cicero Forum** (`/demo`) — **the default sample event**, at the medium size: 96 submissions
mid-review, 45 speakers, 4 tracks, 5 rooms, and a two-day agenda with gaps still in it. The first
14 proposals and 7 speakers are hand-written and are what you meet first; the rest is generated,
and is what gives the review queue and the agenda grid some weight.
- **Provincial Assembly** (`/demo-small`) — the same conference sized like a meetup: 18 submissions,
8 speakers, one day, two rooms.
- **Imperial Congress** (`/demo-large`) — and sized like a large one: 384 submissions, 180 speakers,
three days, ten rooms. Open this when the question is whether a list paginates, a grid stays
readable, or a query falls over.
- **The First Settlement** (`/first-settlement`) — a Roman Senate-themed programme inspired by the
sessions of 13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and
outstanding speaker tasks.

Every generated speaker gets a procedurally drawn portrait and an address on an IANA-reserved
domain (`@demo-large.example` and friends), so nothing the seed writes can receive mail.

Run it twice and you get the same four events, not eight.

## Local development

Expand All @@ -110,7 +121,7 @@ have their own optional settings. Apply migrations before serving a new applicat
| `bun run db:check` | Validate Drizzle migration snapshots |
| `bun run db:migrate` | Apply migrations |
| `bun run db:migrate:remote` | Apply migrations to a deployment target; ignores `.env`, rejects localhost |
| `bun run db:seed` | Seed both demo conferences (idempotent) |
| `bun run db:seed` | Seed all four demo conferences, `demo` included (idempotent) |
| `bun run db:seed:first-settlement` | [Plan or seed only the Roman demo](docs/first-settlement-seed.md) |
| `bun run cf:deploy` | Build and deploy to Cloudflare Workers |

Expand Down
29 changes: 26 additions & 3 deletions db/seed-publication-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@ function source(path: string): string {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8');
}

/**
* `sized-roster.ts` writes a second roster onto the same events — the generated crowd that brings
* each sample event up to its size profile. It is held to the same two invariants, because a
* gallery of forty invited speakers is exactly as empty as a gallery of seven.
*/
const SEEDS = [
'./seed.ts',
'./seeds/first-settlement.ts',
'./seeds/sized-roster.ts',
] as const;

function participantInsert(path: string): string {
const match = source(path).match(
/const participants = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/,
/const \w+ = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/,
);
expect(match, `${path} should contain its participant fixture insert`).not.toBeNull();
return match![0];
}

describe('public speaker seed invariants', () => {
it.each(['./seed.ts', './seeds/first-settlement.ts'])(
it.each(SEEDS)(
'marks the public profiles in %s as confirmed',
(path) => {
expect(participantInsert(path)).toContain("workflowStatus: 'confirmed' as const");
Expand All @@ -28,13 +39,25 @@ describe('public speaker seed invariants', () => {
* `speakerHeadshotPath` answers null for it, and the roster quietly renders initials. Assert the
* wiring, since no type is going to.
*/
it.each(['./seed.ts', './seeds/first-settlement.ts'])(
it.each(SEEDS)(
'gives the public profiles in %s a generated headshot',
(path) => {
expect(participantInsert(path)).toMatch(/headshotFileId: profileArt\.get\(/);
},
);

/**
* The sized siblings build their call for speakers from the shared helpers rather than a third
* hand-written copy of the built-in field list — the drift `seed-form-invariants.test.ts` exists
* to catch. Assert they keep reaching for the helpers instead of spelling the fields out.
*/
it('builds the sized sibling CFP from the shared form helpers', () => {
const sizedDemo = source('./seeds/sized-demo.ts');
expect(sizedDemo).toContain('seedBuiltinFields(cfp.id)');
expect(sizedDemo).toContain('seedRoles(cfp.id)');
expect(sizedDemo).not.toMatch(/builtinKey: '/);
});

it('keeps the public bundle gated to confirmed participants', () => {
const publicQueries = source('../app/embed/queries.ts');
expect(publicQueries).toContain("eq(participant.workflowStatus, 'confirmed')");
Expand Down
79 changes: 76 additions & 3 deletions db/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, inArray } from 'drizzle-orm';
import { eq, inArray, like, or } from 'drizzle-orm';
import { requireEventWindow } from '../lib/event-dates';
import { newIcsUid } from '../lib/ics';
import { ensureDefaultTemplates } from '../lib/services/comms';
Expand All @@ -9,9 +9,12 @@ import {
import { splitPersonName } from '../lib/person-name';
import type { RomanSpeakerHeadshotGender } from '../lib/roman-speaker-headshots';
import { getDb } from './client';
import { EVENT_SIZES, SIBLING_EVENT_SIZES, generatedEmailDomain } from './seeds/event-sizes';
import { seedFirstSettlement } from './seeds/first-settlement';
import { removeEventFiles, seedProfileArt } from './seeds/profile-art-store';
import { ROMAN_PROFILE_ART } from './seeds/roman-profile-art';
import { seedSizedDemo } from './seeds/sized-demo';
import { seedSizedRoster } from './seeds/sized-roster';
import {
emailLog,
event,
Expand Down Expand Up @@ -157,6 +160,25 @@ await db.delete(user).where(
),
);

/**
* The generated crowd is swept by domain rather than by a list, because the list is a function of
* the size profiles and those change. Shrinking `large` from 180 speakers to 120 with a name-by-name
* delete would leave sixty accounts behind that no event references and no later run ever collects —
* and the next run's insert would collide with them on `email`.
*
* Safe after the event sweep above and not before: these accounts own nothing, but their
* submissions and participant rows only disappear when the events they belong to do.
*/
await db
.delete(user)
.where(
or(
...[EVENT_SIZES.medium, ...SIBLING_EVENT_SIZES].map((size) =>
like(user.email, `%@${generatedEmailDomain(size)}`),
),
),
);

// ---------------------------------------------------------------------------
// People and the event
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -254,6 +276,10 @@ const rooms = await db
{ eventId: demo.id, name: 'Outer Peristyle', capacity: 600, floor: 'Ground', position: 0 },
{ eventId: demo.id, name: 'Basilica Gallery', capacity: 180, floor: 'Ground', position: 1 },
{ eventId: demo.id, name: 'Villa Workshop', capacity: 60, floor: 'Lower level', position: 2 },
// The two rooms the generated programme runs in. The hand-written placements below stay in the
// three above, which is what keeps the two halves of the agenda from ever colliding.
{ eventId: demo.id, name: 'East Garden Room', capacity: 120, floor: 'Ground', position: 3 },
{ eventId: demo.id, name: 'Atrium Studio', capacity: 90, floor: 'Lower level', position: 4 },
])
.returning();

Expand Down Expand Up @@ -1503,13 +1529,60 @@ await db.insert(emailLog).values([
},
]);

// ---------------------------------------------------------------------------
// Scale. Everything above is hand-written and is what a reader meets first; this brings the same
// event up to the medium size profile, which is what makes the review queue, the agenda grid and
// the speaker gallery worth looking at. `demo` stays the default sample event — the small and
// large siblings below exist to be compared against it, not to replace it.
// ---------------------------------------------------------------------------

const filler = await seedSizedRoster(db, {
eventId: demo.id,
size: EVENT_SIZES.medium,
organizerUserId: organizer.id,
formId: cfp.id,
timezone: TIMEZONE,
tracks,
formats,
rooms,
personas,
days: [at(day1, 0), at(day2, 0)],
now,
existing: {
speakers: SPEAKER_EMAILS.length,
submissions: submissions.length,
sessions: scheduled.length,
},
// The three rooms the hand-written placements above use.
reservedRooms: 3,
review: {
roundId: rounds[0].id,
reviewerUserIds: reviewers.map((reviewer) => reviewer.id),
criteria: criteriaByRound.get(rounds[0].id)!,
},
});

const siblings = [];
for (const size of SIBLING_EVENT_SIZES) {
siblings.push(await seedSizedDemo(db, { size, organizerUserId: organizer.id, now }));
}

const firstSettlement = await seedFirstSettlement(db, organizer.id, now);

console.log(
`Seeded /${SLUG}: ${submissions.length} submissions, ${uniqueAccepted.length} speakers, ` +
`${scheduled.length} scheduled sessions, ${tasks.length + scopedTasks.length} tasks. ` +
`Seeded /${SLUG} (${EVENT_SIZES.medium.key}): ` +
`${submissions.length + filler.submissions} submissions, ` +
`${uniqueAccepted.length + filler.speakers} speakers, ` +
`${scheduled.length + filler.scheduledSessions} scheduled sessions, ` +
`${tasks.length + scopedTasks.length} tasks. ` +
`Sign in as ${organizer.email} and read the link at /organizer/mail.`,
);
for (const sibling of siblings) {
console.log(
`Seeded /${sibling.slug} (${sibling.size}): ${sibling.submissions} submissions, ` +
`${sibling.speakers} speakers, ${sibling.scheduledSessions} scheduled sessions.`,
);
}
console.log(
`Seeded /${firstSettlement.slug}: ${firstSettlement.submissions} submissions, ` +
`${firstSettlement.speakers} speakers, ${firstSettlement.scheduledSessions} scheduled sessions, ` +
Expand Down
89 changes: 89 additions & 0 deletions db/seeds/event-sizes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { ROMAN_SPEAKER_HEADSHOT_CAPACITY } from '../../lib/roman-speaker-headshots';
import {
ALL_EVENT_SIZES,
DEFAULT_EVENT_SIZE,
EVENT_SIZES,
SIZED_EVENT_SLUGS,
generatedEmailDomain,
} from './event-sizes';
import { ROMAN_PROFILE_ART } from './roman-profile-art';

/**
* These are the assertions that keep the three sizes from quietly interfering with each other. Each
* one has a failure mode that is invisible on the seeded database and only shows up on a screen:
* overlapping portrait ranges look like a bug in the generator, and an event whose accepted talks
* outnumber its slots throws in the middle of a seed run that has already written half an event.
*/

describe('event size profiles', () => {
it('keeps the default sample event on the `demo` slug', () => {
expect(EVENT_SIZES[DEFAULT_EVENT_SIZE].slug).toBe('demo');
expect(SIZED_EVENT_SLUGS[0]).toBe('demo');
});

it('grows monotonically, so the three are actually comparable', () => {
const speakers = ALL_EVENT_SIZES.map((size) => size.speakers);
const submissions = ALL_EVENT_SIZES.map((size) => size.submissions);
expect(speakers).toEqual([...speakers].sort((a, b) => a - b));
expect(submissions).toEqual([...submissions].sort((a, b) => a - b));
});

it('asks for more proposals than it accepts, or the review queue is empty', () => {
for (const size of ALL_EVENT_SIZES) {
expect(size.submissions).toBeGreaterThan(size.speakers);
}
});

/**
* `first-settlement` holds slots 0 to 12. Two events sharing a slot hand two different people the
* same face, which reads as a broken generator rather than as a seed collision.
*/
it('draws non-overlapping portrait ranges that fit the generator', () => {
const ranges = [
{ key: 'first-settlement', from: 0, to: ROMAN_PROFILE_ART.length - 1 },
...ALL_EVENT_SIZES.map((size) => ({
key: size.key,
from: size.headshotSlotOffset,
to: size.headshotSlotOffset + size.speakers - 1,
})),
].sort((a, b) => a.from - b.from);

for (const range of ranges) {
expect(range.to).toBeLessThan(ROMAN_SPEAKER_HEADSHOT_CAPACITY);
}
for (const [index, range] of ranges.slice(1).entries()) {
const previous = ranges[index]!;
expect({ after: previous.key, key: range.key, clear: range.from > previous.to }).toEqual({
after: previous.key,
key: range.key,
clear: true,
});
}
});

/**
* `sized-roster.ts` schedules into 14 half-hour slots a day. `demo` reserves its first three rooms
* for the hand-written agenda, so only the rooms past those count towards its capacity.
*/
it('has room on the grid for every accepted talk', () => {
const SLOTS_PER_DAY = 14;
const reserved: Record<string, number> = { medium: 3 };
for (const size of ALL_EVENT_SIZES) {
const openRooms = size.rooms - (reserved[size.key] ?? 0);
expect({ key: size.key, fits: openRooms * size.days * SLOTS_PER_DAY >= size.speakers }).toEqual(
{ key: size.key, fits: true },
);
}
});

it('puts generated identities on a domain nothing can be delivered to', () => {
for (const size of ALL_EVENT_SIZES) {
expect(generatedEmailDomain(size).endsWith('.example')).toBe(true);
}
});

it('gives every size a distinct slug', () => {
expect(new Set(SIZED_EVENT_SLUGS).size).toBe(SIZED_EVENT_SLUGS.length);
});
});
Loading