Skip to content

Schema and Mutators

plasma’s “one schema, two runtimes” claim rests on a specific TypeScript trick: phantom types. This page explains the trick so nothing feels magic once you’re deep in the query builder.

Every column helper returns a Column<TData, TNotNull, THasDefault, TTable> — a class whose type parameters remember four facts about the column:

  • TData — the JavaScript type this column stores (string, number, FileRef, etc.)
  • TNotNull — is the column non-nullable? (.nullable() flips this to false)
  • THasDefault — does the column carry a default value? (drives insert-type inference)
  • TTable — the name of the table this column belongs to, imprinted when you call table("name", { ... })

Column instances have zero runtime methods for reading these — the information lives only in the type system. That’s the “phantom” part: text().nullable() produces a Column<string, false, false, string> at compile time, but at runtime it’s just an object with .meta.kind.

table("todos", { id: id(), title: text() }) does two things:

  1. Returns an object that is the column record — so todos.id gives you back the same Column instance you passed in. This is how db.select().from(todos).where(eq(todos.id, "x")) type-checks: todos.id really is a Column, and eq accepts Column | JS value.

  2. Stashes the table’s metadata (its name, its options, the full column set for reflection) on a hidden symbolTABLE_META. This is how the query compiler figures out that todos maps to SQL todos and how migrations know which columns to create.

Because every Column remembers its TData / TNotNull / THasDefault, the query builder can compute row shapes without you writing them:

// You wrote:
const todos = table("todos", {
id: id(),
title: text(),
done: int().default(0),
attachment: file().nullable(),
})
// plasma derives:
type TodoRow = InferRow<typeof todos>
// ↑ { id: string, title: string, done: number, attachment: FileRef | null }
type TodoInsert = InferInsertRow<typeof todos>
// ↑ { id?: string, title: string, done?: number, attachment?: FileRef | null | File | Blob }

InferInsertRow widens columns with defaults (done becomes optional) and, for file() columns, accepts raw uploaders (File | Blob) that plasma desugars to FileRef before hitting the outbox. The Select row keeps the strict shape.

You never write these types by hand. Your React component receives them from useLiveQuery(...); your mutator receives them via db.select().

Mutators are functions the two runtimes call

Section titled “Mutators are functions the two runtimes call”

defineMutators<typeof schema, Ctx>()({...}) takes each function of shape ({ db, args, ctx }) => Promise<void> and records it under a name. Both engines look up mutators by name:

  • The browser calls client.mutate("markDone", { id: "t1" }). The browser engine resolves markDone from the mutators record, invokes it with db bound to the IndexedDB engine, and returns after the local optimistic apply.

  • The Worker’s sync handler, when it receives that mutation over the wire, does the same lookup — but with db bound to the D1 executor. Same function, different db.

The full mutator param shape is:

{
db: Db<Schema>
args: TArgs // what the caller passed to client.mutate("name", args)
ctx: TCtx // getContext() on client, auth().ctx on server
clientID?: string // which tab initiated the mutation
mutationID?: number // monotonic per-clientID id, useful for CRDT tags
}

clientID and mutationID are what let a mutator use CRDT primitives — e.g. crdtIncrement(clientID, delta, current) — without threading identity from outside.

Every phantom-type + inference pattern above works at the TypeScript compiler level. plasma has no plasma generate step, no build plugin, no CLI that emits types.ts beside your schema.

The trade-off is that migrations aren’t automatically emitted from a schema diff either — plasma’s migrations story uses runtime ensureSchema and runMigrations calls that reconcile the declared schema with the live database, not codegen.