Files and Blobs
file() は plasma のバイナリ添付プリミティブです。column を宣言し、R2 バインディングを接続すれば、画像 / PDF / その他何でも、構造化データと同じ push / pull / rebase 機構を通じて流れます。
column
Section titled “column”import { file } from "@sh1n4ps/plasma-core"
const notes = table("notes", { id: id(), body: text(), attachment: file().nullable(),})file() は Column<FileRef> を返します — 行に格納される JavaScript の型は FileRef です:
interface FileRef { readonly hash: string // sha256 hex readonly size: number // バイト数 readonly mime: string // MIME タイプ readonly name?: string // オプショナルの元ファイル名}バイト自体は R2 に、hash をキーとして格納されます。行はマニフェストのみを格納します。
クライアント側の write パス
Section titled “クライアント側の write パス”file() column に書き込む mutator は、生のキャリア (File または Blob) または既存の FileRef のいずれかを受け取ります。生の Uint8Array / ArrayBuffer は 意図的に受け付けません — これらは旧来のインライン blob() column の payload 形状で、file() の入力として扱うと blob() mutation が R2 upload に silently 書き換えられてしまうためです。生バイトを持っている場合は Blob で包んでください: new Blob([bytes], { type: mime }):
attachToNote: async ({ db, args }) => { await db.update(notes) .set({ attachment: args.attachment }) // File または FileRef の可能性 .where(eq(notes.id, args.noteId))}client.mutate の境界で、plasma は desugarFileArgs を実行します:
args内の各生キャリアについて、SHA-256 を計算し、FileRefを生成し、バイトをクライアントの_plasma_blobs_localIDB ストアに退避します。[clientID, hash]をキーとして_plasma_blob_uploadsにアップロードレコードをエンキューします。- mutator は今や
args.attachmentにFileRefを見ます — そのロジックは、呼び出し側が事前計算済みのFileRefを渡したケースと同一です。
optimistic apply が着地します。バックグラウンドのアップロードワーカーがキューされた blob を拾い、/sync/blob/:hash に PUT します。
サーバー側のアップロード — PUT /sync/blob/:hash
Section titled “サーバー側のアップロード — PUT /sync/blob/:hash”Worker は:
auth()を実行し、失敗すれば 401 を拒否します。- URL の
:hashが受信バイトの SHA-256 と一致することを検証します。 - column が宣言していれば
maxSizeを強制します。 - column が宣言していれば
mimeAllowListを強制します。 - key = hash で R2 にバイトを
PUTします。 _plasma_blobsにstate = "present"の行が入ります。
PUT は行ではなくバイトをアップロードします — 行の push は /sync/push に独立して到着します。plasma の contiguous-prefix push がゲートします: mutation < N のすべての blob 依存がアップロードされるまで、mutation N はサーバーに届きません。これが順序を保ちます。
サーバー側の read — GET /sync/blob/:hash
Section titled “サーバー側の read — GET /sync/blob/:hash”Worker は:
auth()を実行します(拒否されれば 401)。- ハッシュに対する
_plasma_blob_refsを最大readAuthMaxRefs(デフォルト 128)までフェッチします。 - 各 ref について、参照している行をフェッチし、その table の
auth.read(ctx, row)を実行します。 - いずれかの ref が通過すれば、
cache-control: private, max-age=31536000, immutableで R2 オブジェクトをストリームします。
HEAD バリアントはバイトなしでメタデータを返します。
usePlasmaFile — React hook
Section titled “usePlasmaFile — React hook”FileRef を <img> 用のレンダリング可能な Blob URL に読み込む:
import { usePlasmaFile } from "@sh1n4ps/plasma-react"
function Attachment({ ref }: { ref: FileRef | BrokenFileRef | null | undefined }) { const handle = usePlasmaFile(ref)
if (!handle) return null // ref が null / undefined だった if (handle.status === "pending") return <Spinner /> if (handle.status === "missing") return <BrokenIcon /> if (handle.status === "error") return <RetryButton /> // local / uploading / ready はすべて `url` を持つ return <img src={handle.url} alt={handle.name ?? ""} />}FileHandle 判別可能なユニオン:
type FileHandle = | { status: "pending" } // 初回ロード | { status: "local"; url: string; mime: string; name?: string } // キャッシュ済み、このタブにアップロードレコードなし | { status: "uploading"; url: string; mime: string; name?: string } // アップロードワーカーが送信を試みている | { status: "ready"; url: string; mime: string; name?: string } // サーバーも保持している | { status: "missing" } // BrokenFileRef または 404 | { status: "error"; error: unknown } // 非 404 または throw; リトライ可能アップロードのリトライと失敗
Section titled “アップロードのリトライと失敗”アップロードワーカーは、sync ループから独立したバジェットでリトライします:
createPlasmaClient({ ..., blobUploadRetry: { maxAttempts: 8, initialDelayMs: 1000, maxDelayMs: 60_000, },})最終的な失敗(リトライ枯渇)時には、createPlasmaClient に渡した onError コールバックが { kind: "blob-upload-failed", hash, attempts, error } で呼び出されます。blob 依存が失敗した mutation は outbox でブロックされたまま留まります。以下ができます:
client.retryBlobUpload(hash)— 新しい試行バジェットでアップロードを再キューします。引数は失敗した blob のhashで、blob-upload-failedイベントが運びます。client.discardMutation(mutationID)— このハッシュを参照する outbox エントリを持つ mutation を破棄します。optimistic ビューは次のrebuildOptimisticで取り消されます。
createPlasmaClient({ ..., onError: (err) => { if (err.kind === "blob-upload-failed") { console.warn(`blob ${err.hash} failed after ${err.attempts} attempts: ${err.error}`) // err.hash を UI のリトライボタンに接続する。 } },})R2 の接続 — サーバー側
Section titled “R2 の接続 — サーバー側”import { createSyncHandler, r2Storage } from "@sh1n4ps/plasma-server"
interface Env { DB: D1Database BUCKET: R2Bucket}
export default { async fetch(req: Request, env: Env) { return createSyncHandler({ ..., blobs: { default: r2Storage({ bucket: env.BUCKET }), }, readAuthMaxRefs: 128, // GET ごとのファンアウトを制限 })(req) },}storageRef による table ごとの上書き:
const secretDocs = table("secretDocs", { ...,}, { blobs: storageRef("private"),})
// worker.tsblobs: { default: r2Storage({ bucket: env.PUBLIC_BUCKET }), private: r2Storage({ bucket: env.PRIVATE_BUCKET }),},GC — gcOrphanedBlobs
Section titled “GC — gcOrphanedBlobs”file() column を持つ行を削除すると、blob が orphaned としてマークされます(参照カウントがゼロに落ちる)。実際にバイトを削除するには、スケジュールされた Worker から gcOrphanedBlobs を実行します:
export default { async scheduled(_event, env, ctx) { ctx.waitUntil(gcOrphanedBlobs({ executor: fromD1(env.DB), bucket: env.BUCKET, minOrphanAgeMs: 24 * 3600 * 1000, // 24 時間の猶予期間 limit: 500, // 呼び出しごと })) },}猶予期間は、「同じファイルを再添付する」フローを再アップロードなしで機能させるものです — blob が十分に長く残るため、INSERT INTO ... VALUES ({ hash: same-hash, ... }) がバイトの再アップロードを必要とせずにそれを present に復元します。
reconcileBlobRefs
Section titled “reconcileBlobRefs”生のドライバ書き込みが sync ハンドラの外でユーザー table を埋めた場合、_plasma_blob_refs はずれることがあります。reconcileBlobRefs は file() column を走査し、ref インデックスを再構築します:
ctx.waitUntil(reconcileBlobRefs({ schema, executor: fromD1(env.DB), dialect: sqliteDialect,}))実際にはまれですが、スケジュールされたバックフィルや migration スクリプトが table を直接触ったときのためにツールボックスに残しておいてください。
次に読むべきもの
Section titled “次に読むべきもの”- Auth and permissions — blob 読み取り auth が行レベルの
auth.read述語をどう再利用するか - Deployment — R2 の wrangler.jsonc バインディング
- Troubleshooting / Blob upload stuck — トリアージ