Skip to content

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.

You write two things:

  1. A schema — the tables, their columns, their types.
  2. 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.

The same file runs on two engines:

  • The browser engine (@sh1n4ps/plasma-client) — reads and writes against IndexedDB, optimistically. When you call client.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.

The third layer stitches the two runtimes together. It is entirely plasma’s responsibility. You never write any of it.

The sync loop:

  1. Keeps an outbox in the browser of every mutation that hasn’t been confirmed by the server.
  2. Pushes those outbox entries to POST /sync/push. The Worker engine runs the mutator canonically.
  3. 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.
  4. Rebases on server-authoritative state when local and remote both edited the same rows.
  5. Retries with exponential backoff on network errors, 5xx, and 429.
  6. Notifies subscribed live queries whenever anything relevant changes.

The Push, pull, rebase page unpacks each step.

┌────────────────────────────────────────────────────────────┐
│ 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 ────┤

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.