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.
The minimal table
Section titled “The minimal table”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, namedid. plasma’s change log, IDBkeyPath, and blob-ref index all assume the primary key is calledid. Naming anid()column anything else throws attable()time. - The
defineSchemaobject key must match thetable(name, ...)argument.defineSchema({ notes: table("notes", ...) })is fine;defineSchema({ n: table("notes", ...) })throws with a clear error message.
Column types
Section titled “Column types”| 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 |
Modifiers
Section titled “Modifiers”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 storenull. TheSelectrow getsT | null; theInserttype makes the field optional (undefinedis materialised tonullat write time, so you never readundefinedoff a nullable column)..unique()— DB-level uniqueness constraint. Enforced atrunMigrationsDDL. 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 likecrypto.randomUUID)..encrypted()— the client engine wraps the value in anEnvelopebefore it hits IDB. See the Encryption guide.
id() — the primary key
Section titled “id() — the primary key”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.
ref() — foreign keys and cascades
Section titled “ref() — foreign keys and cascades”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()(ortext()) — 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 tonullon parent delete. Requires.nullable()on the child column.
file() — binary attachments
Section titled “file() — binary attachments”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.
CRDT columns
Section titled “CRDT columns”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.
Table options
Section titled “Table options”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’sfile()columns. Requires the server’sSyncHandlerOptions.blobsto declare the named adapter.
defineSchema — grouping tables
Section titled “defineSchema — grouping tables”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_VERSIONis 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 viaonSchemaMismatch.
Inferring row types
Section titled “Inferring row types”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— whatdb.select().from(...)returnsInferInsertRow— the caller shape fordb.insert().values(...)(columns with defaults /.nullable()become optional)InferUpdateRow— the caller shape fordb.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.
What to read next
Section titled “What to read next”- 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