Skip to content

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

Merged
github-actions[bot] merged 1 commit into
mainfrom
refactor/prisma-to-drizzle
Sep 1, 2026
Merged

refactor(db): Prisma → Drizzle — the fleet has one ORM#136
github-actions[bot] merged 1 commit into
mainfrom
refactor/prisma-to-drizzle

Conversation

@catomean

@catomean catomean commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Stack-uniformity migration (George, 2026-09-01: "Drizzle everywhere" for fleet-owned Postgres apps). Follows the recipe proven in bitbaum/biaslens#26 — solon is the first follow-up repo with a live database (real governance data: 1 org, 2 votes, 1 proposal — nothing here may touch it).

Call sites (before → after)

solon's Prisma surface was ~10× biaslens's: 37 files imported @prisma/client or called prisma.*. All rewritten, now zero:

Layer Files What changed
Client src/lib/db.tssrc/lib/db/client.ts PrismaClient + @prisma/adapter-pg singleton → lazy drizzle(new Pool(...)) proxy (hirnli house pattern; build/CI stays hermetic)
Schema/types src/lib/db/schema.ts (new) 9 tables, 12 pg enums, full relations(), $inferSelect row types
Enum vocabulary src/lib/db/enums.ts (new) dependency-free const objects with Prisma-identical semantics (MemberType.HUMAN === "HUMAN"); z.enum(DecisionCategory) keeps working. methods/prisma-enum.ts renamed db-enum.ts
Domain voting, proposals, membership, decision, treasury, org, recognition, tally findUnique/includedb.query.*.findFirst/with, $transactiondb.transaction, upsertonConflictDoUpdate, count()select({count()})/$count, Prisma.Decimal → numeric-as-string (Number() at the same call sites that already did it), Prisma.JsonValue/InputJsonValue/DbNullunknown/null
API routes 8 routes same shapes; relation-filtered wheres became inArray(col, subquery); repeated org-by-slug lookup extracted to orgBySlug() in domain/org.ts (DRY — 4 routes used it)
Pages/components 8 server components same query semantics, ?? null where Prisma returned null and Drizzle returns undefined
Scripts add-member, add-treasury-source rewritten; $disconnect()db.$client.end()
Tests 3 integration specs + 3 unit specs fixtures on db.insert(...).returning(); enum imports repointed

grep -rni prisma now hits only the lockfile (drizzle-orm's own optional peers), the schema's provenance comments, and AGENTS.md's history note.

Schema parity — proven, not asserted

src/lib/db/schema.ts matches the tables Prisma's migrations created, byte-for-byte: table/column names, TIMESTAMP(3), NUMERIC(10,2)/(12,2), VARCHAR(90), PascalCase enum type names ("MemberType"), Prisma's index/constraint names (organizations_slug_key, members_organization_id_fkey, …), cascade rules, and client-side uuid minting ($defaultFn(randomUUID) — the columns carry no DB default, exactly like @default(uuid())).

Evidence 1 (replay): scratch Postgres 16 — Prisma's 0_init + 1_seed_org1 + 2_voting_methods into db A, drizzle-kit migrate (0000_init + 0001_seed_org1) into db B, normalized pg_dump --schema-only diff: 50/50 statements identical. Only spelling differences normalized away: CURRENT_TIMESTAMP vs now() (same Postgres function) and column order (Prisma's ALTERs appended columns; irrelevant on the live DB, which 0000 never touches). Seed data equal in both (orangecat org, allocation_policy v1, 2 genesis audit events).

Evidence 2 (production): pg_dump --schema-only of the live solon database vs the drizzle-built scratch DB: identical — the only extra statement on prod is COMMENT ON SCHEMA public IS ''.

Evidence 3 (behavior): all 3 integration specs (vote spine, dot allocation, founding seat — 10 tests) green against the drizzle-built scratch DB, plus an HTTP smoke of the built standalone server: GET /api/orgs/orangecat returns the seeded org, audit 200, missing org 404.

Live-DB cutover mechanism (why the deploy is a no-op)

Deploys run fleetcrown/scripts/hetzner/apply-schema.sh, which for drizzle-layout apps applies only migrations not yet recorded in the app's public._deploy_schema_history ledger — and refuses to auto-baseline a populated database. So before this PR merges, the ledger on bitbaum was seeded by hand (after proving prod schema ≡ drizzle schema, Evidence 2):

CREATE TABLE IF NOT EXISTS public._deploy_schema_history (tag text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
INSERT INTO public._deploy_schema_history(tag) VALUES ('0000_init'), ('0001_seed_org1') ON CONFLICT DO NOTHING;

✅ already applied on the box (solon database). The deploy's schema step therefore finds both tags recorded, applies nothing, and ships code only. _prisma_migrations is intentionally left in place until this deploy is verified live (a rollback deploy of a Prisma-era release would still need it); it gets dropped right after verification.

Recipe deltas vs biaslens

  • Live DB: biaslens had no deploy pipeline; solon's cutover is the ledger baseline above (the step biaslens#26 predicted the follow-ups would need — mechanism differs from the predicted drizzle.__drizzle_migrations insert because prod is applied by apply-schema.sh, not drizzle-kit migrate; drizzle's own journal only matters for fresh DBs, i.e. CI).
  • Seed as migration: solon ships reference data in its migration chain; ported verbatim as custom migration 0001_seed_org1 so CI's fresh-DB replay stays "baseline + seed", same as before.
  • Enums as values: biaslens had none; solon imports 12 enums as runtime values in 20+ files, so they live in a dependency-free enums.ts the pgEnums are built from (one SSOT, no drift possible, client-bundle safe).
  • snake_case: solon's schema was fully @mapped — no PascalCase table/column quoting needed (only enum type names keep PascalCase).
  • Decimal columns: biaslens had none; solon keeps drizzle's string mode and converts with the Number() calls the code already had.
  • prisma.config.ts (Prisma 7 driver-adapter era) deleted alongside the classic artifacts; CI's two prisma generate steps and the prisma migrate deploy replay became zero codegen + drizzle-kit migrate; drizzle/meta added to .prettierignore (generated, drizzle-kit owns it).

Verification

npm run verify (prettier + eslint + tsc + design:check + 82 unit tests) and npm run build green. Integration: 10/10 against a fresh drizzle-migrated Postgres. Live smoke via standalone build. Dependabot PRs #129/#132 (@prisma/*) become obsolete and will be closed on merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn

Stack-uniformity migration (George: "Drizzle everywhere" for fleet-owned
Postgres apps), following the recipe proven in biaslens#26. Schema parity
with the live tables is byte-exact and proven by normalized pg_dump diff
(scratch replay AND against the production database itself); the deploy
ledger on the box is pre-baselined so the cutover is a no-op for prod data.

- src/lib/db/{schema,client,enums}.ts replace prisma/* + @prisma/client
- every call site rewritten (domain, API routes, pages, scripts, tests)
- drizzle/0000_init + 0001_seed_org1 replay the full schema + seed on a
  fresh database (CI integration job now runs drizzle-kit migrate)
- prisma deps, prisma.config.ts, generate steps and docs mentions removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn
@github-actions
github-actions Bot merged commit 0708055 into main Sep 1, 2026
2 checks passed
@github-actions
github-actions Bot deleted the refactor/prisma-to-drizzle branch September 1, 2026 22:28
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