| name | add-storage-migration |
| description | Use when changing the shape of any on-device persisted model (session, blueprint/program, feed, exercise-descriptor, ai-plan) or adding a new persisted model - i.e. any edit under app/src/models/storage/versions/, or when a build<...>() chain stops compiling because a `latest/` type changed. Covers the createMigrations() append-only chain, dependsOn for nested models, and the json-schema regen that must ship with the change. |
Adding / changing a storage migration
LiftLog persists everything on-device as JSON. Stored data does not change when the app's types do, so
every model has a migration chain that brings any previously-persisted shape up to the current one.
Full reference: docs/Migrations.md. Code: app/src/models/storage/versions/.
Read docs/Migrations.md before doing anything non-trivial (nested models, brand-new models, feed
ingest/version-rejection). This skill is the fast path for the common case.
Directory layout (what each folder is)
initial/ - the oldest persisted shape of every model. The input to every chain. Frozen -
never edit or remove an existing interface; you may only add a new one for a brand-new model.
latest/ - the current shape, hand-declared and the source of truth. Yours to edit freely.
Self-contained: duplicate the definition here, never re-export from initial/.
migrations/ - the chains, one file per domain (blueprint.ts, session.ts, feed.ts,
exercise-descriptor.ts, ai-plan.ts). migrator.ts is the builder. Reusable transforms live in
migrations/steps/.
any/ - AnyVersion* union types (every persisted version). Type of a freshly-decoded, not-yet-migrated value.
libs/ - serialized primitives that never version (dates, durations, crypto keys, BigNumber).
Changing an existing model (the common case)
- Edit the type in
latest/<domain>.ts to the new desired shape.
- That domain's
build<...>() in migrations/<domain>.ts stops compiling - its output no longer
matches the latest type. This compile error is the signal, not a nuisance.
- Append one new
.add() to the chain transforming the previous shape into the new one. Set
version to the as const number TypeScript asks for (it enforces the incrementing value). Keep the
per-field transform explicit, mirroring the existing steps.
- Non-trivial or reused field logic → put it in a new file under
migrations/steps/ and call it
from the .add() (see steps/add-uses-bodyweight.ts, imported into blueprint.ts).
- If the model embeds another versioned model, do not hand-migrate the child - the parent uses
dependsOn({ field: childMigrations }) and re-verifies automatically. Only touch the parent when the
parent's own fields change.
- If the changed type feeds a generated JSON schema (anything reachable from workout-worker messages or
the program blueprint), regenerate and commit the artifacts:
cd app && npm run json-schema
This also rebuilds the plan-file validator shipped in the plan-builder skill. Both are committed.
- Add/extend a round-trip test - see
migrations/real-migrations.spec.ts and migrator.spec.ts.
Reference example
migrations/blueprint.ts - sessionBlueprintMigrations is a clean chain (createMigrations<Initial…>()
→ three .add() steps stamping version: 2/3/4 → .build<SessionBlueprintJSON>()), and
programBlueprintMigrations shows a pure wrapper using dependsOn({ sessions }) +
pseudoMigrateUntil.
Hard rules (violating these corrupts real user data)
- Never edit an existing
.add() step. Migrations are append-only - each step transforms data
already on users' devices.
- Never edit or remove an existing
initial/ interface. It describes the oldest data still in the
wild and must stay frozen. Adding a new interface is fine.
- Never pass a raw stored payload to
fromJSON. Run it through the model's migrate() first - the
any-version payload type enforces this at compile time, so a read site that won't compile is telling
you to migrate.
- Always update
latest/ and add the migration together - never one without the other.
Verify
From app/: npm run typecheck (the build<...>() pin must be green) and npm test (migration specs).
Run npm run lint before calling it done.