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.
ensureSchema — the first-run path
Section titled “ensureSchema — the first-run path”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.
runMigrations — the reconcile path
Section titled “runMigrations — the reconcile path”For evolution, call runMigrations (typically from a one-off admin
route or a deploy hook, not every request):
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:
- Adds columns that exist on the declared side but not in the DB.
- 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. - Refuses drops. If your schema removed a table or a column,
runMigrationsthrowsMigrationRefusedwith the offending diff instead of silently losing data.
What’s additive-safe
Section titled “What’s additive-safe”| 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:
- Ship the change as a new table / column and migrate data in your app code.
- Bump
SCHEMA_VERSIONand useonSchemaMismatchon the client to prompt the user to reset local state (they lose optimistic pending mutations, gain the new schema). - 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" },})Bump SCHEMA_VERSION when
Section titled “Bump SCHEMA_VERSION when”- 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
argsvalidation). - 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).
Under the hood — what DDL plasma issues
Section titled “Under the hood — what DDL plasma issues”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.
Internal tables
Section titled “Internal tables”| 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 SequencerDO — Multi-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.
Triggers plasma installs
Section titled “Triggers plasma installs”For every user table, plasma installs three AFTER triggers (SQLite) or three AFTER trigger functions (Postgres):
-- Illustrative SQLite trigger for a `todos` tableCREATE 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.
DDL invariants after runMigrations
Section titled “DDL invariants after runMigrations”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_refshas any newfile()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.
Rule of thumb
Section titled “Rule of thumb”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.
wrangler d1 execute alongside plasma
Section titled “wrangler d1 execute alongside plasma”# 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.
drizzle-kit alongside plasma
Section titled “drizzle-kit alongside plasma”If you’re migrating from Drizzle and still have drizzle-kit migrations around, you have two options:
- Retire drizzle-kit for the plasma tables. After moving to
plasma, delete the drizzle-kit migrations for tables plasma
now owns. Let
runMigrationshandle those. - 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:
- Add plasma-declared columns to your live schema. Add
id(if not already text UUID), anyfile()columns, and any defaults plasma expects. - Run
ensureSchemaagainst the live DB. It’ll create_plasma_changesand friends, and install the triggers. Existing rows are NOT backfilled into_plasma_changes— they simply become the “pre-history” that pulls start after. - Optional: seed
_plasma_changesfor historical data. If you want existing rows to appear in the change log for time-travel, seed them:The version numbers restart from 1 for the seeded rows; future writes continue from there.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()) * 1000FROM todos; - Deploy the plasma Worker. Now clients start pulling.
Postgres specifics
Section titled “Postgres specifics”The trigger DDL differs from SQLite:
- Postgres: plasma creates one
plpgsqlfunction per user table + trigger direction (_plasma_trg_<table>_insert_fn, etc.), bound via aCREATE OR REPLACE TRIGGER. Regenerating triggers is safe underCREATE OR REPLACE FUNCTION. - Types:
_plasma_changes.row_versionisBIGSERIALon Postgres,INTEGER PRIMARY KEY AUTOINCREMENTon SQLite. Both are monotonic ids. - Timestamps:
created_atisBIGINT(unix ms) on both. NoTIMESTAMPTZfor consistency across dialects. - JSON: user
json()columns areJSONBon Postgres for index and predicate performance,TEXTon 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.
Renaming a column (title → heading)
Section titled “Renaming a column (title → heading)”- Deploy 1: add the new column via
runMigrations(headingnullable). Backfill all rows toheading = titlefrom a one-shot admin script. Update mutators to write BOTH columns. BumpSCHEMA_VERSIONif reads on client depend on the new name. Existing clients keep working (they readtitle). - Deploy 2: switch reads to
headingin your app code. Continue writing both. Old clients are still fine (they readtitle, which is still populated). - Deploy 3: stop writing
title. After all live clients have updated to the new bundle, run raw SQL to droptitle(wrangler d1 execute "ALTER TABLE todos DROP COLUMN title"). Updateschema.tsto removetitleand bumpSCHEMA_VERSIONone more time.
Changing a column’s kind (int → bigint)
Section titled “Changing a column’s kind (int → bigint)”Same pattern with a new column. int → bigint in particular:
- Deploy 1: add
todoIdBig: bigint().nullable(), backfill withSELECT 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.
Splitting a table
Section titled “Splitting a table”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.
Rollback
Section titled “Rollback”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:
- Idempotency saves you. Every step is
IF NOT EXISTSorCREATE OR REPLACE. Re-runningrunMigrationspicks up where the failure left off and reaches the same end state. ensureSchemafirst,runMigrationssecond. Even for deploys, runensureSchemaon request path. If a partialrunMigrationsleft_plasma_blobsmissing (very unlikely), the nextensureSchemarestores it before serving requests.- 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:
ensureSchemain the fetch handler: yes, keep it. The cost is a bounded number of catalogue queries per cold start.runMigrationsfrom a single admin route: called once per deploy from your CI, not per-request. This is where the actualALTER TABLEstatements 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.
Testing migrations locally
Section titled “Testing migrations locally”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.
What to read next
Section titled “What to read next”- Concepts / Change Log and Cookies
— what the triggers
runMigrationsregenerates actually write - Schema — the schema declarations that drive the diff
- Deployment — where to actually call
runMigrationsin a real deploy