Repository navigation
Migrations silently advance version when migrate() body is empty, leaving DB permanently stale #7
Description
Claude generated
Summary
When a schema change requires a data transform (alter with a typeHash change), db:generate produces a migration whose apply() is a no-op and emits a migrate() stub with // customize transformation here. If the developer runs the app before filling in migrate(), the framework silently bumps the migration version with the data still in its old shape, and there's no way to re-run that migration once the body is finally written — the app just crashes on read forever after.
Repro
- Start with a schema like:
const paneTab = z.object({ id: z.string(), chatId: z.string().nullable() })
- Change it to a discriminated union:
const paneTab = z.object({ id: z.string(), content: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("chat"), chatId: z.string().nullable() }), z.object({ kind: z.literal("view"), viewType: z.string() }), ]), })
- Run
pnpm run db:generate. You get:{ version: N, operations: [{ op: "alter", key: "windowStates", changes: { typeHash: { from: "…", to: "…" } } }], migrate(prev, { apply }) { const result = apply(prev) // customize transformation here return result }, }
- Boot the app once (e.g. an HMR reload during normal dev) before customising
migrate. The migration runs,apply(prev)is a no-op (see below), the kyju version state advances toN, and the on-disk data is now permanently stale. - Later, fill in the
migratebody to actually transform tabs. - Reboot. Migration is skipped (
currentVersion >= targetVersion). The app reads stale data and crashes:Uncaught TypeError: Cannot read properties of undefined (reading 'kind') at … tab.content.kind …
Why it happens
Looking at applyOperations in the bundled core (renderer-host-*.mjs):
case "alter":
if (op.changes.default) {
const current = result[op.key]
const oldDefault = op.changes.default.from
if (isDefaultSubsetMatch(current, oldDefault)) result[op.key] = op.changes.default.to
}
breakA type-hash-only alter does nothing data-wise — it's purely an in-snapshot signal. That's a perfectly reasonable design, but it means the developer is the only line of defense against forgetting to write the transform.
And the migrator:
const targetVersion = migrations.length
…
if (currentVersion >= targetVersion) continueOnce the version is bumped, there's no concept of "this migration's transform changed, please re-run". So the half-finished v22 sticks around forever.
Suggestions
A few options, in increasing order of intrusiveness:
- Schema-validate after each migration runs. If
migrate()returned data that doesn't parse against the new section schema, throw before bumping the version. This would catch every variant of the bug above and surface a real error instead of a vague crash later. - Generator: hash the
migrate()body and persist it. On boot, if a previously-applied migration's body hash changed, refuse to start with a clear error ("migration N body changed since it was applied; reset your dev DB or write a follow-up migration"). This is the dev-only escape hatch. - Generator: refuse to leave a
// customize transformation herestub in the output. Either emit athrow new Error("TODO: write the transform")so the dev sees a hard failure on first run, or refuse to generate at all and prompt the dev to make the change manually.
Option 1 alone would have caught this in <1s — the new schema wouldn't have parsed the unmigrated tabs, and the dev would have known immediately to fill in the transform.
Environment
@zenbujs/core0.0.24- Electron 42
- Node 22
Workaround
Hand-write a follow-up migration (0022_migration.ts) with version: N+1 that re-applies the transform idempotently. Repro project hit this with the pane-tab discriminator migration in robby's hackable-gui — happy to share the migration body if useful.
Activity
somtri commentedon Jul 11, 2026
I'd like to take the other half of this. PR #26 covers option 3 (failing fast on the stub); I'll work on option 1: validating the migrated data against the new schema before the version bump is persisted, so a bad migrate() throws immediately instead of silently advancing.
Disclosure: this is AI-assisted work under human supervision, consistent with how this issue and PR #21 are already tagged.
fix(kyju): never advance a migration's version on a broken migrate()