Files and Blobs
file() is plasma’s binary attachment primitive. You declare a
column, wire an R2 binding, and images / PDFs / anything else flow
through the same push / pull / rebase machinery as your structured
data.
The column
Section titled “The column”import { file } from "@sh1n4ps/plasma-core"
const notes = table("notes", { id: id(), body: text(), attachment: file().nullable(),})file() returns a Column<FileRef> — the JavaScript type stored in
the row is a FileRef:
interface FileRef { readonly hash: string // sha256 hex readonly size: number // bytes readonly mime: string // MIME type readonly name?: string // optional original filename}file() options
Section titled “file() options”| Option | Default | What |
|---|---|---|
immutable |
true |
Reject overwriting the row’s hash with a different one — history stays append-only |
maxSize |
— | Bytes. Server-side PUT /sync/blob/:hash rejects larger uploads |
mimeAllowList |
— | e.g. ["image/*", "application/pdf"]. Server rejects mismatches |
upload |
"proxy" |
Upload path. v1.0 supports "proxy" only — "presigned" and "auto" throw at schema-build time; they land in v0.4 |
inlineThreshold |
— | Files smaller than this many bytes are inlined into the row (skipping R2). Trade-off: no separate upload trip vs. larger row size on every pull |
The bytes themselves live in R2, keyed by hash. The row stores
only the manifest.
Client-side write path
Section titled “Client-side write path”A mutator that writes to a file() column accepts either a raw
carrier (File or Blob) or an existing FileRef. Raw
Uint8Array / ArrayBuffer are deliberately not accepted —
those are the payload shape of the older inline blob() column,
and treating them as file() inputs would silently rewrite
blob() mutations into R2 uploads. If you’re holding raw bytes,
wrap them in a Blob: new Blob([bytes], { type: mime }).
attachToNote: async ({ db, args }) => { await db.update(notes) .set({ attachment: args.attachment }) // may be File or FileRef .where(eq(notes.id, args.noteId))}At the client.mutate boundary, plasma runs desugarFileArgs:
- For each raw carrier in
args, compute SHA-256, produce aFileRef, stash the bytes in the client’s_plasma_blobs_localIDB store. - Enqueue an upload record in
_plasma_blob_uploadskeyed by[clientID, hash]. - The mutator now sees
FileRefinargs.attachment— its logic is identical to the case where the caller passed a pre-computedFileRef.
The optimistic apply lands. The background upload worker picks up
the queued blob and PUTs to /sync/blob/:hash.
Server-side upload — PUT /sync/blob/:hash
Section titled “Server-side upload — PUT /sync/blob/:hash”The Worker:
- Runs
auth()and rejects 401 if it fails. - Verifies the URL
:hashmatches SHA-256 of the incoming bytes. - Enforces
maxSizeif the column declared one. - Enforces
mimeAllowListif the column declared one. PUTs the bytes to R2 under key = hash._plasma_blobsgets a row withstate = "present".
The PUT uploads the bytes, not the row — the row’s push arrives
independently on /sync/push. plasma’s contiguous-prefix push
gates: mutation N doesn’t reach the server until every blob dep of
mutations < N is uploaded. This preserves ordering.
Server-side read — GET /sync/blob/:hash
Section titled “Server-side read — GET /sync/blob/:hash”The Worker:
- Runs
auth()(401 if rejected). - Fetches
_plasma_blob_refsfor the hash, up toreadAuthMaxRefs(default 128). - For each ref, fetches the referring row and runs its table’s
auth.read(ctx, row). - If any ref passes, streams the R2 object with
cache-control: private, max-age=31536000, immutable.
The HEAD variant returns metadata without the bytes.
usePlasmaFile — React hook
Section titled “usePlasmaFile — React hook”Reading a FileRef into a renderable Blob URL for <img>:
import { usePlasmaFile } from "@sh1n4ps/plasma-react"
function Attachment({ ref }: { ref: FileRef | BrokenFileRef | null | undefined }) { const handle = usePlasmaFile(ref)
if (!handle) return null // ref was null / undefined if (handle.status === "pending") return <Spinner /> if (handle.status === "missing") return <BrokenIcon /> if (handle.status === "error") return <RetryButton /> // local / uploading / ready all carry `url` return <img src={handle.url} alt={handle.name ?? ""} />}The FileHandle discriminated union:
type FileHandle = | { status: "pending" } // initial load | { status: "local"; url: string; mime: string; name?: string } // cached, no upload record on this tab | { status: "uploading"; url: string; mime: string; name?: string } // upload worker is trying to send them | { status: "ready"; url: string; mime: string; name?: string } // server has them too | { status: "missing" } // BrokenFileRef or 404 | { status: "error"; error: unknown } // non-404 or thrown; retryableUpload retry and failure
Section titled “Upload retry and failure”The upload worker retries with independent budget from the sync loop:
createPlasmaClient({ ..., blobUploadRetry: { maxAttempts: 8, initialDelayMs: 1000, maxDelayMs: 60_000, },})On terminal failure (exhausted retries), the onError callback
supplied to createPlasmaClient is invoked with
{ kind: "blob-upload-failed", hash, attempts, error }. The mutation
whose blob dep failed sits blocked in the outbox; you can:
client.retryBlobUpload(hash)— re-queue the upload with a fresh attempt budget. The argument is the failed blob’shash, which theblob-upload-failedevent carries.client.discardMutation(mutationID)— drop any mutation whose outbox entry references this hash. The optimistic view reverts on the nextrebuildOptimistic.
createPlasmaClient({ ..., onError: (err) => { if (err.kind === "blob-upload-failed") { console.warn(`blob ${err.hash} failed after ${err.attempts} attempts: ${err.error}`) // Wire err.hash to a retry button in the UI. } },})R2 wiring — server-side
Section titled “R2 wiring — server-side”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, // cap the fan-out per GET })(req) },}Per-table override via storageRef:
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”Deleting a row with a file() column marks the blob as orphaned
(reference count dropped to zero). Run gcOrphanedBlobs from a
scheduled Worker to actually delete the bytes:
export default { async scheduled(_event, env, ctx) { ctx.waitUntil(gcOrphanedBlobs({ executor: fromD1(env.DB), bucket: env.BUCKET, minOrphanAgeMs: 24 * 3600 * 1000, // 24h grace period limit: 500, // per invocation })) },}The grace period is what lets a “reattach the same file” flow work
without re-uploading — the blob stays around long enough that
INSERT INTO ... VALUES ({ hash: same-hash, ... }) restores it to
present without needing the bytes to be re-uploaded.
reconcileBlobRefs
Section titled “reconcileBlobRefs”If raw driver writes have populated user tables outside the sync
handler, _plasma_blob_refs can drift. reconcileBlobRefs walks
the file() columns and rebuilds the ref index:
ctx.waitUntil(reconcileBlobRefs({ schema, executor: fromD1(env.DB), dialect: sqliteDialect,}))Rare in practice; keep in the toolbox for when scheduled backfills or migration scripts touched tables directly.
What to read next
Section titled “What to read next”- Auth and permissions — how blob
read auth reuses the row-level
auth.readpredicates - Deployment — wrangler.jsonc bindings for R2
- Troubleshooting / Blob upload stuck — triage