Skip to content
Docs

Storing Files in Database Columns

A file column stores an upload’s metadata in the row and the bytes in a storage bucket. Mark the column with file metadata, point the table at a bucket, and BifrostQL adds upload, download, and delete operations to the schema. The bytes go to local disk by default, or to a real S3 bucket with the BifrostQL.Aws package.

This is the outbound direction: BifrostQL is the client of an object store. For the opposite direction — object-store clients reading your file columns over the S3 wire — see S3-Compatible Object Storage over SQL.

Storage configuration lives in metadata rules, like every other BifrostQL table setting:

{
"BifrostQL": {
"Metadata": [
":root { storage: bucket:my-app-files;provider:local;maxSize:10485760 }",
"dbo.users { storage: bucket:uploads;prefix:avatars }",
"dbo.users.avatar { file: type:image;maxSize:5242880;accept:image/* }"
]
}
}

A bucket config resolves from the column, then the table, then the host default, so a table can override the model and a column can override its table.

storage accepts bucket, provider, prefix, region, endpoint, pathstyle, maxsize, mimetypes, and maxurlexpiry (the longest presigned-URL lifetime in minutes that _fileDownload will mint, default 60). An unknown key or a malformed segment fails model load rather than being ignored — a typo in a size cap is a security setting that silently stops applying.

file accepts type, maxSize, accept, thumbnails, sizes, public, and path. Where both the bucket and the column cap size, the smaller cap wins.

A column counts as a file column when it carries a file tag or when a bucket config resolves for it. (file-storage exists as a legacy marker and no current code path reads it. Use file.)

The column stores a JSON pointer, not the bytes:

{
"FileKey": "users/avatar/42_20260817142233123_9f3ac1b7.png",
"OriginalName": "portrait.png",
"ContentType": "image/png",
"Size": 184320,
"BucketName": "uploads",
"ProviderType": "local",
"UploadedAt": "2026-08-17T14:22:33.123Z",
"ETag": "9f3ac1b7…",
"CustomMetadata": null
}

The storage key is generated per upload from the table, column, record id, a timestamp, and eight random hex characters. BucketName and ProviderType are informational — the read path resolves the live bucket config from metadata, so moving a bucket does not require rewriting stored pointers. The pointer never holds an access URL: a presigned URL is a short-lived capability, and a stored one would be copied into every history, CDC and audit row. _fileDownload mints the URL at read time.

Three fields appear once any table declares storage or a file column:

mutation Upload($f: Upload!) {
_fileUpload(table: "users", column: "avatar", recordId: "42", file: $f) {
success
fileKey
size
}
}
query {
_fileDownload(table: "users", column: "avatar", recordId: "42", expirationMinutes: 15) {
accessUrl
contentType
expiresAt
}
}
mutation {
_fileDelete(table: "users", column: "avatar", recordId: "42")
}

recordId addresses one row. A composite primary key is passed as its key values joined by -, in declared key order, and the count must match exactly.

On a table declaring concurrency-token, both mutations must carry the token value the row was read at, in the concurrencyToken argument:

mutation {
_fileDelete(table: "orders", column: "invoice", recordId: "42", concurrencyToken: "7")
}

A table mutation carries its token as the token column inside the update fieldset; the file mutations have no fieldset, so it is a named argument instead. The guard itself is the same one every write path runs: a stale token matches zero rows and fails with ErrorCode: CONFLICT, leaving the stored object untouched (an upload’s just-written blob is removed). Omitting the token on a token table is rejected, and supplying one for a table that declares none is rejected too — silently ignoring it would leave a client believing it had guarded a write that it had not. See Mutations.

The upload path orders its steps so that no failure can destroy content, and the write gate is the mutation pipeline — the same one every other write in the product runs:

  1. Reachability. The row is read through the query-intent seam, so tenant scope, soft delete, row scope and column read guards decide whether the caller can see it at all. A caller who cannot is refused here, having uploaded nothing.
  2. Size and type checks, against the smaller of the bucket and column caps and against the configured MIME allow-list. A configured allow-list with no supplied content type is refused.
  3. Upload to a fresh random key. The upload API exposes no key parameter, so no caller can direct a write at a chosen address.
  4. Write the pointer through TableMutationPipeline: policy, validation, tenant pin, the concurrency token, audit stamps, the approval gate, change history, and the CDC outbox — all inside one transaction. On failure, or when the update affects zero rows, the just-written blob is deleted and the call fails.

Step 3 is what makes step 4’s cleanup safe. Because the storage key is random rather than derived from the caller’s arguments, the compensating delete can only remove the blob this call created. A deterministic key would make the same cleanup delete another row’s content — the failure mode recorded as invariant 8 in .claude/rules/protocol-adapter-security.md.

One outcome is deliberately not compensated: on an approval-gated table the pipeline does not reject the write, it enqueues it as a pending change whose payload points at the uploaded object. The object therefore stays, so the approver’s replay has something to point at.

_fileDelete runs the mirror order: the pointer is cleared through the pipeline first, and the object is removed only once that write has committed. The pipeline is the authorization gate and may veto or defer, so removing the object before it ran would destroy content the veto existed to protect. If the object delete then fails, the call reports the storage key of the now-unreferenced object rather than swallowing it.

Storage keys are also checked for traversal. A .. segment, an absolute path, or a key resolving outside the bucket and prefix is refused by both providers.

The default provider writes under the bucket name as a filesystem path, with the bucket’s prefix and then the file key beneath it. Downloads stream from a real FileStream rather than buffering the whole file.

Presigned URLs from the local provider are opaque local://<key> values. Serve local files through your own authorized endpoint; the local provider deliberately does not hand out a filesystem path as a URL.

Add the BifrostQL.Aws package and register the provider before building the host:

using BifrostQL.Aws;
AwsStorageRegistration.Register();

That call adds s3 to the provider registry. Buckets then select it in metadata:

:root { storage: bucket:my-app-files;provider:s3;region:us-east-1;prefix:uploads }

All S3 settings come from the bucket config, so different tables can target different buckets or regions. Set endpoint and pathstyle for an S3-compatible service such as MinIO; leave them unset to use the region endpoint. Credentials come from the standard AWS credential chain — BifrostQL configures none of its own, so instance roles, environment variables, and profiles all work as they do for any AWS SDK client.

_fileDownload returns a presigned GET URL, expiring in 15 minutes by default. The caller’s expirationMinutes can only narrow: it is clamped down to the bucket’s maxurlexpiry (default 60 minutes), and a non-positive value is rejected. expiresAt reports the clamped lifetime. _fileUpload returns no URL at all — fetch one with _fileDownload after the upload.

file-folder publishes a storage prefix as a computed column, which lets a row expose the objects filed under it:

dbo.projects { file-folder: assets:JSON:local:folder=project-assets/{Id},depends=Id }

The placeholder is rendered from the row, depends names the columns needed to render it, and recursive walks subfolders. Both the local and S3 providers can list a folder.