Skip to content

refactor(db): Prisma → Drizzle — the fleet has one ORM - #154

Merged
github-actions[bot] merged 5 commits into
masterfrom
refactor/prisma-to-drizzle
Sep 2, 2026
Merged

refactor(db): Prisma → Drizzle — the fleet has one ORM#154
github-actions[bot] merged 5 commits into
masterfrom
refactor/prisma-to-drizzle

Conversation

@catomean

@catomean catomean commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Final repo of the stack-uniformity migration (George: "Drizzle everywhere") — after biaslens#26, reparaturbonus-zh#130 and solon#136. This was the largest of the four: 46 tables / 69 enums / 29 migrations at branch time, ~500 Prisma call sites in 116 src files, 62 test suites mocking the Prisma client — and, uniquely, a mid-flight master feature (#152) that had to be ported on the way through.

Recipe deltas vs reparaturbonus#130

  • Schema by introspection, not by hand: scratch Postgres 16 built by the full Prisma migration chain, then drizzle-kit pull — exact camelCase columns, "AgeRange"-style enum names, TIMESTAMP(3), and every Prisma constraint/index name pinned by construction. Manual passes on top: mode: 'date', $defaultFn(createId) on exactly the 43 cuid ids, $defaultFn/$onUpdateFn on exactly the 29 @updatedAt tables (both sets diffed against schema.prisma), a bytea customType for the two Bytes columns, ARRAY[]::TEXT[] defaults restored (drizzle-kit misparses them as ["RAY"]), and per-column .op() specifiers stripped (drizzle-kit scrambles them positionally in multi-column indexes; all were Postgres defaults).
  • Type-level FK cycles (Placement↔Incident, HouseRule↔Proposal) break with (table): PgTableExtraConfigValue[] => return annotations — AnyPgColumn casts alone don't cut the inference loop.
  • Nullable TEXT[] truth: Prisma typed scalar lists string[]; the columns are nullable. The UI contract stays non-null via ?? [] at the projection boundary (toResidentUiSummary, ResidentSummary).
  • Relations mirror Prisma's field names (photo, expensesPaid, taskRequestsReceived, …, relationName = Prisma's @relation names), so every include: became the same-shaped with:.
  • types.ts reproduces what @prisma/client exported: row types AND per-enum runtime objects (StaffRole.ADMIN), derived from the pgEnums.
  • isUniqueViolation() replaces PrismaClientKnownRequestError/P2002 (SQLSTATE 23505, unwraps DrizzleQueryError cause).
  • site-access (Which places a staff member is responsible for #152) port: the where-fragment helpers now emit drizzle SQL; the residents-in-my-units rule (placements: { some: … }) is an inArray subquery built on a standalone QueryBuilder so constructing a filter never touches the lazy client (jest + next build run without DATABASE_URL); empty assignment renders false (drizzle inArray throws on []).

Parity — proven, not asserted

Scratch Postgres 16: full Prisma chain (30 migrations) → db A; drizzle/0000 + drizzle/0001 via drizzle-kit migrate → db B; normalized pg_dump --schema-only --no-owner --no-privileges diff (bookkeeping tables excluded): empty — 1486+ DDL lines, all tables, enums, indexes, FKs, uniques and the Message_one_author CHECK identical including names.

The live aoz_wohnen dump was also diffed against db A: semantically identical; only column/enum-value ordering differs (columns added by later ALTERs sit at the end on live; LOW_SATISFACTION was enum-appended) plus live's User_code_key being a unique index rather than a unique constraint — equivalent objects, append-history artifacts, no action needed.

Verification

  • npm run verify green: format:check + lint + typecheck + 204/204 suites, 3584 passed / 8 skipped (3592). Baseline before migration: 202 suites / 3563+8; the deltas are master's two new suites (A coach should not be the last to know their client's house is in trouble #150–152) and dynamically discovered it.each rows (a source-scanning gate now finds 7 insert sites instead of 5) — no hand-written test was added, deleted, skipped or weakened.
  • Hermetic npm run build green (no codegen step left; build is just next build).
  • Seeds proven by execution: on a scratch DB, db:migrate + db:seed + db:seed:admin all exit 0 (25 residents, 18 placements, live-computed compatibility scores), and a second run over the populated DB confirms FK deletion order + idempotent governance path.
  • CI rewired: no prisma generate steps; e2e schema init is npm run db:migrate, seeds are db:seed/db:seed:admin.

Live-DB cutover — non-destructive by construction, already armed

Deploys run fleetcrown's apply-schema.sh, which probes ./drizzle first. Both migrations are marked applied, never run, in both ledgers, on the box, before this merges — so the merge deploy's schema step logs "up to date" and ships code only:

  • aoz_wohnen (live, 19 residents): _deploy_schema_history = {0000, 0001}; drizzle.__drizzle_migrations carries both sha256s + journal timestamps. The Which places a staff member is responsible for #152 Prisma migration had already reached this DB via master's deploy, so its schema is the 0000+0001 shape (verified above).
  • aoz_demo (dormant demo instance — service disabled since 2026-08-26): was 9 migrations stale; the missing, verified-additive Prisma migrations were applied by hand first (15 residents before and after), then the same dual-ledger baseline. scripts/deploy-demo.sh now applies drizzle/*.sql through the same _deploy_schema_history ledger instead of npx prisma migrate deploy.
  • After the first Drizzle deploy is verified live: DROP TABLE "_prisma_migrations" (step 3, run separately).

Row counts for every table were recorded before the cutover and will be re-verified after the deploy (nothing in this PR writes to the live DB; the deploy applies no schema).

Prisma is gone

prisma/ (schema + 30 migrations), @prisma/client, prisma, the package.json#prisma block, all CI generate steps, and every import. grep -rni prisma in src/scripts now hits only historical comments explaining why code is shaped the way it is. Guard tests that used to regex schema.prisma read the pgEnum objects at runtime instead. Docs (CLAUDE.md, README, INFRASTRUCTURE.md, demo guides) updated. Closes the door on dependabot #126/#127.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn

catomean and others added 5 commits September 2, 2026 11:35
…aped live DB

drizzle-kit pull against a scratch Postgres built by the 29 Prisma
migrations, then: mode 'date', $defaultFn(createId) on the 43 cuid ids,
$defaultFn/$onUpdateFn on the 29 @updatedat tables, bytea customType for
the two Bytes columns, ARRAY[]::TEXT[] defaults restored (drizzle-kit
misparses them as ["RAY"]), and per-column .op() specifiers stripped
(drizzle-kit scrambles them positionally in multi-column indexes; they were
all Postgres defaults anyway).

Parity proof: Prisma chain -> db A, drizzle 0000 -> db B, normalized
pg_dump --schema-only diff = EMPTY (1486 DDL lines each; tables, enums,
indexes, FKs, uniques, the Message_one_author CHECK — names included).

Relations mirror Prisma's field names so include:->with: reads identically;
types.ts reproduces Prisma's per-enum runtime objects and row types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
…s + CI + docs)

116 src files with live call sites and 50 type-import files converted:
prisma.x.find* -> db.query.x.*, create/update/delete -> insert/update/delete
builders, upserts -> onConflictDoUpdate, $transaction -> db.transaction,
$queryRaw -> db.execute(sql), Prisma.sql/join/empty -> drizzle sql,
P2002 catches -> isUniqueViolation (SQLSTATE 23505, DrizzleQueryError-aware).
Nullable TEXT[] columns (DB truth Prisma's types papered over) are
normalised at the UI boundary. Type-level FK cycles
(Placement<->Incident, HouseRule<->Proposal) broken with
PgTableExtraConfigValue[] return annotations.

CI: prisma generate steps dropped, schema init = npm run db:migrate,
seeds = db:seed/db:seed:admin. deploy-demo.sh migrates via ledgered psql
loop (same _deploy_schema_history the fleet applier keeps). Docs updated.

Tests and prisma/seed*.ts still reference the old client - next commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
…eeds; delete Prisma

Tests (62 files): jest.mock('@/lib/db') now fakes `db` (query API +
chainable insert/update/delete builders + transactions + execute) instead
of the Prisma model map, with jest.requireActual keeping tables/enums/
helpers real. Assertions moved from `{data}`/`{where}` object equality to
`.values()` payloads and real drizzle where-expressions - discriminating
power kept, nothing weakened to anything(), no test deleted or skipped.
New shared helper src/test-utils/drizzle-where.ts (eqParts/whereParts/
sqlText) keeps mock dispatch DRY. jest transformIgnorePatterns admits the
ESM-only @paralleldrive/cuid2 + @noble/hashes.

Seeds: prisma/seed*.ts -> scripts/db/ (git mv), converted and PROVEN by
execution against a scratch Postgres: db:migrate + db:seed + db:seed:admin
all exit 0, 25 residents / 18 placements / live compatibility scores, and
a second run over the populated DB confirms the FK deletion order and
idempotent governance path.

Prisma is now GONE: prisma/ (schema + 30 migrations) deleted, @prisma/
client + prisma removed from package.json, no remaining import outside
historical comments. The guard tests that used to regex schema.prisma now
read the pgEnum objects at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
…ers, 0001

Master's StaffUnit/SiteAccess feature landed mid-migration in Prisma
terms; this ports its data layer 1:1: siteAccess pgEnum + User column +
StaffUnit table (pinned Prisma constraint names), unitAccess/staffAccess
relations, and drizzle/0001_staff_site_access.sql whose DDL matches the
Prisma migration statement for statement. Scratch parity re-proven with
0001 included: normalized pg_dump diff of the full Prisma chain (30
migrations) vs drizzle 0000+0001 is EMPTY.

site-access.ts now emits drizzle where-fragments: inArray for unit/
housingUnitId scopes (callers name their table's column), the residents-
with-an-ACTIVE-placement rule as an inArray subquery built on a standalone
QueryBuilder (constructing a filter must not touch the lazy client - jest
and next build call it without DATABASE_URL), and `sql\`false\`` for the
assigned-nowhere case (drizzle's inArray throws on []). getCurrentUser
carries siteAccess + assignedUnitIds off the row via `with: unitAccess`.
The boards pass the fragments through `and(filter ?? undefined, ...)` so
an ALL_UNITS viewer's query is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
… 0002

Same drill as #152: InterpreterNeed pgEnum + Resident.interpreterNeed
(default NONE) + Resident_interpreterNeed_idx, drizzle/0002 matching the
Prisma migration statement for statement, InterpreterNeed runtime enum
object in types.ts, and the three new imports repointed at @/lib/db.
Scratch parity re-proven: full Prisma chain (31) vs drizzle 0000-0002,
normalized pg_dump diff EMPTY.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
@catomean
catomean force-pushed the refactor/prisma-to-drizzle branch from 416714c to 12f4004 Compare September 2, 2026 09:41
@github-actions
github-actions Bot merged commit 5307dbb into master Sep 2, 2026
4 checks passed
@github-actions
github-actions Bot deleted the refactor/prisma-to-drizzle branch September 2, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant