Skip to content

feat: queues, partition sets, fix hazards, data-checked --prove, stale-migration guard, schema files as a source - #146

Merged
jasdeepkhalsa merged 11 commits into
mainfrom
feat/exceed
Oct 15, 2026
Merged

jasdeepkhalsa merged 11 commits into
mainfrom
feat/exceed

Conversation

@jasdeepkhalsa

@jasdeepkhalsa jasdeepkhalsa commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

One commit per change, each with its own tests.

Commit What it does
New dbdiff kinds, titled; dbdiff's warnings as findings Titles for the kinds dbdiff now compares (range types, default privileges, event triggers, rules, aggregates, statistics, text search, casts, foreign data) and for renames (Column renamed: public.people.name → full_name). A changed object of those kinds is modified, not named by the DROP that replaces it (which was critical). dbdiff's warnings (kinds it does not compare, renames it would not take) are read from its output.
Supabase Queues A queues check compares pgmq.meta. It creates through pgmq.create / create_unlogged, installing pgmq if needed, and drops through pgmq.drop_queue, which is held back as destructive (it drops the messages). Partitioned queues and a change of kind are manual. Before, a missing queue went unreported: its tables are pgmq's own, so the schema diff leaves them alone.
pg_partman partition sets A partitions check compares part_config registrations. It registers through create_parent with the source's settings, and updates differing settings in place. A changed control column, interval or type, and target-only sets, are manual.
Hazards on every fix Read from PostgreSQL's parse of each fix: blocks reads and writes, blocks writes, may rewrite, scans every row (with NOT VALID/VALIDATE advice), cannot run in a transaction, data loss. Tables the fix itself creates aren't flagged. Shown under each finding, and in --json and over MCP.
--prove checks the rows The copy gets the first SUPAFORGE_PROVE_ROWS (1000) rows of each table, piped with \copy. A kept table that loses rows, or whose values change in a column whose type didn't, fails the proof. Deletes and truncates in the fixes are expected. Tables the fixes rewrite on the copy are named.
Stale-migration guard migrate create records the target environment and a digest of its structure. migrate run refuses the first pending migration written against that environment if the target has changed since, unless --force.
Restore out of locks A restore that exceeds the server's lock table now says why, and offers max_locks_per_transaction or --no-transaction as the ways round it.
diff --source-dir Compares a directory of schema files (supabase/schemas) with the target. The files are loaded into a throwaway database on the target's server, on top of its platform schemas, with out-of-order statements retried. What the copy can't hold (pg_cron) isn't reported as extra. The throwaway database is always dropped.
--prove and generated columns The row check leaves generated columns out. Their values follow their expression, which a fix may mean to change. Before, changing a generated column's expression read as lost values and refused the migration.
--prove and held-back drops A table whose drop is held back keeps the sequence its serial column owns. The proof now accounts for that sequence along with the table. An unexpected sequence names the column that owns it.
Notices, not drift What the schema check couldn't compare is listed under its line, in the JSON beside its issues, and in the CI summary. It is neither counted nor scored, so a re-diff can reach zero. Publications are left out, because the Realtime check compares them. The README lists the kinds now covered.

Live-tested against Supabase and plain PostgreSQL:

  • Queues: created and usable; an extra queue was held back.
  • Partition sets: registered, and settings synced.
  • --prove: a 2,500-row retype proved with rows kept and the rewrite named.
  • Stale guard: refused after a manual change; ran with --force and when unchanged.
  • Restore locks: an exhausted restore was explained.
  • --source-dir: loaded out-of-order files, converged, and dropped the throwaway database.
  • Release smoke battery: every case passes, including new ones for column and table renames, default privileges, replica identity, statistics targets, range types, aggregates, rules, text search and event triggers, --prove with a generated column, queues and --source-dir.

🤖 Generated with Claude Code

jasdeepkhalsa and others added 8 commits October 15, 2026 07:55
…does not

