Adopting a database
surform normalize builds a schema file from a database's state: it merges the database's INFO output — handed over as interchange files — into an SDL file, and writes the result in canonical form: the same style fmt prints, under the same width budget (--max-width, default 100).
The happy path: adopt a whole database
Start from a stub that names the scope, hand normalize the database's INFO FOR DB output, and you get a schema file:
{
tables: {
user: 'DEFINE TABLE user TYPE NORMAL SCHEMAFULL PERMISSIONS FULL'
},
params: {
limit: 'DEFINE PARAM $limit VALUE 10 PERMISSIONS FULL'
},
"@tables": {
user: {
fields: {
email: 'DEFINE FIELD email ON user TYPE string PERMISSIONS FULL'
},
indexes: { },
events: { }
}
}
}namespace acme;
database shop;
param $limit = 10;
table `user` {
schemafull;
permissions: full;
field email: string;
};
Noise the database adds disappears on the way — PERMISSIONS FULL on a field is the engine's default, so canonical SDL elides it.
The command
surform normalize schema.surdl --in-place \
--info root=info-root.txt \
--info ns=acme=info-ns.txt \
--info db=acme/shop=info-db.txtAt least one --info tag is required — with none there is nothing to adopt, and the run would silently be a disguised fmt. One tag per scope you have; missing scopes simply don't merge:
root=<path>—INFO FOR ROOToutput (namespaces, root users, the default config).ns=<name>=<path>—INFO FOR NSoutput.db=<ns>/<name>=<path>—INFO FOR DBoutput plus each table'sINFO FOR TABLEchild map under a reserved"@tables"key.
The interchange is surform migrate pull's output — all three scopes, the per-table child maps included, fetched straight from the live database (a restricted credential pulls only the scopes it can read; see the migration page):
surform migrate pull --endpoint http://127.0.0.1:8000 --ns acme --db shopThe surform-info-*.txt files land in the working directory (--dir moves them; a pull under a restricted credential writes only the scopes it can read) and the ready-made command prints — the same tags the migration page shows from the compare side.
A composition root normalizes as a project: with --in-place every merged statement returns to the file that authored it (the includes stay put; unchanged files are not rewritten), and without it the flattened canonical form prints. One refusal (SDL-E0013), two shapes: an adopted namespace/database — a scope statement belongs in the root's preamble, where its position re-binds every scope below it — and a statement the merge would have to move past an include subtree that another file's definitions still depend on (an adopted analyzer whose index lives in a composed file, say). Place those by hand and re-run.
The four verbs
What normalize does with each difference between your file and the database:
- adopt — statements the database has and your file lacks come in (that is the adoption above).
- elide — values equal to the engine's own defaults disappear. Canonical SDL is minimal:
BATCH 1000 START 0collapses to a bare sequence, an event'sWHEN truevanishes. - preserve — what
INFOcannot express stays yours: databasestrict;, indexconcurrently;, user secrets, a bearer access's stored-but-unprinted JWT clauses. - fold — server-synthesized bookkeeping folds back into the shape that implies it: an array field's
tags.*subfield folds intotags: array<int>, a relation's materializedin/outfields fold into the header arrows.
Placement is topological: adoptees land after the last definition they depend on — an index nests after its table, a fulltext index after its analyzer — with deterministic tie-breaks, so repeated runs are stable.
Running it twice
Normalize the same database twice and the second run changes nothing — the merge of the merged file is the merged file. Combined with the fixed point on fmt, your schema file settles: it stops churning once it matches the database.
Where next
- Schema migration — compare without merging.
- Formatting — the canonical style normalize writes.