Skip to content

Migrations

plasma has no plasma generate CLI, no schema diff file, no migration script per version. Schema evolution is done at runtime by two functions and one string:

  • ensureSchema({ schema, executor }) — creates the tables if missing. Idempotent.
  • runMigrations({ schema, executor }) — reconciles the live DB against the declared schema. Adds new columns, regenerates triggers, refuses drops.
  • SCHEMA_VERSION — a string the client and server both export. A mismatch triggers a well-defined handshake.

Call this in your Worker’s fetch handler:

export default {
async fetch(req: Request, env: Env) {
const executor = fromD1(env.DB)
await ensureSchema({ schema, executor })
return createSyncHandler({ ... })(req)
},
}

ensureSchema runs CREATE TABLE IF NOT EXISTS for every user table and every plasma-internal table (_plasma_changes, _plasma_client_mutations, _plasma_origin_context, _plasma_blobs, _plasma_blob_refs, _plasma_region_versions). It also installs the AFTER INSERT/UPDATE/DELETE triggers that populate the change log.

It’s safe to call on every request. The trigger DDL is CREATE TRIGGER IF NOT EXISTS, so re-runs are cheap.

For evolution, call runMigrations (typically from a one-off admin route or a deploy hook, not every request):

admin.ts
import { runMigrations } from "@sh1n4ps/plasma-server"
export async function POST(req: Request, env: Env) {
// Auth check first — this is a destructive-ish call.
if (req.headers.get("x-admin-token") !== env.ADMIN_TOKEN) {
return new Response("forbidden", { status: 403 })
}
const executor = fromD1(env.DB)
const result = await runMigrations({ schema, executor })
return Response.json(result)
}

runMigrations computes a SchemaDiff between the declared schema and the introspected DB, then:

  1. Adds columns that exist on the declared side but not in the DB.
  2. Regenerates every trigger (drop + create) so the payload’s json_object(...) reflects the current column set. A column added in this run needs its trigger updated before the next write can populate the change log.
  3. Refuses drops. If your schema removed a table or a column, runMigrations throws MigrationRefused with the offending diff instead of silently losing data.
Change Allowed Notes
Adding a new table Full CREATE runs, triggers installed.
Adding a new column ALTER TABLE ADD COLUMN. Nullable columns and columns with defaults work.
Adding a .default() to an existing column Cosmetic; the schema knows about it but existing rows keep their stored values.
Adding a .nullable() to an existing column ⚠️ The DB constraint changes from NOT NULL to NULL. Some drivers require a table rebuild — plasma doesn’t automate this. Manual DDL required.
Making a .nullable() column NOT NULL runMigrations refuses — the DB may have rows with NULL that would violate the new constraint.
Adding a .unique() to an existing column ⚠️ Uniqueness is enforced going forward but existing duplicate rows aren’t detected.
Adding a new mutator Mutators aren’t a DB concept — they exist only in code.
Adding a new file() column Trigger updated, _plasma_blob_refs maintains itself.
Removing a table Refused.
Removing a column Refused.
Renaming a column Refused (both a drop and an add).
Changing a column’s kind Refused (kind change = drop + add).

For the changes plasma refuses, you have three options:

  1. Ship the change as a new table / column and migrate data in your app code.
  2. Bump SCHEMA_VERSION and use onSchemaMismatch on the client to prompt the user to reset local state (they lose optimistic pending mutations, gain the new schema).
  3. Manually run DDL from a wrangler d1 execute — you’re on your own for correctness.

SCHEMA_VERSION — the client/server handshake

Section titled “SCHEMA_VERSION — the client/server handshake”

Every push and pull carries schemaVersion in the request. If the server’s SyncHandlerOptions.schemaVersion doesn’t match, the handler responds 409 Conflict:

HTTP/1.1 409 Conflict
{
"error": "schema mismatch",
"expected": "todos-v2"
}

On the client, plasma raises SyncClientError({ kind: "schema-mismatch", phase, expected }) and calls options.onSchemaMismatch({ phase }) if you supplied it. Return "reset" to wipe local state and start fresh, or "stay" to leave the local IDB alone and just surface the mismatch through onError.

createPlasmaClient({
schema,
mutators,
endpoint: "/sync",
schemaVersion: "todos-v2",
onSchemaMismatch: async ({ phase }) => {
if (confirm(`Server updated; clear local data and reload?`)) {
return "reset"
}
return "stay"
},
})
  • You added a new column that older clients would silently miss on pull (they’d read a null, but their schema would say non-null).
  • You added a required arg to an existing mutator (older clients would push a payload that fails args validation).
  • You changed the semantics of an existing mutator in a way that isn’t backwards-compatible.

Don’t bump it for:

  • Adding a new mutator (older clients simply can’t call it).
  • Adding a new table (older clients ignore it).
  • Cosmetic changes (rename of a .default(), adding an aggregate in a query somewhere).

Every ensureSchema / runMigrations invocation issues DDL you’ll see when you wrangler d1 execute --command ".schema" or \d in psql. Knowing which is which helps when you’re debugging or migrating.

