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 frominitial/.migrations/- the chains, one file per domain (blueprint.ts,session.ts,feed.ts,exercise-descriptor.ts,ai-plan.ts).migrator.tsis the builder. Reusable transforms live inmigrations/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>.tsto the new desired shape. - That domain's
build<...>()inmigrations/<domain>.tsstops 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. Setversionto theas constnumber 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()(seesteps/add-uses-bodyweight.ts, imported intoblueprint.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:
This also rebuilds the plan-file validator shipped in the plan-builder skill. Both are committed.cd app && npm run json-schema - Add/extend a round-trip test - see
migrations/real-migrations.spec.tsandmigrator.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'smigrate()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.