Skip to content

fix(db): adopt prisma migrate with baseline + drift CI guard (#233) - #577

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Neziahtech:feat/prisma-migrate-233
Oct 23, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Neziahtech:feat/prisma-migrate-233

Conversation

@Neziahtech

@Neziahtech Neziahtech commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #233


What & why (#233)

prisma/ shipped only schema.prisma — no migrations/, so every schema change went through prisma db push, which diffs the live DB and applies whatever it decides. Nothing was reviewable, reproducible, or roll-backable. This adopts prisma migrate.

Acceptance criteria

  • Baseline reproduces the current production schema exactly — prisma/migrations/0_init/migration.sql, generated via migrate diff --from-empty --to-schema-datamodel --script. Verified byte-for-byte against a regenerated diff.
  • CI fails on drift between schema and migrations — new db-migrations job boots Postgres and runs scripts/db-drift-check.sh (migrate diff … --exit-code), plus scripts/db-migration-cycle.sh which also proves the guard fires on an un-migrated schema edit.
  • Deploy docs use migrate deploy; db push called out as unsafe — new docs/database-migrations.md; README / CONTRIBUTING / backup-restore / load.yml updated; startup src/index.ts now runs migrate deploy; db:push removed.
  • A new migration can be generated, applied and rolled back on a clean DB — scripts/db-migration-cycle.sh (reset → generate canary migration → deploy → assert table → reset → assert gone).

Proven with a test that fails first

tests/prisma-migrations.test.ts is DB-free and locks schema.prisma to the baseline. Ran both ways: with prisma/migrations hidden it fails (4/4); with the baseline present it passes (4/4).

Scope notes

Baseline generated from main (production shape); it does not include the un-merged #237 unique index — that PR should ship its own migration. No unrelated files touched.

Not verified locally

The DB-backed drift + generate/apply/rollback jobs need a live Postgres; the docker daemon was unreachable on my box. Their logic and bash -n are validated and they run in CI.

…y-Labs#233)

Replace `prisma db push` with a committed migration history so schema
changes are reviewable, deploys are reproducible, and a bad change is
recoverable.

- Baseline migration 0_init reproduces the current production schema
  exactly (generated via `migrate diff --from-empty --to-schema-datamodel`).
- CI job `db-migrations` replays every migration against a real Postgres
  and fails on drift (`migrate diff ... --exit-code`), and proves a new
  migration can be generated, applied and rolled back on a clean DB.
- DB-free vitest test locks schema.prisma to the baseline; it fails while
  the repo has no migration history and on un-migrated schema edits.
- App startup and the load workflow now run `migrate deploy`; `db push`
  is removed as a script and called out as unsafe in docs/database-migrations.md.
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@Neziahtech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Miracle656 added a commit that referenced this pull request Oct 22, 2026
  tsc --noEmit   clean
  vitest         102 files, 1157 passing

  #563  extend the env drift guard to examples and scripts
  #568  let callers read their own usage
  #571  zero-downtime admin key rotation
  #578  pin the connect-time address to defeat DNS rebinding

#563 earned itself immediately. Extending the guard beyond src/ caught two
variables read by the live-spec validator that were never documented:

  LIVE_API_URL (scripts/validate-live-spec.ts:15)
  LENS_URL     (scripts/validate-live-spec.ts:15)

That script came in with #318, which I merged earlier today, so this is a gap
I let through two batches ago. Both are now in .env.example with a note on the
precedence between them.

Its own test case was flagged by the dropped-test guard and turned out to be
split rather than deleted -- "fails when a new un-documented process.env read
is encountered" became one case for src/ and one for examples/. Third time
today that guard surfaced a rename; it has yet to find a real deletion, which
is the outcome you want from it.

#578 closes the SSRF rebinding hole: resolving a host, checking it, then
letting the HTTP client resolve it again leaves a window where the second
answer differs from the one that passed the check. Pinning the connect-time
address removes the window rather than narrowing it.

Two from this group were NOT merged, both failing on their own branch:

  #572  (capability scopes per route) 1 type error alone:
        src/api/auth.ts(165): Property 'scope' does not exist on type
        '{ public?: boolean | undefined; }'
  #577  (prisma migrate with a baseline) 1 failing test alone -- its own:
        "has an init/baseline migration that reproduces schema.prisma exactly"

#573 #574 #575 #576 conflict.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fails one test on its own branch — and it is this PR's own test:

× #233 Prisma migration history replaces db push > has an init/baseline migration that reproduces schema.prisma exactly

tsc --noEmit is clean and the other 1149 tests pass, so the baseline in prisma/migrations/0_init/migration.sql does not reproduce schema.prisma exactly. Most likely something landed in the schema after you generated it — several PRs added models today, including a WebhookDeliveryFailure in #388.

Worth getting exactly right rather than close. This repo has been bitten by prisma db push ignoring committed migrations and half-applying a schema, and a baseline that does not match is how that recurs: the migration history claims to describe the database and quietly does not.

Regenerate the baseline against current schema.prisma, confirm your own test passes, and re-request. The change itself — replacing db push with a real migration history — is one I want.

Miracle656 added a commit that referenced this pull request Oct 23, 2026
…loses #233)

Replaces `prisma db push` with a committed migration history: a generated
baseline in `prisma/migrations/0_init`, a `db-migrations` CI job that replays
every migration against a real Postgres and fails on drift from
`schema.prisma`, a DB-free test that locks the baseline to the schema, and
`migrate deploy` at startup and in the load workflow.

Four changes on top of the contribution.

1. **Startup would have crash-looped on every existing database.** Every Lens
   database was created by `db push`, so it has the full schema and no
   `_prisma_migrations` table. Prisma's baselining guidance is explicit about
   what `migrate deploy` then does: it runs the baseline's `CREATE TABLE`s
   against tables that are already there, and errors. `main()` ends in
   `process.exit(1)`, so that is a crash loop, not a failed migration — and the
   PR's own doc put the fix (`migrate resolve --applied 0_init`) in a runbook,
   which means the deploy survives or not depending on whether a human ran a
   command first. `applyMigrations()` in the new `src/db/migrate.ts` does the
   adoption in code: one `to_regclass` query, and when the schema is populated
   without a history it records the baseline as applied without executing it,
   then deploys. The three states — empty, pre-migrate, already adopted — are
   pinned by `tests/db-migrate-baseline.test.ts`, which also pins that `resolve`
   runs before `deploy` and exactly once.

2. **The baseline had drifted.** `main` has gained `webhook_delivery_failures`
   and `oracle_publications` since it was generated, and the PR's own drift test
   caught it. Regenerated.

3. **Dropping `db push` means drift is no longer self-healed, and that is not
   written down anywhere.** `api_keys.expires_at` is in `schema.prisma` and in
   the baseline; if it is missing from a live database, adoption records the
   baseline as applied and `migrate deploy` has nothing to apply, so the column
   stays missing — and `assertSchemaValid()` only covers the five core tables in
   `EXPECTED_SCHEMA`, so nothing fails at boot. It surfaces as 500s on the
   routes that read it. `docs/database-migrations.md` now carries the one-time
   `migrate diff --from-schema-datasource` check to run against each live
   database before the first deploy on this commit, and says why that check is
   deliberately not wired into startup (Lens shares a Postgres instance with
   wraith; a false positive there would be a refusal to boot).

4. **The operational docs still described the old boot.** `docs/RUNBOOK.md`
   listed `db push --accept-data-loss` as boot step 1 and told an operator to
   roll a schema back by running `prisma db push` from the old checkout — a
   command that no longer exists and that would now leave the database and the
   migration history disagreeing. `docs/database-schema.md` still opened with
   "Current State: `prisma db push`", and `docs/sep40-oracle-publisher.md`
   pointed at the deleted `npm run db:push`. All updated.

Also pinned `*.sql` to LF in `.gitattributes`, alongside the four generated
artifacts that already needed it.

123 test files / 1,472 tests pass; tsc and build clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit ac92b82 into Divinity-Labs:main Oct 23, 2026
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged as 2abf7b3. This is a strong piece of work and the right shape for #233: a generated baseline rather than a hand-written one, a CI job that replays migrations against a real Postgres rather than trusting a file comparison, a DB-free test so npm test catches a forgotten migration with no database present, and the db push path removed from startup, the load workflow, CONTRIBUTING and the README together instead of one of them. docs/database-migrations.md is genuinely good documentation — the "Why not db push" section is the argument, not a restatement of the change.

Your drift test also earned its keep immediately: it failed on merge because main has gained webhook_delivery_failures and oracle_publications since you generated the baseline. I regenerated it.

Four things I changed on the way in.


1. Startup would have crash-looped on every database we have (blocking, fixed)

This is the one that mattered. Every Lens database that exists was created by db push, so it carries the full schema and no _prisma_migrations table. Prisma's baselining docs are explicit about what migrate deploy does from there — it runs the baseline's CREATE TABLEs against tables that are already present, and errors.

From src/index.ts that is not a failed migration. It is:

main().catch(err => {
  log.error({ err }, 'fatal startup error')
  process.exit(1)
})

So: CREATE TABLE "price_points" → 42P07 relation already exists → execSync throws → exit(1) → Render restarts → same thing again. A crash loop, with the API down.

Your own doc has the fix (npx prisma migrate resolve --applied 0_init), under "Adopting the baseline on an EXISTING production database" — and that is the right command. But as a runbook step it means the deploy survives or not depending on whether a human ran something first, and the window between the merge and the next redeploy can be days. That is not a safe thing to leave to ordering.

So I moved the adoption into code, in a new src/db/migrate.ts:

export async function applyMigrations(options: ApplyMigrationsOptions): Promise<void> {
  const baseline = options.baseline === undefined ? baselineMigration() : options.baseline

  if (baseline) {
    const state = await readMigrationState(db)
    if (needsBaselineAdoption(state)) {
      warn?.({ baseline }, 'database predates the migration history; recording the baseline as applied without running it')
      run(`${PRISMA_CLI} migrate resolve --applied ${baseline}`)
    }
  }

  run(`${PRISMA_CLI} migrate deploy`)
}

One query decides it:

select to_regclass('_prisma_migrations') is not null as has_history,
       to_regclass('price_points')       is not null as has_schema

Three states, all three now pinned by tests/db-migrate-baseline.test.ts (11 tests):

database _prisma_migrations schema what runs
empty (CI, fresh local) absent absent deploy only — creates everything
pre-migrate (ours today) absent present resolve --applied <baseline>, then deploy applies nothing
already adopted present present deploy only

A few details that are deliberate rather than incidental:

  • The baseline name is read from disk, not hard-coded as 0_init. baselineMigration() takes the first migration folder by sort order, so renaming or replacing the baseline cannot leave a stale name in a resolve call, and a second migration later is unaffected — resolve adopts the first, deploy applies the rest.
  • The probe is price_points, not indexer_state. Both Lens and wraith have a model mapped to indexer_state; a probe that asks "is there a schema here" has to be unambiguous about whose. The identifier is left unqualified so it follows the connection's search_path rather than assuming public.
  • A half-created database is adopted too, and then fails in assertSchemaValid() with the list of what is missing. That is the better of the two errors — a named missing column beats relation already exists on whichever table the baseline happened to reach first.
  • The pool and the command runner are injected, which is why the tests are DB-free and assert the command sequence (resolve strictly before deploy, exactly once) rather than mocking Prisma.

2. Dropping db push means drift is no longer self-healed — and that was undocumented

This is the subtler cost of the change, and it is worth being plain about because it is permanent.

db push ran on every boot and converged the live schema to schema.prisma, silently. After this PR nothing does. Concretely: api_keys.expires_at is in schema.prisma (line 334) and in the baseline (line 195). If it is missing from a live database — and we have had exactly this, a merged ALTER TABLE that never reached the database — then after adoption migrate deploy has nothing pending to apply, so the column stays missing forever. And assertSchemaValid() will not catch it: EXPECTED_SCHEMA in src/db/schemaVerify.ts covers five tables (price_points, pool_snapshots, price_aggregates, price_snapshots, indexer_state) and api_keys is not one of them. It surfaces as 500s on the authenticated routes, not as a failed boot.

I added a "One-time check before the first deploy" section to your doc with the command to run against each live database:

DIRECT_DATABASE_URL='<direct url for that environment>' \
  npx prisma migrate diff \
    --from-schema-datasource prisma/schema.prisma \
    --to-schema-datamodel prisma/schema.prisma \
    --script

Empty output means the database matches the baseline and is safe to adopt. Statements mean it is not, and they should become a committed migration first.

Two notes in there on why this is a person's job and not the code's:

  • It is not wired into startup on purpose. Lens shares a Postgres instance with wraith, and if the two ever land in the same schema this diff reports wraith's eighteen tables as drift. A false positive there would be a refusal to boot, which is worse than the problem.
  • CI cannot do it either. scripts/db-drift-check.sh replays the migrations into an empty Postgres and diffs that against schema.prisma. It never sees a live database. So the doc's line about this being "the check that would have caught a half-applied db push" is not quite right — a half-applied push leaves production drifted, and this guard does not look at production. I left the guard (it is the right guard for migrations-vs-schema) and moved that claim to the live check.

3. The operational docs still described the old boot

docs/RUNBOOK.md is the doc someone reads at 2am, and it had:

  • db push --accept-data-loss as boot step 1, with the "may change destructively, automatically" warning attached to it;
  • a rollback procedure telling the operator to run npx prisma db push from the old checkout — a command that no longer exists as a script, and which would now leave the database and the migration history disagreeing.

Both rewritten: step 1 is applyMigrations(), the destructive-change warning now says a destructive change reaches production because someone merged the SQL rather than because a diff decided it, and rollback is roll-forward with a reversing migration or a restore. I also added the "drift is no longer self-healing" corollary there, since that is where an operator will look.

Also: docs/database-schema.md still opened with "## Current State: prisma db push" (now marked superseded, with the early-development rationale kept as history), and docs/sep40-oracle-publisher.md pointed at npm run db:push, which this PR deletes.

4. *.sql pinned to LF

.gitattributes already pins *.json, *.yaml, *.csv and *.graphql for exactly this reason — each one after a byte-compare guard failed on a Windows checkout over line endings and nothing else. normalizeSql() strips trailing whitespace so your test survives CRLF today, but the baseline is a generated artifact compared against generated output, so it belongs in that list.


Operational note for whoever deploys this

The Render service is currently suspended, so nothing is live to break and the merge is safe as it stands. Before un-suspending, run the live-diff command from "One-time check before the first deploy" against the production database. If it prints statements, that drift needs a committed migration first — migrate deploy will not fix it for you any more.

123 test files / 1,472 tests pass. tsc --noEmit and npm run build clean.

Miracle656 added a commit that referenced this pull request Oct 23, 2026
…500)

Stamps `api_keys.last_used_at` on each successful authentication, throttled to
one write per key per `KEY_LAST_USED_THROTTLE_SECONDS` (default 60) so a busy
key does not turn a read-only auth check into a write per request, and surfaces
it on `GET /admin/keys`. The in-memory window is claimed before the await, so
concurrent requests for one key do not each decide to write.

Merged locally. The branch predated the `buildApp()`/admin-auth work and four
files conflicted, including the whole `/admin/keys` handler, so I took `main`
for those and re-applied only the #500 parts. In particular I did **not** take
the PR's pagination of `/admin/keys` — that is issue #505, assigned to someone
else — only `lastUsedAt` on the existing listing.

Three things on top of the contribution.

1. **There was no migration, so the column would never have reached a live
   database.** This is the first schema change since #577 landed this morning:
   startup runs `prisma migrate deploy`, not `db push`, so a bare
   `schema.prisma` edit no longer converges anything. The baseline is already
   recorded as applied, nothing would have been pending, and every
   `lastUsedAt` write would have failed against a table with no such column —
   the feature silently dead, exactly the hazard `docs/database-migrations.md`
   warns about. Added
   `prisma/migrations/20261023120000_api_key_last_used_at/migration.sql`.

2. **Regenerating the baseline instead would have broken a fresh database.**
   My first attempt regenerated `0_init` from the new schema *and* added the
   ALTER, which collide: `0_init` creates `last_used_at`, then the ALTER adds
   it again. The baseline stays behind `schema.prisma` on purpose — the later
   migration carries the difference. That means #577's DB-free drift test was
   wrong the moment a second migration existed: it asserted the baseline
   reproduces `schema.prisma` exactly, which would have demanded the very
   regeneration that causes the collision. It now makes that comparison only
   while the baseline is the whole history, defers to the DB-backed replay in
   the `db-migrations` CI job once there is more, and gained a case asserting
   no migration sorts before the baseline (Prisma replays lexicographically, so
   one that did would run against an empty database).

3. **The failed write was swallowed by a bare `catch {}`.** Not refusing a
   valid authenticated request because an audit timestamp could not be stored
   is right; saying nothing is not, and after (1) a missing column is a real
   possibility. It is now logged through the house logger — which redacts
   secrets — once per key, so one bad environment is one line, not one per
   throttle window.

Also moved the `recordApiKeyLastUsed` call to sit immediately after the key is
accepted, before the scope and daily-quota checks: "last used" should mean
"last authenticated", not "last allowed through", or a key sitting at its quota
looks abandoned. And `resetApiKeyLastUsedThrottle()` now runs in `beforeEach` —
the throttle map is module state, and a window claimed by one test silently
throttled the next one's write.

126 test files / 1,499 tests pass; tsc clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
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.

There is no migration history, only db push

2 participants