コンテンツにスキップ

画像付きオフラインノート

このレシピでは、Quick Start が扱わないシナリオ向けのノートアプリを構築します。 すなわち、クライアントが plasma sync サーバーと一切通信しない ケースです。 Tauri / Electron / React Native / エアギャップ環境の Chrome アプリなど、 すべての書き込みがローカルで、outbox すらフラッシュされないアプリです。

添付も引き続き動作します。バイト列は SHA-256 でインデックスされた IDB キャッシュに 留まり、同じ usePlasmaFile hook を通じてレンダリングされます。

notes-app/
├── src/
│ ├── shared/schema.ts
│ ├── main.tsx
│ └── NoteEditor.tsx
├── package.json
└── vite.config.ts

worker.tswrangler.jsonc もありません。これはクライアントのみのビルドです。

  1. 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 ストア) に格納されます。

  2. offline: true でのクライアント構築

    Section titled “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 fetched
    clientGroupID: "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 anyway
    createRoot(document.getElementById("root")!).render(
    <PlasmaProvider client={plasma}>
    <App />
    </PlasmaProvider>,
    )

    このクライアントは、

    • ポーリングタイマーを一切開かない
    • WebSocket サブスクリプションを一切実行しない
    • blob upload を一切トリガーしない — readFile はローカルキャッシュ からのみ提供する
    • それでも outbox エントリのエンキューは行う (将来オンラインに切り替えた際に きれいに拾えるようにするためです)
  3. 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>
    <textarea
    value={draft}
    onChange={(e) => setDraft(e.target.value)}
    onBlur={() => editBody.mutate({
    id: noteId,
    body: draft,
    updatedAt: Date.now(),
    })}
    />
    <div>
    <input
    type="file"
    accept="image/*"
    onChange={(e) => {
    const file = e.target.files?.[0]
    if (!file) return
    attach.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>
    )
    }

    usePlasmaFileFileHandle の union を返します。オフラインモードでは upload が発生しないため handle は status: "local" に留まります。 Blob URL はローカルの IDB キャッシュから直接提供されます。これは意図的です。 オフラインモードで「アップロード中」の状態を出すのは嘘になるからです。

  4. 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>
    )
    }
  • ネットワーク依存ゼロ。 飛行機の中でも、サーバーのない Tauri デスクトップでも、 独自のローカル sync レイヤーと通信する React Native シェルでも動作します。
  • 完全な plasma DSL。 同じ schema、同じ mutator、同じライブクエリ、 同じ file() カラム。オンライン経路のすべてが動作します — sync ループが 一時停止しているだけです。
  • 可逆的。 後から offline: false に切り替えると、outbox は通常の push 経路を通じてフラッシュされます。何も失われません。
  • resetLocalState() は throw します。 再ハイドレートするサーバーがないため、 ワイプは復元不可能です。どうしてもワイプが必要なら、正しい呼び出しは indexedDB.deleteDatabase("notes") してから再構築することです。
  • マルチタブは依然として clientID で分かれます。 同じオフラインユーザーの 2 つのタブは、それぞれ独自の outbox を持ちます。後でクライアントがオンラインに なると、両方のタブの outbox がそれぞれのクライアント ID スロットを通じて push します。問題はありませんが、認識しておいてください。
  • readFile(ref)/sync/blob/* から一切フェッチしません。 データ中の FileRef にローカルのバイト列がない場合 (例: 同僚のエクスポートから import した場合)、 readFile{ status: "missing" } を返します。オフラインモードでは フォールバックのフェッチはありません。

Tauri アプリの場合:

Terminal window
pnpm add -D @tauri-apps/cli
pnpm tauri init
pnpm tauri dev

Tauri を Vite ビルドに向けます。plasma ランタイム全体が WebView の中にあり、 何もネットワークを経由しません。

React Native アプリの場合は、expo またはプレーンな react-native を、 互換性のある IndexedDB polyfill (例: @sh1n4ps/plasma-client-sqlite と組み合わせた /react-native-quick-sqlite) とともに使います。