dbdiff now diffs range types, default privileges, event triggers, rules,
aggregates, extended statistics, text search configurations and
dictionaries, casts, foreign servers, user mappings and foreign tables,
and renames columns and tables rather than dropping them. Each is titled
by what it is ("Cast missing: flag AS boolean", "Column renamed:
public.people.name → full_name"); a changed one is "<Kind> modified",
named from the statement that makes it rather than the DROP that
replaces it, which read as the object going and was marked critical.

dbdiff's warnings — kinds it does not compare, columns or tables it
would not take for renames — are now findings too, with nothing to
apply, so they are seen before an apply rather than missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pgmq makes each queue's tables and registers them as its extension's
own, so the schema diff — which leaves an extension's objects to the
extension — never saw a queue: one the target lacked went unreported.

A queues check now compares pgmq.meta by name and kind, creating a
missing queue with pgmq.create (or create_unlogged), installing pgmq
first where the target lacks it, and dropping an extra one with
pgmq.drop_queue — held back as destructive, since it drops the queue's
messages. A partitioned queue, whose pg_partman settings pgmq.meta does
not record, and a change of kind, are left as manual steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pg_partman's partitions are its own to make and drop, so the schema diff
leaves them alone; what makes a partition set is its registration in
part_config, written by create_parent(). A partitions check now compares
those: a set the target lacks is registered through create_parent, with
its settings (premake, retention and the rest) put as the source has
them, and a setting that differs is updated where it stands. How a set
is partitioned — control column, interval, type — cannot be changed in
place, so a difference there, and a set only the target has, are left
as manual steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fix can be correct and still take production down — an ALTER TABLE
holds a lock that stops every read and write, a column type change may
rewrite the table under it, a new CHECK scans every row — and a report
showed a one-line fix and an hour-long outage the same way.

Every fix now carries hazards, read from PostgreSQL's own parse of its
SQL with the lock levels PostgreSQL documents: blocks reads and writes,
blocks writes, may rewrite, scans every row (with how to avoid holding
the lock: NOT VALID, then VALIDATE), cannot run in a transaction, and
data loss. A table the same fix creates is not flagged. They show under
each finding in the report and are in --json and over MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itten

A schema can converge and the data not — a table recreated, a cast that
loses values — and the proof's copy held no rows, so none of that showed.

The copy now gets a sample of each table's rows first (the first 1000,
SUPAFORGE_PROVE_ROWS to change it, 0 for structure only), piped from the
target with psql's \copy, foreign keys set aside where the role may. After
the fixes, a table they keep must hold as many rows, with the same values
in each column whose type did not change — unless a fix deletes from or
truncates it — or the proof fails, naming the table. A table whose sample
would not load is listed as unchecked. A table whose storage the fixes
replaced on the copy is named as rewritten: on the real target, a long
lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as since changed

A migration file runs later than it is written — after review, after a
merge — against a target that may have moved on: a column added by hand,
another migration run. Applied then, it could fail half way, or succeed
and leave something wrong.

migrate create now records the target environment and a digest of its
structure in the file. migrate run checks the first pending migration
written against the same environment before running anything: if the
target has changed since, it says so and applies nothing, unless --force.
The migrations after it follow from it, so they are not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In one transaction, every table, index and sequence a restore creates
holds a lock until the commit, and the server keeps a fixed number of
them. A large project's restore ran out half way, rolled back, and said
only "out of shared memory". It now says why, and the two ways round it:
raise max_locks_per_transaction, or --no-transaction to commit as it
goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… target

Declarative schemas keep the desired state as SQL files (Supabase's
supabase/schemas). diff --source-dir loads them into a throwaway database
on the target's server — on top of the target's platform schemas, so a
reference to auth.users or a call to auth.uid() resolves, as --prove
does — and compares that with the target: schema, RLS and extensions
unless --check says otherwise. Files load in name order, and a statement
needing something a later file creates is tried again once the rest have
run. What the platform owns and the copy cannot hold (pg_cron lives in
one database per server) is not reported as extra. The throwaway
database is dropped afterwards, whatever happens.

New config field ignoreExtensions: extensions not reported as extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// so a sampled row need not find its parent sampled too; then the COPY,
// whose rows follow on the same stream until it ends.
writer.stdin.write(`\\set ON_ERROR_STOP 0\nSET session_replication_role = replica;\n\\set ON_ERROR_STOP 1\n${into}\n`)
reader.stdout.pipe(writer.stdin)
* loads. Verdicts are read from the target, and the throwaway database must
* be gone afterwards.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
jasdeepkhalsa and others added 3 commits October 15, 2026 12:21
The data half of a proof hashed every column of a sampled table, so a
fix that changes a generated column's expression read as values lost,
and the whole migration was refused. A generated column's values follow
its expression; only the columns it is computed from are compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A destructive drop held back without --allow-destructive leaves its
table on the target, and the proof accounted for the table but not for
the sequence its serial or identity column owns, so the whole apply was
refused. An unexpected sequence now names the column that owns it, and
a held-back drop of that table or column accounts for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dbdiff's warnings about kinds it does not compare were findings of their
own, so a database with, say, an operator class never read as converged:
a re-diff after an apply still found one, and a CI gate on any finding
never passed. They are now the check's notices — under its line in the
summary, in the JSON beside its issues, and in the CI summary — neither
counted nor scored. Publications are left out: the Realtime check
compares them.

The README names the kinds the schema check now covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasdeepkhalsa
jasdeepkhalsa merged commit c94f00b into main Oct 15, 2026
17 checks passed
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