Todo アプリ
このレシピは Quick Start を、 実行・デプロイ可能なプロジェクトへと発展させたものです。読み終える頃には、 実際の D1 データベースに対してユーザーごとの認証付きで動く todo アプリが手に入ります。
プロジェクト構成
Section titled “プロジェクト構成”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 されます。
-
ワークスペースをブートストラップする
Section titled “ワークスペースをブートストラップする”Terminal window pnpm create vite my-todo-app --template react-tscd my-todo-apppnpm add @sh1n4ps/plasma-core @sh1n4ps/plasma-client @sh1n4ps/plasma-server @sh1n4ps/plasma-react @sh1n4ps/plasma-devtoolspnpm add -D wrangler @cloudflare/workers-types -
共有 schema を書く
Section titled “共有 schema を書く”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テーブルのauthpredicate に注目してください。row.userId === ctx.userIdが read/write のゲートになっているため、 あるユーザーの todo は他のユーザーからは見えません。 -
Worker を書く
Section titled “Worker を書く”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)},} -
wrangler.jsonc
Section titled “wrangler.jsonc”{"$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 -
React エントリ
Section titled “React エントリ”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>,) -
TodoList コンポーネント
Section titled “TodoList コンポーネント”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()) returncreateTodo.mutate({id: plasma.newId(),title: draft,updatedAt: Date.now(),})setDraft("")}}><inputvalue={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>)} -
App シェル
Section titled “App シェル”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} />)}</>)} -
Section titled “/sync 向けの Vite proxy”/sync向けの Vite proxyvite.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 に解決されます。 -
ターミナル A:
Terminal window pnpm wrangler dev --localターミナル B:
Terminal window pnpm devhttp://localhost:5173を開きます。todo を追加すると、 optimistic に即座に表示されます。ページをリロードしても todo は残ります (IndexedDB に保存されているためです)。ターミナル A (Worker) を停止して 別の todo を追加しても、まだ動作し outbox に蓄積されていきます。 Worker を再起動すると outbox がフラッシュされます。 -
Terminal window pnpm wrangler deployfrontend の
endpointをデプロイ済みの Worker URL に向けるよう更新し、 frontend をビルドして Cloudflare Workers Static Assets (あるいは任意のホスト) にデプロイします。
得られるもの
Section titled “得られるもの”動く 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 を参照)。