Migrations

ZenDB uses IndexedDB-style migrations. Schema changes are explicit, sequential, and forward-only โ€” there are no down migrations.

db.addEventListener("upgradeneeded", (e) => {
  e.waitUntil((async () => {
    if (e.oldVersion < 1) {
      await db.ensureTable(Users);
      await db.ensureTable(Posts);
    }
    if (e.oldVersion < 2) {
      // Add a new column โ€” update the schema and call ensureTable again
      await db.ensureTable(Posts);
    }
    if (e.oldVersion < 3) {
      // Add constraints after data cleanup
      await db.ensureConstraints(Posts);
    }
  })());
});

await db.open(3); // opens at version 3, firing upgradeneeded if needed

Migration rules #

Why EventTarget? It's the web-standard pattern (like IndexedDB's onupgradeneeded). Third-party code can subscribe to lifecycle events without changing constructor signatures โ€” enabling plugins for logging, tracing, and instrumentation.

Safe migration helpers #

ZenDB provides idempotent helpers that encourage additive-only changes. All of them read from your table schema (the single source of truth) and are safe to run multiple times.

// Add a column โ€” update the schema, then ensureTable
const Posts = table("posts", {
  id: z.string().db.primary(),
  title: z.string(),
  views: z.number().db.inserted(() => 0), // NEW
});

if (e.oldVersion < 2) {
  await db.ensureTable(Posts);
  // -> ALTER TABLE "posts" ADD COLUMN "views" REAL DEFAULT 0
}
// Add an index โ€” declare it in the schema, applied by ensureTable
const Posts = table("posts", {
  id: z.string().db.primary(),
  title: z.string().db.index(), // NEW
});

if (e.oldVersion < 3) {
  await db.ensureTable(Posts);
  // -> CREATE INDEX IF NOT EXISTS "idx_posts_title" ON "posts"("title")
}
// Safe column rename (additive, non-destructive)
const Users = table("users", {
  id: z.string().db.primary(),
  email: z.string().email(),       // keep the old column
  emailAddress: z.string().email(), // NEW
});

if (e.oldVersion < 4) {
  await db.ensureTable(Users);                       // add emailAddress
  await db.copyColumn(Users, "email", "emailAddress"); // copy data across
  // Drop the old column later with raw SQL if you truly need to
}

Helper methods #

Destructive operations #

Destructive helpers (dropColumn, dropTable, renameColumn) are not provided. Write raw SQL if you truly need one:

if (e.oldVersion < 5) {
  await db.exec`ALTER TABLE ${Users} DROP COLUMN deprecated_field`;
}

Migration locking #

If the driver provides withMigrationLock(), migrations run atomically: PostgreSQL uses advisory locks, MySQL uses GET_LOCK, and SQLite uses exclusive transactions. See Drivers & Dialects.

Edit on GitHub