Skip to content

Migrations silently advance version when migrate() body is empty, leaving DB permanently stale #7

Open

Description

@RobPruzan

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

  1. Start with a schema like:
    const paneTab = z.object({ id: z.string(), chatId: z.string().nullable() })
  2. 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() }),
      ]),
    })
  3. 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
      },
    }
  4. 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 to N, and the on-disk data is now permanently stale.
  5. Later, fill in the migrate body to actually transform tabs.
  6. 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
  }
  break

A 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) continue

Once 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:

  1. 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.
  2. 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.
  3. Generator: refuse to leave a // customize transformation here stub in the output. Either emit a throw 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/core 0.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

  1. somtri commented on Jul 11, 2026

    @somtri

    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.

  2. added a commit that references this issue on Jul 31, 2026
    4e937aa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions