コンテンツにスキップ

Todo アプリ

このレシピは Quick Start を、 実行・デプロイ可能なプロジェクトへと発展させたものです。読み終える頃には、 実際の D1 データベースに対してユーザーごとの認証付きで動く todo アプリが手に入ります。

my-todo-app/
├── package.json
├── wrangler.jsonc
├── worker.ts
├── src/
│ ├── shared/
│ │ └── schema.ts # frontend と Worker の両方から import される
│ ├── main.tsx # React エントリ
│ ├── App.tsx
│ └── TodoList.tsx
├── vite.config.ts
└── tsconfig.json

肝となるのは shared ディレクトリです。この配下のすべては、 クライアントバンドル (Vite → ブラウザ) と Worker バンドル (Wrangler → Cloudflare Workers ランタイム) の両方から import されます。

  1. ワークスペースをブートストラップする

    Section titled “ワークスペースをブートストラップする”
    Terminal window
    pnpm create vite my-todo-app --template react-ts
    cd my-todo-app
    pnpm add @sh1n4ps/plasma-core @sh1n4ps/plasma-client @sh1n4ps/plasma-server @sh1n4ps/plasma-react @sh1n4ps/plasma-devtools
    pnpm add -D wrangler @cloudflare/workers-types
  2. src/shared/schema.ts
    import {
    defineMutators, defineSchema, eq, id, int, ref, table, text,
    } from "@sh1n4ps/plasma-core"
    export const users = table("users", {
    id: id(),
    email: text().unique(),
    name: text(),
    })
    export const todos = table("todos", {
    id: id(),
    title: text(),
    done: int().default(0),
    userId: ref(() => users.id, { onDelete: "cascade" }),
    updatedAt: int(),
    }, {
    auth: {
    read: (ctx, row) => row.userId === ctx.userId,
    write: (ctx, row) => row.userId === ctx.userId,
    },
    })
    export const schema = defineSchema({ users, todos })
    export const SCHEMA_VERSION = "todos-v1"
    export interface Ctx {
    userId: string
    }
    export const mutators = defineMutators<typeof schema, Ctx>()({
    createTodo: async ({ db, args, ctx }) => {
    await db.insert(todos).values({
    id: args.id,
    title: args.title,
    userId: ctx.userId,
    updatedAt: args.updatedAt,
    })
    },
    markDone: async ({ db, args }) => {
    await db.update(todos)
    .set({ done: 1, updatedAt: args.updatedAt })
    .where(eq(todos.id, args.id))
    },
    editTitle: async ({ db, args }) => {
    await db.update(todos)
    .set({ title: args.title, updatedAt: args.updatedAt })
    .where(eq(todos.id, args.id))
    },
    deleteTodo: async ({ db, args }) => {
    await db.delete(todos).where(eq(todos.id, args.id))
    },
    })

    todos テーブルの auth predicate に注目してください。 row.userId === ctx.userId が read/write のゲートになっているため、 あるユーザーの todo は他のユーザーからは見えません。

  3. worker.ts
    import {
    createSyncHandler, ensureSchema, fromD1,
    } from "@sh1n4ps/plasma-server"
    import { schema, mutators, SCHEMA_VERSION } from "./src/shared/schema"
    interface Env {
    DB: D1Database
    }
    async function verifyJWT(_token: string) {
    // Replace with your real JWT verification.
    return { id: "demo-user", email: "demo@example.com" }
    }
    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) => {
    const token = req.headers.get("authorization")?.replace("Bearer ", "")
    if (!token) return { ok: false, reason: "no token" }
    const user = await verifyJWT(token)
    return {
    ok: true,
    clientGroupID: user.id,
    clientID: req.headers.get("x-client") ?? crypto.randomUUID(),
    ctx: { userId: user.id },
    }
    },
    })
    return handler(req)
    },
    }
  4. {
    "$schema": "node_modules/wrangler/config-schema.json",
    "name": "my-todo-app",
    "main": "./worker.ts",
    "compatibility_date": "2026-01-01",
    "compatibility_flags": ["nodejs_compat"],
    "d1_databases": [
    {
    "binding": "DB",
    "database_name": "my-todo-app-dev",
    "database_id": "your-d1-id-here"
    }
    ]
    }

    D1 を作成します。

    Terminal window
    pnpm wrangler d1 create my-todo-app-dev
    # Paste the printed database_id into wrangler.jsonc
  5. src/main.tsx
    import { createRoot } from "react-dom/client"
    import { createPlasmaClient } from "@sh1n4ps/plasma-client"
    import { PlasmaProvider } from "@sh1n4ps/plasma-react"
    import { App } from "./App"
    import { schema, mutators, SCHEMA_VERSION } from "./shared/schema"
    export const plasma = createPlasmaClient({
    schema,
    mutators,
    dbName: "todos-db",
    endpoint: "/sync",
    clientGroupID: "demo-user",
    schemaVersion: SCHEMA_VERSION,
    getContext: async () => ({ userId: "demo-user" }),
    authHeaders: async () => ({
    "authorization": `Bearer ${localStorage.getItem("token") ?? "demo"}`,
    }),
    })
    plasma.start()
    createRoot(document.getElementById("root")!).render(
    <PlasmaProvider client={plasma}>
    <App />
    </PlasmaProvider>,
    )
  6. src/TodoList.tsx
    import { useState } from "react"
    import { asc, eq } from "@sh1n4ps/plasma-core"
    import { useLiveQuery, useMutation } from "@sh1n4ps/plasma-react"
    import { plasma } from "./main"
    import { mutators, todos } from "./shared/schema"
    export function TodoList() {
    const rows = useLiveQuery(
    () => plasma.db
    .select()
    .from(todos)
    .where(eq(todos.done, 0))
    .orderBy(asc(todos.updatedAt)),
    [],
    )
    const createTodo = useMutation<typeof mutators, "createTodo">("createTodo")
    const markDone = useMutation<typeof mutators, "markDone">("markDone")
    const deleteTodo = useMutation<typeof mutators, "deleteTodo">("deleteTodo")
    const [draft, setDraft] = useState("")
    return (
    <div>
    <form onSubmit={(e) => {
    e.preventDefault()
    if (!draft.trim()) return
    createTodo.mutate({
    id: plasma.newId(),
    title: draft,
    updatedAt: Date.now(),
    })
    setDraft("")
    }}>
    <input
    value={draft}
    onChange={(e) => setDraft(e.target.value)}
    placeholder="New todo…"
    />
    <button type="submit">Add</button>
    </form>
    <ul>
    {rows.map((t) => (
    <li key={t.id}>
    {t.title}
    <button onClick={() => markDone.mutate({
    id: t.id, updatedAt: Date.now(),
    })}>done</button>
    <button onClick={() => deleteTodo.mutate({ id: t.id })}>×</button>
    </li>
    ))}
    </ul>
    </div>
    )
    }
  7. src/App.tsx
    import { PlasmaDevtools } from "@sh1n4ps/plasma-devtools"
    import { plasma } from "./main"
    import { TodoList } from "./TodoList"
    import { schema } from "./shared/schema"
    export function App() {
    return (
    <>
    <TodoList />
    {import.meta.env.DEV && (
    <PlasmaDevtools client={plasma} dbName="todos-db" schema={schema} />
    )}
    </>
    )
    }
  8. vite.config.ts
    import react from "@vitejs/plugin-react"
    import { defineConfig } from "vite"
    export default defineConfig({
    plugins: [react()],
    server: {
    proxy: {
    "/sync": "http://localhost:8787",
    },
    },
    })

    これにより、pnpm dev の実行中はクライアント側の endpoint: "/sync" がローカルの Worker に解決されます。

  9. ターミナル A:

    Terminal window
    pnpm wrangler dev --local

    ターミナル B:

    Terminal window
    pnpm dev

    http://localhost:5173 を開きます。todo を追加すると、 optimistic に即座に表示されます。ページをリロードしても todo は残ります (IndexedDB に保存されているためです)。ターミナル A (Worker) を停止して 別の todo を追加しても、まだ動作し outbox に蓄積されていきます。 Worker を再起動すると outbox がフラッシュされます。

  10. Terminal window
    pnpm wrangler deploy

    frontend の endpoint をデプロイ済みの Worker URL に向けるよう更新し、 frontend をビルドして Cloudflare Workers Static Assets (あるいは任意のホスト) にデプロイします。

動く todo アプリそのもの以外に、次のものが手に入ります。

  • 即時の UI 更新 — すべての mutation は IDB から同期的に返ります。 ハッピーパスでスピナーは出ません。
  • オフライン耐性 — Worker を止め、タブを閉じ、翌日に接続を復元しても、 outbox が待ってくれています。
  • タブをまたいだリアルタイム — 同じ URL を 2 つ開いてください。 片方のタブで todo を追加すると、次の pull でもう片方に表示されます (デフォルトは 5 秒ポーリング、WebSocket を配線すれば即座に反映されます)。
  • ユーザーごとの分離 — 2 つの異なる authorization トークンを試してください。 各ユーザーは自分の todo だけを見られます。
  • todo ごとのコメントを、結合された comments テーブルとして追加し、 useLiveQuery 内で innerJoin を使う。
  • crdtOrSet<string>() カラムでリアクションを追加し、複数のタブが 同じ絵文字を追加しても収束するようにする。
  • 「これまでに完了した todo の数」を、現在アクティブな全タブでの合計として 表示する crdtPnCounter を追加する。
  • file().nullable() カラムと R2 の配線でファイル添付を追加する (ファイルと blob を参照)。