Table Purpose Created by
_plasma_changes Every write’s change log entry (row_version, table, id, op, value, created_at, origin) — Change Log and Cookies ensureSchema
_plasma_client_mutations Per-(clientGroupID, clientID) last_mutation_id watermark — the de-dup ledger ensureSchema
_plasma_origin Single-row scratch that carries (clientGroupID, clientID, mutationID) down to the AFTER-write triggers ensureSchema
_plasma_blobs Per-hash blob state (absent / uploading / present / orphaned) — Files and Blobs ensureSchema
_plasma_blob_refs Reverse index (hash, table_name, row_id, column_name) for blob-read auth ensureSchema
_plasma_region_versions Per-region monotonic version state used by SequencerDOMulti-region ensureRegionVersionsTable

You can SELECT from any of these to inspect state. Modifying them directly is not supported and will corrupt sync semantics — always go through the mutator / sync-handler path.

For every user table, plasma installs three AFTER triggers (SQLite) or three AFTER trigger functions (Postgres):

-- Illustrative SQLite trigger for a `todos` table
CREATE TRIGGER IF NOT EXISTS _plasma_trg_todos_insert
AFTER INSERT ON todos
BEGIN
INSERT INTO _plasma_changes
(table_name, row_id, op, value, created_at, client_group_id, client_id, mutation_id)
VALUES
('todos', NEW.id, 'put', json_object(...), (unixepoch() * 1000),
(SELECT client_group_id FROM _plasma_origin LIMIT 1),
(SELECT client_id FROM _plasma_origin LIMIT 1),
(SELECT mutation_id FROM _plasma_origin LIMIT 1));
END;

Same shape for UPDATE (with NEW.*) and DELETE (with OLD.*, op = 'del', value = NULL). Postgres wraps the body in a plpgsql function and binds it to each trigger.

Key implication: any write to a user table — including raw driver writes bypassing plasma — populates _plasma_changes and reaches every client on their next pull. This is how wrangler d1 execute "INSERT INTO todos ..." still syncs to browsers. See Coexisting with raw SQL.

After a successful runMigrations:

  • Every declared table exists with the declared column set (plus new columns via ALTER TABLE ADD COLUMN).
  • Every trigger is regenerated to reflect the current column list.
  • _plasma_blob_refs has any new file() column’s trigger installed.
  • The schema-managed indexes for .unique() columns exist.

Coexisting with raw SQL and other migration tools

Section titled “Coexisting with raw SQL and other migration tools”

Some workflows need to issue DDL outside plasma’s runtime path: seeding a fresh D1 with reference data, running one-off analytic queries, migrating data during a refactor, coexisting with wrangler d1 migrations or drizzle-kit.

Writes to user tables through raw SQL are fine. The AFTER triggers propagate them into _plasma_changes and every client sees them on the next pull.

Writes to _plasma_* internal tables through raw SQL are not fine. They corrupt de-dup / cursor / blob-ref state. If you need to reset, use resetLocalState on the client and truncate _plasma_changes yourself.

Terminal window
# Fine — user table.
wrangler d1 execute my-app --command "INSERT INTO todos (id, title, done, userId, updatedAt) VALUES ('cron-1', 'Nightly report', 0, 'system', 1735000000000)"
# ONLY if you know what you're doing — internal table.
wrangler d1 execute my-app --command "DELETE FROM _plasma_changes WHERE row_version < 5000000"

The first one flows through plasma’s triggers → change log → next pull picks it up. The second one is a hand-crafted change log compaction; use compactChangeLog instead if possible.

If you’re migrating from Drizzle and still have drizzle-kit migrations around, you have two options:

  1. Retire drizzle-kit for the plasma tables. After moving to plasma, delete the drizzle-kit migrations for tables plasma now owns. Let runMigrations handle those.
  2. Keep drizzle-kit for non-plasma tables. If your app has admin-only tables that plasma doesn’t sync (analytics, audit, scheduled job state), keeping drizzle-kit for those is fine — they don’t overlap with plasma’s schema management.

Don’t try to keep both managing the same table. drizzle-kit’s DROP COLUMN or RENAME will bypass plasma’s MigrationRefused and can corrupt clients.

Migrating an existing production DB into plasma

Section titled “Migrating an existing production DB into plasma”

When you’re moving an existing Drizzle / raw-SQL production DB onto plasma:

  1. Add plasma-declared columns to your live schema. Add id (if not already text UUID), any file() columns, and any defaults plasma expects.
  2. Run ensureSchema against the live DB. It’ll create _plasma_changes and friends, and install the triggers. Existing rows are NOT backfilled into _plasma_changes — they simply become the “pre-history” that pulls start after.
  3. Optional: seed _plasma_changes for historical data. If you want existing rows to appear in the change log for time-travel, seed them:
    INSERT INTO _plasma_changes (row_version, table_name, row_id, op, value, created_at)
    SELECT row_number() OVER () AS row_version,
    'todos', id, 'put', json_object('id', id, 'title', title, ...),
    extract(epoch from now()) * 1000
    FROM todos;
    The version numbers restart from 1 for the seeded rows; future writes continue from there.
  4. Deploy the plasma Worker. Now clients start pulling.

