import
retrofit import bootstraps an oplog from one or more .sql files.
It walks the DDL in retrofit’s vocabulary and appends the equivalent
op add calls to a fresh (or extended) oplog.
Read concepts.md first for the oplog / cut / element
identity vocabulary. This doc covers what import sees, what it
produces, what it cannot produce, and why.
Usage
Pass one or more <source> paths (or - for stdin). Schema
selection follows the six-step resolver
chain: pass --schema <name>
explicitly, or rely on [directory_scope] / implicit oplog-dir /
default_schema.
retrofit import schema.sql # resolver picks schema
retrofit import --schema sales schema.sql # explicit
retrofit import --schema sales 01_tables.sql 02_views.sql # batch, argv order
retrofit import --schema sales --from-file order.txt # curated ordering (see below)
retrofit import --schema sales - < schema.sql # stdin (once per invocation)
retrofit import --schema sales schema.sql --dry-run # parse, validate, skip the write
retrofit import --schema sales schema.sql --seal # append a Seal after the import
retrofit import --schema sales extra.sql # extend a populated oplog
retrofit import --schema sales schema.sql --force # re-bootstrap; discard prior content
retrofit import --schema sales seed.sql --label env:dev # label preserved seed rows
retrofit import --schema sales lookups.sql --purpose ref # classify all preserved DML as ref
retrofit import --schema sales schema.sql --skip-manifest skips.jsonl # skipped statements as JSONL
retrofit import --schema sales schema.sql --summary-file summary.json # aggregate summary as JSON
retrofit import --schema sales schema.sql --engine-version 17 # pin the imported schema's engine versionWhen the source carries custom DML (INSERT/UPDATE/DELETE/
MERGE), import preserves it into data files rather than dropping
it, one file per table, not one per run (see § “Custom DML
statements: the data file” below). --purpose classifies each
statement a reference-data declaration does not claim first; --label
(repeatable) names the dataset the records belong to, which
classifies them seed and stamps the labels on them when neither
--purpose nor a reference-data declaration classifies them first
(rung 3 of the chain under purpose below).
If --schema <name> names an unregistered schema, import
lazy-creates it. It mints the oplog (<name>.oplog in cwd, or at
the project root when cwd is outside it), appends [[schema]], auto-pins
default_schema on the first-ever schema, then proceeds with the
DDL. Lazy-create fires only on the explicit --schema form; the
resolver chain never invents names.
--dry-run skips the oplog write and the [[schema]] engine_version
update, but not lazy-create: against an unregistered schema it still
leaves the one-op bootstrap oplog and the [[schema]] row on disk. To
preview with no side effect, run schema create first, then
import --dry-run.
Multi-file batches are all-or-nothing: parse + validate runs over the
combined op stream, appended events flush only after the whole
batch passes, and the oplog file is written atomically once.
Each appended op renders as + <opid> <Operator> <target>
(text) or a {"type":"appended",...} record (JSON), followed by
a terminal {"type":"ok",...}. The resolved schema name appears
as a note: schema-context: <name> line on stderr (text mode) or as
the schema field on the terminal ok envelope (JSON mode). See
cli-output-contract.md for the full
shape.
Source order
Multi-file batches are imported in argv order. import does not
re-sort sources; whatever order the shell hands the CLI is the order
the ops mint into the oplog. The contract is deterministic and silent,
which means shell-glob ordering bugs are silent too.
The common trap: a Flyway / Liquibase-style tree of V<N>__<desc>.sql
filenames imported via a bare glob.
retrofit import --schema mig V*.sql # WRONG for V1..V20The shell expands V*.sql lexicographically (V1, V10, V11, V2, V20),
so the resulting oplog interleaves V10 / V11 before V2. A misordered
tree either refuses at the first ALTER whose target a later file
creates (validation-error), or imports cleanly and shows up later as
unexpected snapshot output.
Recipe: your own files
If you author the source tree, use a zero-padded numeric prefix. The shell glob then sorts correctly with no extra ceremony:
01_tables.sql 02_constraints.sql 10_indexes.sql 20_views.sql
retrofit import --schema mig *.sql # bare glob is correctRecipe: third-party migration trees
If you’re importing someone else’s tree (Flyway, Liquibase, Goose, …), you can’t rename the files. Sort the argv with a version-aware comparator before passing it to retrofit:
# sort -V is widely available on macOS and GNU coreutils,
# though not strictly POSIX. The $(…) recipe assumes whitespace-free
# basenames: fine for Flyway/Liquibase trees, but quote or use a
# shell array if your filenames could contain spaces.
retrofit import --schema mig $(ls V*.sql | sort -V)
# zsh: numeric glob qualifier
retrofit import --schema mig V*.sql(n)Sort the file list, not a concatenated stream: argv mode preserves
per-file error context (parse-error: cannot parse SQL at V07__foo.sql)
where the stdin pipe (cat $(…) | retrofit import - …) collapses the
location to -. Piping works, but if any source is malformed you lose
the file name in the failure envelope.
Caveat: the tree has to build from the oplog’s state
import validates each statement against the state the oplog and the
earlier statements built, so a migration tree imports when its files,
in order, build the schema from the oplog’s current state (nothing, on
a first import). A tree that assumes objects created outside it fails
at the first statement that does not validate, one error per run: fix
or remove that statement and re-run, so a tree with N such statements
takes N runs.
--allow-capability-gaps does not rescue this. It only demotes capability-gap
skips (statements retrofit would otherwise drop because no operator
covers them) from a failing exit back to exit 0 + a warning. It does not
relax structural validation-errors or rule-* refusals; those still
abort the import regardless of --allow-capability-gaps.
For such a tree, apply it to a PostgreSQL database and run
drift over a pg_dump --schema-only of the result, or
import that dump.
Caveat: dotted minor versions (V2.1, V17.1)
Flyway permits V<major>.<minor>__<desc>.sql for hotfix migrations
between integer majors (V2 < V2.1 < V3). sort -V doesn’t quite
agree: it sorts V2.1__b.sql before V2__c.sql because the
comparison after V2 is .1__b.sql vs __c.sql, and . (0x2E) sorts
before _ (0x5F). The version segment is right, the trailing-suffix
interleaving is wrong. If your tree mixes integer-only and dotted
versions:
- Confirm the sorted argv before importing (
ls V*.sql | sort -Vand read the output), or - Pass the files explicitly via
--from-file order.txt(see “Curated ordering via--from-file” below).
Padding majors (V01, V02, V10) only fixes the V1/V10/V2 trap
for integer-only versions; it does not resolve the dotted-minor
interleaving (the . < _ byte ordering still places V02.1 before
V02).
Curated ordering via --from-file
For trees the shell can’t sort correctly (dotted-minor versions,
hand-curated subsets, mixed authoring sources), keep the curated
order in a newline-delimited text file alongside the .sql files:
# migrations/order.txt: curated for dotted-minor sequencing
V1__init.sql
V2__seed.sql
V2.1__hotfix_seed.sql # interleaves after V2 per Flyway semantics
V3__indexes.sqlThen pass the file to import:
retrofit import --schema mig --from-file migrations/order.txtRules:
- Paths in the file resolve relative to the file’s directory,
not the process CWD. So
--from-file migrations/order.txtworks from any working directory; the listing is portable. Absolute paths in the file are honored verbatim. - Blank lines and
#-prefixed comment lines (with optional leading whitespace) are ignored, so the file can be self-documenting. - Combining with positional sources is supported: positionals are imported first, then the file list, in declared order.
-(stdin) inside--from-fileis rejected; stdin imports stay one-source-per-invocation. Pipe via stdin in a separate invocation if you need it, then import the remaining files.- Importing a migration history this way? Reach for
--seal-per-fileto seal each file as its own checkpoint, rather than collapsing the whole history into one prefix.
Sealing imported batches
A correctly-ordered multi-file batch is a stable baseline worth
sealing. --seal derives a Seal reason from the argv basenames in
order (e.g. imported V01__init.sql, V02__add_index.sql, …), which
doubles as a record of the order you imported in. When two sources in
the batch share a basename, each colliding name widens to the shortest
trailing path no other source ends with, so a Diesel history
(<version>_<name>/up.sql) reads imported 001_a/up.sql, 002_b/up.sql.
The widened label stays relative (never the absolute path), and a batch
with no collision keeps its basenames:
retrofit import --schema mig $(ls V*.sql | sort -V) --seal--seal emits a single trailing Seal after the whole import, collapsing
the batch into one sealed prefix. When the sources are an existing
migration history (one already-stable checkpoint per file, the case
--from-file is built for), use --seal-per-file instead to emit one
Seal after each source’s ops:
retrofit import --schema mig --from-file migrations/order.txt --seal-per-fileEach migration becomes its own sealed segment, as if you had run
import --seal once per file; with no seal at all, later work has no
stable point to anchor on.
--seal-per-file composes with the reason rules above:
- Bare, each per-file Seal derives its
reasonfrom that file’s basename, widened against the rest of the batch when the basename collides (every Diesel or Prisma migration isup.sqlormigration.sql), soidentifynames the migration a database is at. --seal-per-file --seal=<text>applies the literal<text>to every per-file seal.- A single-source import is a plain
--seal: one Seal over the same span, with the same reason. - An empty (zero-op) source mints no seal, so there is one seal per
source that minted ops, not one per input file. The summary says so
(
sealed 41 ops across 82 sources (2 sources minted no ops, so were not sealed)), and theimport-summaryrecord carries the count assources_zero_op.
Every Seal import appends passes the gate op seal runs over the range
it is about to seal, before anything is written: a reference record the
range would cover with no witness inside it refuses data-witness-missing
(exit 1), naming the record. That includes a record this import itself
mints, when the source carries rows for a reference table beside the DDL;
the recourse is to import without --seal, then retrofit data witness,
then retrofit op seal. A reference replace anchored in the range with
no upsert form at its anchor refuses replace-no-upsert-form (exit 2),
as op seal does (docs/migrate.md). --dry-run rehearses the same
refusals.
See --help-all for the full seal-reason rendering rules.
What import recognizes
The walker’s vocabulary maps DDL statements to operator descriptors
in internal/oplog/catalog/operators.go. The recognized set:
CREATE TABLE: including inline column constraints,GENERATED ALWAYS AS (<expr>) STORED, and identity columns (GENERATED { BY DEFAULT | ALWAYS } AS IDENTITY).CREATE INDEX: btree, gin, gist, brin, hash, spgist; predicate (WHERE) and access method (USING) bind as first-class params.ALTER TABLE … ADD CONSTRAINT { PRIMARY KEY | FOREIGN KEY | CHECK | UNIQUE }: including the legacy namedNOT NULLshape viaCHECK (col IS NOT NULL), which folds to oneAddNotNullper column. (The inlineCREATE TABLE … UNIQUE (cols)and the standaloneALTER TABLE … ADD CONSTRAINT … UNIQUE (cols)spellings both mintAddUnique, as doesUNIQUE USING INDEX <idx>when the referenced index is constraint-eligible. AUNIQUEcarrying a facetAddUniquecannot reproduce stays skip-manifested:NULLS NOT DISTINCT,INCLUDE (…),WITH (…),USING INDEX TABLESPACE,USING <method>, or a missing column list in any form other than that eligibleUSING INDEXone.)ALTER TABLE … { ADD | DROP } COLUMN: paired cross-tableDROP COLUMN+ADD COLUMNof an unreferenced column coalesce into a singleMoveColumn (simple)op.ALTER TABLE … ALTER COLUMN:SET DEFAULT,DROP DEFAULT,SET NOT NULL→AddNotNull,DROP NOT NULL→DropNotNull,DROP EXPRESSION(clears aSTOREDgenerated expression; PG13+),ADD GENERATED,DROP GENERATED,SET GENERATED(identity mutations).ALTER TABLE … ALTER CONSTRAINT … { DEFERRABLE | NOT DEFERRABLE }andALTER TABLE … RENAME CONSTRAINT.ALTER TABLE … REPLICA IDENTITY ....ALTER TABLE … RENAME TO→RenameTable.ALTER TABLE … RENAME COLUMN→RenameColumn.ALTER INDEX … RENAME TO→RenameIndex.ALTER VIEW … RENAME TO→RenameView.DROP TABLE [IF EXISTS] <name>[, <name>…]: mints oneDropTableper tracked-live target (PG permits comma lists).DROP INDEX [IF EXISTS] <name>[, <name>…]: mints oneDropIndexper tracked-live target.DROP TYPE [IF EXISTS] <name>[, <name>…]: mints oneDropTypeper tracked-live target.CREATE VIEW/CREATE OR REPLACE VIEW/DROP VIEW: single-schema forms; cross-schema views are skipped.CREATE MATERIALIZED VIEW/DROP MATERIALIZED VIEW: single-schema; cross-schema matviews are skipped.CREATE FUNCTION/CREATE TRIGGER: single-schema; bodies andWHENclauses are element-ID-keyed.CREATE POLICYand row-level-security toggles.CREATE EXTENSION/DROP EXTENSION/SET search_path: cluster-scope extension lifecycle;SET search_pathmutates the registered search path captured at bootstrap.COMMENT ON TABLE | COLUMN | FUNCTION. A function comment resolves its signature asALTER FUNCTION … RENAMEdoes, sopg_dump’sf(n integer)namesf(integer); a bareCOMMENT ON FUNCTION fresolves only when one overload offis live, and any other miss records acomment-function-unresolvedskip.COMMENT ON CONSTRAINT … ON <table>, for a primary key, foreign key, unique or check constraint, resolved by the name the constraint holds at the statement, so a later rename keeps the comment;<table>_pkeyalso names an inlinePRIMARY KEY. Any other target (a PostgreSQL 18 named NOT NULL, an unknown name) records acomment-constraint-unresolvedskip.COMMENT ON CONSTRAINT … ON DOMAINstays in thecommentsbucket.DO $$ … $$;anonymous blocks →RunRawScript, unless a data-motion recognizer intercepts the block first.
Unresolved trigger callbacks
When CREATE TRIGGER names a callback function that the input and current cut
do not define, import accepts the trigger and mints a synthetic CreateFunction
for the unresolved callback. The synthetic op has
language=internal, appears in op show, and contributes to the imported op
count even though the user did not author function DDL. It exists only to give
the referencing CreateTrigger an element identity in the oplog.
The round-trip is deliberately asymmetric: snapshot suppresses the synthetic function’s DDL
because it has language=internal, but still emits the referencing CREATE TRIGGER.
The emitted DDL therefore applies successfully
only when the target database already provides <name>; otherwise PostgreSQL
rejects the trigger at apply time with function … does not exist. Import
exits 0: an unresolved callback is reported as a finding, not refused.
Text mode reports the condition as:
finding: function-synthesized-stub: callback <name> is unresolved; a stub was synthesized and will fail at apply if <name> does not exist in the target database
See CLI output contract for the text and JSON channel shapes.
DROP … IF EXISTS liveness
DROP TABLE, DROP INDEX, and DROP TYPE with IF EXISTS are
liveness-aware. Import evaluates liveness independently at each
target’s source position. A tracked-live target mints its DropTable,
DropIndex, or DropType; an ordinary absent target is suppressed without
an op or skip-manifest entry. In a mixed target list, live targets therefore
mint while absent targets do not. The bare form remains explicit drop intent
and follows the same mint path, but fails or records a skip when its target
cannot be resolved instead of using IF EXISTS suppression.
This per-target rule preserves a pg_dump --clean --if-exists prologue as a
no-op against a fresh import target without treating every guarded drop as
prologue noise. DROP SCHEMA IF EXISTS is the deliberate carve-out: it stays
suppressed because the catalog has no DropSchema operator. A table name
still recorded as an unminted deferred-data-motion target is also not treated as
an ordinary absent table; its drop receives the never-silent
cross-skipped-data-motion-target disposition.
What import cannot produce, and why
import records each change as the source states it. A second
pg_dump --schema-only is a state, not a change: on a populated
oplog import refuses it with state-source-on-populated-oplog (see
When a bare re-import is not what you
meant), and it is
drift’s input.
Two shapes are the exception, recognized only when the source carries
their full evidence: a cross-table DROP COLUMN + ADD COLUMN of an
unreferenced column coalesces into MoveColumn (simple), and a
solid-match ExtractTable is recognized (see below).
Data-motion SMOs
import recognizes one data-motion shape from a migration script: a
solid-match ExtractTable. The source must be
fully consumed (every column reparented into a child, source
constraint/index-free, source DROP TABLE’d), matching the replay
op exactly. On a match, import mints the ExtractTable and
emits an import-extract-recognized advisory.
The source-surviving column-extract shape (ExtractTable’s survivor
arm, CREATE child + INSERT … SELECT + ALTER TABLE source DROP COLUMN, with no source DROP TABLE) is authorable with op add.
import does not recognize it: recognition keys on a DROP TABLE, which
this shape lacks, so it takes the import-data-motion-deferred path
below.
Everything that only partially matches (the source-surviving
column-extract, and the other four data-motion shapes CopyTable,
JoinTable, SplitTable and MergeTable, plus the partition
converts) emits the plain primitives import already produces
(CreateTable + DropColumn + …) with a never-silent
import-data-motion-deferred advisory pointing at op add. Coalesce
in the unsealed tail, or author the op directly. See
drift.md § “Data-motion SMOs” for the op add recipes.
A child born by CREATE TABLE … AS SELECT in a deferred shape is not
minted at all, so every later statement against it (ALTER TABLE,
CREATE INDEX, DROP TABLE, and row statements such as an UPDATE
backfill) lands on the skip manifest as cross-skipped-data-motion-target
until an in-schema CREATE TABLE reuses the name. The rows are not
captured as a data record: author the data-motion op first, then capture
them at HEAD.
Out-of-vocabulary statements: the skip manifest
Sequences (standalone), privileges (GRANT/REVOKE/OWNER), cross-schema
objects, composite-type attribute mutations (ALTER TYPE … {ADD|DROP|ALTER} ATTRIBUTE: composites are born-complete; mutate
via Drop+Create), and any other DDL outside the vocabulary land on a
skip manifest. Each skipped statement carries its kind, name,
schema, source line, and the raw text; nothing is silently dropped.
In JSON mode that per-statement detail rides intermediate
{"type":"skipped",…} stream records; the terminal envelope carries only the
count, as an integer skipped field. Text mode has no
per-statement surface at all: it renders one aggregate summary line, tallying
the skips by friendly kind and adding a disposition breakdown when the set
spans more than one class. Use --skip-manifest to get the individual records
in a text-mode run. See
cli-output-contract.md for the record’s field set.
Declarative-partition DDL is still skip-manifested (recognition is
op add-authored). The structured skip is the
contract: you can see exactly what import passed over.
A legacy inheritance child (CREATE TABLE c (…) INHERITS (p), no
PARTITION OF) is skip-manifested whole as
pg-legacy-inherits-partition, a loss-bearing gap: no op models the
inherited columns or the link. Every later statement against the child
(CREATE INDEX, ALTER TABLE, row statements, DROP TABLE) lands on the
manifest as cross-skipped-legacy-inherits-child until a plain
CREATE TABLE reuses the name.
--skip-manifest <path>: the skips as a file
--skip-manifest <path> writes one JSONL record per skipped statement to
<path>. It is a convenience sink over the stream above, not a second
classification pass: the file carries the same records, projected through the
same helper as the wire event, including raw_text, which is un-truncated on
both. Read the file surface as the base record: a wire event may carry an extra
key the projection adds on top (the zero-op-step advisory adds source,
naming the same input the record’s src_file already points at).
retrofit import --schema sales schema.sql --skip-manifest skips.jsonl{"kind":"CreateSeqStmt","line":2,"name":"sq","raw_stmt_kind":"CreateSeqStmt","raw_text":"CREATE SEQUENCE sq","schema":"","src_file":"schema.sql"}Keys serialize in alphabetical order, so the bytes are stable across runs,
but key by name, not by position. schema is empty for a statement that
carried no qualification, and src_file names the file line counts within,
so a record stays locatable across a multi-file import. Treat the field set as
open and tolerate keys you don’t recognize.
The write happens at terminal emission, independent of output mode: a run that
exits 2 because it dropped gap-class statements still writes the file,
which is the debugging case the flag exists for. That covers the dispositional
exits: the ones that classified a skip set and then failed on it. A run
refused before terminal emission (state-source-on-populated-oplog,
data-file-collision,
parse-error, and the other pre-terminal refusals) writes neither sink. The
sinks are written by the terminal emitter, so a run that returns ahead of it
produces no file, including when the walker had already accumulated skip
records, as a data-file-collision refusal has. Their exit codes vary by error
class: most refuse with exit 2, but parse-error exits 1.
--summary-file <path>: the aggregate as a file
--summary-file <path> is the file-sink twin for the aggregate. It writes the
import-summary record to <path> as a single JSON object (not JSONL;
there is exactly one summary per run):
retrofit import --schema sales schema.sql --summary-file summary.json{"imported":2,"run_raw_script":0,"skipped":1,"skipped_by_class":{"decision":1},"skipped_by_kind":{"CreateSeqStmt":1}}imported, run_raw_script, skipped, and skipped_by_kind are the
normative keys; skipped_by_class, sealed, and sources_zero_op join
conditionally. skipped_by_class splits the same skipped total along the
disposition axis rather than re-basing it. The per-kind and per-class tallies
live only here; they are deliberately not duplicated onto the terminal
envelope, so a consumer that wants them must read this record.
Unlike the skip stream, the import-summary record is never streamed to stdout
in text mode; --summary-file is how a text-mode run gets it. Like
--skip-manifest, the write lands on a dispositional exit-2 run and is skipped
by the same pre-terminal refusals.
Both flags can be set on the same run: the two sinks compose, and neither
suppresses the other. They must name different files; pointing both at one path
refuses as summary-file-collides-with-skip-manifest rather than letting the
summary object clobber the JSONL.
Custom DML statements: the data file
Custom data-migration DML (INSERT, UPDATE, DELETE, and
MERGE) is not part of retrofit’s vocabulary, but import must not
destroy it. Rather than skip-manifest it (which would only report
the loss), import sets each such statement aside, verbatim, into a
runnable per-import file: the data file.
Each import that encounters custom DML mints one data file per table it writes:
- Name:
<schema>.<table>.<YYYYMMDDHHMM>[.append][.p<K>].data.sql, the table and then the UTC minute the run captured it at. The stamp is opaque and never read back. - Location: the schema’s
data_root, a[[schema]]field inretrofit.tomlscaffolded byschema create; unset, it inherits[project] data_root, then defaults todataunder the project root. Aseedrecord lands under the firstseed_pathsentry instead, when one is declared. - Key:
{table, band, label-set, anchor}, carried in the header. A record of another band (ref,seed, or thehistoryband thattransitionandcleanseshare) at the same anchor is another key and is left standing, so a--purpose transitionimport beside a reference record is a second file, never a rewrite. - Re-capture at a held key: a floor is overwritten, an
appendaccumulates, the floor’s own source moves it, a witnessed key refusesdata-witnessed-key, and identical records mint nothing. See § “Re-ingesting into the corpus” below. - Name collision: a computed name already held by a record of a different key takes the next minute, and the next, until the name is free, so two records of one table minted in one minute are two files.
- Readers use the header: every fact comes from the header, never the name, so a moved or renamed file still resolves.
The user-facing sentence is one table per file, and it is the
grain to curate by: rm data/<label>/c.* removes table c at every
anchor. Two shapes hold more than one file for a table: a capture
whose record count exceeds the page bound is a page set
(.p2, .p3, … with page=K/N on each header), and a floor plus
later appends at one anchor are separate files. Removing one member
of either does not curate the table out (the sibling still speaks
for it), so curate the set, and let retrofit data check confirm the
corpus reads cleanly before you commit.
Why these statements can’t live in the oplog: an op carrying an
opaque DML body would be silently broken by a later element-ID
rename (the journal would render knowingly-broken SQL), and the only
opaque-body op retrofit has (RunRawScript) is restricted to
DO blocks. So custom DML lives outside the oplog, where a
later rename can’t reach it. The files stay consumable: snapshot
and migrate discover the corpus automatically (see below and
migrate.md / snapshot.md). The corpus
is every addressed *.data.<format> under data_root (.data.sql; a
legacy *.dml.sql is still read, and only an obsolete header refuses
with data-file-malformed).
import never runs a data file. The rows reach a database only
when you apply them: run the file yourself, or apply the SQL snapshot
or migrate emits, which carries the bodies (migrate splices each in
after its anchor op’s DDL). The skipped_by_kind counts for
InsertStmt/UpdateStmt/DeleteStmt/MergeStmt in the import
summary record tell you how many statements landed in data files.
Each file is line-oriented. A v1 header tuple names the import
source, the one table the file declares, the anchor it is pinned at,
and the file’s axes (purpose, disposition, body, format,
labels, and page on a paginated capture). Every record rides
inside the file’s single body fence: a marker line, its verbatim
text, and an end line that closes the body. The body is written
exactly as captured and appends nothing to it: a statement
that arrives without a trailing ; is stored without one, and
migrate supplies the terminator when it renders:
-- retrofit:data-file v1 source="01_seed.sql" table=["public","users"] anchor-op=988edcb612ab purpose=seed disposition=append body=data format=sql labels="[\"env:dev\"]"
-- retrofit:dml-section
-- retrofit:dml seq=1 src-file="01_seed.sql" src-line=42 kind=InsertStmt
INSERT INTO users (id, email) VALUES (1, 'a@example.com');
-- retrofit:dml-end
-- retrofit:dml seq=2 src-file="01_seed.sql" src-line=44 kind=UpdateStmt
UPDATE users SET active = true WHERE id = 1;
-- retrofit:dml-end
-- retrofit:dml-section-endA body=none file is a floor (it declares the table empty at
its anchor) and carries no fence at all: the header is the whole
file.
The body is the whole lines between the marker line and the end line. The newline just before the end line is part of the framing, not of the body, so a body never gains a trailing newline its author did not write. Everything in between is kept byte for byte: no escaping, no unescaping, no normalization.
The retrofit:data-file / retrofit:dml marker tokens are the
on-disk wire grammar and are kept stable for re-ingest; the file
itself is what you call a data file. The header tuple carries the
file’s facts, in this fixed order:
source: where the file came from (the import source, a drift dump, orscaffold). Provenance prose; nothing tests it.table: the one table the file speaks for, as a two-element JSON array["<schema>","<relname>"], each half spelled exactly as the oplog spells it. Every record body must write that table and no other (H-5); a record that writes another relation belongs in its own file.anchor-op: the 12-hex OpID of the op minted immediately before the statements’ source position.migratesplices the bodies back in just after that op’s DDL. A statement with no preceding minted DDL in its source file falls back to that run’s first minted op; a data-only import (zero DDL) instead anchors at the current oplog head (see below). An authored statement whose table does not stand at its anchor refuses withdml-table-not-found, and an empty oplog models no table, so a data-only import that captures a statement into one refuses that way (a statement no rung classifies is listed, not captured). Statements anchored at different ops land in different files.purpose: the producer-assigned classification (see below). Absent meansunclassified.disposition:append,replaceorrestate.replacedeclares the file is the table’s full observed row set at its anchor; import infers it from a TRUNCATE / unqualified-DELETEshape: the INSERTs for that table that follow the arming statement mint as onereplacefile, and an arming statement with no following INSERT mints areplace body=nonefloor. Everything else isappend;--dispositionoverrides the inference run-wide.restate(hand-authored or scaffolded only) restates the table’s rows for exactly the file’s label set, ignoring that chain’s earlier records. Areplaceorrestatefile must carry a fold-band purpose (seedorref). Records of one (table, label-set) chain are versioned by their anchor-op position: the latest floor at-or-before a render point supersedes earlier state, whilesnapshot --atstill renders older versions.body:data(the fence holds the rows) ornone(a floor declaring the table empty; no fence). Absent meansdata.format:sql, the inline body’s serialization; written on abody=datafile only, and the file’s extension must agree with it (.data.sql).labels: the optional opaque label set (see--label), emitted only when the file carries at least one label.page:K/Non a paginated capture only.
Each record marker inside the fence carries:
seq: a 1-based counter in capture order, one stream across every file a run mints, so records merge back into source order across files. Two records at one anchor with the sameseqin different files are an order ambiguity, refused withdata-file-order-ambiguous.src-file(relative to the project root) andsrc-line: where the statement came from. JSON-quoted, likesource. A hand-authored record carriessrc=handinstead.kind: the AST node kind (InsertStmt, …). Areplacefile admitsInsertStmt(and hand-authoredCopyStmt) records only.end: the optional framing tag (see Escalated framing below). Present only when the body needs one; it names the end line that closes this record. It is the marker’s last token.
A pg_dump -- TABLE DATA band is not captured by import: the
run lists the band’s tables (data-band-listed) with a hint to
retrofit data capture <source>, and mints nothing for them. Capture
accepts INSERT-style dumps; regenerate a COPY ... FROM stdin dump with
pg_dump --inserts or --column-inserts first. See data.md.
retrofit data check [<path>…] runs the file-level checks over the
working tree (every discovered *.data.<ext> file under data_root
by default), reporting each refused file with the predicate id that
refused it and naming any *.data.* file the walk would not read.
It emits no SQL and mints nothing; run it before committing a
hand-edited corpus.
Escalated framing: when a body contains marker-like lines
The default end line is the fixed text -- retrofit:dml-end. That
works until a body itself contains a line starting with
-- retrofit:dml, at which point the reader could not tell the
body’s line from the real frame. So:
A record must use escalated framing whenever any line of its body
begins, at column 0, with the literal -- retrofit:dml. Escalated
framing adds an end=<tag> token to the marker line and closes the
body with -- retrofit:dml-end:<tag> instead:
-- retrofit:dml seq=3 src-file="03_notes.sql" src-line=7 kind=InsertStmt end=1
INSERT INTO notes VALUES ('a literal
-- retrofit:dml-end
inside a string, so this record escalated its tag');
-- retrofit:dml-end:1A tagged record is closed only by its own tagged end line. Inside
one, every other marker-like line (including
-- retrofit:dml-section-end) is ordinary body text. That is what
makes escalated framing the way to carry self-referential content.
Choosing a tag:
- A tag is a decimal integer with no leading zeros (
1,2,17). - Tags are unique across the whole file, not per record and not
per section. The tag assigned is the lowest one still free in
the file, in record order, so the first escalating record usually
takes
1, the second2, and so on. A record that does not escalate consumes no tag, which is why a file may legally holdend=1, then an untagged record, thenend=2. - A tag must also be absent from the body it closes: if the body
already contains the line
-- retrofit:dml-end:<tag>at column 0, that tag would close the record early, so retrofit skips it. Tag allocation may therefore skip integers: a body containing-- retrofit:dml-end:1getsend=2even when1is otherwise free. - When you add or copy a record by hand, pick a tag that is both free
across the entire file and absent from that record’s body. Reusing
one fails the next read with
data-file-malformed, and the message names the lowest tag still available across the file; if that tag collides with a line in your body, take the next one that does not.
The literal prefix is -- retrofit:dml and nothing wider: the
sibling -- retrofit:raw, -- retrofit:snapshot-data and -- retrofit:data
markers belong to other features and ride as ordinary body content,
so they never force escalation.
One placement rule follows from all this: a record whose body
contains a column-0 -- retrofit:dml-section-end line cannot be
placed inside a section, because its body would swallow the
section’s own terminator. That shape fails with data-file-malformed
on write and on read. The same body outside a section is fine.
If a data file will not parse
A framing fault fails the read with data-file-malformed, naming the
record and line. The common ones:
- A body’s end line is missing, or was damaged; a CRLF rewrite is the usual cause, since end-line recognition is byte-exact. When only trailing whitespace differs, the message says so, because the fix is your editor’s line-ending setting rather than the body.
- A body contains a
-- retrofit:dmlline but the record was not escalated. The remedy is the tag escalation above. - Anything other than blank lines and ordinary
--comments sits between one record’s end line and the next marker line. - A record’s body is empty.
- A marker line carries the retired
bytes=field. That token framed bodies before end lines did; delete it from each marker line and close each body with a-- retrofit:dml-endline.
purpose: how import classifies preserved DML
Each preserved statement is tagged with a purpose so downstream
verbs know what to do with it. The enum is:
transition: one-time custom DML tied to a schema change.migratesplices it inline, right after its anchor op’s DDL.cleanse: like a transition, but emitted under arun-oncemarker so a re-applied migration won’t repeat it.ref: production reference data that belongs to the current state. Always applied by bothsnapshotandmigrate; never label-filtered.seed: datasets (fixture / environment rows). Opt-in on both consumers by naming the dataset with--label <label>; otherwise excluded and counted, causenot-selected.unclassified: the fail-closed default (an absentpurpose=token folds to this).migraterefuses unclassified bodies unless you pass--allow-unclassified-dml;snapshotexcludes and counts them.
Classification is a four-rung chain, decided once per statement; the first rung that applies wins:
- The reference-data declaration: an oplog attribute, not
configuration. A literal
INSERTthe reference fold admits (anON CONFLICTclause, for one, fails it) into a table whose resolved read at the run’s cut isreference_data=trueclassifiesref, whatever--purposeor--labelsays.refis never label-selected. --purpose <value>: a run-level assertion stamped onto every other statement the run preserves.--labeldataset: a run that declares one or more--labelvalues resolves its records toseed. A label names a dataset, and a dataset is a seed band by construction, so the band follows from what you already said rather than being asked for twice. Any other statement into a declared reference table never resolves toseed: without--purposeit is listed with causeno rung.- Residual: listed, not minted. There is no producer-default
transitionorseedrung: a purpose must be asserted, target a declared reference table, or follow from--label. A flagless authored DML statement is therefore reported on theskippedstream with causeno purpose or labeland lands in no data file.
--label: opaque dataset names
--label <label> (repeatable) declares a dataset for the
run. It is rung 3 of the chain above: absent --purpose and outside
a reference-data declaration, the records it covers resolve to seed, and
the labels are then stamped onto them. Labels select cohorts within
the seed band, so only seed records carry them: a record that
rungs 1 or 2 classified ref / transition / cleanse, or that
the residual lists, is never labeled. The value is free-form (a
key:value form is accepted but not required); labels are sorted and
deduped on disk. They carry no meaning to retrofit; they exist so you
can later name the dataset at emit time with --label <label> on
snapshot / migrate.
Re-ingesting into the corpus
A re-ingest whose records anchor at a new op mints a new data file. At a key that already holds a record:
- Identical records mint nothing.
- A divergent floor (
disposition=replaceorrestate) at an unwitnessed key replaces that key’s version as a set: matched files are rewritten in place under their original names, surplus pages are deleted and named in the report, missing pages are minted, and each replaced file is reportedoverwrote:with its digest (git retains the old bytes). - An
appendaccumulates: the incoming statements join the standing record after its own, reporteddata-appended:with the digest and the statements added. Two data-only imports into one table move no head, so they share the anchor and leave both sources' statements in the one record, in import order. - Accumulation is per source file. Re-importing an unchanged
src-fileis a no-op, re-importing it changed replaces that source’s statements rather than adding them again, and a statement restating a keyed row another source states supersedes that source’s statements. The header’ssource=stays the first writer’s, each statement keeps its ownsrc-file/src-line, and a statement another source already holds is skipped as a duplicate. - An
appendof a different purpose than the standing record’s is not merged into it and overwrites it. - A witnessed key refuses
data-witnessed-key.
Importing the source a floor was captured from (data capture s.sql,
then import s.sql) moves the floor rather than deduplicating against
it. The last verb run on a source decides what the key says about it,
whatever the bytes, so the floor’s file retires and s.sql’s statements
become the key’s append (replace → append, joining a standing
append there, if any). The report names the clear leg migrate no
longer renders and the way back:
moved ref: zip - s.sql is now a delta at <anchor>, not the whole table (replace → append)
note: rows the table held that s.sql does not are no longer deleted at migrate
to restore the whole table: retrofit data capture --schema app -- s.sql({"type":"data-moved",…} with table, anchor, labels,
src_file, from, to and path under --as json). Only the
floor’s own source moves it: another source restating the floor’s rows
is still the silent duplicate skip, and a source from stdin has no
identity to move by. The way back holds at identical rows too: data capture s.sql on the moved key mints the floor again and reports the
move back (overwrote @<anchor> (append → replace), or supersedes
once HEAD has moved; see docs/data.md). --dry-run reports the move
as would move … and writes nothing.
--purge-superseded opts out of keeping superseded cross-position
blocks, except a witnessed record, sealed or in the tail, which the
purge never touches: each is left byte-intact and reported as
kept: <schema.table>@<anchor> <labels> <path> (witnessed by <opid>; …)
({"type":"data-file-kept",…} under --as json).
A data-only import (a source carrying zero DDL) leaves the oplog
byte-unchanged and anchors its records at the current oplog head.
Against an empty oplog it refuses with dml-table-not-found: no
table stands at the head its records would anchor on.
PG18-gated statements
import parses with the PostgreSQL 17 grammar (libpg_query v6), so
PG18-only syntax (NOT ENFORCED, generated VIRTUAL columns, WITHOUT OVERLAPS) does not parse: the run exits 1 with parse-error.
PostgreSQL versions gives every verb’s row at
each major.
--engine-version <major>: pinning the target
--engine-version <major> sets the engine version the imported schema is
pinned to. On a mint (the run that creates the oplog), one resolved value
seeds both the oplog’s # engine-version: header and the schema’s
[[schema]] engine_version override, so the two start equal.
They are independently mutable thereafter; see the adopt-run caveat below.
retrofit import --schema sales schema.sql --engine-version 17Resolution runs in this order, first hit wins:
--engine-version- the prior oplog header, whenever the run adopted or extended an existing oplog
[project] engine_versioninretrofit.toml- the built-in default
Rung 2 is the one worth knowing: when importing onto a populated oplog, an oplog already pinned to a major keeps that pin without the flag, and omitting the flag does not fall back to the project default.
--engine-version does not move an existing oplog pin. On a run that
adopts or extends an oplog, the existing header block is preserved verbatim, so
the flag reaches only the [[schema]] override; passing a major that differs
from the header leaves the two pointing at different values. What happens next
depends on which way they diverge:
- Override above the pin (
--engine-version 18on a 17-pinned oplog): the run succeeds, and the next bareimportre-resolves from the header and overwrites the override back to 17. The pin change simply does not stick. - Override below the pin (
--engine-version 17on an 18-pinned oplog): the run also succeeds, but it leaves the project wedged: a target may not lag the pin, so the next command that loads the project the normal way refuses withengine-version-target-below-oploguntil you restore the override or lower the pin.schema enginewith an explicit--schemais the way out: its write path is exempt from that invariant precisely so it can repair the mismatch.
Use retrofit schema engine <major> --schema <name> rather than an
import flag: it is the verb that moves both axes together, and the one
the refusal hint points at.
A value this build does not recognize is refused as
unknown-engine-version before anything is written; the envelope lists the
supported majors.
Re-running import
The default behavior on a populated oplog is to extend it: the new
ops chain off the existing tail. This is the case for multi-file schemas
where 01_tables.sql was imported first and 02_indexes.sql was added
later, and it produces the same ops as one multi-file invocation on a
fresh schema, apart from the minted ids.
There is no flag to ask for that; extending is what a bare import
does. One opt-in changes it:
--force: discard the existing oplog content and re-bootstrap from the new sources. The prior tail is gone. Useful when re-importing from an authoritativepg_dump.
When a bare re-import is not what you meant
Extending by default makes one mistake cheap to make: offering a
whole-schema state source (a pg_dump --schema-only of the current
database) to an oplog that already has content. That source describes
where the database is, not the steps that got it there, so appending
it would mint a second copy of every object rather than the delta. The
run refuses with state-source-on-populated-oplog before writing
anything.
The refusal’s hint names the three verbs that each do something different with that file:
retrofit drift <source>: the database moved out of band and you want the oplog to converge on it. Mints the difference between the cut and the source as new ops, appended to the existing tail. The history is preserved; this is the usual answer.retrofit import --force <source>: you want to start over from the source. The existing oplog content is discarded and replaced by a fresh bootstrap. Prior history is gone. A branch that appended to the old history and is then merged with the restarted one holds twoCreateSchemaroots of one schema, whichvalidateandreconcilerefuse: take the restarted side and add the branch’s ops again on it.retrofit data capture <source>: the part you wanted was the rows, not the schema. Captures the source’s data into the corpus and leaves the oplog untouched. This verb is named only when the source actually carries rows (INSERTorCOPY); aCOPYband must first be regenerated in INSERT form withpg_dump --insertsor--column-inserts.
An oplog that holds ops but declares no matching CREATE SCHEMA
refuses with schema-not-in-oplog instead. That one is decided on
oplog state alone, independently of what the source looks like, so it
is a different question from the state/steps confusion above.
Failure modes
source-empty: a<source>that is a stream (-,<(…), a FIFO or another descriptor) delivered zero bytes, which on a pipe is the signature of a producer that failed upstream. Exit 2, Paramsource, refused before any parse; a zero-byte.sqlfile is read as it always was. Check the producer, or save its output to a.sqlfile and pass the path.kind-mismatch: a<source>that is not a.sqlfile path,-, or a stream, such as a URL scheme, a non-.sqlextension, or an extension-less regular file. The hint names each accepted form (retrofit import - < <file>,retrofit import <(…)).data-band-over-cap: a table’s INSERT band in one source is larger than the schema’sdata_band_max_bytes(or the built-in default when the key is absent, including a schema this run would lazily create). The bound is checked per source file before any source is parsed and before anything is registered or written, so a batch with one over-cap source refuses whole; every over-cap(source, table)is named in one envelope. Raisedata_band_max_bytesfor the named schema, or remove the key for the built-in default.state-source-on-populated-oplog: the source is a whole-schema state dump and the target oplog is already populated. See When a bare re-import is not what you meant for the three recovery verbs the hint names.schema-not-in-oplog: the target oplog holds ops but declares noCREATE SCHEMAwhose name folds to the resolved--schema. Evaluated on oplog state alone;--forcere-bootstraps past it, andschema removedrops the file.unknown-flag: a flagimportdoes not register. The retired flag that askedimportto extend a populated oplog is one:importextends one by default, so the flag was withdrawn with no alias and no deprecation window, and typing it now lands here with a hint saying to drop it.rule-*(and explicit wire-stable codes likechange-primary-key-dangling-fk): an emitted op failed its precondition validator (e.g.,CreateTableon a table that already exists in the cut,AddColumnon a column that already exists). The envelope names the first failing op. When the source shape suggests the database has moved without you (duplicateCreateTable, dangling FK reference on a populated oplog), the hint points atretrofit drift. Seedrift.mdfor the converge-to-state use case.schema-mismatch:--schema <X>was passed, but the source declares or references a different user schema (CREATE SCHEMA <Y>or<Y>.<table>). Pass--schema <Y>or split the source.default-schema-unset: the resolver chain produced no result (no--schema, no-f, no[directory_scope]match, no implicit oplog-dir match, nodefault_schema). Pass--schema,--chdirinto a project dir, or runschema set-default.no-project: running outside aretrofit.toml-walkable tree.importneeds a registry to mint into; runretrofit initfirst.label-empty: a--labelvalue was empty or whitespace-only.skip-manifest-clobbers-managed-file: the--skip-manifestor--summary-filepath resolves to a file the run manages: the oplog,retrofit.toml(wherever--configorRETROFIT_CONFIGput it), or any name the run could mint or read under the data root, whether or not that file exists yet and whetherdata_rootis declared or defaulted. Any other name under the data root is allowed, at any depth. The check follows where the path lands, so.., symlinks and case variants on a case-insensitive filesystem are caught, and it runs before anything is written. Choose a path outside the managed files.summary-file-collides-with-skip-manifest:--summary-fileand--skip-manifestresolve to the same file. The two sinks compose, so the summary object would land last and overwrite the per-skip JSONL; the run refuses in the same preflight instead. Give each flag its own path.data-file-collision: a re-ingest collides with another key’s data file at the mint path, a hand-authored (src=hand) file, or a claim sharing some but not all of its labels. Relabel the new set as its own cohort, orrmthe existing file if it should not exist.data-witnessed-key: the mint key carries a governing witness. Capture at HEAD,retrofit op remove <witness opid>for a tail witness nothing anchors on, orretrofit op unsealwhen the key is sealed.dml-table-not-found: an authored data statement’s table does not stand at the op its rows anchor on (for a data-only import, the oplog head; an empty oplog models no table). Import or author the table first.import-move-column-fold-mismatch:importfolded anADD COLUMN, a key-correlatedUPDATE(a down’s padINSERTfirst) and aDROP COLUMNinto one keyedMoveColumn, but the second pass minted no move or derived another arm than the fold was judged by (exit 2). The source is refused rather than the statement dropped; the message names the statement and, for an arm mismatch, both arms. The hint asks you to report the script and, as a workaround, to split the move intoDropColumn+AddColumnand author the data step withop add.parse-error: the source SQL failed to parse. Line number in the envelope.arg-binding:-(stdin) was combined with other sources, appeared more than once, or appeared inside--from-file(stdin can only be drained once and is excluded from curated listings); or the invocation supplied no sources at all (no positionals and no--from-file, or a--from-filewhose body parsed to zero paths).io-error: the source file is unreadable, the oplog cannot be written. Detail line carries the syscall reason.
Scope contract
import’s in-scope set is every operator whose DDL the walker
recognizes, plus the two shapes recognized from their full evidence
(see What import cannot produce, and
why). The recognized DDL shapes
are listed in § What import
recognizes above; the full per-operator inventory is in
smo-catalog.md.
The pg_dump round-trip story (what import accepts that pg_dump
emits, where the parities sit) lives in
pg_dump-parity.md.
Related
concepts.md: oplog, cut, element identity.drift.md: the “modify a dump, reconcile” verb for what import cannot infer.pg_dump-parity.md: option-by-option audit of import’s pg_dump alignment.smo-catalog.md: every operator with status.cli-output-contract.md: per-verb stdout / stderr / exit-code shape.