Skip to content

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.

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
}
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.

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:

  1. For each raw carrier in args, compute SHA-256, produce a FileRef, stash the bytes in the client’s _plasma_blobs_local IDB store.
  2. Enqueue an upload record in _plasma_blob_uploads keyed by [clientID, hash].
  3. The mutator now sees FileRef in args.attachment — its logic is identical to the case where the caller passed a pre-computed FileRef.

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:

  1. Runs auth() and rejects 401 if it fails.
  2. Verifies the URL :hash matches SHA-256 of the incoming bytes.
  3. Enforces maxSize if the column declared one.
  4. Enforces mimeAllowList if the column declared one.
  5. PUTs the bytes to R2 under key = hash.
  6. _plasma_blobs gets a row with state = "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.

The Worker:

  1. Runs auth() (401 if rejected).
  2. Fetches _plasma_blob_refs for the hash, up to readAuthMaxRefs (default 128).
  3. For each ref, fetches the referring row and runs its table’s auth.read(ctx, row).
  4. If any ref passes, streams the R2 object with cache-control: private, max-age=31536000, immutable.

The HEAD variant returns metadata without the bytes.

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; retryable

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’s hash, which the blob-upload-failed event carries.
  • client.discardMutation(mutationID) — drop any mutation whose outbox entry references this hash. The optimistic view reverts on the next rebuildOptimistic.
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.
}
},
})
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, // cap the fan-out per GET
})(req)
},
}

Per-table override via storageRef:

const secretDocs = table("secretDocs", {
...,
}, {
blobs: storageRef("private"),
})
// worker.ts
blobs: {
default: r2Storage({ bucket: env.PUBLIC_BUCKET }),
private: r2Storage({ bucket: env.PRIVATE_BUCKET }),
},

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.

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.