Skip to content

Schema

Your schema is the single most important file in a plasma app. This guide walks through every declaration, in the order you’d typically add them to a growing project.

import { defineSchema, id, table, text } from "@sh1n4ps/plasma-core"
const notes = table("notes", {
id: id(),
body: text(),
})
export const schema = defineSchema({ notes })

Requirements:

  • Exactly one id() column, named id. plasma’s change log, IDB keyPath, and blob-ref index all assume the primary key is called id. Naming an id() column anything else throws at table() time.
  • The defineSchema object key must match the table(name, ...) argument. defineSchema({ notes: table("notes", ...) }) is fine; defineSchema({ n: table("notes", ...) }) throws with a clear error message.
Helper JavaScript type SQL storage
id() string (UUID by default) TEXT PRIMARY KEY
text() string TEXT
int() number INTEGER
bigint() bigint BIGINT
boolean() boolean 0/1 in SQLite, BOOLEAN in Postgres
blob() Uint8Array BLOB (small inline bytes)
json() unknown JSON string
file() FileRef JSON string ({ hash, size, mime })
ref(() => other.id) Same as target column TEXT foreign key
crdtCounter() number (read via sumCrdtCounter) JSON { [clientID]: n }
crdtPnCounter() number (read via pnRead) JSON { p, n }
crdtLwwRegister<T>() T (read via lwwRead) JSON { value, ts, clientID }
crdtOrSet<T>() T[] (read via orSetValues) JSON tag-and-tombstone struct

Every column returns a Column<...> you can chain modifiers on. All modifiers are pure functions that return a new Column — the original is unchanged, so you can share bases:

const nameCol = text()
const users = table("users", {
id: id(),
firstName: nameCol, // TEXT NOT NULL
middleName: nameCol.nullable(), // TEXT (nullable)
handle: nameCol.unique(), // TEXT UNIQUE NOT NULL
})
  • .nullable() — the column may store null. The Select row gets T | null; the Insert type makes the field optional (undefined is materialised to null at write time, so you never read undefined off a nullable column).
  • .unique() — DB-level uniqueness constraint. Enforced at runMigrations DDL. Duplicate inserts throw at mutator time.
  • .default(value) — value applied when the caller omits the column at insert time. Any JSON-serialisable value (or a plasma runtime helper like crypto.randomUUID).
  • .encrypted() — the client engine wraps the value in an Envelope before it hits IDB. See the Encryption guide.

id() is a text()-shaped column whose default is crypto.randomUUID(). If you supply an id at insert time, plasma uses yours; if you omit it, plasma calls randomUUID().

await db.insert(notes).values({
// id auto-filled with a UUID
body: "hello",
})
await db.insert(notes).values({
id: "custom-id",
body: "hello",
})

id.default() cannot be overridden — the point of id() (versus text()) is to guarantee the column is a stable primary key that plasma’s triggers can rely on.

const users = table("users", { id: id(), name: text() })
const todos = table("todos", {
id: id(),
title: text(),
userId: ref(() => users.id, { onDelete: "cascade" }),
})
  • The getter (() => users.id) is invoked lazily so you can declare tables in any order.
  • The referenced column must be another id() (or text()) — plasma doesn’t support composite keys.
  • onDelete:
    • "noAction" (default) — child rows are left with a dangling fk. plasma tolerates this because the client’s optimistic view might briefly show a parent that the server has already deleted.
    • "cascade" — child rows deleted when the parent is deleted.
    • "restrict" — parent delete throws if child rows exist.
    • "setNull" — the fk column is set to null on parent delete. Requires .nullable() on the child column.
const notes = table("notes", {
id: id(),
attachment: file().nullable(),
})

At insert time, the caller can pass a File, a Blob, or an existing FileRef:

await client.mutate("attach", {
noteId: "n1",
attachment: fileInput.files[0], // browser File object
})

