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

135 lines
7.1 KiB
Markdown

# Schema migrations v1 — add + delete, declarative, auto on boot
> Brainstormed and approved 2026-08-31. Story:
> [databasev2 12](../../stories/databasev2/12-schema-migrations.md).
> 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.