Skip to content

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.

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.

db.select().from().where().orderBy().limit()

Section titled “db.select().from().where().orderBy().limit()”
// Drizzle
const rows = await db
.select()
.from(todos)
.where(eq(todos.done, 0))
.orderBy(asc(todos.updatedAt))
.limit(20)
// plasma — identical
const 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-core with 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).
// Drizzle
const rows = await db
.select()
.from(todos)
.innerJoin(users, eq(todos.userId, users.id))
.where(eq(users.id, ctx.userId))
// plasma — identical
const 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.

// 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(),
})
// plasma
import { 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 needs pgTable / sqliteTable / mysqlTable at declaration time.
  • id() is mandatory and must be named id: no composite PKs, no serial integers. The change log and IDB key path assume it.
  • timestamp doesn’t exist as a helper: use int() for epoch ms. plasma doesn’t ship a Date-backed column type in v1.0 — every timestamp is int all the way through.
  • $defaultFn maps to .default() — plasma applies defaults the same way but expects a value or serialisable function.
  • No $onUpdate — you set updatedAt explicitly on every mutator that touches the row.
// Drizzle
type Todo = typeof todos.$inferSelect
type NewTodo = typeof todos.$inferInsert
// plasma
import 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.

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.

// Anywhere in your app
app.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 })
})
// src/shared/schema.ts — declared once
import { 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 React
const create = useMutation<typeof mutators, "createTodo">("createTodo")
create.mutate({
id: plasma.newId(),
title: "Buy milk",
updatedAt: Date.now(),
})
// From a plain Node script
await plasma.mutate("createTodo", { id: ..., title: ..., updatedAt: ... })

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.

For each Drizzle db.insert/update/delete call site in your codebase:

  1. Extract the args (the values passed to .values() / .set() and the where clauses).
  2. Wrap in a mutator named after the action.
  3. Register the mutator in defineMutators({...}).
  4. Replace the call site with client.mutate("actionName", args) (browser) or plasma.mutate("actionName", args) (Node / test).

Drizzle’s top-level relational API:

// Drizzle
const 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 tuple
const 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.

None of this exists in Drizzle proper — these are the reasons the switch is worth doing at all.

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.

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.

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() column + usePlasmaFile(ref) hook. Content-addressable, edge-cached, auth-gated. Drizzle-with-R2 requires manual upload plumbing.

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 id as a single string PK.
  • Multi-tenant server-only usage — no browser side means no benefit from the sync layer.

For a mid-sized Drizzle project (10-30 tables, 50-100 mutation sites):

  • Port pgTable / sqliteTabletable (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 $inferSelectInferRow, $inferInsertInferInsertRow.
  • Group every db.insert/update/delete call into named mutators in a defineMutators registry.
  • Add per-table auth predicates on TableOptions.auth for 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 runMigrations at deploy (or in your admin route).
  • Bump SCHEMA_VERSION for 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.