Ships one SQLite schema version so that no phone loses a row: the snapshot is proven before release, only one version is pending, shipped steps and fixtures stay byte for byte as a ledger recorded them, every from-to path and a forced-failure restore drill pass on Node, the upgrade is proven on iOS and Android, and the version is recorded only after the developer confirms the release.
Rules that must hold
- Snapshot first, proven before every release. The open path copies the file
(
VACUUM INTO '<file>.v<from>.bak') before the first pending step and migrates nothing without it; the restore drill proves it on real files (restore-drill). Why: the snapshot is the only way back from a step that committed but was wrong. - One pending version per release (bump by one). At most one step is newer than the ledger;
while it is pending, change it in place and re-freeze its fixture; step files run 1..N without a
gap and
MIGRATIONSlists exactly those files in order (bump-by-one). Why: every shipped step runs on every future install forever, and a second pending step is a permanent version no phone ever needed. - Shipped means frozen, and the ledger says what shipped.
contracts/schema-ledger.jsonlists each version that reached a device with the sha256 of its step file and fixture, as the build's git ref holds them (--ref); a shipped step or fixture never changes, not even a comment, a formatter rewrite or a lint autofix; the ledger only grows, and CI compares it with the target branch (ledger,shipped-frozen,ledger-append-only,ref). Why: the replay compares schemas, so an edited data move slips past it, and installed apps never rerun a step. - The pending version is frozen before it ships: its
db-vN.sqlis dumped from a database that version wrote and stampsuser_version = N(pending-fixture). Why: the next step is tested on it, and the ledger pins it the day it ships. - That fixture holds hostile rows: an escaped apostrophe, a non-ASCII letter and an emoji
(
hostile-rows). Why: copy and transform steps mangle exactly those;'test1'survives everything. - Every version from 2 has a content test
test/sqlite/vNNNN-<name>.test.tsthat loadsdb-v(N-1).sql, migrates and compares stored values row by row, untouched tables included (content-test). Why: a schema or count check passes a step that rewrites every value wrong. - Every from-to path is proven: an empty file and every frozen fixture, upgraded to every later
version, give that version's frozen schema, keep every row and pass integrity and foreign key
checks, in Jest (
migration-paths.test.ts) and in the checker (every-path,path-tests). Why: users skip updates, backups restore old files into new builds, and a step that works on a fresh install can fail on every phone that has data. - The forced-failure drill passes: a step failing halfway leaves the file's content as it was
and its snapshot equal to it; a committed wrong step is undone by restoring the snapshot, which then opens as
ready; the previous build opens the upgraded file asnewer-versionand leaves its bytes untouched, no-walbeside it (restore-drill,migration-restore-drill.test.ts). Why: the way back is the code path nobody runs until the day it is needed. - Stop and ask before the version leaves the machine, then record it. Any store, TestFlight,
Play testing, EAS preview build or EAS Update on any channel (preview included) counts as shipped,
with no exception;
record-shipped.mjs --yesruns only after the developer confirmed the release and it left. Why: from that moment the step is permanent on someone's phone. - Never send a phone back past a shipped version; fix forward. Re-releasing older code, an
older TestFlight build,
eas update:rollback(or its aliaseas update:roll-back-to-embedded), or reverting an EAS Update rollout (eas update:revert-update-rollout,eas channel:rolloutEnd > Revert) puts code with N-1 steps on phones at version N; so does expo-updates by itself when an update crashes before its first screen on its first launch. Halting a store phased release or staged rollout is fine, and so is pausing the channel (eas channel:pause <channel>): neither sends anybody back. Those phones opennewer-version, in memory, with none of the user's data. Why: a rollback cannot un-migrate a file; version N+1 can repair it. - Restore only with the app stopped, the replaced files kept, and the developer's yes.
restore-snapshot.mjschecks the copy, movesapp.db,-waland-shmintokept-<time>/, and reads the result back; afterwards only a fixed build launches. Why: a leftover-walreplays newer pages into the restored file, and the same build reruns the same step. - Prove the upgrade over the previous release on iOS and Android (a named simulator and emulator: the previous build, data, the new build over it) before the stop-and-ask. Why: Node proves the SQL; only a device proves the build carries the step and finds the real file.
- A schema step ships in a store build, unless its update follows the variant channels. An EAS Update with a step needs rule 12's proof first (a Release build of the same JavaScript over the previous release's data, crash-free to the first screen), then goes to the preview channel (that publish ships the version: stop and ask, then record it), runs crash-free on a preview build that holds data, and only then goes to production with a staged rollout (ota-updates' channel model: one channel per variant, published per channel). Why: the open path migrates before the first render, so a crash before it makes expo-updates roll the phone back to N-1 code over a version-N file, with no one deciding it.
Workflow
- Read references/release-ritual.md (what shipped means, the ledger,
one pending version, the order, rollbacks). The data layer comes from persistence-and-migrations;
without
src/data/migrations/migrations.tsthis skill does not apply. - First time only: copy
templates/contracts/andtemplates/test/sqlite/. An app that already shipped versions runsnode ${CLAUDE_SKILL_DIR}/scripts/record-shipped.mjs . --adopt --build '<first build>' --ref <newest release tag>, shows the entries to the developer (stop and ask: are these all out?), then reruns with--yes. - Baseline:
node ${CLAUDE_SKILL_DIR}/scripts/check-migration.mjs .printsRESULT: PASSbefore any change; fix a red baseline first. - Write the content test first, then the step, following persistence-and-migrations' "Adding
version N+1"; imitate
examples/v0002-pending/(step, list, frozen fixture, checksums, content test, ledger with v1 shipped). Read references/proofs-and-drills.md for the content test and hostile rows. - Freeze the pending fixture with hostile rows (persistence-and-migrations'
inspect-database.mjs --dump), record its checksum andFIXTURESentry. - Run
npx jest test/sqlite --ci --maxWorkers=2andnode ${CLAUDE_SKILL_DIR}/scripts/check-migration.mjs .; fix, rerun until both pass. - Prove the upgrade on a named iOS simulator and Android emulator ("Upgrade over the previous release" in release-ritual.md); hand the file check and kill test to persistence-and-migrations.
- Commit the step, test, fixture, checksum and repository change together with a
Gate-Change:trailer (git-commits-and-reporting); CI runsnode ${CLAUDE_SKILL_DIR}/scripts/check-migration.mjs . --base origin/main. - Stop and ask: "This release makes schema version N permanent on every phone that installs it:
. Ship it?" For an EAS Update add: "The Release build of this code ran crash-free
over the previous release's data (and, for production, on the preview update ). If it crashes
before its first screen, expo-updates sends phones back to the previous update, and a rollout
revert or
eas update:rollbackdoes the same; those phones then run without their data until a fix-forward update arrives." Only on a yes does the build or update go out (eas-build-and-submit, ios-release-testflight, android-release-play, ota-updates). - After it left:
node ${CLAUDE_SKILL_DIR}/scripts/record-shipped.mjs . --build '<build>' --ref <the build's commit or tag>(dry run;--refhashes what shipped and fails when the working tree differs), then--yes; commit the ledger. - A pending step went wrong on a developer or QA device: read
references/restore-on-device.md, run
node ${CLAUDE_SKILL_DIR}/scripts/restore-snapshot.mjs --udid <udid> --bundle-id <id>(or--serial <serial> --package <id>, or--dir <folder>), show the plan, stop and ask, then rerun with--yes. A shipped step that went wrong is fixed forward in version N+1, never restored. - Finish with
node ${CLAUDE_SKILL_DIR}/scripts/check-migration.mjs .until it printsRESULT: PASS.
Definition of done
-
contracts/schema-ledger.jsonlists every shipped version with both hashes; no entry changed; at most one pending version, and step files run 1..N. - No shipped step or fixture changed by a single byte (check-migration
shipped-frozen). - The pending version has its frozen
db-vN.sql(hostile rows included), checksum andFIXTURESentry, and its content test loadsdb-v(N-1).sqland compares values. -
test/sqlite/migration-paths.test.tsandmigration-restore-drill.test.tsare present;npx jest test/sqlite --cipasses. - The upgrade over the previous release ran on a named iOS simulator and Android emulator, or the report says it is owed.
- The developer confirmed the release before it left;
record-shipped.mjs --ref <build ref> --yesran after it left and the ledger is committed (or the report says the version is still pending). - A step shipped by EAS Update went to the preview channel first (recorded as shipped), and the report cites rule 12's Release-build proof and says its first-render path was crash-free on the preview update before production.
- No rollback or rollout revert past a shipped version was done or planned; a wrong shipped step has its fix-forward version.
- Any restore ran only after the developer's yes, with
kept-<time>/left in place. -
node ${CLAUDE_SKILL_DIR}/scripts/check-migration.mjs .printsRESULT: PASS(in CI with--base <target branch>). Expected only before persistence-and-migrations built the open path:SKIP src/data/app-database.ts [restore-drill] not yet created (persistence-and-migrations creates it). An app withoutsrc/data/migrations/migrations.tsprintsNOT APPLICABLE: ...and has nothing to ship.
Anti-patterns
- "It's only a comment" in a shipped step. The ledger hash fails, and the next edit will not be a comment; restore the file and change the schema in a new version.
- Two steps in one release. v2 and v3 both pending: fold them into one v2 (nothing shipped).
- Re-dumping a shipped fixture because a test fails. It is the database phones hold; fix the step.
- A content test that compares counts or the schema. Read the values back, row by row.
- Fixtures of
'test1'rows. Freeze apostrophes, non-ASCII letters and emoji, or the next copy step's mangling goes unseen. - Testing only fresh install to latest. The phone with list 1 already in it is the one that fails; every fixture to every later version.
- Recording a version "now, before the build", or never. Record after it left: early, a still changing step looks frozen; never, and the next edit of a shipped step is invisible.
eas update:rollback,eas update:roll-back-to-embedded, reverting an update rollout, or re-releasing the previous code after a migration. Phones at version N open in memory with none of their data; pause the channel (eas channel:pause) and fix forward.- A schema step sent as an EAS Update straight to production. A crash before the first screen makes expo-updates roll phones back on its own; prove it on a Release build and on the preview channel first, or ship the step in a store build.
- A private "staging" channel or a runtime channel override for the proof. ota-updates' variant model allows neither; the preview channel is the proof, and publishing there ships the version.
- Recording from whatever is checked out today. Without
--ref, an edit made after the release is pinned as "shipped" and the real bytes never are. - Restoring a snapshot with the app open, or deleting
-waland the old file. Stop the app, move them aside, keep them until the case is closed. - Restoring users' phones. Nothing reaches them; a repair is a new version.
- Proving the upgrade on a booted or shared simulator. Create
<project>-migration, name it in every command, delete it afterwards.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/release-ritual.md | What shipped means, the ledger, frozen files and formatters or linters, one pending version, merging two branches, the ritual in order, the upgrade over the previous release on iOS and Android, EAS Update, error recovery and rollbacks, adopting the ledger, CI, owners, multi-package | Workflow steps 1-3 and 7-10 |
| references/proofs-and-drills.md | What each proof catches, the content test, hostile rows, every from-to path, the restore drill, how the checker runs app code, the rule table, pitfalls, limits | Workflow steps 4-6, and when a rule fails |
| references/restore-on-device.md | When to restore or fix forward, what a snapshot is, the restore in a folder, on an iOS simulator, on Android, physical phones, pitfalls | Workflow step 11 |
templates/contracts/schema-ledger.json |
The empty ledger (copied once; record-shipped fills it) | Workflow step 2 |
templates/contracts/schema-ledger.schema.json |
The ledger's JSON schema | Workflow step 2 |
templates/test/sqlite/migration-paths.test.ts |
Every from-to path in Jest, generated from the fixtures folder | Workflow step 2 |
templates/test/sqlite/migration-restore-drill.test.ts |
The forced-failure drill (halfway failure, committed wrong step undone) through openAppDatabase |
Workflow step 2 |
templates/test/sqlite/database-content.ts |
Helper: a database's whole content, its rows in a stable order, row counts, lost rows | Workflow step 2 (copied with the tests) |
templates/test/sqlite/restore-snapshot-files.ts |
Helper: the snapshot restore on node:fs, the same steps restore-snapshot.mjs takes on a device | Workflow step 2 (copied with the tests) |
examples/v0002-pending/ |
A pending v2 (list sort order): step, list, frozen hostile db-v2.sql, checksums, content test, fixtures test, ledger with v1 shipped |
Workflow step 4 |
scripts/check-migration.mjs |
The ritual checker (ledger, frozen shipped files, one pending version, fixture, hostile rows, content test, path tests, every-path and restore drill on node:sqlite); options --base, --base-ledger, --no-replay, folder options |
Workflow steps 3, 6, 8 and 12 |
scripts/record-shipped.mjs |
Appends the pending version to the ledger after the same rules pass; --ref hashes the release's commit and fails when the working tree differs; dry run unless --yes; --adopt for versions shipped before the ledger |
Workflow steps 2 and 10 |
scripts/restore-snapshot.mjs |
Restores <db>.v<N>.bak in a folder, on a named simulator or on Android (run-as: a debuggable build); checks first on copies, keeps the replaced files, dry run unless --yes (and the dry run leaves the folder exactly as it was) |
Workflow step 11 |
scripts/lib/ |
Helpers: the ledger and static rules, the shared ritual, the node:sqlite replay and drill, device file access, and the synced app-modules.mjs (do not edit that one here) |
Never by hand |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves the three scripts pass the good repo and catch every planted bug | After changing a script, a template or the example |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/ |
The base app (persistence-and-migrations' open path, runner and v1) and overlay fixtures for the three scripts (record-shipped-ref/ cases become a git repo at run time, as each git-setup.json says) |
When adding a rule |
Related skills
persistence-and-migrations- the runner, open path, snapshots, step files, fixtures, inspect-database and kill test this ritual builds on.error-handling-typed-results- outcomes such as newer-version and migration-failed are typed results, never resets.git-commits-and-reporting- the Gate-Change trailer and the release report naming the new version.ci-pipeline-and-gates- the CI job running check-migration with--base.eas-build-and-submit,ios-release-testflight,android-release-play- the release builds that make a version shipped.ota-updates- updates that can carry a step, and why they are not rolled back past it.ios-simulator-build,android-emulator-build- the Release builds for the upgrade proof.data-export-and-restore- user-facing backups (the envelope, never the file) and the support copy of the database file.golden-and-snapshot-tests- the review policy for frozen files.