ADR-002
The database enforces integrity — keys, delete rules, types and atomic writes
Context
Most integrity rules live only in router code, and several are already broken.
- Links by text instead of foreign keys.
festival_waves.festival_slug(schema.ts:721),reviews.target_type/target_slug(241-242),events.venue_username/organizer_username(617-618),events.artistsandbooking_requests.artistsas JSON handle lists (638,376),plan_items.item_id(569),moderator_elections.winner_candidate_idandelection_vote_history.from/to_candidate_idwithout FKs (449,489-490), everycity_slugcolumn, andevents.series_idas text (636). - No delete rule anywhere. All 51 foreign keys in the latest snapshot (
migrations/meta/0018_snapshot.json) areno action. The consequence is real:auth.deleteAccount(routers/auth.ts:458-626) touches 27 tables by hand in "FK order" but missesartist_followsandfestival_waves, both added later with FKs todancers(schema.ts:707,722-723). For any dancer who followed an artist or waved at someone, the whole batch fails with an FK violation — GDPR erasure (RAZ-155) silently stopped working. - Enums are TypeScript-only. Every status/kind column is
textwith.$type<>()(e.g.schema.ts:93,159,241,385,568); the database accepts any string. They already disagree:review.ts:6accepts'event'as a target type, the schema union atschema.ts:241does not. jsonandjsonbare mixed (dancers.dance_stylesisjsonatschema.ts:9,dance_levelsisjsonbat12;profiles.styles/languages/socials,booking_requests.styles/artists,festival_submissions.payload,gigs.stylesarejson).jsonhas no equality operator, no GIN index and no containment.- Time is stored three ways. Every timestamp is
timestampwithout time zone;eventsstores UTC in it by convention (schema.ts:599-601); slot and booking times are'HH:MM'text (378-379,403-404,550);gigs.whenis free text (527). Server code formats event times with the server clock (plan.ts:713-714usesgetHours()), ignoringevents.timezone. - Nullable where it must not be.
created_atis nullable on 34 of 38 tables and missing onfestivals,dinnersanddinner_group_members(onlycitieshas itNOT NULL);dancers.is_adminis nullable (schema.ts:16, hence?? falseincontext.ts:50);events.slug/nameare nullable (606-607);festival_signupsis unique on(festival_id, dancer_id)with a nullabledancer_id(79,96), so stubs are never deduplicated. - Counters drift.
hangouts.people_countis set to 1 on create (hangouts.ts:97) and never updated bytoggleRsvp;city_videos.vote_countandelo_scoreare read-modify-write without a lock (cityVideo.ts:262-281), so concurrent votes lose updates;community_groups.report_countduplicatescount(community_group_reports). - Capacity checks race.
festivalSignup.join(festivalSignup.ts:84-101) anddinner.join(dinner.ts:107-118) count, then insert: two requests can both see the last free spot. - No transactions are possible today. The app uses
drizzle-orm/neon-http(server/utils/db.ts:2), whosetransaction()throws "No transactions support in neon-http driver" (node_modules/drizzle-orm/neon-http/session.js:151-152).booking.setAvailabilitycallsctx.db.transaction(booking.ts:292), so it can never succeed; multi-statement writes such aselection.close(election.ts:335-364) and dinner group assignment run statement by statement. - Foreign-key columns without indexes:
sessions.dancer_id(looked up on every delete),booking_requests.space_id/profile_id/organizer_id/requester_id,dinner_signups.dancer_id,giveaway_entries.dancer_id,festival_signups.dancer_id,referrals.referee_id,hangout_rsvps.dancer_id.
Options
- Keep integrity in application code and add tests. Every new table repeats the
deleteAccountmistake; scripts and the v3 sync bypass routers anyway. - Postgres native
enumtypes for every status. Strong, but adding a value needsALTER TYPE … ADD VALUE(cannot run inside a transaction block on older Postgres, cannot remove values) and drizzle-kit diffs them poorly. - Constraints in the database, conventions in one place: real FKs with an explicit delete rule,
CHECKconstraints for closed value sets,jsonbonly,timestamptz,NOT NULLdefaults, derived counters, transactions for multi-statement writes.
Decision
Option 3, applied through the expand/contract process of ADR-001. The rules:
- Every reference is a foreign key to a primary key. Text handles, slugs and JSON id lists become FK columns or join tables (the ADRs per context say which). Where a relation is "one of several parents" we use an exclusive arc: one nullable FK column per parent plus
CHECK (num_nonnulls(a_id, b_id) = 1)— never(type, id)text pairs. - Every FK declares
ON DELETE, by ownership: CASCADEfor rows that only exist for their parent (sessions, sign-ups, picks, RSVPs, follows, waves, votes in an open election, a dancer's goals);SET NULLfor rows that must survive as history or money records (orders, reviews, gigs, booking requests, video votes, guideline versions) — with the PII on the row scrubbed by the same deletion;RESTRICTwhere deleting the parent is a business decision (an event with paid orders, a ticket type that was sold).auth.deleteAccountthen shrinks to "delete the dancer and scrub PII onSET NULLrows", and a test asserts that every FK todancershas a rule.- Closed value sets use
CHECK (col IN (…))ontext, generated from one TypeScriptas constarray per enum (shared/enums.ts) that both zod and Drizzle import. We deviate from native enums on purpose: adding a value is a plainALTER TABLE … DROP/ADD CONSTRAINT, and removing one is possible. jsonbonly, and only for documents (payloads, provenance, metadata blobs). Lists of scalars (styles, languages, roles) aretext[]with a GIN index; lists of references are join tables. No newjsoncolumns; existing ones convert withALTER COLUMN … TYPE jsonb USING col::jsonb(ortext[]) after a backfill check.- Instants are
timestamptz. Wall-clock data keeps its zone: events carry an IANAtimezone, recurring slots usetimeplus the venue's zone, dates without time usedate. Existingtimestampcolumns convert withUSING col AT TIME ZONE 'UTC'(they hold UTC today). Free-text times (gigs.when) become structured columns plus an optional display note. NOT NULLwith defaults forcreated_at/updated_at, booleans and anything the code already treats as required.updated_atis maintained by one trigger function, not by each router.- Counters are derived with
count(*)and an index, unless a read path proves too slow; then they are maintained by a trigger in the same transaction, never by application read-modify-write. ELO updates use a singleUPDATE … SET elo = elo + $deltaper row inside a transaction withSELECT … FOR UPDATEon both videos. - Capacity is enforced atomically: the insert is conditional (
INSERT … SELECT … WHERE (SELECT count(*) …) < capacityinside a transaction holding aFOR UPDATElock on the parent row). - Every FK column is indexed (leading column of some index).
- Two database clients.
useDb()stays onneon-httpfor single-statement reads and writes (lowest latency on Vercel). A newwithTx(fn)uses the Neon serverlessPool(drizzle-orm/neon-serverless, WebSocket) for interactive transactions; every multi-statement mutation must use it.db.batch()remains for fixed statement lists.
Rendering diagram…
Diagram source
erDiagram
dancers ||--o{ sessions : "CASCADE"
dancers ||--o{ attendances : "CASCADE"
dancers |o--o{ orders : "SET NULL + scrub email"
dancers |o--o{ reviews : "SET NULL"
events ||--o{ ticket_types : "CASCADE"
ticket_types ||--o{ order_items : "RESTRICT"Consequences
- Account deletion, the v3 sync and admin scripts all get the same guarantees as the routers.
- Adding a status value is a migration, not just a TypeScript edit. That is the point.
- The conversion of large tables (
events11,192 rows,profiles6,589,dancers3,932 —docs/research/2026-10-12-city-canonicalisation.md:22-25) is quick at this size, but eachALTER … TYPErewrites the table and takes anACCESS EXCLUSIVElock; they run in a low-traffic window, one table per migration. - A second driver (
neon-serverlessPool) is added; it needs WebSocket support in the Vercel Node runtime (available) and must be closed per request.
Stories unblocked
RAZ-155 (account erasure works again), RAZ-118 (correct "N in" counts), RAZ-158 (venue availability can be saved), map:meetup-2.2-free-spot-counter (free spots cannot be oversold), map:meetup-5.1-dinner-sign-up (dinners cannot be overfilled).