Mental Model
Before you read another line of code, load these three layers into your head. Every other page in this documentation makes more sense once you can name them.
Layer 1 — the declarations
Section titled “Layer 1 — the declarations”You write two things:
- A schema — the tables, their columns, their types.
- A set of mutators — the functions that change rows.
Both live in ordinary TypeScript files. Both are imported by two different runtimes.
// src/schema.ts — one file, both sides read it.export const todos = table("todos", { id: id(), title: text(), done: int().default(0),})
export const mutators = defineMutators<typeof schema, Ctx>()({ markDone: async ({ db, args }) => { await db.update(todos).set({ done: 1 }).where(eq(todos.id, args.id)) },})There is no client-only schema and no server-only schema. There is no client-only mutator and no server-only mutator. There is one of each.
Layer 2 — the two runtimes
Section titled “Layer 2 — the two runtimes”The same file runs on two engines:
-
The browser engine (
@sh1n4ps/plasma-client) — reads and writes against IndexedDB, optimistically. When you callclient.mutate("markDone", { id: "t1" }), the mutator runs locally against your IDB store, returns immediately, and the UI updates on the very next React tick. -
The Worker engine (
@sh1n4ps/plasma-server) — runs the same mutator canonically against D1 (or Postgres via Hyperdrive) after the browser pushes the mutation across the wire.
Both engines translate db.update(todos).set({...}).where(...) into
the native storage layer for their side. Neither engine needs the
other to be online to make progress.
Layer 3 — the sync loop
Section titled “Layer 3 — the sync loop”The third layer stitches the two runtimes together. It is entirely plasma’s responsibility. You never write any of it.
The sync loop:
- Keeps an outbox in the browser of every mutation that hasn’t been confirmed by the server.
- Pushes those outbox entries to
POST /sync/push. The Worker engine runs the mutator canonically. - Pulls the server’s change log via
GET /sync/pull. The client applies the confirmed changes to its base store, then replays any un-confirmed outbox entries on top. - Rebases on server-authoritative state when local and remote both edited the same rows.
- Retries with exponential backoff on network errors, 5xx, and 429.
- Notifies subscribed live queries whenever anything relevant changes.
The Push, pull, rebase page unpacks each step.
Where each layer lives
Section titled “Where each layer lives”┌────────────────────────────────────────────────────────────┐│ Layer 1 — declarations (isomorphic TypeScript) ││ ││ schema.ts ────────────────► @sh1n4ps/plasma-core ││ mutators.ts ────────────────► (no runtime deps) │└─────────────────┬─────────────────────┬────────────────────┘ │ │ ▼ ▼┌───────────────────────────┐ ┌───────────────────────────┐│ Layer 2 — browser engine │ │ Layer 2 — Worker engine ││ @sh1n4ps/plasma-client │ │ @sh1n4ps/plasma-server ││ │ │ ││ IndexedDB (optimistic) │ │ D1 / Postgres (canonical)││ Live queries + IVM │ │ Trigger-emitted change ││ Optimistic outbox │ │ log ││ Blob local cache │ │ R2 blob storage adapter │└───────────────┬───────────┘ └───────────────┬───────────┘ │ │ │ Layer 3 — sync loop │ │ (owned by plasma) │ │ │ └──► POST /sync/push ────────┤ │◄── GET /sync/pull ────────┤ │◄── WebSocket /sync/ws ──────┤ │ PUT /sync/blob/:hash ────┤ │ GET /sync/blob/:hash ────┤What plasma is (and isn’t)
Section titled “What plasma is (and isn’t)”plasma is the layer between your app’s state and its persistence. It removes the tax you normally pay for offline support + realtime sync: the outbox, the rebase, the retry logic, the change log, the live query invalidation, the file upload state machine.
plasma is not a state library for UI-local ephemeral state (form inputs, hover, “did the user close the modal?”). That still belongs in React state / your favourite atom library. plasma is for data that should survive a page reload and, sometimes, reach the server.
What to read next
Section titled “What to read next”- Schema and mutators — how the isomorphic types actually flow
- Optimistic vs canonical —
what happens after
mutate()returns - Push, pull, rebase — the sync loop in detail