writeonce/docs/superpowers/specs/2026-08-31-schema-migrations-design.md
shoney.arickathil 1b6d633ed1 docs(db2-migrate): spec + story for schema migrations v1
- brainstorm settled: declarative and automatic at boot; v1 verbs are
  add and delete only; data/seed migrations deferred to v2
- added fields zero-fill by kind: the grammar has no field-default
  syntax and v1 refuses to grow compiler surface for it
- same-kind delete+add refuses as a disguised rename; retype and
  vanished classes refuse by name
- schema lives in the log itself: WO_WAL_SCHEMA head record, written by
  fresh-log open and compaction; name-keyed diff also closes the
  silent cid-renumbering hole
- story is iteration 12, board row added, db2-migrate prefix claimed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 072e007b144ff6689b6ff920ea10665900c1a2ef)
2026-08-31 21:54:27 +02:00

7.1 KiB

Schema migrations v1 — add + delete, declarative, auto on boot

Brainstormed and approved 2026-08-31. Story: databasev2 12. Scope settled during brainstorm: declarative model, add/delete only, zero-value fill, data/seed migrations deferred to v2.

The problem

A @table class is the schema and WO_DATA's log is the database, but the two are never compared. A record names its class by cid — the class's declaration index — and replay decodes every record against the compiled shape. So today, all verified:

  • Adding a field makes every old record under-run its decode and replay refuses the whole log as "corruption".
  • Deleting a field leaves trailing bytes — the same refusal.
  • Reordering @table declarations renumbers cids and rows decode into the wrong class, silently when shapes happen to match.
  • Nothing records which schema wrote the log. The durable/resident mode mismatch is checked at boot (databasev2 2); the shape is not.

Changing a @table class is a database migration. The language performs it.

The model, and why

Declarative, automatic, at boot. The binary already carries the schema — wo_classdesc has the class name and per-field names (v2 metadata), so no .wob format change is needed. At startup the runtime compares the shape recorded in the log against the compiled shape and either replays (match), migrates (add/delete), or refuses (everything else). No migration files, no offline step: writeonce's doctrine is one binary and no ops choreography, and a migration artifact would be a second source of truth for a schema the class declaration already states.

v1 verbs: add and delete only. They are the two changes whose data consequence is unambiguous: an added field has no stored values and takes the kind's zero value (Int 0, Float 0.0, Bool false, Text empty, optional nil); a deleted field's stored values are freed and physically dropped when the rewritten log is swapped in. Zero-fill is chosen over a default-value syntax because the grammar has no field defaults today and v1 refuses to grow compiler surface for a value the program can backfill itself.

Everything else refuses, precisely. Refusal messages follow the resident: keys house style: name the class, the field, the stored shape, the compiled shape, and what to do about it.

  • A same-named field with a different kind: refused — no implicit conversions.
  • A deleted field and an added field of the same kind in one step: refused — byte-for-byte indistinguishable from a rename, and one interpretation destroys data the other preserves. The message says to deploy the delete and the add as two separate steps when both are genuinely meant. (@renamed_from is the v2 answer.)
  • A stored class absent from the compiled program: refused — its rows demand a verdict the runtime must not guess. (The existing durable-to-volatile refusal from databasev2 2 stays as-is.)
  • Index or storage-flag changes: refused in v1.

Mechanics

The schema record. A new WAL record kind, WO_WAL_SCHEMA, CRC-framed like every other record, describing every durable class: class name, storage flags, and per field its name and kind plus the container/reference metadata, then the secondary-index layout. It is written as the first record of a fresh log and as the first record compaction writes, so the head of a log always states the shape of everything after it.

The boot diff. Before replay, the runtime peeks the log head.

  • No schema record — a legacy log. Replay proceeds against the compiled shape exactly as today; the next compaction writes the record and the log is self-describing from then on. A legacy log whose shape already changed still fails as today: there is nothing recorded to diff against.
  • Record present and equal to the compiled shape — normal replay. Equality is by class NAME and field NAME + kind, so pure declaration reordering is a match, which closes the silent cid-renumbering hole as a side effect.
  • Record present and different — classify per class into migrate or refuse, as above.

The migration pass. Replay decodes each record with the OLD shape, reconstructed from the schema record — old kinds drive the value decoder, old names drive the mapping. Rows are built directly in the new shape through a per-class field map: surviving fields move to their new slot, deleted fields' values are freed, added fields take the zero value. Old cids map to new cids by class name. A delta record's field index is an old-shape index; the fold happens in old shape and maps afterward, so a delta on a deleted field folds to a no-op. The pass then writes a fresh log the way compaction does — schema record, one full-row record per live row, fsync, rename — and boot continues on the new log. Keys-resident tables get their offset maps rebuilt from the new log by the machinery compaction already carries. One stderr line per migrated class reports what changed, rows rewritten, and values dropped.

Crash safety is inherited, not added. The rename swap is atomic: a crash anywhere in the pass leaves the old log intact and the next boot re-migrates from it. This is databasev2 3's mutation-proven design, reused.

Acceptance criteria

  • Given a log written with shape A and a binary compiled with shape A plus a new field, when the program boots, then it runs, the field reads as the kind's zero value, and the log head carries the new schema.
  • Given shape A minus a field, when it boots, then the dropped values are freed (leak-clean under ASan) and no record of the field remains in the rewritten log.
  • Given a simultaneous delete and add of the same kind, when it boots, then it refuses naming both fields and the two-step alternative.
  • Given a retype or a vanished class with stored rows, when it boots, then it refuses naming the class, field and both shapes.
  • Given pure declaration reordering, when it boots, then no migration runs and every row lands in its right class.
  • Given a keys-resident table across a migration, when read after boot, then folds resolve through the new offsets and a delta on a deleted field is gone.
  • Given a legacy log with an unchanged shape, when it boots, then replay is byte-for-byte today's behaviour and the next compaction writes the schema record.
  • Given a kill between the new log's write and the rename, when the next boot runs, then it re-migrates from the intact old log.

Out of scope (v2 candidates, each with its reason)

  • Rename — needs declared intent (@renamed_from); v1 refuses the ambiguous diff instead of guessing.
  • Retype / conversions — even lossless ones invite silent surprises; refusal costs one explicit backfill program.
  • Dropped-class data verdicts — refusing keeps the data; deleting it is a decision, not a migration.
  • Data/seed migrations (@seed(n)) — deferred by decision; the site's trap 1 stays documented in the deploy guide.
  • Field-default syntax — new grammar for what zero-fill plus app code already covers.
  • Index migrations — indexes are rebuilt at boot anyway; the refusal is about flags, not data, and can be relaxed later with evidence.