- Schema pull
- Row-level security
- Supported changes
- Blocked plans
- Apply-time refusals
- Unsupported workflow features
- Engine capability mapping
- Failure and recovery
- Configuration and credentials
- Supabase compatibility tests
- SchemaBot storage
Per-target schema_overrides can map one canonical namespace directory to a
physical PostgreSQL schema. This keeps plans, tasks, and pull output canonical
when physical schema names differ between targets. See
Per-Target Schema Overrides for
configuration and identifier rules.
SchemaBot can plan declarative PostgreSQL schemas and execute a deliberately narrow set of changes through the pg-sprite engine. This page describes the current support boundary. A change outside that boundary is not routed to a less safe executor: its plan is blocked.
PostgreSQL target support is separate from using PostgreSQL for SchemaBot's own state. See SchemaBot storage for that distinction.
schemabot pull prints the pulled PostgreSQL schema as readable SQL, or as the
API response when JSON output is selected. A PostgreSQL schema is the SchemaBot
namespace. With no namespace selection, pull discovers every schema that is not
a server or known extension schema. That discovery is best-effort: an extension
whose schema the reserved list does not name is pulled as if it were an
application schema, so select the schema explicitly with --namespace when the
database hosts extensions. Only ordinary and partitioned tables are exported;
views, materialized views, and foreign tables are not, and partition children
are not exported separately from their parent.
The pull is refused in full if any selected table cannot round-trip through the declarative format. This includes partitioned parents, foreign-key relationships, unlogged tables, explicit collations, and sequence defaults that cannot be represented safely, as well as tables carrying objects the format does not model at all: triggers, table and column comments, non-default relation options, and table inheritance. If any selected table is refused, no partial output is produced. Only basic catalog detail is supported on PostgreSQL; a request for detailed catalog output is rejected rather than answered with the basic shape.
schemabot pull --lint audits each pulled table for the schema-shape rules
that have a PostgreSQL analog: a missing primary key or a key column whose type
is not bigint or uuid, real / double precision / float(n) columns, a
quoted table name that is not lowercase, and a btree index that another index
or the primary key already covers. Every finding is a warning, and a clean
audit returns an empty list for the namespace. Each pulled entry is audited as
the create set the renderer emits: CREATE TABLE and CREATE INDEX statements,
with validated RLS declarations when present. The audit checks table and index
shape, not access policy correctness. Unsupported declarations fail the request
rather than producing a partial audit. The rules and the MySQL-only rules they
leave out are in
lint and safety levels.
Pull includes row-level security (RLS) settings, policies, and policy comments
when a table defines them. Tables without RLS settings or policies keep their
ordinary table and index definitions; no extra flag is needed. A disabled table
with policies still exports those policies and an explicit DISABLE setting.
A pulled RLS definition can be checked back into its namespace directory and planned. An unchanged definition produces no changes. Changes to policies, policy comments, or RLS settings become one reviewed operation per table. The existing unsafe-change consent gate applies because these changes affect who can access rows. No additional RLS flag is needed.
At execution, pg-sprite locks the table, regenerates the ordered SQL, and checks it against the reviewed operation before executing any of it. The entire policy replacement commits in one transaction, including settings and comments. A mismatch refuses execution and requires a new plan. This binds the SQL to execute, not every prior policy predicate: if concurrent edits still produce exactly the reviewed SQL, that SQL remains authorized.
RLS drift comparison retains the physical schema qualifier. Dispatching a reviewed RLS operation to a differently named physical schema requires a fresh plan and review on that target; ordinary table namespace mapping does not authorize this RLS remapping. The executor still verifies the exact ordered SQL under its lock.
Unchanged RLS definitions use a catalog comparison without an exclusive target lock or an ownership requirement. Planning a change and executing it take a bounded exclusive table lock. Keep policy updates small and expect a busy table to refuse rather than wait indefinitely. Creating a new table with an RLS declaration and combining structural changes with managed RLS remain unsupported. Direct RLS apply requests without the stored desired schema files are refused.
Existing table-only files remain supported on tables with live RLS. They manage columns and indexes without changing policies or RLS settings; older rollback baselines keep this behavior too. Omitting RLS clauses does not disable RLS or drop policies. Explicit RLS declarations use the atomic path above, which refuses mixed structural and RLS changes. A no-change result for a table-only file says nothing about whether its access policies match.
New plans for a namespace containing managed RLS tables remain rollback-incapable until complete RLS definitions can be applied during recovery. Pull still exports the full definition; rollback capture never strips access policies to claim support.
For example, an existing table can pull as:
CREATE TABLE "documents" (
"id" bigint NOT NULL,
"owner_id" bigint NOT NULL,
CONSTRAINT "documents_pkey" PRIMARY KEY (id)
);
ALTER TABLE "documents" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "documents" NO FORCE ROW LEVEL SECURITY;
CREATE POLICY "readers" ON "documents"
AS PERMISSIVE FOR SELECT TO PUBLIC
USING ((owner_id = 1));Planning this file against that same live definition returns NoChanges: true.
Editing the policy to USING (true) produces an unsafe policy replacement
for review. Applying it broadens access to every row allowed by the remaining
policies and grants; review that access change before consenting.
RLS comparison and rendering use pg-sprite's validated model. Policy roles and
qualified helper functions must already exist. Unsupported policy dependencies
still refuse the entire pull rather than returning an incomplete definition.
pull --lint validates the declaration and audits table and index shape; it
does not judge whether a policy grants the right access.
The apply path normally executes a plan one statement at a time. Each statement runs as its own task and its own PostgreSQL transaction. An RLS replacement runs as one task and one atomic transaction, as described above. A second exception is a greenfield create set: its table and indexes run as one task through a pg-sprite sequence, with each sequence step in its own bounded transaction. A failed set leaves its committed prefix on the target, and the next plan reconciles that live catalog. Every statement must satisfy all of these conditions:
- pg-sprite classifies the statement for native execution.
- The statement is a kind the shape check admits: an
ALTER TABLEshape accepted by pg-sprite's privilege-tier check, a greenfieldCREATE TABLE(see Greenfield tables), or aCREATE INDEX(see Index builds). Statement kinds outside that set fail closed. - For a change to an existing table, the target is an ordinary or partitioned
table. Native
ALTER TABLEand blockingCREATE INDEXstatements also require its measured on-disk size to be no more than the configured native-apply ceiling (1 GiB by default; seepostgres.native_safe_table_size_limit_bytesin configuration). For a partitioned parent, the measurement includes its complete partition tree. The ceiling bounds work that scales with existing data, so it does not apply to aCREATE TABLEorCREATE INDEX CONCURRENTLY. - The target role passes the privilege preflight for the planned statement.
A greenfield create is checked against the schema — the role needs
CREATEon the target schema — because no table exists to state facts about. - A
CREATE INDEX CONCURRENTLYbuild is bounded bypostgres.concurrent_index_max_duration, which defaults to 24 hours, set as the build's server-sidestatement_timeout. The bound starts when the build starts; the session setup before it runs under a separate headroom sized for setup as slow as the server's own limits allow, so the build gets the full configured bound. Setup that outruns the headroom shortens the build by the excess, since the apply as a whole still ends at the bound plus the headroom. The catalog reads pg-sprite runs in the build's own session ahead of the build share itsstatement_timeout, so the bound ends one of those too and the failure names the option all the same.
The common supported case is a metadata-only ALTER TABLE, such as adding a
nullable column:
ALTER TABLE public.users ADD COLUMN email text;The planner, rather than a fixed list in SchemaBot, decides whether a
particular ALTER TABLE can use the native path. Review the plan result for
the decision on the exact target schema. Even a native-routed statement is
bounded at apply time: each attempt has a 3-second lock budget and a
30-second statement budget, with bounded retries for lock contention. A
statement that exceeds the execution budget is not allowed to continue
unbounded.
While a statement runs, PostgreSQL progress metadata carries two phase
vocabularies in one flat map. phase is SchemaBot's own record of the apply:
preflight for as long as the apply runs, then its terminal outcome
(completed, failed, or refused).
server_phase is the phase column of PostgreSQL's
pg_stat_progress_create_index view, verbatim (for example building index: scanning table or index validation: scanning index), and is present only
while PostgreSQL publishes a progress row for a concurrent index build. The
metadata also carries step, steps_total, and the sanitized statement
for the statement in flight; executor_operation, pg-sprite's class for the
step (admitting, optimistic, brief, validate-constraint,
concurrent-index-build); and attempt. Whenever a progress row is
published, all six counters — blocks_done, blocks_total, tuples_done,
tuples_total, lockers_done, lockers_total — are present, zeros
included: a zero is a reading, and an absent key means PostgreSQL has not
published a row. PostgreSQL scopes block and tuple counters to phases, but a
completed phase's values can persist into the next phase. The completed heap
scan's block counters remain visible while live tuples are sorted, so that
sorting phase's zero-width band absorbs the carry-over and reports its start.
Table progress during a concurrent index build is a whole-build estimate, not a phase ratio. Each server phase owns a fixed band of the 0–100 scale in PostgreSQL's documented phase order, a phase with block or tuple counters interpolates within its band, and a waiting or sorting phase reports the band's start. The estimate stays below 100 until the apply completes and never moves backwards within a build; a poll that finds no progress row — between phases, or while the view cannot be read — keeps the last derived percent.
A multi-statement plan — several tables changed, or one declarative edit that the planner expands into several steps — is not applied atomically. The statements execute in order, each committing or failing in its own transaction, so a mid-plan failure leaves the earlier statements committed and the schema partially changed. Keep a PostgreSQL change to a single statement when a partially applied plan would be unsafe to leave in place.
A CREATE INDEX against an existing table plans as a native statement. The
choice between a blocking and a concurrent build is pg-sprite's, not the
author's: its planner constructs CREATE INDEX CONCURRENTLY as the safer form
of a plain CREATE INDEX, and the plan surfaces that concurrent form for
review. A statement already written with CONCURRENTLY surfaces as authored.
The apply runs the reviewed build through pg-sprite's dedicated concurrent
index-build executor — outside a transaction block, with lock_timeout
disabled and statement_timeout set to
postgres.concurrent_index_max_duration (24 hours unless configured), so the
server's own timer is what ends an over-long build — and reports completion
only once the catalog shows the index valid. A build that fails part-way
leaves an invalid index; the stored failure names it and is retryable, and
when the bound is what ended the build it names the option to raise. A build
that runs past the bound and provably leaves nothing behind is refused
permanently, naming the option, because an identical retry would spend the
same bound again.
The native-apply size ceiling does not gate this path: the build is bounded
by postgres.concurrent_index_max_duration instead, and a build the bound
ended or an invalid-index outcome is reported as such rather than as a
table-size verdict.
Losing the driver pod mid-build does not stop the statement: the server keeps
building until it finishes, fails, or its statement_timeout ends it, so the
recovery re-plan on the next drive meets that build's outcome — a valid index
it has nothing left to build, a build still in flight (the apply fails
retryable, naming the index and the backend building it, until that build
ends), or a failed build's invalid leftover.
The next drive recovers that leftover itself: when the build finds an invalid
index under the requested name, or quarantined on the table by an interrupted
recovery, that pg-sprite can prove abandoned — on the target table, with no
backend building it, or with a builder the engine role cannot see through the
progress view — it runs pg-sprite's recovery, which removes the entry under a
lock-held proof of abandonment and then builds, so a change interrupted
mid-build converges without an operator dropping anything. The recovery runs
as one caller-owned envelope — proof, drops, then the build — under the
engine's own deadline of the same configured bound, with no server-side
statement timeout, since a served build would spend the bound once on the
drops and again on the build. The lock is the
proof: a build still holding the table, visible or not, stops the recovery at
its lock budget and the apply fails retryable, still naming the index. A
recovery that cannot prove the entry unchanged through to its removal fails
retryable the same way; one that finds the connection pool a session short is
refused permanently, since a retry sees the same pool. A concurrent build against a partitioned
parent is refused permanently, because PostgreSQL cannot build parent-level
indexes concurrently.
Indexes declared together with a new table take a different path: they run as plain, non-concurrent steps inside the greenfield create set, because the table has no readers yet (see Greenfield tables).
Cancel is supported for an existing table's concurrent index build. Plain DDL is never cancellable: each statement commits or fails on its own, so a cancel of one is declined as a typed unsupported operation and the durable request resolves terminally.
A cancel of a running build signals the build's backend through the apply's
pg-sprite progress tracker. When that signal cannot reach a running statement
— the build has not started, has already returned, or its backend cannot be
observed or signalled by the engine role — the engine cancels the apply's own
context instead, which ends whatever statement the apply is on. Membership in
pg_signal_backend lets the signal land on the build backend directly; without
it the cancel still takes effect through the apply's context.
The cancel is answered from the outcome the apply settles on, never from the
signal alone. When a cancelled initial or recovery build leaves its own invalid
index, the drive removes it through pg-sprite's abandonment proof before
settling the apply.
That cleanup is detached from the already-cancelled apply context and runs
under its own bound of seconds, kept well inside the cancel's settle wait so
that a slow cleanup cannot turn a cancel that is about to succeed into a
not-settled answer; it cannot spend another full build bound. The summary
reports what pg-sprite's report proves: the index was removed only when an
entry was dropped; an entry the server refuses to drop concurrently is named
under its quarantine name for an operator to remove; an entry already gone is
reported as such. Cleanup failure does not change the terminal cancelled
outcome. When pg-sprite exposes the leftover's identity, the summary and server
log name it; otherwise the summary names both the original and quarantine-name
possibilities and gives the catalog query needed to identify it. The terminal
apply does not retry cleanup: the entry remains until an operator follows the
invalid-index recovery guidance. The full cleanup error stays in server logs.
A signal that races the build's completion may
find the index already valid: that apply is reported as already completed, not
cancelled, and the durable request reconciles to the completed outcome. A cancel
that arrives after the apply has already failed is accepted over the failure,
carrying the failure detail. A cancel whose build has not settled within the
engine's wait is reported as still running, and the next attempt waits for it
again.
On shutdown the engine halts its running concurrent index builds the same way — by cancelling their contexts — so the process does not exit with a build running on the target under a lease nobody renews. A halt is not an operator cancel: the apply records the build's failure, stays active, and the next driver to claim it recovers the leftover index before its own build. Plain DDL is left to finish; a halt whose budget expires while such a statement is still running reports that the target may still be held.
Stop remains unsupported. A concurrent build has no resumable midpoint, so a stop would be a permanent cancel under a misleading name; non-concurrent statements likewise have no engine phase to pause.
A CREATE TABLE for a table that does not exist on the target plans as a
native statement and executes through pg-sprite's create path. The two facts
the create depends on are proved in the session that executes, not at plan
time, because absence or privilege at plan time proves nothing about apply
time: the role holds CREATE on the target schema, and the name is free.
A target configured with table_owner runs each create step with SET LOCAL ROLE, so the owner's default privileges apply to the table and its serial
sequences. The connected engine role needs membership in that role (GRANT app_owner TO engine), and the owner needs USAGE and CREATE on the schema.
Without table_owner, the connected role owns new tables as before. The
owner is checked at plan time as the role the create will run as: missing
membership blocks the create step with the GRANT that provides it, and an
owner the target has no role for blocks it with no grant to print.
table_owner is one role per target, deliberately scalar. A per-namespace
map mirroring schema_overrides is a later extension for a target whose
schemas have different owners; no such target exists today.
A name needed by the create set that is already occupied is a permanent refusal. The occupant is a relation of any kind — table, view, index, or sequence — or a standalone type (an enum, domain, range, or shell type; every table owns a composite type of its name, so these collide too). Re-plan first: a lost race for the table name resolves on the next plan, which sees the occupant and diffs against it. A collision that recurs after a re-plan is a schema-file problem — an explicitly named constraint or index claims a name another relation holds (an unnamed constraint picks a free index name on its own), or a serial column's auto-named sequence lands on a standalone type of that name — so drop or rename the occupant, or give the constraint, index, or sequence another name. A target schema that does not exist is also a permanent refusal.
The create path refuses shapes whose outcome it cannot prove, each
permanently: IF NOT EXISTS (its no-op outcome cannot be proven),
CREATE TABLE ... PARTITION OF attaching a partition to a live parent, and a
statement that claims the same relation name twice.
A schema file may declare a new table together with CREATE INDEX statements
on that table. SchemaBot plans and applies this greenfield create set as one
change and one task. pg-sprite executes the table and indexes as an ordered
sequence; because the table has no readers yet, index steps use plain
non-CONCURRENTLY builds. Each step commits in its own bounded transaction, so
a failure can leave the table and earlier indexes committed. Such a failure
is never retried as the same set: its detail names the failed step and the
committed table, then ends on the refusal's own remedy, or on a re-plan to
reconcile that live prefix when the refusal has none.
The target preflight accepts ordinary tables, leaf partitions, and partitioned
parents. pg-sprite also defines specific refusals for parent-level index
builds, index adoption, and NOT VALID foreign keys on PostgreSQL versions
before 18. The SchemaBot PostgreSQL apply path's table-change ladder invokes
that partition-specific admission check immediately before execution; a
CREATE TABLE returns from the optimistic path before the ladder and does not
reach it. Unsupported changes to a
partitioned parent therefore fail with pg-sprite's typed refusal rather than a
raw server error; they remain outside the supported SchemaBot envelope even if
a plan is otherwise shown as native.
SchemaBot blocks every planner verdict that is not a native execution verdict.
This includes changes that require copy-and-swap, require a rewrite before
they can be planned safely, are refused by pg-sprite, or carry an unrecognized
planner contract or verdict. A native verdict is also blocked when its
statement shape is outside the ALTER TABLE, CREATE TABLE, and
CREATE INDEX set accepted by the apply path's shape check. The admitted set
is defined by the pinned
pg-sprite version, not by SchemaBot: when a pg-sprite upgrade widens the
shapes its engine executes, SchemaBot's plan and apply gates widen with the
pin, with no SchemaBot code change. That shape check admits CREATE INDEX,
so an index build against an existing table is not blocked at plan time; it
executes as described in Index builds. It is blocked only
when pg-sprite could not construct the concurrent form of a plain
CREATE INDEX, since running the submitted form would falsify the plan's own
verdict.
The plan comment lists the table and SchemaBot's reason for the refusal. When the create path refuses a statement's shape, that reason names the offending clause and how to remove or work around it. For a PostgreSQL plan, the plan Check Run concludes unsuccessfully, and an apply attempt is rejected before any apply or task is queued. Rewrite the declarative change into an eligible form or use a separately reviewed operational process; flags do not override an engine-blocked verdict.
The engine does not execute DROP TABLE or other statement kinds outside its
admitted set. That includes tables that exist on the target but that no schema
file in the namespace declares. The common case is onboarding an existing
database, where every table nobody has written a file for yet is undeclared;
the same happens when a file is deleted from the PR, or when an imperative
tool leaves a table behind during coexistence. A namespace is owned whole: the
schema files declare the set of tables, not just each table's shape, so the
only convergence for a table with no file is a drop. The planner surfaces
each undeclared table as a blocked, destructive DROP TABLE change: the
engine will never run the drop, so the one choice left is whether to report
the divergence, and hiding it would turn a target that does not match its
declaration into a passing check. The plan and the schema pull enumerate the
namespace's tables through the same pg-sprite query, so a pulled baseline
cannot leave behind a table that the next plan then blocks on.
Deleting every schema file in one namespace keeps that namespace in the plan
and surfaces every live table it contains as one of these blocked drops.
Because the check fails for the whole database while any change is blocked,
an undeclared table holds up every other change to that database until it is
resolved — including changes in other namespaces, since every namespace at
the PR head is reconciled on every plan. Plan an onboarding accordingly:
declare the existing tables first, or list namespaces that stay outside
SchemaBot under ignore_namespaces.
Two remedies clear it. Declare the table in a schema file to bring it under
management — the file need only match the live table for the drop to
disappear from the plan. Or drop the table through a separately reviewed
operational process. schemabot pull and schemabot onboard write the files
for the tables the plan holds accountable, so onboarding an existing database
is a pull, not hand-writing every file; the pull refuses a namespace as a
whole when any of its tables carries objects the format cannot represent
(triggers, table or column comments, relation options, inheritance, partitioning,
or foreign keys on either side), and those tables need a hand-written file or
a reviewed drop before the rest can be pulled. CREATE UNLOGGED TABLE,
PARTITION BY and a child of INHERITS flattened to its own columns are all
accepted in a hand-written file.
Foreign keys are planned but not applied. A file may declare a foreign key
whose referenced table is unqualified and already exists on the target in the
same namespace; a file that creates the referenced table in the same plan does
not count, so leave the foreign key out of the file, apply the referenced
table first, then run the ADD CONSTRAINT outside SchemaBot and add the
foreign key back to the file. A declared foreign key that matches the live one plans no change. One the file
adds plans as an ADD CONSTRAINT that names the referenced table with its
namespace, and a live one the file omits plans as a destructive
DROP CONSTRAINT; both are shown for review and blocked, because SchemaBot
does not apply foreign-key changes on PostgreSQL yet. A blocked step blocks the
whole plan. A dropped foreign key is usually one the file left out: declare
it in the file to keep it, which plans no change, or run the DROP CONSTRAINT
outside SchemaBot and re-plan to remove it. A foreign key into another
namespace cannot be declared, so when the table carries one, the blocked step
names it and the plan stays blocked until it is removed. An added one may sit
on a column the same plan adds, so leave it out of the file to apply the rest of the plan first, then run the ADD CONSTRAINT
outside SchemaBot and add the foreign key back to the file. A new table that
declares a foreign key is blocked as a whole: leave the foreign key out of the
file to create the table, then add it the same way. Planning copies each
referenced table's shape, so the planning role needs SELECT on it; it never
blocks writes to it.
The pull refuses to render a table with a foreign key on either side, because it would describe the table incompletely, so such a table needs a hand-written file. One that owns foreign keys is declared with them; one that other tables reference is declared without them, since the constraints live on the referencing tables, not in its own definition. A table that owns a foreign key into another namespace cannot be declared at all: remove that foreign key or drop the table through a separately reviewed process. A drop of a referenced table has to take the referencing constraints with it. The plan names the constraints on both sides, so both sides of one relationship explain themselves.
Archive tables named <table>_archive_YYYY[_MM[_DD]] with no schema file are
exempt from the verdict, as they are in the MySQL engine's view of a live schema:
an archive is a retired copy kept outside declarative schema files. An archive
that a file explicitly declares is managed like any other declared table.
That naming convention is the only naming-based per-table exemption — a leading
underscore means nothing on PostgreSQL. ignore_tables entries, exact names
and patterns, also withhold live tables; a file declaring an ignored table is refused as a
contradiction. ignore_namespaces is the per-namespace exemption. The
plan discloses the tables it exempted, by namespace, in the PR comment and in
schemabot plan and schemabot apply output, so a reviewer can tell an
archive the verdict skipped from a table it found declared.
The underlying table set is pg-sprite's own view of which tables its
declarative model manages; SchemaBot adds only that naming policy exemption.
Tables whose definition lives elsewhere are not enumerated: a
partition is declared through its parent's PARTITION BY and follows the
parent's verdict wherever the parent lives, and extension-owned tables (such
as PostGIS's spatial_ref_sys) belong to their extension — no file can
declare them and the server refuses to drop them. An inheritance child and an
unlogged table are ordinary tables with definitions of their own, and each
needs its own file.
The enumeration covers ordinary and partitioned tables only. Undeclared views, materialized views, and foreign tables are outside the declarative format and are not reported. Because namespaces are derived from the schema files present in the PR, deleting the last file of a namespace removes that namespace from the plan altogether: its tables are not enumerated and the plan reports no change for it, so remove a namespace's final table through a separately reviewed process rather than by deleting its file.
SchemaBot's own catalog reads — the undeclared-table enumeration and the
schema pull — name every catalog relation, function and operator with an
explicit pg_catalog. qualification, so a target whose search_path lists a
user schema ahead of pg_catalog cannot shadow the catalog and turn either
into a silently wrong answer. pg-sprite's introspection pins its own
transaction-local search_path; its preflight reads run on the shared
connection pool with the session's search_path and are the library's
surface.
Planning establishes the statement shape. Apply time re-checks facts that can change or that depend on the target:
- A table larger than the configured ceiling is refused permanently for native
ALTER TABLEand blockingCREATE INDEX. This is SchemaBot's native-apply ceiling, not a PostgreSQL limit. It defaults to 1 GiB and is set withpostgres.native_safe_table_size_limit_bytes; see configuration for the trade-offs of raising it. The ceiling gates work whose cost scales with an existing table. ACREATE TABLEis exempt, andCREATE INDEX CONCURRENTLYis admitted under thepostgres.concurrent_index_max_durationbound instead; a build the bound ended and invalid-index outcomes are reported as such rather than as a size verdict. - For a change to an existing table: a missing target, or an object that is not an ordinary or partitioned table, is refused permanently.
- For a
CREATE TABLE: a relation of any kind already occupying the name, a missing target schema, a duplicate name inside the statement,IF NOT EXISTS, andPARTITION OFagainst a live parent are each refused permanently, with a reason directing a fix or a re-plan. - After a
CREATE TABLEcommits, pg-sprite reads back the constraint-index and sequence names the table owns. A table that owns a suffixed name in place of one the schema file claims — an occupant took the first choice between the catalog probe and the statement — is refused permanently. The reason names as many of the missing and owned names as it has room for beside the table name and counts the rest, so the remedy stays visible on the narrowest CLI surface; the table is left standing for the operator to rename the relation or drop, then re-plan. A read-back that does not complete is also refused permanently, even though pg-sprite marks it retryable: SchemaBot's retry re-runs the whole plan, whoseCREATE TABLEhas already committed, so it could only collide with the table this apply created. The reason directs the operator to compare the table's names against the schema file, then re-plan. - Insufficient privileges are refused permanently before DDL runs. The stored
failure includes the provisioning
GRANTderived by pg-sprite. - Exhausting the 30-second statement budget is a permanent native-safety refusal. Exhausting the lock budget is retryable after contention clears.
- Every apply other than a concurrent index build runs under a fixed five-minute client-side ceiling. It is not configurable: it exists to guarantee a terminal progress state when a dial hangs or a connection is black-holed, not to bound legitimate work, which the statement and lock budgets above already bound. An apply the ceiling ends is recorded as a retryable operational failure.
- A concurrent index build runs under the
postgres.concurrent_index_max_durationbound (24 hours by default), set as the build's server-sidestatement_timeout, rather than that fixed ceiling. A build that finds an invalid index already under the requested name or quarantined on the table that pg-sprite proves abandoned — a failed build's leftover on the target table with no backend building it, one whose builder the engine role cannot observe, or quarantine debris an interrupted recovery left — recovers it inside the same apply: the proven entry is removed and the index built inside one caller-owned envelope under the engine's own deadline of the same bound. A build that leaves an invalid index behind — including one the bound's timer cancelled — or finds one another backend is visibly still building fails as a retryable operational failure naming the index and the next step; the invalid index, not the cause that produced it, is the outcome the retry acts on, and the next drive recovers the leftover as abandoned. When the bound is what cancelled the build, the detail also namespostgres.concurrent_index_max_duration, since the retry rebuilds under the same bound and the operator's step is to raise it first. A recovery that fails before its build — the proof lock lost to a build still holding the table, or the entry changed between two verification points — fails retryable with the same naming; one that finds the pool a session short is refused permanently, as below. An invalid index under the requested name that this change can never clear — one on a different table, or one that backs a constraint or belongs to a partitioned table — is refused permanently with the same naming, as is a build that ran past the bound and provably left nothing, whose refusal namespostgres.concurrent_index_max_durationas the option to raise. A parent-level index build on a partitioned table is refused permanently, and so is a target connection pool too small to hold the build's sessions at once — the recovery holds one more session than the build, so a pool sized to the build alone refuses the recovery — since the pool is sized by the target DSN and a retry sees the same pool. - Other operational failures are recorded as retryable when no create-set prefix committed and expose a sanitized message; connection and server details remain in server logs. A create-set failure after the table commits is not retryable and directs the operator to re-plan against the live schema.
A permanent refusal before execution leaves the target unchanged. A refusal from a later create-set step can leave its earlier steps committed; it is never retried as the stale set, and its detail ends on the refusal's own remedy, falling back to a re-plan only when the refusal carries none.
PostgreSQL targets do not support:
- pending drops;
- deferred cutover (
--defer-cutover); - stop, start, cutover, revert, skip-revert, or volume controls;
- cancel of anything other than a concurrent index build (see Control operations).
Each PostgreSQL DDL statement on this path runs as its own transaction that
commits or fails. A stop request, or a cancel of plain DDL, is stored through
the normal durable control path, but the engine resolves it as a typed,
terminal unsupported-operation decline; it does not report a successful stop or
keep retrying the request. The apply continues to its own completion or
failure. If an in-flight plain DDL statement must be interrupted, find its
backend in pg_stat_activity and use PostgreSQL's pg_cancel_backend through
an operator-controlled database session; the apply records that as a failure
the next drive retries.
Pending drops is a MySQL/Spirit quarantine and cleaner. PostgreSQL plans do not
gain that behavior from enabling the server-level pending_drops block.
PostgreSQL DDL also has no deferred table-swap phase, so deferred cutover is
rejected before an apply is queued.
The PostgreSQL engine implements the required engine.Engine interface and
only the optional capabilities listed as implemented below. This table maps
the interface contract to the behavior described in Control
operations, Unsupported workflow
features, and Failure and
recovery. TestOptionalCapabilitySet in
pkg/engine/postgres/postgres_test.go
records one verdict per optional interface and fails when package engine
declares one this table does not classify; TestCapabilityMappingMatchesEngine
beside it reads this table and fails when an optional interface's row states a
verdict the engine does not hold, or when an engine.Engine method has no row.
The text of the Why and Where columns is not checked by any test.
A declined operation is refused at one of two layers, and the table says
which. Typed decline at drive time means the request is durably recorded
and acknowledged by the API, then resolved terminally by the driver when the
engine returns a typed unsupported-operation error; the operator sees the
decline in the apply's control history. Refused at intake means the API
rejects the request before any engine is consulted, with a 409 naming the
engine the operation belongs to; nothing durable is recorded and the engine's
own decline exists only to satisfy the interface, because the driver calls it
solely from a state this engine never reports.
| Interface / method | PostgreSQL behavior | Why | Where |
|---|---|---|---|
engine.Engine.Name |
Implemented | Selects the engine with the stable postgres identifier. |
pkg/engine/postgres/postgres.go |
engine.Engine.Plan |
Implemented | Plans supported PostgreSQL DDL against the live catalog and blocks statements without a safe native route. | pkg/engine/postgres/postgres.go |
engine.Engine.Apply |
Implemented | Starts bounded native DDL or a greenfield create sequence in this process; a concurrent index build runs outside a transaction as PostgreSQL requires. | pkg/engine/postgres/apply.go |
engine.Engine.Progress |
Implemented | Reads the apply record held by this engine instance and augments a running concurrent index build from PostgreSQL's progress view. | pkg/engine/postgres/apply.go |
engine.Engine.Stop |
Typed decline at drive time | A concurrent index build has no resumable midpoint, while other statements commit or fail on their own; the durable request resolves terminally and never settles the apply as stopped or cancelled. | pkg/engine/postgres/postgres.go, pkg/tern/local_control.go |
engine.Engine.Cancel |
Implemented for concurrent index builds; typed decline for plain DDL | PostgreSQL can cancel the build backend or its apply context, but plain DDL has no separately managed work to cancel safely. | pkg/engine/postgres/cancel.go |
engine.Engine.Start |
Typed decline at drive time | Stop cannot create a stopped engine phase, so there is nothing to resume. | pkg/engine/postgres/postgres.go, pkg/tern/local_control.go |
engine.Engine.Cutover |
Typed decline at drive time | DDL changes the target directly; there is no deferred table swap to trigger. | pkg/engine/postgres/postgres.go, pkg/tern/local_control.go |
engine.Engine.Revert |
Refused at intake; typed decline never reached | Each successful statement commits directly and creates no revert window, so the API refuses the request before any engine is consulted and the driver never enters the state from which it would call the engine. | pkg/api/control_handlers.go, pkg/engine/postgres/postgres.go |
engine.Engine.SkipRevert |
Refused at intake; typed decline never reached | With no revert window, every committed statement is already permanent; refused at the same intake gate as revert. | pkg/api/control_handlers.go, pkg/engine/postgres/postgres.go |
engine.Drainer.DrainContext |
Implemented | Recovery must wait for in-process apply goroutines before re-planning, then discard their instance-local progress. The wait ends with the drive's context, leaving the work tracked, so a drive whose claim is gone does not sit behind it. | pkg/engine/postgres/postgres.go |
engine.ShutdownHalter.HaltForShutdown |
Implemented | Shutdown cancels in-process concurrent index builds so they do not keep target resources after this instance stops renewing its lease; bounded plain DDL is allowed to finish. | pkg/engine/postgres/postgres.go |
engine.OwnedWorkHalter.HaltWorkOwnedBy |
Implemented | A drive handing its apply back cancels the concurrent index builds it started, and only those, and waits for its plain DDL to finish; another drive's applies on the same engine are left running. | pkg/engine/postgres/postgres.go |
engine.DeferredCutoverSignalChecker.DeferredCutoverSignalExists |
Not applicable; not implemented | Direct DDL has no deferred cutover gate or durable table-swap signal. | pkg/engine/engine.go, pkg/engine/postgres/postgres.go |
engine.ExternallyAuthoritativeProgress.ProgressIsExternallyAuthoritative |
Not applicable; not implemented | Progress is held in the engine instance's in-memory apply map, not in an external service that every instance can query authoritatively. | pkg/engine/postgres/apply.go |
engine.SynchronousWorkRegistration.RegistersWorkSynchronously |
Implemented; returns true |
Apply claims the tracked progress entry before returning and has no remote provisioning phase. |
pkg/engine/postgres/postgres.go |
engine.CancelledArtifactReleaser.ReleaseCancelledArtifacts |
Not applicable; not implemented | PostgreSQL does not create copy tables owned by this engine; cancellation attempts to remove any invalid concurrent index before settling, and a removal that fails is named in the terminal summary for operator follow-up rather than deferred to a generic artifact release. | pkg/engine/postgres/cancel.go, pkg/engine/postgres/apply.go |
engine.CancelledResumeStateReclaimer.ReclaimCancelledResumeState |
Not applicable; not implemented | Each statement runs in this process against the target directly; nothing is set up in a remote service ahead of the work that owns its teardown, so a cancel that lands between drives finds no stored resume state naming work to reclaim. | pkg/engine/engine.go, pkg/engine/postgres/apply.go |
engine.ControlResumeValidator.ValidateControlResumeState |
Not applicable; not implemented | Control requests use ResumeState.MigrationContext only as the in-memory apply key and have no opaque, operation-specific remote state to validate. |
pkg/engine/postgres/apply.go, pkg/engine/postgres/cancel.go |
engine.ShardKeyedPlanning.PlansEachShard |
Not applicable; not implemented | A PostgreSQL target is not sharded, so its tasks carry no shard and the plan never needs to speak for one. | pkg/engine/engine.go |
engine.TargetDrainer.DrainTargetContext |
Not applicable; not implemented | The PostgreSQL engine tracks its in-flight work as a whole, so a drive waits for all of it through DrainContext. |
pkg/engine/engine.go |
An accepted apply is stored before a driver executes it. If SchemaBot restarts after queueing but before execution, a new instance claims the pending work and applies it. The engine drains any in-flight background statement before a recovery path re-plans, then clears its in-memory progress so stale state from one apply cannot be reported for another.
This recovery guarantee does not make the DDL resumable within a statement or create-set task. Each statement or sequence step is one PostgreSQL transaction: it commits or fails, and recovery re-plans against the live target before further work. A privilege refusal is a permanent failed task, includes the required provisioning advice, and leaves the target unchanged.
Rollback captures the same live table set the forward plan manages. Tables
ignore_tables withholds, by exact case-sensitive name or by pattern, are
excluded before introspection, as are
archive-named tables with no schema file. An explicitly declared archive remains
managed and keeps its original definition, so rollback can reverse its changes.
A declared archive the forward plan creates has no original to keep: the
rollback re-plan finds it live with no file and, like any archive-named table
without one, leaves it in place and discloses it as exempt rather than dropping
it. Pull still exports archive-named tables; these capture exclusions do not
change pull behavior or permit a table to be both ignored and declared.
Capture is complete for that managed set or the plan is rollback-incapable; an unrenderable ignored table or undeclared archive does not prevent capture. Managed RLS tables still prevent capture until recovery can restore their complete access rules alongside sibling tables. Capture never strips policies to mark a plan rollback-capable, and a catalog read failure or cancellation still ends planning. RV-7 requires complete originals before rollback can proceed; missing originals in older plans are never reconstructed from the current target.
Select PostgreSQL in the repository's schemabot.yaml:
database: app
type: postgresThe server must configure that database environment as a local target with a
PostgreSQL DSN. The DSN may be a PostgreSQL URL or keyword/value string and may
use the same env:, file:, or secretsmanager: reference forms as other
SchemaBot secrets. Planning and applying both require DSN credentials and
verify the connection before use.
TLS posture depends on which of the two DSN configuration paths supplies the
DSN. On the raw-DSN path, SchemaBot uses the DSN as given: an explicit
sslmode is honored; when the first host in the DSN is an RDS host and
sslmode is absent, sslmode=require is added; a non-RDS host with no
explicit sslmode connects under the driver's default, sslmode=prefer,
which falls back to an unencrypted connection when TLS fails. On this path,
set sslmode explicitly for multi-host DSNs and for any non-RDS host that
must not connect in plaintext.
The assembled path — dsn_from with ca_ref, described in
configuration — is fail-closed
instead: every assembled DSN carries sslmode=verify-full, weaker modes are
rejected at config load, and the server certificate is verified against the
named CA bundle (the embedded AWS RDS bundle or a mounted PEM file), never the
ambient trust store. A pinned file: CA bundle also requires a
certificate-verifying sslmode: paired with a mode that does not verify the
server certificate, the engine refuses every apply before execution, because
the pinned roots would never be consulted.
For apply, the target role must be able to connect to the database, use the
target schema, and act as the table owner for in-place ALTER TABLE. A
greenfield CREATE TABLE instead requires CREATE on the target schema.
SchemaBot checks the tier required by the exact planned statement and refuses
before execution with specific provisioning advice when the role is
insufficient.
Planning requires a strictly stronger grant than the apply-time set above.
pg-sprite's planner introspects the desired schema by creating a scratch
schema inside a transaction it then rolls back, so the planning role also
needs CREATE on the target database, and the connection must be read-write
rather than a hot standby. Nothing persists — the transaction is rolled back —
but a role provisioned with only the apply-time privileges fails at plan time
with a permission error on the database. A schema file that declares a foreign
key also needs SELECT on each referenced table, because planning copies that
table's shape.
The Supabase adapter tests
run against the pinned Supabase PostgreSQL 17.6.1.136 image, using its
non-superuser postgres role. They need Docker, not a hosted project or API keys.
They run in CI's per-package integration shard and with make test-integration.
| Case | What passing proves |
|---|---|
| Add a nullable column | Pull, plan, apply, and replan converge without losing rows or table grants |
| Add an index | The adapter executes a concurrent index build and converges to the desired schema |
| Table with RLS | Pull retains access rules and an unchanged plan converges; a direct native column addition preserves row isolation |
| Copy and swap | The plan is blocked and leaves the table unchanged |
Run just these cases locally:
docker pull supabase/postgres:17.6.1.136@sha256:f371b5f3f2ac0a05703f33d6e6134515fb2498cab708fb948a0aeb7481467c00
go test -tags=integration -run '^TestSupabaseEngine$' -count=1 ./pkg/engine/postgresSuccessful output ends with ok for github.com/block/schemabot/pkg/engine/postgres.
A passing refusal case proves rejection, not support for applying that change.
This is a small adapter suite, not a claim of hosted Supabase support. It does
not exercise Supavisor, the Data API, Auth service, Realtime, or hosted TLS.
The Supabase fixture test covers pull and comparison; the separate atomic RLS
integration tests cover policy replacement. The fixture RLS test also calls the native apply path directly and checks visibility with the image's authenticated role and
bootstrap auth.uid() function. It does not test the full plan-to-apply workflow
for an RLS table or HTTP authentication.
Keep the test and CI image pins in sync when updating the fixture, and rerun these cases before changing the tested version here.
storage.dialect: postgres selects PostgreSQL for SchemaBot's internal plans,
applies, tasks, and other control-plane state:
storage:
dialect: postgres
dsn: "env:SCHEMABOT_DSN"This setting does not enable PostgreSQL target execution. Target execution is
selected independently by type: postgres in the repository and server
database configuration. Conversely, PostgreSQL targets do not require
SchemaBot's own storage database to use PostgreSQL. See
Configuration → Storage Dialect for the
storage DSN and bootstrap behavior.
The storage DSN must reach an endpoint that gives each connection its own PostgreSQL session, because SchemaBot coordinates its instances with session-scoped advisory locks. Startup refuses a transaction-mode pooled connection string rather than run without that coordination. See Configuration → PostgreSQL storage needs a session-per-connection endpoint.