Files & Object Storage
Object storage on the same two scopes as tables: the private scope (the current
user) and the resource scope (a shared boundary). Files are path-addressed;
blobs live in the storage adapter (a local directory in dev, S3-compatible
later) keyed opaquely, while the _files metadata table is the source of truth
for access, listing, and metering.
tallpond.files("avatars") // private scope
tallpond.resource(roomId).files("shared") // resource scope
Buckets are declared in the schema (buckets at the top level, resource.files(...)
inside a resource type). See schema-and-deploy.md.
Methods#
.upload(path, file, options?) // options: { contentType, cacheControl, upsert }
.download(path, { owner? }?) // → Blob
.url(path, { owner? }?) // → string, same-origin cookie-authed (no request)
.list(prefix?) // → FileMetadata[]
.metadata(path, { owner? }?) // → FileMetadata
.updateMetadata(path, { contentType?, cacheControl? })
.delete(path, { owner? }?)
.move(fromPath, toPath) // same scope only (metadata-only; blobs are id-addressed)
.copy(fromPath, toPath)
.link(path) // resource scope only: publish your private file here
.unlink(path, { owner? }?) // resource scope only: withdraw a linked file
.moveRoom(paths, roomId, { owner? }?) // resource scope only: relocate into a room
.url() returns a same-origin, cookie-authenticated URL for use directly as an
<img src> — cookie-authed GET/HEAD on a file blob is exempt from the CSRF
header (safe under SameSite=Lax, where a cross-site request never carries the
cookie). It performs no request; it just builds the string.
Access#
- Private buckets are owner-only: every op is scoped to the caller's own files. Top-level buckets carry no access rules.
- Resource buckets evaluate their declared
FileAccessIR(list/read/upload/update/delete/unlink) with the same default-deny engine as tables, including{ role, via }provenance gating. Thecreatorvalue means "the uploader" and scopes a read/update/delete/list to the caller's own files; a role grants a blanket allow. A role is checked against your effective role in the file's room (see Rooms below), which is your resource role in the default room and your granted role anywhere else.
Within a shared bucket, paths are namespaced by the uploader: two members
uploading beach.jpg do not collide. list() returns all owners' files when the
rule is a blanket allow (each entry carries owner); reading or deleting another
member's file passes { owner } and requires that blanket allow.
Rooms#
A resource bucket is partitioned by the resource's rooms, the same way its tables are. A file's room decides who can see it; the bucket's access rules decide what a role may do once they can.
const ws = tallpond.resource(workspaceId)
await ws.files("photos").upload("team.jpg", blob) // default room
await ws.room(leadershipId).files("photos").upload("q3.png", b) // that room
Reads span rooms; writes pick one.
resource(id).files(b).list()returns every file in every room where your effective role passes the bucket'slistrule. An upload through the resource scope lands in the default room — visible to the whole resource. Name a room to write anywhere else.Your role is your role in the room. A grant is a ceiling-limited assignment, so it can only narrow what your resource role already allows. No grant means no role: a file in a room you hold nothing in reports
not_found, neverforbidden, so a room's contents cannot be inferred from an error.A room scope is that room alone —
resource(id).room(x).files(b)neither reads nor writes anything outside it.Rooms are not part of a file's identity. A path stays unique per
(bucket, resource, owner)whatever room holds it, so a move never collides; taking a path that exists in another room is a409naming the reason.Moving between rooms takes a list, requires
adminin every room the files are currently in, plus write access in the destination, and is free (metadata only, like a path move):await ws.files("photos").moveRoom(["team.jpg"], leadershipId)A room cannot be deleted while it holds files —
409 room_not_empty, the same as for rows. Move or delete them first.Private-scope files have no room. There is no resource to be partitioned inside;
tallpond.files(b)is owner-only as it has always been.
An app that never creates a second room is unaffected in every particular: one room means one behaviour, and it is the one that was there before.
Linking#
A private (creator-owned) file can be published into multiple resources without copying the blob — the file analogue of publishing a row into resource contexts. Upload once privately, then link into each resource through the same-named resource bucket:
const photo = await tallpond.files("photos").upload("beach.jpg", blob)
await tallpond.resource(groupA).files("photos").link(photo.path)
await tallpond.resource(groupB).files("photos").link(photo.path)
Semantics:
- One blob, many mounts. The file stays canonical in the creator's private
namespace; a
_file_contextsrow makes it readable in the resource. Linked files appear in resourcedownload/metadata/listalongside direct uploads (a direct upload at the same(owner, path)wins). linkrides theuploadrule — you may link where you may upload — including anyviagate, so an app can force publishing through a function.unlinkhas its own rule (the moderation lever):creatorscopes it to your own links; a union like["creator", "admin"]lets admins withdraw anyone's.- Constraints validate at link time. The target bucket's
acceptandmaxFileSizeare checked against the file's stored metadata; a mismatch isinvalid_request. There is no auto-transform — an app wanting one performs it in a function and links the derivative. - Payer never changes. The uploader keeps paying the file's at-rest storage
(and its quota accounting); a link or unlink is a flat metadata-write fee
(
db_usage) paid by the caller — never the bytes. - Links have no room. A mount is a many-to-many publication across
resources, while a room is a partition inside one, so the two axes do not yet
meet: a linked file behaves as default-room content — visible wherever the
default room is, and absent from a named room's reads.
linkandunlinkare rejected from a room scope rather than silently ignoring it. - Lifecycle. Deleting the private file cascades away its links everywhere.
Deleting a resource, or removing/leaving a member, drops the relevant links
only — the underlying private files survive.
unlinknever touches the file.
Metering#
- Upload places a
storage_writeescrow hold on the at-rest payer (a resource bucket withpayer: "owner"bills the resource owner; otherwise the uploader), enforces the bucket'sacceptandmaxFileSizemid-stream (413 on overflow), and captures the flat R2 Class A + Worker request charge. - Download meters
storage_egressto the fetching session. - Bytes at rest are measured by the recurring sweep and charged pro-rata as
storage_at_restat the R2 GB-month rate. The payer remains the uploader except forpayer: "owner"resource buckets, which bill the resource owner.
See metering.md.
Cascades#
Deleting a resource, or removing/leaving a member, deletes the relevant files and
their blobs. File buckets carry no per-bucket lifecycle yet, so member removal
uses the remove default (the hidden column and read-filtering are in place
for future hide/retain).
Errors#
not_found (unknown bucket/file, or a members-only resource to a non-member),
forbidden (access denied, or targeting another member's file without a blanket
allow), conflict (upload to an existing path without upsert; move/copy onto
an existing path), invalid_request (rejected content-type, over the size
limit), insufficient_balance / spend_cap_exceeded (402, on upload).