The client desugars the raw uploader to a FileRef before the outbox, and uploads the bytes to R2 via PUT /sync/blob/:hash in the background. On the server side the mutator sees a FileRef{ hash, size, mime } — never the raw bytes.

Options:

file({
immutable: true, // default; forbid re-writing the same row's ref to a different hash
maxSize: 25 * 1024 * 1024, // reject uploads above 25 MB
mimeAllowList: ["image/*", "application/pdf"],
})

See the full Files and blobs guide.

crdtCounter (grow-only), crdtPnCounter (signed), crdtLwwRegister<T> (register), and crdtOrSet<T> (observed-remove set). Each has a read helper and a set of mutation helpers:

const stats = table("stats", {
id: id(),
active: crdtPnCounter(), // read: pnRead(row.active), write via pnIncrement / pnDecrement
})

Concurrent writes from different tabs are automatically merged on the server (mergeCrdtColumns). See CRDT columns.

The third argument to table() accepts a TableOptions object:

const todos = table(
"todos",
{ id: id(), title: text(), userId: ref(() => users.id) },
{
auth: {
read: (ctx, row) => row.userId === ctx.userId,
write: (ctx, row) => row.userId === ctx.userId,
},
resolveConflict: ({ server, client }) => ({
...server,
...client,
title: client.title, // client's title wins
done: Math.max(server.done ?? 0, client.done ?? 0),
}),
changeLogSuppressed: false,
blobs: storageRef("primary"),
},
)
  • auth — per-row read/write predicates. Executed on both engines. See Auth and permissions.
  • resolveConflict — called when the same row is updated concurrently by the local optimistic run and the server. Return the reconciled row. See Conflict resolution.
  • changeLogSuppressed — skip the change_log triggers. Local-only tables (session drafts, per-tab caches). See Offline mode.
  • blobs — override the storage adapter for this table’s file() columns. Requires the server’s SyncHandlerOptions.blobs to declare the named adapter.
export const schema = defineSchema({
users,
todos,
comments,
})
export const SCHEMA_VERSION = "todos-v1"
  • The object keys must match the table(name, ...) argument for every entry. Mismatch throws.
  • SCHEMA_VERSION is a string you bump whenever you make an additive-incompatible change (see Migrations). A version mismatch between client and server triggers a 409 and gives your client a chance to reset or stay via onSchemaMismatch.
import type { InferRow, InferInsertRow, InferUpdateRow } from "@sh1n4ps/plasma-core"
type Todo = InferRow<typeof todos>
type TodoInsert = InferInsertRow<typeof todos>
type TodoUpdate = InferUpdateRow<typeof todos>
  • InferRow — what db.select().from(...) returns
  • InferInsertRow — the caller shape for db.insert().values(...) (columns with defaults / .nullable() become optional)
  • InferUpdateRow — the caller shape for db.update().set(...) (every column becomes optional)

You typically don’t write these types explicitly — the query builder infers them for you. They’re here when you need to type an external function or a React component prop.

Relational queries — where’s .query.x.findMany?

Section titled “Relational queries — where’s .query.x.findMany?”

Not shipped in v1.0. plasma’s query builder is db.select().from(...).innerJoin(...).where(...) and the equivalent for leftJoin. There’s no drizzle-style .query.todos.findMany({ with: { comments: true } }) top-level relational API yet.

The workaround is joins in the query builder:

const rows = await db
.select()
.from(todos)
.innerJoin(comments, eq(comments.todoId, todos.id))
.where(eq(todos.userId, ctx.userId))

The row shape is a joined tuple { todos: {...}, comments: {...} } rather than a nested { ...todo, comments: [] } shape. That’s the current friction point for drizzle migrants; a nested-relational API is a v1.1 candidate but not yet scoped.

  • Mutators — write functions that manipulate the schema you just defined
  • Migrations — how to evolve the schema once it’s shipped
  • Live queries — read the schema reactively