Storage
Private file storage per app, for the bytes a database row is the wrong shape for: uploads, avatars, attachments, generated documents, exports.
{
"capabilities": ["database", "storage"],
"storage": {} // {} means "the platform defaults"
}That is the whole setup. There is no bucket name, no region, no credential, no endpoint URL, and no tenancy parameter anywhere — not in the manifest, not in your code, not in an environment variable. Isolation is not something you configure and can therefore get wrong; it is a property of how the store is attached. One store per app, per environment.
This page is the complete surface: how to declare it, every method, how a browser gets bytes in, how you serve them back out, the key rules, the limits, and what happens locally and on deploy.
#Declaring it
A capability and its config block are one declaration in two halves. Write both.
{
"capabilities": ["database", "storage"],
"storage": { "maxObjectBytes": 1048576 }
}The two halves are checked against each other in both directions, so you cannot half-declare it:
"capabilities": ["storage"] without a "storage" block
XE1002 storage: must be declared when the storage capability is enabled; use {} for the platform defaults
a "storage" block without "storage" in capabilities
XE1002 capabilities: must include "storage" when a storage block is declaredWrite "storage": {} when you want the defaults. The block has to be present, not non-empty — an empty
object is a real, meaningful declaration that says "the platform's limits are fine".
#Limits are lowerable, never raisable
| Field | Default | Ceiling |
|---|---|---|
maxObjectBytes — one object | 25 MiB (26214400) | 25 MiB — already the ceiling, so it can only go down |
readBytes — read per handler call | 32 MiB (33554432) | 128 MiB (134217728) |
writeBytes — written per handler call | 32 MiB (33554432) | 128 MiB (134217728) |
Every one of the three may be lowered. None may be raised past its ceiling, and asking for more is a build error rather than a silent clamp:
"storage": { "readBytes": 268435456 }
XE1002 storage.readBytes: Too big: expected number to be <=134217728That is deliberate. An app whose declared limit is a lie is worse than one that will not build — the manifest is meant to be readable as a description of what the app can do, and a clamped value would make it a suggestion instead.
Lowering is genuinely useful. An app that only ever stores small thumbnails should say so:
"storage": { "maxObjectBytes": 262144 }Now an accidental multi-megabyte put is refused by the platform, in every environment, without a check
in your handler.
#The surface
ctx.storage splits exactly the way ctx.db does. A query gets the read half. A mutation or an
endpoint gets all of it.
// queries, mutations, and endpoints — the read half
get(key: string): Promise<{ key, size, contentType, uploadedAt, bytes } | null>;
head(key: string): Promise<{ key, size, contentType, uploadedAt } | null>;
list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
objects: readonly { key, size, contentType, uploadedAt }[];
cursor: string | null;
}>;
// mutations and endpoints only
put(key: string, value: Uint8Array | string, options?: { contentType?: string }):
Promise<{ key, size, contentType, uploadedAt }>;
delete(key: string): Promise<void>;Five methods. size is a number, uploadedAt is a real Date, contentType is always a string
(application/octet-stream when you did not set one), and bytes is a Uint8Array — get hands you the
whole object, not a stream.
The split is structural, not a runtime check. Calling put from a query is a compile error, so it
cannot reach production:
XE1205 TS2339: Property 'put' does not exist on type 'ReadonlyAppStorage'.And an app that never declared the capability has no ctx.storage at all:
XE1205 TS2339: Property 'storage' does not exist on type 'QueryContext'.There is nothing to route around and no flag to flip. See Capabilities → deny by default.
#Absence is not an error
A missing key is null from get and head — not a thrown error, so if (object === null) is the whole
handling. delete on a key that holds nothing succeeds: you asked for the object to be gone, and it
is. put replaces whatever was there and hands back a receipt.
import { defineServer, mutation, query } from '@impetik/xeer/server';
export default defineServer({
queries: {
'files.stat': query({
input: { key: 'string' },
handler: async (ctx, input) => {
const object = await ctx.storage.head(input.key);
return object === null
? null
: { size: object.size, contentType: object.contentType, uploadedAt: object.uploadedAt };
},
}),
},
mutations: {
'files.remove': mutation({
input: { key: 'string' },
handler: (ctx, input) => ctx.storage.delete(input.key),
}),
},
});Do not return a storage receipt straight out of an operation. An operation's output has
to be plain data, and put, get, head, and list return
interfaces — so handler: (ctx, input) => ctx.storage.put(…) compiles on the server but
arrives at the caller typed as never. Pick out the fields you want, as above.
#What is not on the surface
The raw bucket is never exposed, so there are no multipart uploads, no conditional writes, no ranged reads, no storage classes, no ETags, and no signed-URL minting. Each of those has cost and failure modes an app cannot be handed unbudgeted, and each can be added later without breaking the five methods above. What that means for uploads in practice is the next section — read it before designing around a signed URL you have seen in another product, because there is not one here.
#Keys
A key is a slash-separated path, and the rules are stricter than "any string the store accepts". A key ends up quoted in a URL path, a log line, and an audit row later, so it is made safe once, here, rather than escaped differently at every call site — and a key that reads as a path traversal invites code that treats it as one.
- Each segment may use letters, digits,
! . _ ~ ( ) ' * -, and the space character. ASCII only. - No leading slash, no trailing slash, no empty segment (
//), no.or..segment anywhere. - At most 512 bytes, measured as UTF-8.
uploads/42/avatar.png ✓
notes/2026-07-25/a.txt ✓
exports/Q3 report.csv ✓ spaces are fine
../secret ✗ dot segment
/leading ✗ leading slash
double//slash ✗ empty segment
émoji.png ✗ not ASCII
a:b.png ✗ colon is not in the charsetA refused key is a refusal from your own handler's frame, with the offending key quoted and truncated:
A storage key must be slash-separated printable segments without dot segments: "uploads/local:alice:1f0qk3/a.txt"#The appUserId gotcha
Do not build a key out of ctx.auth.appUserId directly. This is the single most common way to write
storage code that passes review and then fails:
// ✗ Refused under `xeer dev` and `xeer test`.
await ctx.storage.put(`uploads/${ctx.auth.appUserId}/photo.png`, bytes);A local persona's appUserId looks like local:alice:1f0qk3, and : is not a legal key character. So
the line above is refused offline even though the same code may well work once deployed, where ids have a
different shape. A rule that depends on the environment is not a rule you can build on.
Derive a key-safe scope instead. Hashing is the pattern to reach for: it is stable, fixed-length, has no charset surprises, and it keeps the user id itself out of a string that will end up in logs.
/** A key-safe, stable scope for one caller. Stable across requests, and never leaks the id. */
async function scopeOf(appUserId: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(appUserId));
return Array.from(new Uint8Array(digest).subarray(0, 16), (byte) =>
byte.toString(16).padStart(2, '0')).join('');
}
const key = `uploads/${await scopeOf(ctx.auth.appUserId)}/${crypto.randomUUID()}.png`;Sanitising also works if you would rather keep keys legible — appUserId.replaceAll(':', '-') is legal
under the charset. It is weaker, though: two different ids can collide into one scope, and the id stays
readable in every log line that quotes the key. Hash unless you have a reason not to.
The keyspace is not your authorization model. A per-user prefix is organisation, not a
permission — nothing stops a handler you wrote from listing another scope. Record ownership as a database
row and put that table under ownedTable(); then a
lookup that is not the caller's simply finds nothing. The walkthrough below does exactly that.
#Uploading from a browser
There is no signed-upload URL and no upload token. Every byte goes through an endpoint you wrote — the
same handler, the same ctx.auth, the same authorization as any other write. Here is the whole path, from
the file input to the stored object.
#1. Declare a table to record what was stored
The object store holds bytes; the database holds what they are and who owns them.
{
"capabilities": ["database", "storage"],
"database": {
"tables": {
"attachments": {
"fields": {
"key": { "type": "string", "maxLength": 512 },
"name": { "type": "string", "maxLength": 200 },
"contentType": { "type": "string", "maxLength": 128 },
"size": { "type": "number" },
"ownerId": { "type": "string", "maxLength": 128 }
},
"indexes": { "by_owner": ["ownerId"], "by_key": ["key"] }
}
}
},
"storage": {}
}by_key is there because the download endpoint looks a row up by its key, and a filter has to match a
declared index — see filtering.
#2. The upload endpoint
import { defineServer, endpoint, ownedTable } from '@impetik/xeer/server';
async function scopeOf(appUserId: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(appUserId));
return Array.from(new Uint8Array(digest).subarray(0, 16), (byte) =>
byte.toString(16).padStart(2, '0')).join('');
}
export default defineServer({
authPolicies: { attachments: ownedTable() },
endpoints: {
'POST /api/uploads': endpoint(async (request, ctx) => {
const name = new URL(request.url).searchParams.get('name') ?? 'upload';
const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
const bytes = new Uint8Array(await request.arrayBuffer());
if (bytes.byteLength === 0) return Response.json({ error: 'empty body' }, { status: 400 });
// Mint the key yourself. Never trust one the client sent.
const key = `uploads/${await scopeOf(ctx.auth.appUserId)}/${crypto.randomUUID()}`;
const stored = await ctx.storage.put(key, bytes, { contentType });
const row = await ctx.db.table('attachments').insert({
key: stored.key, name, contentType: stored.contentType, size: stored.size,
ownerId: ctx.auth.appUserId,
});
return Response.json({ id: row.id, key: row.key, size: row.size }, { status: 201 });
}),
},
});Three things are load-bearing:
request.arrayBuffer()is the upload. No parsing, nomultipart/form-data, no library. The browser sends the file as the body and the handler stores it.- The server mints the key. A key from the client is a key an attacker chose.
crypto.randomUUID()under a hashed per-user scope needs no validation and cannot collide. ownerIdis passed explicitly. UnderownedTable()the policy refuses any owner but the caller, so passingctx.auth.appUserIdis both required by the generated insert type and exactly what the policy will accept.
#3. The client
An ordinary fetch. No SDK call, no signing step, no pre-flight.
import { invalidateQueries, useMutation, useQuery, useState } from '@impetik/xeer/client';
export default function App() {
const files = useQuery('attachments.list', {});
const remove = useMutation('attachments.remove');
const [error, setError] = useState<string | null>(null);
const upload = async (file: File) => {
setError(null);
const response = await fetch(`/api/uploads?name=${encodeURIComponent(file.name)}`, {
method: 'POST',
headers: { 'content-type': file.type || 'application/octet-stream' },
body: file,
});
if (!response.ok) {
setError(`Upload failed: ${response.status}`);
return;
}
// An endpoint write does not refresh open clients on its own.
invalidateQueries(['attachments']);
};
return (
<main>
<input
type="file"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
{error && <p role="alert">{error}</p>}
<ul>
{(files.data ?? []).map((file) => (
<li key={file.id}>
<a href={`/api/uploads?key=${encodeURIComponent(file.key)}`}>{file.name}</a>
<span>{file.size} bytes</span>
<button onClick={() => void remove({ id: file.id })}>Delete</button>
</li>
))}
</ul>
</main>
);
}body: file sends the File as-is; the browser streams it. The
invalidateQueries call is not optional — live
updates are driven by mutations, and this write went through an endpoint, so
nothing refreshes unless you say so.
#4. Serving the bytes back out
Objects are not publicly served. There is no URL that reaches the store directly, which means a read
goes through your code and ctx.auth decides who sees what — exactly like a database row.
'GET /api/uploads': endpoint(async (request, ctx) => {
const key = new URL(request.url).searchParams.get('key');
if (key === null) return Response.json({ error: 'key required' }, { status: 400 });
// The row is the authorization. Under `ownedTable()` another user's row is simply not found.
const row = await ctx.db.table('attachments').first({ where: { key } });
if (row === null) return new Response('Not found', { status: 404 });
const object = await ctx.storage.get(key);
if (object === null) return new Response('Not found', { status: 404 });
return new Response(object.bytes.slice().buffer, {
status: 200,
headers: { 'content-type': object.contentType, 'content-length': String(object.size) },
});
}),The row lookup happens before the get, and that ordering is the authorization: ownedTable() filters
the read, so a key belonging to somebody else does not resolve to a row and the bytes are never fetched.
A caller cannot tell "not yours" from "does not exist", which is usually what you want.
object.bytes.slice().buffer hands Response a plain ArrayBuffer. Passing the Uint8Array itself does
not type-check against BodyInit in an app's TypeScript configuration; the .slice() copy is the shortest
thing that does.
#The request budget, honestly
An upload is bounded by your requestBytes budget — 1 MiB by default,
4 MiB at most. Over it, the request is refused before your handler is called at all:
HTTP/1.1 413 Payload Too Large
{"protocol":"xeer.runtime.v0","error":{"code":"request_too_large","message":"Request body budget exceeded."}}So maxObjectBytes can be 25 MiB while a single browser request can only carry 4 MiB. That is not a
contradiction, it is two different limits: the store will hold a 25 MiB object, and one HTTP request to
your app will not deliver it. To store something larger, your server has to assemble it — write the parts
under a prefix and combine them in a later handler, or generate it server-side in the first place.
Raise the request budget when you need the headroom:
"budgets": { "requestBytes": 4194304 }If you were hoping for a browser-to-store direct upload, it does not exist today. Plan for the 4 MiB ceiling rather than around a URL you cannot mint.
#Listing and paging
list returns up to 100 objects by default and at most 1,000. A prefix follows every key safety rule
but may end in a slash or mid-segment, because that is how you ask for what a person would call a folder:
await ctx.storage.list({ prefix: 'uploads/42/' }); // everything "in" uploads/42
await ctx.storage.list({ prefix: 'uploads/4' }); // uploads/42 and uploads/47 alikeKeep calling with the cursor you were handed until it is null:
'attachments.keys': query({
input: {},
handler: async (ctx) => {
const keys: string[] = [];
let cursor: string | null = null;
do {
const page = await ctx.storage.list({
prefix: `uploads/${await scopeOf(ctx.auth.appUserId)}/`,
limit: 100,
...(cursor === null ? {} : { cursor }),
});
keys.push(...page.objects.map((object) => object.key));
cursor = page.cursor;
} while (cursor !== null);
return keys;
},
}),Two rules:
- The cursor is opaque. Pass back exactly what you were given and never construct one. A cursor that is not a token from a previous call is refused.
cursor === nullis the only end condition. Do not compare a page's length against yourlimit— a page can come back short and still be truncated, and that loop would never finish.
A whole-store walk in one handler is a design smell more often than not. If you find yourself paging to
find something, the database row is the index you actually wanted; list is for reconciliation and
cleanup.
#Limits at call time
readBytes and writeBytes are per handler invocation and reset on every call. They are not a monthly
quota — they bound what one request can move.
getcharges the object's size againstreadBytes, checked from the object's declared size before the body is transferred, so an object that cannot fit costs nothing to refuse.headandlistare free of the read budget. Metadata is cheap on purpose: check before you fetch.putcharges the byte length againstwriteBytesbefore the write, so a refused budget never leaves a partially-billed object behind.
Exceeding one is an ordinary refusal, in the same shape as any other:
Storage read byte budget exceeded (33554432 bytes).
Storage write byte budget exceeded (33554432 bytes).
Storage object exceeds the declared maxObjectBytes of 65536: 131072 bytes.The caller sees 400 operation_failed from a query or mutation and 500 endpoint_failed from an endpoint.
See refusing a call.
There is no aggregate cap. Nothing limits total stored bytes or object count today — every limit that ships is a per-call one. During the closed beta the control on storage spend is who may deploy at all. Do not read the absence of a quota as permission to store without bound; budget for it in your own code, because a limit added later would be one you have already exceeded.
#Local development and tests
Storage is fully emulated by xeer dev, xeer preview, and xeer test. No account, no network, no
setup — the store exists on the first put.
What is identical locally and in production: the contract, every key rule, and every byte budget. The same runtime code enforces all three, so a key your dev server refuses is refused in production and a budget you hit offline is the budget you hit deployed. What differs is only what sits behind the binding — API parity, not topology parity.
xeer dev # objects persist across restarts
xeer preview # a separate store from dev's
xeer test # a fresh, empty store every run, thrown away afterwardsxeer devandxeer previewpersist across restarts, and they do not share a store. Stop the dev server, start it again, and yesterday's objects are still there. Upload underxeer devand the same key is a 404 underxeer preview.xeer teststarts empty every run, so a storage assertion is deterministic rather than dependent on what a previous run left behind.
Testing storage needs nothing special — the personas are separate app users, so the same tests that prove a database rule prove a storage one:
import { as, expect, expectFailure, test } from '@impetik/xeer/test';
test('an upload through the endpoint stores the bytes and records the row', async () => {
const alice = as('alice');
const response = await alice.endpoint('POST', '/api/uploads?name=hello.txt', {
body: 'hello from a browser',
headers: { 'content-type': 'text/plain' },
});
expect(response.status).toBe(201);
const created = response.json() as { id: string; key: string; size: number };
expect(created.size).toBe(20);
const download = await alice.endpoint('GET', `/api/uploads?key=${encodeURIComponent(created.key)}`);
expect(download.status).toBe(200);
expect(download.body).toBe('hello from a browser');
expect(download.headers['content-type']).toBe('text/plain');
});
test('each persona gets its own key scope', async () => {
const alice = as('alice');
const bob = as('bob');
const mine = await alice.mutation('attachments.write_text', { name: 'a.txt', text: 'alice' });
const theirs = await bob.mutation('attachments.write_text', { name: 'b.txt', text: 'bob' });
expect(await alice.query('attachments.keys', {})).toContain(mine.key);
expect(await alice.query('attachments.keys', {})).not.toContain(theirs.key);
});
test('a key holding a raw appUserId is refused', async () => {
const alice = as('alice');
const identity = await alice.identity();
expect(identity.appUserId).toContain(':');
const refused = await expectFailure(alice.mutation('attachments.write_raw_key', { text: 'nope' }));
expect(refused.message).toContain('slash-separated printable segments');
});That last test is worth writing once in any app that derives keys from an identity. It is the check that
turns the : gotcha from a deploy-time surprise into a red test.
#Throwing local data away
xeer state reset --state dev --confirm <app>This clears the objects along with the database for that mode — one state root holds both, so there is
nothing extra to clean up. --state preview and --state test do the same for theirs. A recovery copy is
written first, and the path is printed.
It refuses while a dev server is holding that state, rather than pulling the floor out from under a running process:
XE1812 dev state is owned by live dev process 43176 at …/.xeer/generated/.wrangler/stateStop the server and run it again.
xeer export moves database records only. Stored objects are not in the
export document — a row that names a key is exported, the bytes behind that key are not, so an import into
a fresh state gives you rows whose get returns null. If you need the objects
somewhere else, read them out through a handler you wrote.
#Preview and production are separate stores
The same content-addressed artifact binds a different store in each environment. That is what makes a preview deployment something you can point at real traffic-shaped experiments without touching what your users have:
- Preview data is disposable. Write whatever you like there.
xeer promoteships the bundle to production, not preview's objects — production reads what production already had. - Nothing crosses over. No key written in preview is readable in production, and there is no call you can write that reaches the other one. The store is chosen by the binding, and the binding is chosen by the environment.
- The store name is never in the artifact. A build's identity stays a function of your application's content alone, which is why the artifact you reviewed is the artifact you can roll back to.
Adding the capability to an app that is already deployed provisions the store on the next deploy, and it is idempotent: a deploy that is retried adopts what exists rather than making a second one.
Removing storage from the manifest detaches the store; it does not destroy it. The objects stay, and
re-declaring the capability re-attaches the same store with everything still in it. That is on purpose —
deleting a manifest line should not be a way to lose data.
#Deleting an app
xeer delete is the one operation that destroys stored objects, and it requires the exact app name in
--confirm.
xeer delete --confirm my-appIt takes the app offline first, removes both Worker scripts, and only then tears down each provisioned
store — preview before production. The ordering matters: while a script exists it still holds the binding
and could still be writing, so emptying underneath it would race the very writes the teardown exists to
end. xeer delete --json reports what happened per resource, so you are told when the platform is still
working rather than left guessing.
Teardown is resumable. If it does not finish in one pass it is completed by a background sweep, and a store
left mid-teardown can never be picked up by a later app: names are salted with the app id, and an app id is
never reused. To take an app offline without destroying anything, use
xeer disable instead — a disabled app's objects stay exactly as
they are.
#Next
- Capabilities — the declaration model, and
databasealongside this. - Server functions and the database — the rest of
ctx, and endpoints. - Manifest reference — every field and its exact rule.
- Auth —
ownedTable(), which is what makes a stored object private.