画像付きオフラインノート
このレシピでは、Quick Start が扱わないシナリオ向けのノートアプリを構築します。 すなわち、クライアントが plasma sync サーバーと一切通信しない ケースです。 Tauri / Electron / React Native / エアギャップ環境の Chrome アプリなど、 すべての書き込みがローカルで、outbox すらフラッシュされないアプリです。
添付も引き続き動作します。バイト列は SHA-256 でインデックスされた IDB キャッシュに
留まり、同じ usePlasmaFile hook を通じてレンダリングされます。
プロジェクトの形
Section titled “プロジェクトの形”notes-app/├── src/│ ├── shared/schema.ts│ ├── main.tsx│ └── NoteEditor.tsx├── package.json└── vite.config.tsworker.ts も wrangler.jsonc もありません。これはクライアントのみのビルドです。
-
schema
Section titled “schema”src/shared/schema.ts import {defineMutators, defineSchema, eq,file, id, int, table, text,} from "@sh1n4ps/plasma-core"export const notes = table("notes", {id: id(),title: text(),body: text(),attachment: file().nullable(),updatedAt: int(),})export const schema = defineSchema({ notes })export const SCHEMA_VERSION = "notes-offline-v1"export interface Ctx { userId: string }export const mutators = defineMutators<typeof schema, Ctx>()({createNote: async ({ db, args }) => {await db.insert(notes).values({id: args.id,title: args.title,body: "",updatedAt: args.updatedAt,})},editBody: async ({ db, args }) => {await db.update(notes).set({ body: args.body, updatedAt: args.updatedAt }).where(eq(notes.id, args.id))},attach: async ({ db, args }) => {// args.attachment can be a File / Blob (from an <input type="file">)// or a pre-computed FileRef. desugarFileArgs handles the conversion.await db.update(notes).set({ attachment: args.attachment, updatedAt: args.updatedAt }).where(eq(notes.id, args.id))},})file()カラムはFileRefマニフェストを保存します。実際のバイト列は SHA-256 ハッシュをキーとして_plasma_blobs_local(IDB ストア) に格納されます。 -
Section titled “offline: true でのクライアント構築”offline: trueでのクライアント構築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: "notes",endpoint: "unused-in-offline-mode", // required by type, never fetchedclientGroupID: "local-user",schemaVersion: SCHEMA_VERSION,getContext: async () => ({ userId: "local-user" }),offline: true, // ← the key knob})plasma.start() // no-op with offline: true — safe to call anywaycreateRoot(document.getElementById("root")!).render(<PlasmaProvider client={plasma}><App /></PlasmaProvider>,)このクライアントは、
- ポーリングタイマーを一切開かない
- WebSocket サブスクリプションを一切実行しない
- blob upload を一切トリガーしない —
readFileはローカルキャッシュ からのみ提供する - それでも outbox エントリのエンキューは行う (将来オンラインに切り替えた際に きれいに拾えるようにするためです)
-
ノートエディタ
Section titled “ノートエディタ”src/NoteEditor.tsx import { useEffect, useState } from "react"import { asc, eq } from "@sh1n4ps/plasma-core"import { useLiveQuery, useMutation, usePlasmaFile } from "@sh1n4ps/plasma-react"import { plasma } from "./main"import { mutators, notes } from "./shared/schema"export function NoteEditor({ noteId }: { noteId: string }) {const rows = useLiveQuery(() => plasma.db.select().from(notes).where(eq(notes.id, noteId)),[noteId],)const note = rows[0]const handle = usePlasmaFile(note?.attachment)const editBody = useMutation<typeof mutators, "editBody">("editBody")const attach = useMutation<typeof mutators, "attach">("attach")// Local-state input to avoid the 'one mutation per keystroke'// trap (see Beta #3 in the CHANGELOG's v1.1 queue).const [draft, setDraft] = useState(note?.body ?? "")useEffect(() => setDraft(note?.body ?? ""), [note?.body])if (!note) return <p>Not found</p>return (<div><h1>{note.title}</h1><textareavalue={draft}onChange={(e) => setDraft(e.target.value)}onBlur={() => editBody.mutate({id: noteId,body: draft,updatedAt: Date.now(),})}/><div><inputtype="file"accept="image/*"onChange={(e) => {const file = e.target.files?.[0]if (!file) returnattach.mutate({id: noteId,attachment: file,updatedAt: Date.now(),})}}/>{handle?.status === "pending" && <span>Loading…</span>}{handle?.status === "missing" && <span>Attachment missing</span>}{(handle?.status === "local" || handle?.status === "ready") && (<img src={handle.url} style={{ maxWidth: "100%" }} />)}</div></div>)}usePlasmaFileはFileHandleの union を返します。オフラインモードでは upload が発生しないため handle はstatus: "local"に留まります。 Blob URL はローカルの IDB キャッシュから直接提供されます。これは意図的です。 オフラインモードで「アップロード中」の状態を出すのは嘘になるからです。 -
App シェル
Section titled “App シェル”src/App.tsx import { useState } from "react"import { asc } from "@sh1n4ps/plasma-core"import { useLiveQuery, useMutation } from "@sh1n4ps/plasma-react"import { plasma } from "./main"import { NoteEditor } from "./NoteEditor"import { mutators, notes } from "./shared/schema"export function App() {const rows = useLiveQuery(() => plasma.db.select().from(notes).orderBy(asc(notes.updatedAt)),[],)const [selected, setSelected] = useState<string | null>(null)const createNote = useMutation<typeof mutators, "createNote">("createNote")return (<div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}><aside><button onClick={() => {const id = plasma.newId()createNote.mutate({ id, title: "Untitled", updatedAt: Date.now() })setSelected(id)}}>+ New</button><ul>{rows.map((n) => (<li key={n.id}><button onClick={() => setSelected(n.id)}>{n.title}</button></li>))}</ul></aside><main>{selected ? <NoteEditor noteId={selected} /> : <p>Select a note</p>}</main></div>)}
オフラインで得られるもの
Section titled “オフラインで得られるもの”- ネットワーク依存ゼロ。 飛行機の中でも、サーバーのない Tauri デスクトップでも、 独自のローカル sync レイヤーと通信する React Native シェルでも動作します。
- 完全な plasma DSL。 同じ schema、同じ mutator、同じライブクエリ、 同じ file() カラム。オンライン経路のすべてが動作します — sync ループが 一時停止しているだけです。
- 可逆的。 後から
offline: falseに切り替えると、outbox は通常の push 経路を通じてフラッシュされます。何も失われません。
オフラインで制限されるもの
Section titled “オフラインで制限されるもの”resetLocalState()は throw します。 再ハイドレートするサーバーがないため、 ワイプは復元不可能です。どうしてもワイプが必要なら、正しい呼び出しはindexedDB.deleteDatabase("notes")してから再構築することです。- マルチタブは依然として
clientIDで分かれます。 同じオフラインユーザーの 2 つのタブは、それぞれ独自の outbox を持ちます。後でクライアントがオンラインに なると、両方のタブの outbox がそれぞれのクライアント ID スロットを通じて push します。問題はありませんが、認識しておいてください。 readFile(ref)は/sync/blob/*から一切フェッチしません。 データ中のFileRefにローカルのバイト列がない場合 (例: 同僚のエクスポートから import した場合)、readFileは{ status: "missing" }を返します。オフラインモードでは フォールバックのフェッチはありません。
オフラインシェルのデプロイ
Section titled “オフラインシェルのデプロイ”Tauri アプリの場合:
pnpm add -D @tauri-apps/clipnpm tauri initpnpm tauri devTauri を Vite ビルドに向けます。plasma ランタイム全体が WebView の中にあり、 何もネットワークを経由しません。
React Native アプリの場合は、expo またはプレーンな react-native を、
互換性のある IndexedDB polyfill (例: @sh1n4ps/plasma-client-sqlite と組み合わせた
/react-native-quick-sqlite) とともに使います。
次に読むもの
Section titled “次に読むもの”- オフラインモード (ガイド) —
PlasmaClientOptions.offlineとテーブルごとのchangeLogSuppressedの 完全な contract - ファイルと blob — 後でオンラインに切り替えるときの R2 配線
- Todo アプリ — 「いずれオンライン」版が欲しい場合の 完全な sync バックエンド