Repository navigation
Releases
v0.5.2
A release that lets a caller watch a whole migrate run, adds a percent and an ETA to progress snapshots, gives progress-tracking callers drift-checked entry points for index adoptions, constraint validations and index renames, and stops a healthy copy-and-swap stream being ended by the server's WAL release cadence. Every command and every change that ran on v0.5.1 runs the same way.
Why v0.5.2 and not v0.6.0
For a v0.5.1 caller of the CLI, migrate, diffplan or executor, everything here is additive: new fields are omitempty, a nil tracker behaves exactly as before, and no existing executor signature changed. The one exception is the low-level pkg/decode slot-lag API: decode.SlotStatus.WithinLagCeiling gives way to decode.SlotLagWatch, which keeps state across readings, and an unkeyed SlotLagExceededError struct literal stops compiling because the type gains fields. That API is the copy-and-swap catch-up's own, and pg-sprite's applier is its only known caller. Read the Upgrade notes if you call pkg/decode directly or pin the progress format_version.
Highlights
Progress
migrate.Options.Progressreports a whole run to one tracker —Runis one step andRunDesiredhas one step per planned statement. Within a step the detail shows what is running now: each statement of a safer sequence, a concurrent index build with the server's phase and counters, and a copy-and-swap'scopy,catch-upandchecksumphases. New step-scoped entry points (executor.RunStep,ExecuteNativeAsStep,RunSequenceAsStep,ExecuteCreateAsSteps) andschemachange.RunOptions.Tracker/Stepreport into a tracker the caller already started. (#353)- A derived
percentandeta_ns— worked out on each poll for the copy, the checksum pass and a concurrent index build (one band perpg_stat_progress_create_indexphase), never 100 while a step runs, and omitted where no total exists. The checksum pass now reportswork.rows_to_hash. (#352) - Drift-checked progress entry points —
ExecuteNativeAdoptingWithProgress,ExecuteNativeValidatingWithProgressandExecuteIndexRenameWithProgressare the progress-tracked counterparts of the adopting and validating sequence paths andExecuteIndexRename. Each re-checks the planned definition under its lock and refuses withadopted-index-drift,validated-constraint-driftorrenamed-index-drift.ExecuteNativeWithProgresscarries no planned definition, so a caller that runs diff-plannedADD CONSTRAINT ... USING INDEXorVALIDATE CONSTRAINTstatements should switch. (#354)
Copy-and-swap
- The slot-lag ceiling is judged on unconfirmed WAL — the stream's own lag (
pg_current_wal_lsn() − confirmed_flush_lsn) overSlotLagCeilingends the run at once with causestream-behind; retained WAL over it ends the run with causewal-retainedonly once it has stayed over for the server's release interval plus one catch-up cycle. A healthy stream under steady writes is no longer ended mid-copy because the server releases WAL only every 15 s. (#336)
Security and CI
- Go 1.26.11 and
klauspost/compressv1.20.4 clear 31 reachable advisoriesgovulncheckreported atv0.5.1. (#349) - Every PR, and
maindaily, is scanned for reachable vulnerabilities. (#350)
Upgrade notes
- Progress report
format_version6 → 7. Additive:detail.percent,detail.eta_nsandwork.rows_to_hash. A consumer pinning 6 must reject or add support for 7; the history is in docs/progress-report.md. Plan (19), lint (10) and suggest (5) are unchanged, as is the exit-code ladder (0/1/2/3). pkg/decode(breaking).SlotStatus.WithinLagCeiling(c)is removed: builddecode.NewSlotLagWatch(c, grace)once per slot and callJudge(status, now)on each reading in order (not safe for concurrent use).SlotStatusgainsUnconfirmed;ErrSlotLagUnknownalso covers an unmeasurable unconfirmed WAL;SlotLagExceededErrorkeeps its name forerrors.Asand gainsCause(SlotLagStreamBehind,SlotLagWALRetained) andUnconfirmed. A volume needs headroom above the ceiling of about the write rate times the grace.- Library API. New:
migrate.Options.Progress,executor.RunStep,executor.ExecuteNativeAsStep,executor.RunSequenceAsStep,executor.ExecuteCreateAsSteps,executor.ExecuteNativeAdoptingWithProgress,executor.ExecuteNativeValidatingWithProgress,executor.ExecuteIndexRenameWithProgress,schemachange.RunOptions.Tracker/Step,decode.SlotLagWatch,decode.NewSlotLagWatch,decode.SlotLagCause,decode.RestartReleaseInterval. Setting bothOptions.ProgressandCopySwap.Copier.Trackeris refused before anything runs. - Go toolchain. The module's
godirective is now1.26.11, so a consumer module needs Go 1.26.11 or later.
Docs
- docs/progress-report.md — obtaining progress, percent and ETA, format version 7 (#352, #353)
- docs/schemabot-integration.md — the tracked entry points, the slot-lag causes in the runbook (#336, #353, #354)
- docs/copy-and-swap-design.md, SAFETY.md, docs/invariants.md (ST-3) — the two lag measures and the grace (#336)
Contributors
Full changelog: v0.5.1...v0.5.2
v0.5.1
A release that widens what the declarative door and migrate will run, hardens the copy-and-swap route under sustained writes, and moves to pgx v5.12.0. Nothing a v0.5.0 user relies on stops working: the copy-and-swap route is still a preview you open per run, and every change that ran before runs the same way.
The declarative door now covers the shapes Supabase projects actually write: a new table created together with its row security in one transaction, table changes in a file that declares row security, policies that read another table, and extension types named unqualified. migrate runs ALTER TYPE ... ADD VALUE natively, and enum-typed columns are published as supported. On the copy-and-swap route, the decoding stream keeps itself alive while the catch-up is busy, a deadlocked flush is retried, and the live verify pass settles chunks row by row. A failed verdict now carries the server's SQLSTATE and message. Plan reports move to format_version 19; lint (10), progress (6) and suggest (5) are unchanged, as is the exit-code ladder (0/1/2/3).
Why v0.5.1 and not v0.6.0
Every change here is additive for a v0.5.0 caller: new JSON fields are omitempty, the new reason code and library symbols add arms rather than change them, and statements that used to be refused now run instead of the reverse. The one reclassification — conversion drift seen by verification or the catch-up is now refused with copy-conversion-drift instead of an unclassified failed — makes those stops match what the copier already reported. Read the Upgrade notes if you route on verdict outcomes or read connection strings with ssl=true.
Highlights
Declarative door: row security and extension schemas
- A missing table is created together with its row security —
migrate --desiredon a row-security file whose table does not exist creates the table, enables RLS, and creates its policies in one transaction, so no role that new tables are granted to by default (on Supabase,anonandauthenticated) can reach it in between. The verdict reportstable_created: true. (#318) - Table changes run in a file that declares row security — when the live row security matches the file and only the table definition differs, the file plans and runs like one without row security, through
diffplan.PlanWithRowSecurityandmigrate.RunDesiredWithRowSecurity; it never writes a policy or a row security setting. (#332) - Policies may read schema-qualified tables — a membership-check policy that reads another table is admitted when the file names that table schema-qualified. An unqualified, CTE, or database-qualified name is still refused (
ErrPolicyRelationDependency), as is a table carrying the policy's own table name (ErrPolicyReadsOwnTable). (#320) --extension-schema/WithExtensionSchemas— names schemas such asextensionsthat a desired file's unqualified extension types, functions, and operators resolve from, searched after the target schema andpublicso they never shadow either; plans still print each such name qualified. Accepted ondiffandmigrate --desired, including files that declare row security, where it widens the table definition only. (#319, #333)Options.WithoutAcknowledgements()— returns the policy with every typed acknowledgement cleared, so an adapter can handRunDesiredthe desired-state view of theOptionsit handsRun, derived from the irreversible-operations registry instead of cleared field by field. (#326)
migrate and the capability matrix
ALTER TYPE ... ADD VALUEruns natively without locking a table — under the brief lock budget, after checking that the name resolves to an enum, the role hasUSAGEon its schema and membership in its owning role (elseinsufficient-privileges, with the grant that fixes it), and confirming before commit that the labels written belong to that type (else the new permanentenum-type-driftcode, nothing added). (#334)- Enum-typed columns on plain tables are supported on both front doors — adding one (catalog-only with a constant default), changing its default, indexing it, and creating a table with one, with the enum in the table's schema or
public. (#347)
Copy-and-swap fixes
- The decoding stream answers the server while the catch-up is away from it — a stream-owned heartbeat reports the already-confirmed position whenever no report has left within a quarter of
wal_sender_timeout, so a slow flush, snapshot, or slot read, or a pool busy with copy workers, no longer gets the stream ended mid-copy withunexpected EOF. The slot never moves past what the caller confirmed. (#331) - A flush rolled back as the deadlock victim is requeued — a catch-up flush the server cancels with
40P01is flushed again on the next cycle, bounded byCatchupOptions.DeadlockAttempts, matching what a copier pass already did. (#298) - The live verify pass settles a differing chunk key by key — a chunk settles once every one of its rows has caught up, rather than only when no write touched it between two reads, so a chunk under sustained writes no longer ends
UnresolvedError{Cause: hot}. (#322) - Conversion drift is
copy-conversion-driftfrom every phase — drift in aUSINGexpression's functions or operators noticed by verification or the flusher now refuses with the same environmental cause the copier uses, and a refused verdict from a blocked-writes run still says the table is fenced. (#287) - A stopped catch-up wait says what it waited on —
Runreturns*schemachange.CatchupWaitErrorwith the catch-up's status and how far the capture was short of its marker, instead of a bare context error. (#292) - Catalog reads are pinned to
pg_catalog—checkpoint.Store.Ensurequalifies every relation, function, type, and operator it reads, so a caller's own pool with a shadowingsearch_pathcannot change what it finds. (#308)
Verdicts and schemadiff
- A failed verdict carries
sqlstateandserver_error— read from the first*pgconn.PgErrorin the failure's chain; the server'sDETAILline stays out because it can quote row values. (#313) - A desired expression is compared across replays — a check whose text PostgreSQL prints differently each time it is reparsed (nested row comparisons) no longer plans a drop and an add on every export, which also unblocks unrelated changes
migrate --desiredrefused alongside it. (#314) - Index constraints one
CREATE TABLEwould merge are refused —pullrefuses a table with twoPRIMARY KEY,UNIQUE, orEXCLUDEconstraints declared the same way (ErrUnrenderableMergedConstraints) instead of writing a file that silently rebuilds the table without one, and a desired file whoseCREATE TABLEthe server would merge is refused asunsupported-statement(ErrDesiredConstraintMerged). (#315)
Connections
- pgx
v5.12.0, with the DSN scanners following its libpq grammars — pg-sprite's own reading of a connection string'ssslmode, which decides between honoring the caller's mode and enforcing verify-full on RDS/Aurora, now agrees with pgx on every keyword and URL rule, checked by a differential fuzz target. (#344) - The session-bound read-back no longer takes a catalog lock —
NewPoolreadslock_timeoutandstatement_timeoutback withSHOW, so a catalog lock held by another session's DDL can no longer cancel the read and refuse a correctly bounded session. (#335)
Testing and CI
make load-proofdrives the built binary through a copy-and-swap over a large table under a concurrent write workload, judged by timing-free oracles. (#321)- Every irreversible-operations registry entry and CLI acknowledgement flag is run against its gate on a real server; the registry's completeness is stated as invariant RF-12. (#288, #291)
- Pinning tests for the resume clear's bounds and the table lock lookup's
grantedpredicate; the release workflow's test steps run in a read-only job; the Supabase fixture's service pins are refreshed. (#312, #325, #305, #327)
Upgrade notes
- Plan report
format_version18 → 19. Additive: the report-levelextension_schemasfield, present when--extension-schemais given and covered by the fingerprint. A consumer pinning 18 must reject or add support for 19; the history is in docs/plan-report.md. - Verdict fields.
sqlstateandserver_erroron failed verdicts;table_createdon a desired-state run that created the table with its row security. One new executor code,enum-type-drift(permanent). No new outcome or refusal reason. - Conversion drift outcome. A copy-and-swap run stopped by conversion drift during verification or the catch-up now reports
outcome: refusedwithcopy-conversion-drift, where it used to report an unclassifiedfailed. Route on the cause, as the copier's refusal already required. - Changes that were refused now run.
ALTER TYPE ... ADD VALUE; a row-security file whose table is missing; table changes in a file that declares row security; policies reading schema-qualified tables; extension types named unqualified with--extension-schema. - Connection strings. In a
postgres://URL the last repeated parameter now wins andssl=truemeanssslmode=require, as pgx applies them; on RDS/Aurora a URL withssl=trueis honored asrequirerather than forced to verify-full. Keyword strings follow libpq's backslash rules. Strings pgx rejects are rejected as before. - Library API. New:
migrate.RunDesiredWithRowSecurity,diffplan.PlanWithRowSecurity,migrate.Options.WithoutAcknowledgements,statement.DesiredSchema.WithExtensionSchemas,statement.DesiredWithRowSecurity.WithExtensionSchemas,schemachange.CatchupWaitError,applier.CatchupOptions.DeadlockAttempts. - Dependencies.
github.com/jackc/pgx/v5v5.10.0 → v5.12.0 (wi...
v0.5.0
A breaking release for anyone who ran copy-and-swap on v0.4.1. The copy-and-swap route — the one a general ALTER COLUMN ... TYPE takes — is now a preview that stays closed until the run opens it: pg-sprite migrate --copy-and-swap on the CLI, CopySwapOptions.Enabled as a library caller. Without it a statement the planner routes there is refused before the route's preflight runs or anything is written, with exit 2, reason: copy-and-swap-refused, cause: copy-and-swap-not-enabled, class: capability-boundary. Every native change runs exactly as before. The route closes because the engine is complete end to end but has not yet been exercised under production-like load; that proof is the next piece of work, and the gate opens when it lands.
Around the gate, this release fixes four ways a copy-and-swap run could stop or confirm the wrong thing, types the outcome of a data-fault abandon, closes the last acknowledgement gap on the declarative door, and adds five planner refusals for changes PostgreSQL itself would reject after taking the table's lock. Plan reports move to format_version 18 and lint to 10; progress (6) and suggest (5) are unchanged, as is the exit-code ladder (0/1/2/3).
Why v0.5.0 and not v0.4.2
A v0.4.1 binary runs copy-and-swap whenever the planner routes a statement to it. The same command on this release exits 2 unless --copy-and-swap is on the command line, and a library caller who set CopySwapOptions.Connection alone still compiles and now gets a refusal instead of a run. That is a behaviour break, and in the 0.x line a minor bump is where breaking changes go, as the v0.4.1 notes said. Read the Upgrade notes before taking this release.
Highlights
Copy-and-swap is a preview route you open per run
--copy-and-swap/CopySwapOptions.Enabled— the opt-in. The zero value keeps the route closed; the CLI flag cannot be combined with--dry-run. A refused statement touched nothing: target resolution and planning read the catalog to route it, the route's preflight never runs. Every runnable example in the docs and the demo tour now carries the flag. (#301)abandoned: trueon the verdict — a failed copy-and-swap verdict says whether the Runner discarded the run on a data fault (nothing left behind: rerun the corrected statement plain) or could not (the checkpoint row and the run's hold on the source stand: rerun with--restart). Both carrycode: data-fault; the typed field is what an adapter branches on instead of the detail's wording. (#302)
Copy-and-swap fixes
- A chunk pass rolled back as a deadlock victim gets another pass — when the copier and the catch-up each insert a unique value the other holds uncommitted, the server breaks the cycle by cancelling one; a cancelled copier pass is now retried like a unique-value collision instead of stopping the run. (#295)
- Reconcile clears the shadow above a partial watermark — a slot-loss reconcile now removes the shadow rows the earlier run committed but never checkpointed before it recopies, so a unique value that moved out of one of them can no longer collide with the recopy on every attempt and leave the run in
reconciling. (#279) - The table lock holder lookup is scoped to the session's own database —
TableLockSession.ConfirmandLookupTableLockHolderreadpg_lockswith adatabasepredicate, so a lock held under the same key in another database of the same server no longer confirms as this database's lock. (#284) RunDesiredrejects every acknowledgement —RestartandAcceptBlockingare now rejected withErrRestartNotSupportedandErrAcceptBlockingNotSupported, the wayForce,AllowDestructive, andBlockWritesalready were, so a value left on an embedder's configuration cannot discard a stopped run's checkpoint on the first copy-and-swap statement of a later plan. One registry inpkg/migratenow lists every irreversible operation with the acknowledgement that lifts its refusal, and a unit test fails CI when a new acknowledgement or destructive shape arrives without an entry. (#300, #282)- The pool floor is proven, not asserted — two live tests show that with every copy worker holding a connection, a pool of
CopySwapMinConnsstill flushes a concurrent source write into the shadow and a pool of only the workers does not;docs/schemabot-integration.mdstates which part of the floor each connection carries. (#304)
Planner: refuse what the server would refuse after taking the lock
- Retyping or dropping a column a row-level security policy reads is refused with
dependent-policy, naming the policies to drop and recreate around the change. The dependent-view read becomespreflight.ReadColumnDependents, which reads views and policies per column in one statement. (#303) - Retyping or dropping a column a trigger's
UPDATE OFlist orWHENcondition names, a generated column reads, or a partition key uses is refused withdependent-trigger,dependent-generated-column, orpartition-key. PostgreSQL 14 would otherwise drop the generated column silently along with the dropped column. (#309) - Adding a
NOT NULLcolumn with no default to a table that holds rows is refused withnot-null-without-default; the plan suggests a constant default or a backfill first. On an empty table it stays catalog-only. (#310) - Dropping the primary key of a published table under
REPLICA IDENTITY DEFAULTis refused withreplica-identity, as it already was underUSING INDEX. Publication membership is read from the publication catalogs in the transaction's snapshot (FOR TABLE,FOR ALL TABLES,FOR TABLES IN SCHEMAon 15+, partitions through a parent); the executor's re-check under the drop's lock reads the same facts. (#307) - A
VALIDATE CONSTRAINTinside a transaction block that already holds a blocking lock on the table is flagged bylintasvalidate-in-transaction, followingCOMMIT, savepoints, nestedBEGIN,REINDEX TABLE,TRUNCATE, andLOCK TABLEthe way the server does. The planner treatsVALIDATEas the online idiom only beside operations that takeSHARE UPDATE EXCLUSIVEalone; beside anything else the statement is refused asrewrite-requiredand told to split. (#311) schemadiffkeeps a unique index a foreign key references — a plainCREATE UNIQUE INDEXnamed by a foreign key'sconindidwas hidden from the live model, so a desired file that matched the table planned the index again and failed with42P07. (#306)
CI
- The integration matrix runs the race detector on the newest PostgreSQL major only,
pkg/schemachangeruns on its own runner in that job, and thegoneLocktest helpers release the dead session without waiting out the keepalive. (#297, #299, #296)
Upgrade notes
- Copy-and-swap needs an explicit opt-in. CLI: add
--copy-and-swapto everymigrateinvocation that may route to the shadow table; it cannot be combined with--dry-run. Library: setmigrate.CopySwapOptions.Enabled;Connectionalone no longer runs. Without it, exit 2 withcopy-and-swap-refused/copy-and-swap-not-enabled, raised before the route's preflight. Orchestrators that leave the route closed get this refusal for every statement the router assigns to it and run every native statement as before. - A run stopped under
v0.4.1needs the flag too. Resuming it, starting it over with--restart SCHEMA.TABLE, or cleaning it up all go through the route, so they all need--copy-and-swap. A stopped live run keeps its slot, and the slot keeps WAL, until you do one of them. - Plan report
format_version15 → 18. Additive: thedependent-policy,dependent-trigger,dependent-generated-column,partition-key, andnot-null-without-defaultreasons; thedependent_policies,dependent_triggers,dependent_generated_columns, andpartition_key_tablesdecision fields, each present whenever its dependents block the change, whatever the decision's reason. A consumer pinning 15 must reject or add support for 18; the history is in docs/plan-report.md. - Lint
format_version6 → 10. The five reasons above join the embedded Reasons vocabulary (lint never reports them; they are judged from live facts) and thevalidate-in-transactioncode is new. - Verdict fields.
abandoned(bool, failed copy-and-swap verdicts only) and the fourdependent_*/partition_key_tableslists on a refused verdict. No new outcome, refusal reason, or executor code; exhaustive switches need no new arm. - Changes that previously ran now refuse. Dropping the primary key of a
REPLICA IDENTITY DEFAULTtable a publication publishes refuses even under--allow-destructive; retyping or dropping a column a policy, trigger, generated column, or partition key depends on refuses;ADD COLUMN ... NOT NULLwithout a default refuses on a table with rows; anALTER TABLEthat validates a constraint beside a blocking operation refuses asrewrite-required. PostgreSQL refused each of these at the server before, after taking the lock; the difference is that the dry run now says so. - Library API.
migrate.ErrAcceptBlockingNotSupportedis new andRunDesiredreturns it, withErrRestartNotSupported, for the acknowledgements it rejects;preflight.ReadColumnDependentsreplaces the dependent-view read;statement.Opcarries the lock predicate the planner and lint share. No dependency changes.
Docs
- docs/copy-and-swap.md — the preview gate: what is refused, what is read, how to open the route (#301)
- docs/schemabot-integration.md —
CopySwap.Enabledas the adapter's policy, theabandonedfield, the pool floor connection...
v0.4.1
A major release — the largest pg-sprite has shipped, and a breaking one. It ships the copy-and-swap engine: a statement PostgreSQL can only run as a table rewrite — a general ALTER COLUMN ... TYPE today — is now executed online by building a shadow table, copying and verifying it in chunks, replaying live writes from a logical replication slot, and swapping it in under a bounded lock. Around it, migrate applies declarative --desired files, refuses destructive and app-breaking changes until a typed acknowledgement names the table, and runs a two-stage stop with checkpointed resume. Plan reports move to format_version 15, progress to 6, lint to 6, suggest to 5; nine executor outcome codes and two refusal reasons are added; statements and connections that v0.3.3 accepted now refuse by default (see Upgrade notes). The exit-code ladder (0/1/2/3) is unchanged.
The version is 0.4, not 1.0, because pg-sprite is still pre-1.0: under semantic versioning a 0.x minor bump is where breaking changes go, and v0.3.3 → v0.4.x is that signal. Read the Upgrade notes before taking this release.
Why v0.4.1 and not v0.4.0
There is no v0.4.0 release. The v0.4.0 tag was pushed, but its release workflow timed out in the test step before GoReleaser ran, so no GitHub release and no binaries were ever published for it. Because the Go module proxy had already recorded the tag, it was left in place rather than moved: a moved tag would not reach module consumers and would break go.sum for anyone who had fetched it directly. #294 fixed the workflow step, and the first tag cut after that fix is v0.4.1. Treat v0.4.1 as the release that v0.4.0 was meant to be; it is v0.4.0 plus #274, #283, #286, #290, #293, and #294. Do not depend on v0.4.0.
Highlights
Copy-and-swap: a type rewrite runs online
- Router and front door — the planner routes a general
ALTER COLUMN ... TYPEto copy-and-swap instead of refusing it,migratedrives the route to one verdict, and a run that commits reports the newexecuted-via-copy-and-swapoutcome. Static inadmissibility is reported in--dry-run, and renames never reach the route. (#184, #183, #208, #231) - Shadow build by execute-and-introspect — the shadow table is created under the table lock, the statement is applied to the empty shadow, and the resulting shape is introspected from the catalog; nothing is derived by AST transformation. Indexes are paired across the retyped column by definition, never by name, and renamed back at swap. A new identity or serial column, a new column the copy cannot fill, or a shadow left without the source's primary key refuses at the build. (#117, #121, #137, #138, #271, #235, #274)
- Parallel chunked copy — a keyset chunker over the proven primary key copies the table in bounded transactions, carries a retyped column through the statement's own
USINGexpression, pins what that conversion resolves to, and retries a chunk whose insert collides with a unique index — committing the rest of the chunk around the held-off rows and retrying only those. (#127, #128, #144, #167, #243, #239, #283) - Chunk checksum with live repair — every chunk is compared between source and shadow in one snapshot with SHA-256 digests, under an explicit divergence policy that mints the proofs the cutover gate consumes; a differing chunk is recopied against the catch-up and compared again when a moved unique value collides. (#134, #136, #143, #161, #191, #244)
- Live writes through a logical replication slot —
pkg/decodecreates, streams, inspects, and drops the route's slot with caller-confirmed feedback;pkg/applierbuffers changes per key, drains them against the copier's position, flushes column-wise with a unique-move fallback, and waits for a delivered commit until the source shows it visible. A slot-lag ceiling and an orphan-slot reaper bound what the server has to retain. (#150, #151, #148, #149, #152, #156, #241, #265) - Gated cutover and bounded swap — the ST-5 fidelity checklist mints
CutoverReady; the swap runs atREAD COMMITTEDunder a bounded lock, hands identities and sequences across, checks the write fence, and re-derives the swapped-table proof from the catalog for a post-swap resume.--keep-oldretains the source under its_oldname for a rollback window. (#137, #139, #146, #187, #252) - Offline mode —
--block-writes=SCHEMA.TABLEfences the table with a trigger instead of streaming through a slot, for servers without logical decoding; every write that reaches the fence fails with SQLSTATE55006until the swap. (#163, #260, #262)
Stop, resume, start over
- Checkpoint — one resume row per target in the engine-owned
pgsprite.pgsprite_checkpointtable, written with a guarded upsert and loaded through a typed identity check; the row derives the table shape from its columns and carries the shadow proof. A row in an unknown phase reads as incompatible, not as an invariant violation. (#141, #162, #216) - Two-stage stop and
--restart— the first Ctrl-C /SIGTERMsaves where the copy reached and exits; a resume picks the run up from the checkpoint;--restart=SCHEMA.TABLEdiscards the leftover shadow, checkpoint, and fence or slot under the run's lock and starts over. (#227) - Data-fault abandon — a run that stops on a data fault (a value the
USINGconversion cannot produce, a unique collision the repair cannot settle) abandons itself and leaves nothing behind; the demo tour walks the fault and the rerun. (#228, #266, #267) - Slot-loss reconcile — when the server has dropped the slot, the run reconciles from the checkpoint instead of failing, keeping the publication when a reconcile replaces the slot. (#159, #236, #293)
Acknowledged risk is typed and named
--allow-destructive=SCHEMA.TABLE—DROP COLUMN,DROP CONSTRAINT,ALTER COLUMN ... DROP NOT NULL, and aDROP INDEXrun under--accept-blockingrefuse with reasondestructive-changeuntil the acknowledgement names the resolved table; the planner decides destructiveness for every operation shape explicitly, and a refusedDROP INDEXshows the index's live usage. (#253, #273, #276, #277, #259)--force=SCHEMA.TABLEcovers app-breaking renames — a column rename is refused asrewrite-requiredunless the override names the table, on the CLI and through the library alike. (#230)--accept-blocking=SCHEMA.TABLE— the bounded accepted-blocking path reserved in v0.3.3 is now on the CLI for a single-relationDROP INDEX,REINDEX INDEX, orREINDEX TABLE(exit 3);DROP INDEX ... CASCADEis refused. (#115, #189)
Declarative schema changes apply through migrate
migrate --desired=FILE --schema=NAME— converges one table from a desired-state file through the same planner and executor as--alter, refuses statically inadmissible steps in dry run, and reports a run executed only when a fresh plan is empty. (#126, #217)- Row security —
schemadiffexports, compares, and shows reviewable row-security changes; the executor applies them atomically and binds execution to the reviewed SQL. (#113, #118, #119, #123, #130) - Model coverage — foreign keys appear in the plan and refuse for apply (
declarative-foreign-key); table storage parameters, column-owned sequence declarations, and changed generated-column expressions are modelled; a liveNOT VALIDconstraint is validated in place; a live unique index is adopted for a new key constraint; the plan resolves against the target schema's own types and functions. (#169, #176, #177, #170, #195, #180, #179)
Planner and native paths
- Server-proven rewrite decisions — the server proves whether adding a column rewrites the table; an array type change routes to a rewrite unless the element is unchanged; the relabel route refuses type shapes it cannot fully read; column statistics are refreshed after a native type change. (#181, #196, #220, #198)
- New refusals — dropping what the replica identity index keys on, retyping or dropping a column a view reads, and cascading drops are refused with typed reasons; an unnamed
CHECKorFOREIGN KEYis named so its online sequence can be built; every attribute of a primary key or unique constraint is carried into its online sequence. (#178, #223, #219, #197) ALTER INDEX ... RENAME TO— parsed as its own kind and run natively for a plain index. (#238, #248)lint— flags aCONCURRENTLYoperation inside a transaction block. (#222)diff— reports an index left invalid by an unfinished build as an advisory rather than planning its drop. (#249)
Connections and the server boundary
- Session affinity is required — statement-mode poolers refuse with
ErrNoSessionAffinity; a copy-and-swap connection on another database refuses; the pool is sized for copy-and-swap and one too small refuses; session timeouts below one millisecond refuse. (#256, #229, #257, #201) - TLS and host detection — RDS host detection and
sslmodediscovery are hardened, and every connection attempt is verified when--ca-certis set. (#120, #186) - Catalog reads are
pg_catalog-qualified — every introspection and decision read acrosspreflight,executor,migrate,schemadiff, andschemachangenamespg_catalogexplicitly, so a hostilesearch_pathcannot redirect them; the scratch build's stand-in index and foreign-key reads are pinned under the build's ownsearch_path, and every functiondbconn.TerminateBlockerscalls is qualified for embedders who run it under their ownsearch_path. (#202, #204, #205, #207, #209, #210, #211, #213, #214, #290, #286) - Per-table advisory locks — concurrent runs against different tables no longer serialise on one lock. (#116)
- Release workflow — the tagged tree is tested against one long-lived database, the same shape as the CI matrix, instead of per-test containers. (#294)
Observability
- **Pro...
v0.3.3
A patch release that gives every refusal a typed routing class, publishes the support matrix from the binary, lands the bounded accepted-blocking library path, and lets greenfield tables be created under a named owner. Plan reports move to format_version 5 with additive fields; five outcome codes are added; the refusal-reason vocabulary and every existing library signature are unchanged. The accepted-blocking path is library-only — the CLI has no flag for it in this release.
Highlights
Refusals become a typed routing contract
verdict.Refusal— every refusal is constructed from a validatedClass,Reason, and, exactly forno-online-safety-problem,Owner;Classes,Owners,ParseClass,ParseOwner, the five class constructors,Verdict.WithRefusal, andVerdict.Refusalexpose the closed contract. Classification is keyed on typed causes or refusal sites rather than reason prose, and unknown or incoherent combinations fail closed under RF-7. Plan and desired-state JSON carryclassandowner; plan reportformat_version3 → 4. (#97)- Replay and docs pin the emitted vocabulary — replay expectations consume the engine-emitted class and optional owner, reject run-scoped classes, validate the complete manifest before starting the harness, and size output from its rows. Documentation guards compare every refusal reason, class, owner, progress format version, and exit-code row in both directions. (#101, #111)
One embedded capabilities matrix
pkg/capabilities— the embedded 53-row YAML matrix is the source for the generated support tables.Area,Tier,StatusMark,EnginePath,FrontDoorStatus,FrontDoors, andRow, withLoad,Rows,Validate, andRenderDocument, expose validated machine-readable claims tied toverdict.Reasons();make gen-capabilitiesrenders the marked regions and the CI/release gate fails without modifying the working page when they drift. (#98, #100)pg-sprite capabilities— a new offline command prints the exact matrix embedded in that binary as an aligned terminal table or stable--json, whose top-level object carries the binaryversionandcapabilitiesrows. The capabilities guide adds canonicaljqrecipes that select by fields or ID, never array position. (#99, #104)
Accepted blocking is explicit, bounded, and distinguishable
verdict.AcceptedBlockingDecision/AcceptedBlockingEligible— a closed registry admits only one-relationDROP INDEX,REINDEX INDEX/REINDEX TABLE, and the blocking partitioned-parent index build represented by the same in-process typed refusal proof. Site-keyed eligibility deliberately fails closed after a JSON round trip; plan statements exposeblocking_passthrough_eligible, moving plan reportformat_version4 → 5. (#105)executor.ExecuteAcceptedBlocking— runs one admitted blocking index statement in an engine-owned transaction under an explicit non-zeroBlockingBudget{LockTimeout, StatementTimeout}and returnsBlockingReport.*BlockingExecutionErrorseparates a server failure from*BlockingOutcomeUnknownError; lock exhaustion executes nothing, while a lost outcome requires catalog inspection. AB-1 and AB-2 record both bounds and the no-retry lock-budget rule. (#107)verdict.OutcomeExecutedWithoutOnlineSafety—Verdict.WithAcceptedBlockingretains the refusal identity and explicit budgets after a commit;ErrAcceptedBlockingmaps it to reserved process exit 3 so it cannot look like online-safe exit 0. The four-code ladder is pinned across the public docs. This release provides the library primitive and plan eligibility only; no CLI flag invokes it. (#108, #103, #109)
Greenfield tables can be born under a named owner
migrate.Options.CreateOwner— when non-empty, every bounded create step runsSET LOCAL ROLE <owner>before its SQL, so the table, serial sequences, indexes, and default privileges use that owner. The option is one scalar owner perOptions; a per-schema map is deferred until a real target needs it. Empty keeps the existing connected-role path unchanged. (#114)preflight.CheckCreatePrivilegesAs— proves that the owner exists, the connected role can assume it, and the owner has schemaUSAGEandCREATE;ErrCreateOwnerNotFoundis a typed insufficient-privileges refusal. The gate usespg_has_role(..., 'MEMBER')(plus PostgreSQL 16's SET-option check), notUSAGE, so aNOINHERITengine role is admitted.CreationRole.Owner()andSetsRole()separate the intended owner fromRole(). (#114)- Owner verification after commit —
executor.ErrCreateOwnerMismatch,ErrCreateOwnerUnverified, and*CreateOwnerMismatchErrorfail closed when the catalog disagrees or cannot be read. The executor never repairs ownership withALTER ... OWNER; invariant ST-9 requires the committed owner to be proven. (#114)
Connection and compatibility boundaries
dbconn.NewPoolpreserves execution bounds through poolers — lock and statement timeouts are sent as startup parameters and applied on every physical connection, then read back.ProveSessionAffinity,ErrSessionSettingsDiscarded, andErrNoSessionAffinityrefuse connections that discard settings or rebind between server sessions; session-mode pooling remains supported. (#81)- Supabase compatibility harness — a pinned local stack exercises native schema changes, API and pooler boundaries, Realtime continuity, privilege failures, and readable DDL cases in CI; the public guide documents direct versus pooled connections, supported workflows, and tested limits. PgBouncer test readiness now waits for a bounded login and query through the pooler rather than only an open port. (#106, #112)
- Concurrent-index executor layout — the previous monolithic implementation is split into focused
cic_*files for errors, budgets, builds, invalid-index handling, and backend classification, with no behavior, identifier, or signature change. (#110)
Upgrade notes
- Plan report
format_version3 → 5. Version 4 addsclassandownerto refused statements and aggregate target-dependent refusals; version 5 addsstatements[].blocking_passthrough_eligibleto every refusal. All are explanatory and excluded from the fingerprint. A consumer pinning version 3 must reject or add support for 5. - Five new executor outcome codes, 28 → 33.
blocking-outcome-unknown(non-permanent),invalid-blocking-budget(permanent),unsupported-accepted-blocking(permanent),create-owner-mismatch(permanent), andcreate-owner-unverified(non-permanent) appear inexecutor.Codes()andCode.Permanent(). Exhaustive consumers need five arms. - New verdict outcome and process exit.
executed-without-online-safetyand exit 3 mean an explicitly accepted blocking statement committed under both budgets. Exit 0 remains online-safe success, 1 failure or usage error, and 2 refusal with nothing committed. The CLI path is reserved but not wired in this release. - Library API additions, no removals. New in
verdict: the refusal class/owner/proof APIs, accepted-blocking eligibility APIs,OutcomeExecutedWithoutOnlineSafety,ExitCodeAcceptedBlocking,ErrAcceptedBlocking, andErrInvalidAcceptedBlocking. New inexecutor:BlockingBudget,BlockingReport,BlockingExecutionError,BlockingOutcomeUnknownError,ExecuteAcceptedBlocking, the create-owner errors, andCreateOwnerMismatchError. New inpreflight:CheckCreatePrivilegesAs,ErrCreateOwnerNotFound,CreationRole.Owner, andCreationRole.SetsRole. New inmigrate:Options.CreateOwner. New packagecapabilitiesexports the typed matrix and renderer. New indbconn:ProveSessionAffinity,AdvisoryLockHolder,ErrSessionSettingsDiscarded, andErrNoSessionAffinity. No exported symbol was removed and no existing function signature changed. - Pooled connections are checked more strictly.
dbconn.NewPoolnow needs a second connection while proving affinity and refuses transaction-mode endpoints or sessions that did not retainlock_timeoutandstatement_timeout. Use a direct or session-mode endpoint with one spare server connection. - No refusal-reason vocabulary change. Refusals now carry one of five classes and, where required, one of four owners; consumers should route on those typed fields rather than rebuilding classes from reason text.
- Create-owner scope is deliberately scalar. One
migrate.Optionsvalue names one create owner for its greenfield target. A per-schema owner map remains deferred until a concrete multi-schema caller requires it.
Docs
- docs/refusal-classes.md — the shipped five-class routing contract and owner vocabulary (#97)
- docs/capabilities.md — generated support tables, the offline command, and query recipes (#98, #99, #104)
- docs/capabilities-contract.md — the embedded YAML and stable JSON contract (#98, #99, #100)
- docs/lock-budgeted-passthrough.md — eligibility, budgets, interruption semantics, and the four-code exit ladder (#102, #103, #105, #107, #108)
- docs/plan-report.md — format versions 4 and 5, refusal class/owner, and blocking eligibility (#97, #105)
- docs/execution-model.md — accepted-blocking and create-owner outcome codes (#107, #114)
- docs/engine-role.md — named-owner membership, schema privileges, and
NOINHERITroles (#114) - [docs/invariants.md](https://github.com/block/pg-sprite/blob/main/docs/inva...
v0.3.2
A patch release that closes the loop on the create path and on indexes left invalid by an unfinished build, and hardens every catalog read against a shadowing search_path. Plan reports move to format_version 3 with one additive field; two outcome codes are added; the refusal-reason vocabulary is unchanged. The copy-and-swap package skeletons, proof types and convergence test harness land here with no user-visible behaviour change — type rewrites still refuse as not-native-safe-rewrite-required.
Highlights
The plan carries why a create shape was refused
plan.Statement.Cause— a refused create statement in adiff-source plan now carries the executor's typedCreateShapeCause(json:"cause,omitempty"), stamped once at the refusal boundary byRefuseUnsupportedCreateShape; a refusal that carries no typed cause fails closed withErrInvariantViolation. The text renderer, the greenfield refusal summary and the desired-schema path readCause.Description()instead of each re-deriving the cause from the error.causeis explanatory and excluded from the plan fingerprint, alongsidereason. Plan reportformat_version2 → 3. (#84)
The create path proves the table owns the names it claimed
executor.ErrCreateNameMismatch— after the create path'sCREATE TABLEcommits and before any index build,ExecuteCreatereads the born table's owned constraint-index and sequence names and compares them with the first-choice names the desired file claimed. A claim the table does not own — a concurrent writer tookt_pkeyinside the probe's time-of-check window and the server silently choset_pkey1— fails step 1 with the permanent outcomecreate-name-mismatch;*CreateNameMismatchError{Missing, Unclaimed}names both sides, sorted. The table is left in place because a committed creation cannot be proven unused; unclaimed extras alone are not a mismatch. (#85)executor.ErrCreateNamesUnverified— a read of the owned names that cannot complete is its own non-permanent outcome,create-names-unverified, never a pass; the read's cause stays in the chain forerrors.Isbut never picks the code.migrate.RunDesiredsetsFailedStep/FailedStepSQLfor both create-path codes. The first-choice prediction inpkg/statementnow mirrors the server's numbering forINCLUDEcolumns, repeated column and expression contributions, and identitySEQUENCE NAME, verified two-oracle against every supported shape. (#85)
Indexes left invalid by an unfinished build
schemadiff.Index.Invalid—pg_index.indisvalidjoins the comparison on plain tables: a live index that matches the desired definition but is invalid diffs to acreate-indexchange instead of "no change", and the diff never emits a drop for an invalid entry whatever the desired file says about its name. On a partitioned parentindisvalid = falsemeans unattached partition indexes, so validity plays no part there. A freshly pulled baseline that re-diffs to a lonecreate-indexis the documented signal to checkindisvalid. (#86)executor.DropAbandonedIndex(ctx, pool, sql, budget)— removes an abandoned invalid index with the same proof, quarantine and OID-verifiedDROP INDEX CONCURRENTLYsequencing asRebuildAbandonedIndex, without resuming the build.IndexRecoveryReport.Buildstays zero for drop-only recovery. (#90)
Catalog reads resist a shadowing search_path
dbconn.NewPoolun-shadows the catalog — anAfterConnecthook runs once per physical connection, readsSHOW search_path, and removes every explicitpg_catalogentry that has a non-catalog entry before it. PostgreSQL searchespg_catalogimplicitly before every entry unless the path names it, so a role configured withsearch_path = app, pg_cataloghad made every unqualifiedpg_classresolve toapp.pg_class. Stripping, rather than prepending, leavescurrent_schema()andmigrate's unqualified target resolution unchanged. Recorded as invariant CO-9. (#93)dbconn.LocalSearchPath(schemas ...string)— the single builder for every transaction-localSET LOCAL search_pathpg-sprite sets (identifiers sanitized, the same rewrite applied);schemadiffandexecutoruse it, and a test walks the production sources and fails if any other file sets asearch_path. (#93)schemadiff.ListManagedTables(ctx, pool, schema)— the catalog enumerationpullbaselines a schema from (ordinary and partitioned tables; partitions, extension-owned relations, views, materialized views, foreign tables and sequences excluded), exported so an owner detecting undeclared tables calls the same query instead of the SQL the docs used to show inline. Every relation, operator and cast ispg_catalog-qualified. Decision reversed from the earlier recipe:INHERITSchildren are listed — an undeclared table is undeclared whether or notpullcan write its file. (#92)
Copy-and-swap foundations
- Package skeletons and shared types —
pkg/copier,pkg/checksum,pkg/decode,pkg/applier,pkg/checkpoint,pkg/schemachange, each with its SAFETY.md core row.copier.Chunk/copier.Watermark,decode.ChangeKind/LSN/Column/ChangeEvent,checkpoint.Phase/Checkpoint, and the proof typesdbconn.TableLock,preflight.CopySwapTarget,checksum.VerifiedShadow,checksum.CleanWatermarkare declared without constructors; the PR that earns each proof adds its constructor. The router still marksBackendCopyAndSwapunavailable. (#87) - Convergence test harness —
internal/testutilgains a seeded, rate-limited DML load generator (StartLoad/LoadSpec, spec validated up front, serialization races counted) and a convergence oracle that runs bothEXCEPTdirections and both counts in one read-onlyREPEATABLE READsnapshot. A repository-wide test ininternal/safetyderives the proof-type registry from code shape and requires SAFETY.md, the review checklist and the TCB model to name every type. (#87, #91)
Upgrade notes
- Plan report
format_version2 → 3. One additive field:statements[].cause, present exactly when adiff-source plan withtable_exists: falserefuses a statement asunsupported-statement; absent for every other refusal. A consumer pinningformat_version == 2must accept 3. Fingerprints are unchanged;causeis excluded likereason. The duplicate-name refusal detail no longer appends the colliding name; the typed cause carries it. - Two new outcome codes.
create-name-mismatch(permanent) andcreate-names-unverified(non-permanent) appear inCodes()andCode.Permanent(); both mean theCREATE TABLEcommitted and was not rolled back. A consumer with an exhaustive switch over outcome codes needs two arms; the routing table indocs/schemabot-integration.mddescribes the rerun path — rename or drop the occupant, re-diff to an empty plan. - Library API additions, no removals. New in
plan:Statement.Cause. New inexecutor:DropAbandonedIndex,ErrCreateNameMismatch,ErrCreateNamesUnverified,*CreateNameMismatchError. New inschemadiff:Index.Invalid,ListManagedTables. New indbconn:LocalSearchPath,TableLock. New inpreflight:CopySwapTarget,PKType. New packagescopier,checksum,decode,applier,checkpoint,schemachange. No signature changed. - Pooled sessions may see a rewritten
search_path. Only an explicitpg_catalogentry with a non-catalog entry before it is removed; a leading or solepg_catalogand every other entry are left as configured. The rewrite is a per-connectionset_config, so behind a transaction-mode pooler it applies to the server connection the hook ran on. - Diff round-trip exception. A table carrying an invalid index re-diffs to exactly its rebuild rather than to an empty plan, and a desired file that removes an index whose live entry is invalid diffs to an empty plan — the diff will not drop it. Both are recorded in
docs/limitations.mdanddocs/pull.md. - No refusal-reason vocabulary change. Progress reports are byte-compatible with v0.3.1.
Docs
- docs/plan-report.md — the
causerow, the Causes vocabulary and a third pinned example, checked by the docs tests (#84) - docs/execution-model.md — the two create-path outcome-code rows (#85)
- docs/schemabot-integration.md — routing on
statements[].cause, the sixth outcome and its rerun path,ListManagedTables(#84, #85, #92) - docs/capabilities.md — the invalid-index round-trip exception; the undeclared-table recipe cites
ListManagedTables(#86, #92) - docs/capabilities-contract.md — the machine-readable capabilities matrix contract (#95)
- docs/refusal-classes.md — the refusal class routing contract (#94)
- docs/invariants.md — the never-drop-invalid trade under OC-2; CO-9 catalog un-shadowing (#86, #93)
- docs/copy-and-swap-design.md — copy-and-swap v1 design decisions (#88)
- docs/testing.md — the test harness, the single-snapshot oracle and the primary-key rule (#87, #91)
Contributors
Full changelog: v0.3.1...v0.3.2
v0.3.1
A patch release carrying the two library additions that landed just after the v0.3.0 cut, giving orchestrators two typed signals around the create path: a closed vocabulary for why a desired statement's shape was refused, and a catalog read of the constraint-index and sequence names a table actually ended up owning. No format_version, outcome-code or refusal-reason vocabulary changes; the CLI's behaviour is unchanged.
Highlights
Create-shape refusals carry a typed cause
executor.CreateShapeCause— the create path's shape refusals (PARTITION OF,INHERITS,LIKE,OF type,IF NOT EXISTS,CONCURRENTLYon a table born this run, a duplicate relation name, a multi-operation statement, an unsupported kind) now carry one of nine flat kebab-case causes instead of only prose, so a consumer branches on the cause the way it branches on outcome codes.CreateShapeCauses()enumerates the closed set;Description()is the single owner of each human sentence;*CreateShapeError{Cause, Name}unwraps to the sentinel the cause belongs to, so existingerrors.Iscallers and theOutcomeCodemapping are untouched;CreateShapeCauseOf(err)reads the cause through wrappers, including aSequenceStepError. A cause added without aDescription()orUnwrap()arm fails the suite rather than rendering "unknown create-shape refusal" at runtime. (#82)
Read back what a table owns
preflight.LookupOwnedRelationNames(ctx, pool, schema, table)— one catalog read returning the constraint-index names (pg_constraint, contypep/u/x; a foreign key's borrowed index on the referenced table is excluded) and column-owned sequence names (pg_dependdeptypea/i, relkindS) a table owns, sorted and duplicate-free, whether the server invented the name or the desired file stated it. PostgreSQL never raises42P07/42710for an implicit name taken inside the absence proof's time-of-check window — it silently suffixes (t_pkey1,t_id_seq1) — and this read is what lets a caller detect that after theCREATE TABLEstep. Schema is required; a missing relation isErrTableNotFound, a non-table at the name isErrNotTable. The create path does not yet run the comparison itself. (#83)
Upgrade notes
- Library API additions, no removals. New in
executor:CreateShapeCauseand its nine constants,CreateShapeCauses(),CreateShapeCause.Description(),*CreateShapeError(Unwrap()returns the existing sentinel),CreateShapeCauseOf(err). New inpreflight:OwnedRelationNames{ConstraintIndexes, Sequences},LookupOwnedRelationNames,ErrTableNotFound,ErrNotTable. No signature changed. - Six create-shape sentences lost their prefix. The
INHERITS,LIKE,OF,CONCURRENTLY, multiple-operations and unsupported-kind refusals no longer start withstatement is not a shape the create path can run:; every renderer already says the create path refuses the statement.PARTITION OF,IF NOT EXISTSand duplicate-name text is byte-identical. A consumer matching refusal text should switch toCreateShapeCauseOf;errors.IsagainstErrUnsupportedCreateStep,ErrPartitionOfUnsupported,ErrIfNotExistsUnsupportedandErrDuplicateCreateNamekeeps working. - No
format_version, outcome-code or refusal-reason vocabulary change. Plan and progress reports are byte-compatible with v0.3.0; fingerprints are unchanged.
Docs
- docs/execution-model.md — the "Create-shape causes" table, pinned by a docs test like the outcome codes (#82)
- docs/schemabot-integration.md — what
LookupOwnedRelationNamesis for and what it excludes (#83)
Contributors
Full changelog: v0.3.0...v0.3.1
v0.3.0
Concurrent index builds learn to run under an orchestrator — a caller-owned deadline instead of a fixed server timeout, a gated stop path for operators, lock-aware progress, and a proven recovery for the invalid index a dead build leaves behind. Around that, pg-sprite pull baselines a live schema into desired files, greenfield plans disclose exactly what the create path runs, and the create path refuses shape and name problems before anything executes.
Highlights
Concurrent index builds an orchestrator can own
ConcurrentBudget.CallerOwnedrunsCREATE INDEX CONCURRENTLYwithstatement_timeout = 0and the caller's cancellable context as the statement's only bound — for a build that should live as long as a lease can be renewed rather than a fixed number of minutes. A context that cannot be cancelled is refused before any session is acquired, so the statement stays bounded by construction;lock_timeoutstays disabled in both modes, and both settings are reset before the session returns to the pool. The bounded mode and every existing caller are unchanged. (#76, #78)- Cancellation is a three-way partition in both modes: the caller's own context ending is
cancelled-by-callerwhichever of the server's 57014 or the client's context error arrives first; a 57014 under a live context iscancelled-externally; a 57014 at the bounded deadline is a*BudgetError. An orchestrator can now tell my lease lapsed, an operator stopped it, and the server's budget killed it apart. (#76, #78) progress.Tracker.CancelBuild(ctx)is the operator's stop path:pg_cancel_backendover the tracker's reserved session, under the lock that guards the build's lifecycle, only while the build is active and only against a backend that is running a statement. The backend PID never leaves the tracker. (#76, #78)- Progress snapshots report lockers:
work.lockers_total,work.lockers_done,detail.current_locker_pidfrompg_stat_progress_create_index, so a build stuck in the "waiting for old snapshots" phase names what it is waiting on. Snapshots also carrydetail.statement, the canonical SQL of the step in flight — including on the terminalfailedsnapshot. (#72, #76)
Proven recovery of abandoned invalid indexes
executor.RebuildAbandonedIndex(library-only) removes and rebuilds the invalid index a dead concurrent build leaves behind, after proving abandonment: a boundedLOCK TABLE … IN SHARE UPDATE EXCLUSIVE MODEexcludes any live concurrent index command — visible or hidden — then the entry is re-verified by OID, quarantined aspgsprite_abandoned_<oid>, dropped withDROP INDEX CONCURRENTLYunder OID verification, and the index rebuilt. The result is anIndexRecoveryReport. (#79)- The classifier never reads an unobservable builder as absent.
pg_stat_progress_create_indexhides another role's command withoutpg_read_all_statsand records nothing undertrack_activities = off; that case is now its own code,invalid-index-builder-unobservable, and the lock proof decides. An invalid entry the server will not drop concurrently — a partitioned table's index (invalid by design when builtON ONLY), an index partition, or a constraint's index — isinvalid-index-not-droppableand is left for an operator, never quarantined.invalid-index-preexistingis gone — see upgrade notes. (#79)
Baseline export: pg-sprite pull
pg-sprite pull --url=… [--schema public] [-o schema]writes one desired-state.sqlfile per ordinary or partitioned table in a schema, with per-tablePULLED/REFUSED/ERRORoutcomes and a summary — not fail-fast. Constructs the declarative model cannot express refuse per table. The demo tour proves the loop:pull, thendiff --jsonagainst the export returns zero statements. (#67, #74)
Greenfield plans say what will run
- Greenfield plan statements disclose the create path's execution:
exec_sqlis the statement as written, decisions aremetadata-only, and noCONCURRENTLYsubstitution is shown for a table that does not exist yet — the create path runs the desired file's builds verbatim under boundedlock_timeout/statement_timeout. The disclosure is a no-op unless the report proves the table absent, so a live table's online sequence cannot be collapsed. (#69) - Shape refusals surface at plan time:
PARTITION OF,INHERITS,LIKE,OF,IF NOT EXISTS, and a set that claims the same relation name twice are refused on the plan asunsupported-statement, from the same rules the executor applies at admission;diff --sqlcomments out the refused statement with its cause. (#70) - Claimed relation names are proven free before the first step: explicit index names, the first-choice name of each
PRIMARY KEY/UNIQUE/EXCLUDEconstraint index, and eachserial/ identity column's<table>_<column>_seqare probed inpg_class; an occupant is acreate-collisionwith nothing executed. (#71)
Verified against Aurora-shaped failures
- The AWS-boundary tier now pins reader read-only behaviour (SQLSTATE 25006 is terminal), connection loss mid-change (terminal
execution-failed, no catalog trace), and metadata failover with an open writer session. (#75)
Upgrade notes
- Progress report
format_versionmoves from 1 to 3. New fields:detail.statement;work.lockers_total,work.lockers_done,detail.current_locker_pid. A strict consumer should re-sync from docs/progress-report.md. - Greenfield plan fingerprints change value (the fingerprint hashes
exec_sql, which now commits to what runs).format_versionstays 2. A consumer holding a pre-upgrade greenfield fingerprint gets oneplan-fingerprint-mismatchand re-plans; fingerprints for existing tables are unaffected. - Implicit-name collisions are now refused — a policy change. A desired file whose
PRIMARY KEY/UNIQUEindex orserialsequence first-choice name is already taken used to converge with a server-suffixed name (t_pkey1,t_id_seq1); it now refuses withcreate-collisionuntil the occupant is dropped or renamed, the constraint's index is named explicitly, or the column uses a named sequence or non-serial type. - Outcome-code vocabulary changed — a consumer with a total switch over
executor.Codes()will not build until it routes the new members.invalid-index-preexistingis removed;invalid-index-build-in-flight,invalid-index-abandoned,invalid-index-other-table,invalid-index-not-droppableandinvalid-index-builder-unobservableare added alongside the existinginvalid-index-own-leftoverandinvalid-index-unproven, andcancelled-by-callerjoinscancelled-externally. The invalid-index family is not one retry class:Code.Permanent()says which members a retry unchanged would reproduce (other-table,not-droppable) and which wait on a recovery, a builder, or a re-taken proof; see docs/invalid-index-recovery.md.cancelled-by-calleris the caller's own context ending and belongs with the operational outcomes, not the refusals. - Library API.
progress.Tracker.StartSteptakes the step's statement (StartStep(step, operation, statement)).ConcurrentBudgetgainsCallerOwned(mutually exclusive withOverall;ErrCallerOwnedOverallBudget,ErrCallerOwnedNeedsCancellableContext).statement.ImplicitIndexNamesis renamedstatement.ImplicitRelationNamesand now also returns the sequence names aserial/ identity column owns. New:executor.RebuildAbandonedIndex,executor.CreateShapeRefusals,preflight.CheckNamesAbsent,plan.DiscloseGreenfieldExecution,plan.RefuseUnsupportedCreateShape,progress.Tracker.CancelBuild. New error sentinels:ErrCancelledByCaller,ErrNoActiveBuild,ErrBuildNotRunning,ErrUnboundedBudget. - Refusal-reason vocabulary:
unsupported-statementnow also covers plan-time create-shape refusals;create-collisionnow also covers index, constraint-index and sequence names, not only the table.
Docs
- docs/capabilities.md — the canonical matrix, now with an Engine path column per row, a
DROP TABLErow, and who owns undeclared tables (#66, #77) - docs/pull.md — baseline export and the zero-diff verification loop (#74)
- docs/invalid-index-recovery.md — the seven-code ownership model,
Code.Permanent(), and what recovery handles (#79) - docs/execution-model.md — caller-owned builds and the cancellation partition (#76, #78)
- docs/mysql-vs-postgresql.md, docs/binary-coercible-type-changes.md — MySQL comparison, three-bucket cost model, when
ALTER COLUMN TYPErelabels versus rewrites (#68, #73)
Contributors
Full changelog: v0.2.0...v0.3.0
v0.2.0
Desired-state execution grows a library front door and learns to create the table it is converging toward — declaring a brand-new table on a fresh database now works end to end under the engine's proof discipline.
Highlights
Greenfield CREATE TABLE through the declarative front door
- A desired file whose table does not exist yet now converges — the engine proves the name is free (
CheckTableAbsent, read againstpg_classso a missing privilege can never masquerade as absence), proves the role holdsCREATEon the schema, then runs theCREATE TABLEand its index builds as brief bounded steps under the usuallock_timeout/statement_timeoutbudgets. A rerun converges to an empty plan. (#60, #62, #63) - Refusals are typed and land before anything runs — an occupied name (relation or standalone type) is the new
create-collisionreason; a privilege gap isinsufficient-privileges;PARTITION OF,INHERITS,LIKE,OF, andIF NOT EXISTSshapes refuse withunsupported-statement. (#63) - Plan order states execution order — desired-file statements are ordered at parse (the
CREATE TABLEfirst, indexes keeping input order after it) everywhere the file replays: the greenfield plan, the create path's steps, and the scratch-schema introspection that derives a diff once the table exists. (#63)
Library-level desired-state execution
migrate.RunDesiredconverges one live table onto its parsed desired schema: derive the convergence plan, admit it as a whole (table existence, destructive guard, routed dispositions, optionalExpectedFingerprintpin), then run each planned statement back through the samemigrate.Runpipeline with fresh introspection and classification, stopping at the first refusal or failure. The result carries the plan, per-statement verdicts, and an aggregate outcome with committed-prefix detail. (#49, #53)- Two new refusal reasons:
destructive-change(the plan discards live structure — desired-state execution never runs it) andplan-fingerprint-mismatch(the plan derived at execution time is not the pinned reviewed plan). Library-only for now — themigrate --desiredCLI verb follows separately. (#53)
Classification and resolution fixes
- Alter attempts pin
search_pathto the target schema (thenpublic) whenever the statement is schema-qualified — the same resolution the create path and introspection use, so unqualified secondary names (a column's type, an expression's function) can no longer silently bind a same-named object inpublic. (#63) ALTER COLUMN ... DROP NOT NULLnow classifies as destructive — droppingNOT NULLdiscards the same guarantee as dropping the equivalent constraint;DROP DEFAULTdeliberately stays non-destructive. (#53)
Upgrade notes
- A greenfield desired plan now executes where it previously refused — automation branching on the old
unsupported-statementrefusal for a not-yet-existing table sees the create run instead. - Callers relying on ambient
search_pathresolution must qualify secondary names — alter attempts on a schema-qualified statement now resolve unqualified secondary names (a column's type, an expression's function) in the target schema, not the session'ssearch_path. ALTER COLUMN ... DROP NOT NULLnow reportsdestructive: truein plan reports, and desired-state execution refuses it like any other drop.DROP DEFAULTdeliberately stays non-destructive.- Refusal-reason vocabulary additions:
create-collision,destructive-change,plan-fingerprint-mismatch. A consumer enumerating the closed set should re-sync.
Docs
- docs/capabilities.md — the canonical tiered support matrix, now with an online-safety column (#56, #57)
- docs/execution-model.md — autocommit-each-step and the committed prefix (#50)
- Known refusal and disclosure gaps on the create path are documented in docs/limitations.md.
Contributors
Full changelog: v0.1.0...v0.2.0
v0.1.0
First release of pg-sprite — an online schema-change engine for PostgreSQL that classifies every DDL operation before running it, substitutes the safer native form where one exists, and refuses honestly (with typed guidance) where it doesn't.
Highlights
Planner: classify first, never guess
- All SQL enters through one parse boundary using the real PostgreSQL grammar —
libpg_querycompiled to Wasm (wasilibs/go-pgquery), so a parse failure is a typed error, never a guess, and builds stay pure Go. (#6) - The declarative diff is derived by execute-and-introspect — desired DDL runs in a transaction-scoped scratch schema and the model is read back from PostgreSQL's own catalogs, then rolled back; no reimplementation of PostgreSQL's semantics to drift out of date. (#6, #20)
- Every operation is classified native-safe · needs-rewrite · refuse before anything runs — the classifier and router are the one place the safety decision lives, and both front doors (imperative
--alter, declarative--desired) converge on the same pipeline. (#7) - One versioned plan report for both front doors —
diffandmigrate --dry-runemit the same machine-readable plan (format_version: 2), with typed guidance on rewrite-required statements. (#8, #37) - Column and table renames classify as app-breaking — a rename is an application contract change, not a metadata tweak, and the verdict says so. (#19)
Execution: the safer form by default
- The optimistic front door commits instant changes and refuses the rest with typed verdicts — a metadata-only change just runs; anything else gets a
not-native-safeverdict naming the reason. (#5) - Blocking forms are substituted with the safer native sequence by default —
CREATE INDEXruns asCREATE INDEX CONCURRENTLY,ADD CONSTRAINT UNIQUEbuilds the index concurrently first; overriding requires a typed--forceacknowledgement naming the exact target table. (#28, #30, #32) CREATE INDEX CONCURRENTLYrecovers fail-closed — a failed concurrent build detects the invalid index and cleans up rather than leaving it behind. (#15)- Every statement runs under bounded
lock_timeoutwith bounded retry — DDL that cannot get its lock backs off and retries a bounded number of times instead of queueing behind traffic. (#27) - Preflight names the exact missing GRANTs — an engine role without the needed privileges gets a typed refusal listing the GRANT statements to run, before any DDL is attempted. (#31)
- Parent-level index builds on partitioned tables are refused — the unsupported partitioned-parent case fails closed instead of taking locks it cannot honor. (#33)
- Execution progress is machine-readable across strategies — a strategy-wide
Progress()contract backed bypg_stat_progress_create_indexand sequence step position. (#41)
CLI: compiler-grade diagnostics
migrate --dry-run,diff,lint, andsuggestrender as colored compiler-style diagnostic reports — with a stable exit-code contract, and--jsoneverywhere for machines; the raw SQL script moved behind--sql. (#36, #43, #46)lintproduces offline typed findings — file/line/column, severity, reason codes, and the safer form inline. (#9)suggestrecommends safer native forms with typed caveats — and is explicit that a safer form is not a semantic equivalent. (#13, #14)- A runnable demo tour doubles as the artifact smoke test —
make demowalks the CLI the way a user invokes it; VHS-generated terminal GIFs are committed with their tapes. (#39, #42)
Testing: real databases, all supported majors
- The full suite runs against real PostgreSQL 14 → 18 — integration tests via testcontainers, with the version matrix as a merge gate. (#3, #4, #21)
- An AWS-boundary test tier via Ministack — RDS/Aurora API fidelity, managed-password rotation reconnects, and control-plane error contracts are exercised in CI without touching AWS. (#11, #12, #25, #29)
- Docs-only changes skip the test matrix — with exclusion patterns that are themselves tested. (#23)
Docs
- Vision, architecture, and low-level design ship with the code — including the SchemaBot integration contract, the engine-role provisioning contract (tiered access for schema changes), and a README written as decided outcomes with demo GIFs. (#2, #10, #17, #18, #22, #24, #35, #40, #44, #45)
Upgrade notes
- First tagged release. The module is
github.com/block/pg-sprite; the CLI installs via the attached binaries orgo install github.com/block/pg-sprite/cmd/[email protected]. - The plan report is
format_version: 2and verdicts carry typedguidance. Consumers pinning pre-release pseudo-versions should re-validate their plan/verdict mapping when moving to the tag. (#37, #38) - This release requires Go 1.26. Consumers embedding the module must be on that toolchain or newer.
Contributors
Full Changelog: https://github.com/block/pg-sprite/commits/v0.1.0