Skip to content

Quick Start

In plasma:

  • the browser runs your mutators optimistically against IndexedDB
  • the Worker runs the same mutators canonically against D1
  • plasma owns sync, push, pull, rebase, live queries, and retries

You write one schema and one set of mutation functions. Both runtimes use them.

  1. Create a shared file both the browser and the Worker will import.

    src/schema.ts
    import {
    defineMutators,
    defineSchema,
    eq,
    id,
    int,
    table,
    text,
    } from "@sh1n4ps/plasma-core"
    export const todos = table("todos", {
    id: id(),
    title: text(),
    done: int().default(0),
    updatedAt: int(),
    })
    export const schema = defineSchema({ todos })
    export const SCHEMA_VERSION = "todos-v1"
    interface Ctx { userId: string }
    export const mutators = defineMutators<typeof schema, Ctx>()({
    createTodo: async ({ db, args }) => {
    await db.insert(todos).values({
    id: args.id,
    title: args.title,
    updatedAt: args.updatedAt,
    })
    },
    markDone: async ({ db, args }) => {
    await db.update(todos)
    .set({ done: 1, updatedAt: args.updatedAt })
    .where(eq(todos.id, args.id))
    },
    })

    The defineMutators<typeof schema, Ctx>() shape captures both the schema (so db.insert(todos) is type-checked) and the ctx type (so ctx.userId is available inside every mutator).

  2. worker.ts
    import { createSyncHandler, ensureSchema, fromD1 } from "@sh1n4ps/plasma-server"
    import { schema, mutators, SCHEMA_VERSION } from "./schema"
    interface Env {
    DB: D1Database
    }
    export default {
    async fetch(req: Request, env: Env): Promise<Response> {
    const executor = fromD1(env.DB)
    await ensureSchema({ schema, executor })
    const handler = createSyncHandler({
    schema,
    mutators,
    executor,
    schemaVersion: SCHEMA_VERSION,
    auth: async (req) => ({
    ok: true,
    clientGroupID: req.headers.get("x-group") ?? "demo",
    clientID: req.headers.get("x-client") ?? "anonymous",
    ctx: { userId: req.headers.get("x-user") ?? "demo-user" },
    }),
    })
    return handler(req)
    },
    }

    ensureSchema runs additively-safe DDL: it creates the tables, the change-log trigger set, and plasma’s internal bookkeeping tables. auth is called on every request; return { ok: false, reason } to reject.

  3. src/main.tsx
    import { createPlasmaClient } from "@sh1n4ps/plasma-client"
    import { PlasmaProvider } from "@sh1n4ps/plasma-react"
    import { createRoot } from "react-dom/client"
    import { schema, mutators, SCHEMA_VERSION } from "./schema"
    import { TodoList } from "./TodoList"
    export const plasma = createPlasmaClient({
    schema,
    mutators,
    dbName: "todos",
    endpoint: "/sync",
    clientGroupID: "demo",
    schemaVersion: SCHEMA_VERSION,
    getContext: async () => ({ userId: "demo-user" }),
    })
    plasma.start()
    createRoot(document.getElementById("root")!).render(
    <PlasmaProvider client={plasma}>
    <TodoList />
    </PlasmaProvider>,
    )

    plasma.start() opens the pull polling loop. Realtime pokes over WebSocket are opt-in — pass subscribe: createWebSocketSubscription({...}) to createPlasmaClient when you’re ready. The endpoint value is resolved against location.origin in the browser; on Node / Workers you must pass an absolute URL.

    Note that plasma is exported here so TodoList.tsx can import it below.

  4. src/TodoList.tsx
    import { useLiveQuery, useMutation } from "@sh1n4ps/plasma-react"
    import { asc, eq } from "@sh1n4ps/plasma-core"
    import { plasma } from "./main"
    import { mutators, todos } from "./schema"
    export function TodoList() {
    const rows = useLiveQuery(
    () => plasma.db
    .select()
    .from(todos)
    .where(eq(todos.done, 0))
    .orderBy(asc(todos.updatedAt)),
    [],
    )
    const create = useMutation<typeof mutators, "createTodo">("createTodo")
    const markDone = useMutation<typeof mutators, "markDone">("markDone")
    return (
    <div>
    <button
    onClick={() => create.mutate({
    id: plasma.newId(),
    title: "Try plasma",
    updatedAt: Date.now(),
    })}
    >
    Add todo
    </button>
    <ul>
    {rows.map((row) => (
    <li key={row.id}>
    {row.title}
    <button onClick={() => markDone.mutate({
    id: row.id,
    updatedAt: Date.now(),
    })}>
    done
    </button>
    </li>
    ))}
    </ul>
    </div>
    )
    }

    The useLiveQuery result updates the moment either:

    • the local mutation fires (optimistically), or
    • the pull loop delivers a change from the server (canonically).

    You never subscribe explicitly. plasma’s IVM (Incremental View Maintenance) tracks which rows are in the query’s window and pushes diffs to your component.

You wrote schema.ts once.

  • The browser used it for typed local queries and optimistic writes.
  • The Worker used it for canonical writes to D1.
  • The same createTodo mutator ran in both places.
  • Live queries updated the moment the local mutation landed, then the sync loop reconciled with the server in the background.
  • Sync, retry, rebase, and the change log were handled by plasma.

The four files above are the shape of a plasma app but not a complete runnable project — you still need wrangler.jsonc, a Vite proxy for the browser, and the two-terminal dev loop.

Head to the Todo App recipe for a copy-paste runnable version with the full project layout, D1 setup commands, and a note on local vs deployed.

  • Todo App recipe — the runnable version of what’s above (start here to actually see it work)
  • Next Steps — where to go once the todo app runs
  • Mental model — the three layers that make one schema serve two runtimes
  • Guides / Deployment — deploying the Worker + D1 setup to Cloudflare production