Migrating from Drizzle
plasma’s schema DSL and query builder are Drizzle-inspired on purpose. If you know Drizzle, you already know most of plasma’s read side. This guide walks through what changes and what doesn’t when you move an existing Drizzle project onto plasma.
The one-slide summary
Section titled “The one-slide summary”| Layer | Drizzle | plasma | Migration cost |
|---|---|---|---|
| Schema DSL | pgTable("todos", { id: text().primaryKey() }) |
table("todos", { id: id() }) |
Low — helper names differ, sed doesn’t quite work |
| Query builder | db.select().from().where(eq()) |
Same shape | Nearly zero |
| Row type inference | $inferSelect / $inferInsert |
InferRow / InferInsertRow |
Rename |
| Write path | db.insert() inline anywhere |
Only through defineMutators registry |
This is the redesign |
| Migrations | drizzle-kit (versioned SQL files) | ensureSchema + runMigrations (runtime reconcile, additive-only) |
Different mental model |
| Relations | db.query.todos.findMany({ with }) |
.innerJoin() / .leftJoin() in the builder |
Rewrite one call per usage |
| Reactivity | None (Drizzle is a one-shot ORM) | useLiveQuery reactive subscriptions |
Additive gain |
| Offline / sync | Bring-your-own | Built-in | Additive gain |
The two rows that matter most: the write path becomes mutator- centric, and relations lose the sugar. Everything else is a short mechanical port.
What stays the same
Section titled “What stays the same”db.select().from().where().orderBy().limit()
Section titled “db.select().from().where().orderBy().limit()”// Drizzleconst rows = await db .select() .from(todos) .where(eq(todos.done, 0)) .orderBy(asc(todos.updatedAt)) .limit(20)
// plasma — identicalconst rows = await db .select() .from(todos) .where(eq(todos.done, 0)) .orderBy(asc(todos.updatedAt)) .limit(20)eq,ne,gt,gte,lt,lte,and,or,inArray,isNull,isNotNull,like,asc,desc,count,sum,avg,max,min— all from@sh1n4ps/plasma-corewith the same shape as Drizzle.db.insert().values(),db.update().set().where(),db.delete().where()— same shape, but only usable inside a mutator (see below).
innerJoin / leftJoin
Section titled “innerJoin / leftJoin”// Drizzleconst rows = await db .select() .from(todos) .innerJoin(users, eq(todos.userId, users.id)) .where(eq(users.id, ctx.userId))
// plasma — identicalconst rows = await db .select() .from(todos) .innerJoin(users, eq(todos.userId, users.id)) .where(eq(users.id, ctx.userId))Joined row shape is { todos: {...}, users: {...} } — same as
Drizzle when you don’t project.
What changes mechanically
Section titled “What changes mechanically”Schema declaration
Section titled “Schema declaration”// Drizzle (Postgres)import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"
export const todos = pgTable("todos", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), title: text("title").notNull(), done: integer("done").default(0).notNull(), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), updatedAt: timestamp("updated_at").notNull(),})
// plasmaimport { defineSchema, id, int, ref, table, text } from "@sh1n4ps/plasma-core"
export const todos = table("todos", { id: id(), title: text(), done: int().default(0), userId: ref(() => users.id, { onDelete: "cascade" }), updatedAt: int(),})
export const schema = defineSchema({ users, todos })Notes:
- Dialect-free: plasma’s
table()is the same regardless of SQLite (D1) or Postgres backend. Drizzle needspgTable/sqliteTable/mysqlTableat declaration time. id()is mandatory and must be namedid: no composite PKs, no serial integers. The change log and IDB key path assume it.timestampdoesn’t exist as a helper: useint()for epoch ms. plasma doesn’t ship a Date-backed column type in v1.0 — every timestamp isintall the way through.$defaultFnmaps to.default()— plasma applies defaults the same way but expects a value or serialisable function.- No
$onUpdate— you setupdatedAtexplicitly on every mutator that touches the row.
Row inference
Section titled “Row inference”// Drizzletype Todo = typeof todos.$inferSelecttype NewTodo = typeof todos.$inferInsert
// plasmaimport type { InferRow, InferInsertRow, InferUpdateRow } from "@sh1n4ps/plasma-core"
type Todo = InferRow<typeof todos>type NewTodo = InferInsertRow<typeof todos>type TodoUpdate = InferUpdateRow<typeof todos>Same widening rules: columns with defaults become optional in
InferInsertRow; every column becomes optional in InferUpdateRow.
What gets redesigned — the write path
Section titled “What gets redesigned — the write path”This is the biggest structural change. In Drizzle you write
db.insert(todos).values(...) anywhere in your code — a route
handler, a React component, a scheduled job. In plasma every write
must live inside a mutator registered up-front.
Before (Drizzle)
Section titled “Before (Drizzle)”// Anywhere in your appapp.post("/todos", async (req, res) => { const { title } = req.body await db.insert(todos).values({ id: crypto.randomUUID(), title, userId: req.user.id, updatedAt: new Date(), }) res.json({ ok: true })})After (plasma)
Section titled “After (plasma)”// src/shared/schema.ts — declared onceimport { defineMutators, eq } from "@sh1n4ps/plasma-core"
interface Ctx { userId: string }
export const mutators = defineMutators<typeof schema, Ctx>()({ createTodo: async ({ db, args, ctx }) => { await db.insert(todos).values({ id: args.id, title: args.title, userId: ctx.userId, updatedAt: args.updatedAt, }) },})Then anywhere you want to create a todo:
// From Reactconst create = useMutation<typeof mutators, "createTodo">("createTodo")create.mutate({ id: plasma.newId(), title: "Buy milk", updatedAt: Date.now(),})
// From a plain Node scriptawait plasma.mutate("createTodo", { id: ..., title: ..., updatedAt: ... })Why the change
Section titled “Why the change”The db inside a mutator is bound differently on each engine.
Same function, two runtimes: optimistically against IDB in the
browser (so mutate() returns instantly), canonically against D1
on the Worker (after the browser pushes the mutation). Reachable
db.insert() calls outside a mutator have no equivalent
optimistic-side execution — that’s why plasma reserves writes for
the registry.
The mechanical port
Section titled “The mechanical port”For each Drizzle db.insert/update/delete call site in your
codebase:
- Extract the args (the values passed to
.values()/.set()and the where clauses). - Wrap in a mutator named after the action.
- Register the mutator in
defineMutators({...}). - Replace the call site with
client.mutate("actionName", args)(browser) orplasma.mutate("actionName", args)(Node / test).
What loses sugar — relations
Section titled “What loses sugar — relations”Drizzle’s top-level relational API:
// Drizzleconst withComments = await db.query.todos.findMany({ with: { comments: true }, where: eq(todos.userId, ctx.userId),})// Returns: [{ id, title, comments: [{...}, ...] }, ...]plasma doesn’t have db.query.*.findMany({ with }) in v1.0. Use the
regular join instead:
// plasma — flat joined tupleconst rows = await db .select() .from(todos) .leftJoin(comments, eq(comments.todoId, todos.id)) .where(eq(todos.userId, ctx.userId))// Returns: [{ todos: {...}, comments: {...} | null }, ...]Then group by hand in the caller if you need nested shape. The nested-relational sugar is queued for v1.1; this is the biggest single papercut for Drizzle migrants.
What plasma adds
Section titled “What plasma adds”None of this exists in Drizzle proper — these are the reasons the switch is worth doing at all.
Reactive live queries
Section titled “Reactive live queries”function TodoList() { const rows = useLiveQuery( () => plasma.db.select().from(todos).where(eq(todos.done, 0)), [], ) return <ul>{rows.map((t) => <li key={t.id}>{t.title}</li>)}</ul>}The component re-renders whenever any of the source tables change, even if the change came from another tab or another user via the sync loop. Under the hood, the IVM engine diffs only the affected rows — an insert to a 10k-row table with a top-20 window doesn’t re-render.
Offline outbox + optimistic UI
Section titled “Offline outbox + optimistic UI”create.mutate(...) returns synchronously from IDB. The mutation
sits in an outbox until the network confirms it. Close the tab,
open it tomorrow, still-pending writes flush. Kill the Worker,
mutations keep going. This costs zero extra code on your side.
CRDT columns
Section titled “CRDT columns”For state that must converge without conflict:
const messages = table("messages", { id: id(), reactions: crdtOrSet<string>(), // observed-remove set})
// Add-wins on concurrent add + remove of the same emoji.Not expressible in Drizzle without hand-rolling merge logic. See the CRDT guide.
File attachments (R2)
Section titled “File attachments (R2)”file() column + usePlasmaFile(ref) hook. Content-addressable,
edge-cached, auth-gated. Drizzle-with-R2 requires manual upload
plumbing.
What plasma isn’t good at
Section titled “What plasma isn’t good at”If your Drizzle project has any of these characteristics, plasma probably isn’t the right migration target:
- Public feeds — Twitter timeline shape, one row fanned out to thousands of subscribers. plasma’s change log grows with writes; fine for per-user data, not for broadcast.
- Analytics / batch aggregation — plasma’s IVM is per-window; it doesn’t compete with columnar OLAP.
- Composite primary keys — plasma requires
idas a single string PK. - Multi-tenant server-only usage — no browser side means no benefit from the sync layer.
Migration checklist
Section titled “Migration checklist”For a mid-sized Drizzle project (10-30 tables, 50-100 mutation sites):
- Port
pgTable/sqliteTable→table(dialect-free). One file per table stays one file per table. - Add
defineSchema({...})at the end of your schema. - Bump every timestamp column to
int()(epoch ms). - Add
id: id()to every table if you weren’t already using UUID text PKs. - Rewrite
$inferSelect→InferRow,$inferInsert→InferInsertRow. - Group every
db.insert/update/deletecall into named mutators in adefineMutatorsregistry. - Add per-table
authpredicates onTableOptions.authfor row-level access. Replaces middleware in most cases. - Replace call sites with
client.mutate("name", args)/useMutation. - Rewrite
.query.x.findMany({ with })calls into.innerJoin()/.leftJoin(). - Replace drizzle-kit migration invocations with
runMigrationsat deploy (or in your admin route). - Bump
SCHEMA_VERSIONfor the first deploy that’s incompatible with the pre-plasma clients.
For an app you’d expect a Drizzle-migrant to touch, the port is a few days of mechanical work. The Drizzle → plasma value swap is big enough that most teams find it worth it.
What to read next
Section titled “What to read next”- Schema — every column type, every modifier
- Mutators — the mutator contract in depth
- Live queries — the reactive read path
- Migrations —
ensureSchema/runMigrations/SCHEMA_VERSION - Todo App recipe — a runnable project