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, region, credential, endpoint URL, or tenancy parameter — not in the manifest, not in your code, not in an environment variable. One store per app, per environment.
#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) |
Any of the three may be lowered. Raising one past its ceiling is a build error, not a silent clamp:
"storage": { "readBytes": 268435456 }
XE1002 storage.readBytes: Too big: expected number to be <=134217728Lower one and the platform enforces it in every environment, with no check in your handler:
"storage": { "maxObjectBytes": 262144 }#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'.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. There are no multipart uploads, no conditional writes, no ranged reads, no storage classes, no ETags, and no signed-URL minting. Uploads go through your own endpoint instead — see Uploading from a browser.
#Keys
A key is a slash-separated path. The rules are stricter than "any string the store accepts", because a key ends up quoted in a URL path, a log line, and an audit row.
- 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.
// ✗ 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.
The line above is refused offline even though it may work once deployed, where ids have a different shape.
Derive a key-safe scope instead. Hashing is stable, fixed-length, and keeps the user id out of a string that ends 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.
#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,
});
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 omitted. The generated insert type knowsownedTable()manages this field, and the runtime stampsctx.auth.appUserId. The manifest still keepsownerIdrequired, so every stored row has an owner.
#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, {
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 is an ArrayBuffer-backed Uint8Array, so it is a valid BodyInit and can be returned
directly without a copy or cast.
#The request budget
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."}}These are two different limits: the store holds a 25 MiB object, but one HTTP request will not deliver it. To store something larger, assemble it server-side — write the parts under a prefix and combine them in a later handler, or generate it on the server in the first place.
Raise the request budget when you need the headroom:
"budgets": { "requestBytes": 4194304 }#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.
If you are paging to find something, use a database row as the index instead. list is for
reconciliation and cleanup.
#Limits at call time
readBytes and writeBytes are per handler invocation and reset on every call. They bound what one
request can move, not a monthly total.
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. Budget for it in your own code: 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.
The contract, every key rule, and every byte budget are identical locally and in production — the same runtime code enforces all three. Only what sits behind the binding differs.
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 do not share a store. A key uploaded underxeer devis a 404 underxeer preview.xeer teststarts empty every run, so a storage assertion never depends on a previous run.
Testing storage needs nothing special — personas are separate app users:
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');
});Write that last test once in any app that derives keys from an identity — it turns the : gotcha into a
red test instead of a deploy-time surprise.
#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.
--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:
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:
- Preview data is disposable.
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. The store is chosen by the binding, and the binding by the environment.
- The store name is never in the artifact, so the artifact you reviewed is the one you can roll back to.
Adding the capability to a deployed app provisions the store on the next deploy. It is idempotent: a retried deploy 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.
#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.