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.
Columns carry their type at declaration
Section titled “Columns carry their type at declaration”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 tofalse)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 calltable("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.
Tables re-project the columns
Section titled “Tables re-project the columns”table("todos", { id: id(), title: text() }) does two things:
-
Returns an object that is the column record — so
todos.idgives you back the sameColumninstance you passed in. This is howdb.select().from(todos).where(eq(todos.id, "x"))type-checks:todos.idreally is aColumn, andeqacceptsColumn | JS value. -
Stashes the table’s metadata (its name, its options, the full column set for reflection) on a hidden symbol —
TABLE_META. This is how the query compiler figures out thattodosmaps to SQLtodosand how migrations know which columns to create.
Row types are derived, not written
Section titled “Row types are derived, not written”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 resolvesmarkDonefrom the mutators record, invokes it withdbbound 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
dbbound to the D1 executor. Same function, differentdb.
What plasma injects on the mutator params
Section titled “What plasma injects on the mutator params”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.
Why not code generation?
Section titled “Why not code generation?”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.
What to read next
Section titled “What to read next”- Optimistic vs canonical — how the same mutator produces two different effects
- Schema (Guide) — every column type + modifier
- Mutators (Guide) — writing production-safe mutators