The trigger DDL differs from SQLite:

  • Postgres: plasma creates one plpgsql function per user table + trigger direction (_plasma_trg_<table>_insert_fn, etc.), bound via a CREATE OR REPLACE TRIGGER. Regenerating triggers is safe under CREATE OR REPLACE FUNCTION.
  • Types: _plasma_changes.row_version is BIGSERIAL on Postgres, INTEGER PRIMARY KEY AUTOINCREMENT on SQLite. Both are monotonic ids.
  • Timestamps: created_at is BIGINT (unix ms) on both. No TIMESTAMPTZ for consistency across dialects.
  • JSON: user json() columns are JSONB on Postgres for index and predicate performance, TEXT on SQLite.

Deploy runMigrations against your Postgres DB the same way as D1: from an admin route or a deploy hook. Hyperdrive fronts the connection transparently.

Zero-downtime patterns for refused changes

Section titled “Zero-downtime patterns for refused changes”

runMigrations refuses drops / renames / kind changes. The workaround is a three-step migration that keeps both shapes during the deploy window.

  1. Deploy 1: add the new column via runMigrations (heading nullable). Backfill all rows to heading = title from a one-shot admin script. Update mutators to write BOTH columns. Bump SCHEMA_VERSION if reads on client depend on the new name. Existing clients keep working (they read title).
  2. Deploy 2: switch reads to heading in your app code. Continue writing both. Old clients are still fine (they read title, which is still populated).
  3. Deploy 3: stop writing title. After all live clients have updated to the new bundle, run raw SQL to drop title (wrangler d1 execute "ALTER TABLE todos DROP COLUMN title"). Update schema.ts to remove title and bump SCHEMA_VERSION one more time.

Changing a column’s kind (intbigint)

Section titled “Changing a column’s kind (int → bigint)”

Same pattern with a new column. intbigint in particular:

  • Deploy 1: add todoIdBig: bigint().nullable(), backfill with SELECT id, ..., CAST(idInt AS BIGINT) AS todoIdBig FROM todos.
  • Deploy 2: read from todoIdBig, write to both.
  • Deploy 3: raw SQL drop idInt, remove from schema.

Same idea: create the new tables, dual-write, cut over reads, drop the old.

Every case is: add-forward, backfill, dual-write, cut over, drop-old. plasma refuses to do the drop for you — you drop manually once you’re confident no client depends on the old column.

runMigrations is not transactional across statements. If it fails halfway (network blip mid-DDL), you may be left in a partial state — some columns added, some triggers regenerated, others not. The recovery path:

  1. Idempotency saves you. Every step is IF NOT EXISTS or CREATE OR REPLACE. Re-running runMigrations picks up where the failure left off and reaches the same end state.
  2. ensureSchema first, runMigrations second. Even for deploys, run ensureSchema on request path. If a partial runMigrations left _plasma_blobs missing (very unlikely), the next ensureSchema restores it before serving requests.
  3. No automatic rollback of column additions. ALTER TABLE ADD COLUMN is one-way. If you decide the added column was a mistake, drop it in a follow-up manual DDL after clients no longer need it (see the three-step pattern above).

For genuine “the migration broke prod” scenarios: fall back to the previous Worker deploy. That restores the old SCHEMA_VERSION and the old handler. Existing extra columns in the DB are harmless — the old code just doesn’t reference them.

Multi-instance / multi-region migration timing

Section titled “Multi-instance / multi-region migration timing”

If you have N Worker instances behind the same D1 (or the same Postgres via Hyperdrive), the current fetch-handler ensureSchema runs on every request from every instance. That’s concurrency-safe — every DDL is IF NOT EXISTS — but wasteful.

Best practice:

  • ensureSchema in the fetch handler: yes, keep it. The cost is a bounded number of catalogue queries per cold start.
  • runMigrations from a single admin route: called once per deploy from your CI, not per-request. This is where the actual ALTER TABLE statements live.

For a multi-region setup (SequencerDO), migrate the schema before the code that expects the new columns is deployed. The sequencer is agnostic to user schemas, so the migration doesn’t need to touch it.

runMigrations runs against any SqlExecutor — you can point it at a better-sqlite3 in-memory DB for tests:

import BetterSqlite3 from "better-sqlite3"
import { fromBetterSqlite3, runMigrations, ensureSchema } from "@sh1n4ps/plasma-server"
const sqlite = new BetterSqlite3(":memory:")
const executor = fromBetterSqlite3(sqlite)
await ensureSchema({ schema: v1Schema, executor })
// insert some rows...
await runMigrations({ schema: v2Schema, executor })
// assert the added column is present, no data lost.

fromBetterSqlite3 is a real SqlExecutor implementation, so identical mutation semantics on both engines carry through the test.