コンテンツにスキップ

Files and Blobs

file() は plasma のバイナリ添付プリミティブです。column を宣言し、R2 バインディングを接続すれば、画像 / PDF / その他何でも、構造化データと同じ push / pull / rebase 機構を通じて流れます。

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 をキーとして格納されます。行はマニフェストのみを格納します。

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 を実行します:

  1. args 内の各生キャリアについて、SHA-256 を計算し、FileRef を生成し、バイトをクライアントの _plasma_blobs_local IDB ストアに退避します。
  2. [clientID, hash] をキーとして _plasma_blob_uploads にアップロードレコードをエンキューします。
  3. mutator は今や args.attachmentFileRef を見ます — そのロジックは、呼び出し側が事前計算済みの FileRef を渡したケースと同一です。

optimistic apply が着地します。バックグラウンドのアップロードワーカーがキューされた blob を拾い、/sync/blob/:hashPUT します。

サーバー側のアップロード — PUT /sync/blob/:hash

Section titled “サーバー側のアップロード — PUT /sync/blob/:hash”

Worker は:

  1. auth() を実行し、失敗すれば 401 を拒否します。
  2. URL の :hash が受信バイトの SHA-256 と一致することを検証します。
  3. column が宣言していれば maxSize を強制します。
  4. column が宣言していれば mimeAllowList を強制します。
  5. key = hash で R2 にバイトを PUT します。
  6. _plasma_blobsstate = "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 は:

  1. auth() を実行します(拒否されれば 401)。
  2. ハッシュに対する _plasma_blob_refs を最大 readAuthMaxRefs(デフォルト 128)までフェッチします。
  3. 各 ref について、参照している行をフェッチし、その table の auth.read(ctx, row) を実行します。
  4. いずれかの ref が通過すれば、cache-control: private, max-age=31536000, immutable で R2 オブジェクトをストリームします。

HEAD バリアントはバイトなしでメタデータを返します。

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 のリトライボタンに接続する。
}
},
})
worker.ts
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.ts
blobs: {
default: r2Storage({ bucket: env.PUBLIC_BUCKET }),
private: r2Storage({ bucket: env.PRIVATE_BUCKET }),
},

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 に復元します。

生のドライバ書き込みが sync ハンドラの外でユーザー table を埋めた場合、_plasma_blob_refs はずれることがあります。reconcileBlobRefs は file() column を走査し、ref インデックスを再構築します:

ctx.waitUntil(reconcileBlobRefs({
schema,
executor: fromD1(env.DB),
dialect: sqliteDialect,
}))

実際にはまれですが、スケジュールされたバックフィルや migration スクリプトが table を直接触ったときのためにツールボックスに残しておいてください。