# Xeer documentation The complete documentation from https://docs.xeer.run, as one file. ---

The full-stack framework

Describe your app. Ship it in minutes.

Xeer gives you a typed server, a reactive client, a database, authentication, and a test runner that already know about each other — then puts the whole thing on a real URL with one command.

Start building See the code
## Four commands, start to production ```sh npx @impetik/xeer new my-app cd my-app && npm install npx xeer dev # local server, instant feedback npx xeer test # the suite that ships with your app npx xeer deploy # a real URL on a global edge network ``` No bundler to configure, no database to provision, no CI to write, no dashboard to visit. `xeer new` scaffolds a working app with a database, a client, and a passing test suite — or pick a different starting point with `--template todo`, `blog`, or `personal-site`. Everything after that is your code. ## A taste of the model An app is three things: a **manifest** that declares what it has, a **server** of typed functions, and a **client** that reads them and re-renders when they change.
### The manifest `xeer.app.json` is the whole shape of your app in one file — tables, indexes, entrypoints, the capabilities it is allowed to use. The compiler reads it and generates types for everything below. ```json { "format": "xeer.application-source.v0", "name": "notes", "entrypoints": { "client": "src/client.tsx", "server": "src/server.ts" }, "database": { "tables": { "notes": { "fields": { "text": { "type": "string", "maxLength": 500 }, "ownerId": { "type": "string", "maxLength": 128 } }, "indexes": { "by_owner": ["ownerId"] } } } }, "capabilities": ["database"] } ```
### The server Queries read, mutations write, endpoints handle raw HTTP. `ctx.db` is typed from the manifest, and `ctx.auth` is the verified identity of the caller — never something the browser asserted. ```ts import { defineServer, mutation, query } from '@impetik/xeer/server'; export default defineServer({ queries: { 'notes.list': query({ input: {}, handler: (ctx) => ctx.db.table('notes') .find({ where: { ownerId: ctx.auth.appUserId } }), }), }, mutations: { 'notes.create': mutation({ input: { text: 'string' }, handler: (ctx, input) => ctx.db.table('notes').insert({ text: input.text, ownerId: ctx.auth.appUserId, }), }), }, }); ```
### The client `useQuery` knows the operation's input and output types from the server you just wrote. It also knows which tables the query read — so when anyone, in any browser, mutates one of them, this component re-renders. You did not wire that up. ```tsx import { useMutation, useQuery, useState } from '@impetik/xeer/client'; export default function App() { const notes = useQuery('notes.list', {}); const createNote = useMutation('notes.create'); const [text, setText] = useState(''); return (
setText(event.currentTarget.value)} /> {notes.loading &&

Loading…

}
); } ``` ## What you get without asking

A database and a file store, declared

Name a capability in the manifest and its typed surface appears on ctx — a transactional database, an object store, or both. No connection string, no bucket name, no credential.

Anything you did not declare does not exist in your app: reaching for it is a compile error.

Capabilities →

Live by default

Every query records the tables it read; every mutation reports the tables it wrote. Open clients refetch only what actually changed — across tabs, across users, across the world.

Live updates →

Identity before the first request

Every visitor to a Xeer app arrives with a verified, stable identity — no sign-in screen required. Own data per user from day one, and add named accounts when you actually want them.

Auth →

Authorization you can prove

Declare a table owned-per-user or scoped-to-a-workspace and the rule is enforced in the data layer, not in a handler somebody might forget. Then write a test that proves it.

Table policies →

A test runner in the box

xeer test builds your app, boots it with fresh isolated state, and runs your tests as three different users. No test framework to pick, no harness to build.

Testing →

Preview, promote, roll back

Ship to a side-by-side URL, look at it, then promote the exact same bundle to production. If it goes wrong, put a previous version back by its content address.

Deploy →

Secrets that stay secret

Store configuration per environment and read it from ctx.env. Server code cannot be imported by client code — the compiler refuses — so a key cannot reach a browser by accident.

Environment variables →

Machine-readable end to end

Every command takes --json. Every failure carries a stable code with a suggested repair. There is an MCP server. Pair with an agent and it can actually read the output.

Building with agents →

## Built for developers working with agents Xeer's whole surface is designed to be read by a program as easily as by a person. Commands emit structured envelopes rather than prose. Failures carry stable [`XE####` codes](/reference/diagnostics) with a machine-readable location and a suggested edit. The app's entire shape lives in one declarative manifest, so an agent can reason about what exists without guessing. And [`@impetik/xeer-mcp`](/guides/agents#the-mcp-server) exposes the whole loop — scaffold, check, test, build, deploy — as tools. That is not a bolt-on. It is why the framework is small enough to hold in your head, and small enough to hand to something that has to hold it in a context window.

Xeer is in closed beta. The CLI, the compiler, and the local development loop are open source and work offline with no account at all — xeer new, xeer dev, and xeer test need nothing but Node.js. Deploying needs an account, and accounts are currently by invitation. See the FAQ.

## Next - **[Quickstart](/quickstart)** — a running, deployed app in five minutes. - **[Project structure](/guides/project-structure)** — what each generated file is for. - **[Server functions and the database](/guides/server)** — the whole data API. - **[CLI reference](/reference/cli)** — every command. --- # Quickstart Five minutes from nothing to a deployed app. You need **Node.js 22.12 or newer** and nothing else. ## 1. Create an app ```sh npx @impetik/xeer new my-app cd my-app npm install ``` Nothing to install first. `npm install` then puts Xeer in the project, so from here on `npx xeer ` resolves the local copy — and `npm run dev`, `npm run check`, `npm run build`, and `npm test` work too, for you and for anyone who clones the repository. > Keep the scope. A bare `npx xeer` looks for a package literally named `xeer`, which is not this one, and > `npx x` is an unrelated package entirely. The package is > [`@impetik/xeer`](https://www.npmjs.com/package/@impetik/xeer); the command it provides is `xeer`. Running it often enough to want it on your `PATH`? `npm install --global @impetik/xeer`, and then drop the `npx` from every command below. `xeer new` writes a complete, working notes app: a manifest, a typed server, a Preact client, styles, and a passing test suite. Eleven files, all of them yours to edit. Start from something else with `--template`: ```sh npx @impetik/xeer new my-blog --template blog ``` | `--template` | | | --- | --- | | `notes` *(default)* | Per-user notes: one table, create and delete, ownership proven in tests. | | `todo` | A per-user task list: add, tick off, delete, and an open count read through an index. | | `blog` | Posts anyone can read and only their author can delete, with a list and a read view. | | `personal-site` | A few static pages from one shared content module, and no database at all. | Every template checks, tests, and builds clean, and none of them scaffolds sign-in UI — see [step 2](#2-run-it). The rest of this page follows `notes`. ## 2. Run it ```sh npx xeer dev ``` That starts a local server with your client and server running together. Client edits hot-reload; server edits recompile and restart behind a health check. The URL is printed on startup — Xeer picks a free port unless you pass `--port`. Open it and add a note. It is already stored in a real database, and it is already **scoped to you** — the server filters by the caller's identity, and the database enforces it. Notice what you did not have to do: there is no sign-in screen, and there was nothing to configure. **Every caller of a Xeer app has a verified identity from their first request**, so an app can own data per user without shipping any auth UI at all. Add sign-in later, when you want a *named* account. See [Auth](/guides/auth). Try two browser windows side by side. Add a note in one and it appears in the other immediately: every query records which tables it read, so a write to one of them refetches exactly the queries that care. See [Live updates](/guides/live-updates). To see per-user isolation for yourself, open a private window and visit the local sign-in route to become a different user: ```text http://127.0.0.1:5173/_xeer/auth/sign-in?persona=bob&returnTo=/ ``` Bob's list is empty, because these are two different application users. That is a [local persona](/guides/local-development#local-personas) — the mechanism that makes authorization exercisable before you deploy anything. ## 3. Change something Open `src/server.ts`. Add a field to a note by declaring it in the manifest first — the manifest is the source of truth, and the types follow from it. In `xeer.app.json`, add `pinned` to the `notes` table: ```json { "database": { "tables": { "notes": { "fields": { "text": { "type": "string", "maxLength": 500 }, "ownerId": { "type": "string", "maxLength": 128 }, "pinned": { "type": "boolean", "optional": true } }, "indexes": { "by_owner": ["ownerId"] } } } } } ``` Now `ctx.db.table('notes')` accepts and returns `pinned` in your editor, and a mutation that sets it type-checks: ```ts 'notes.pin': mutation({ input: { id: 'string', pinned: 'boolean' }, handler: async (ctx, input) => { const note = await ctx.db.table('notes').get(input.id); if (!note || note.ownerId !== ctx.auth.appUserId) throw new Error('Note not found.'); return ctx.db.table('notes').update(input.id, { pinned: input.pinned }); }, }), ``` `xeer dev` picks the change up as you save. If you get something wrong, the error names the file, the line, and a suggested repair — see the [diagnostics reference](/reference/diagnostics). Run `npx xeer check` at any time for the same validation without a running server. ## 4. Test it ```sh npx xeer test ``` `xeer test` builds your app, boots it with fresh isolated state that never touches your dev data, and runs `tests/*.test.ts`. Every call is made as a named persona, so ownership is asserted rather than assumed: ```ts import { as, expect, expectFailure, test } from '@impetik/xeer/test'; test('bob cannot remove a note owned by alice', async () => { const note = await as('alice').mutation('notes.create', { text: 'Only for alice' }); const refused = await expectFailure(as('bob').mutation('notes.remove', { id: note.id })); expect(refused.code).toBe('operation_failed'); expect((await as('alice').query('notes.list', {})).map((row) => row.id)).toContain(note.id); }); ``` The scaffolded suite already contains five tests like this and they pass on a fresh project. See [Testing](/guides/testing). ## 5. Deploy it ```sh npx xeer auth login # opens a browser, prints a confirmation code npx xeer deploy ``` `xeer auth login` signs **you** in — the person deploying. It has nothing to do with your app's own users, who need no Xeer account at all; see [Auth](/guides/auth). `xeer deploy` builds a content-addressed artifact, verifies it, and ships it. When it finishes it prints your application's URL. That URL is live on a global edge network, serving your client and your server, with its own database and real identity for every visitor.

Deploying needs a Xeer account, and accounts are currently by invitation. Everything in steps 1 through 5 works with no account and no network — the framework, the compiler, the dev server, and the test runner are open source and run entirely on your machine. If you would like an invitation, see the FAQ.

Prefer to look before you leap: ```sh npx xeer deploy --environment preview # a side-by-side URL; production keeps serving npx xeer promote # make exactly that version live ``` That is the whole shipping story, plus `xeer rollback` when you need yesterday's version back. See [Deploy, preview, promote, roll back](/guides/deploy). ## Where to go next | If you want to… | Read | | --- | --- | | Understand every generated file | [Project structure](/guides/project-structure) | | Write queries, mutations, and endpoints | [Server functions and the database](/guides/server) | | Read data and re-render | [The client](/guides/client) | | Enforce who can see what | [Auth](/guides/auth) | | Switch users locally, and manage local data | [Local development](/guides/local-development) | | Add a database table or a file store | [Capabilities](/guides/capabilities) | | Store an API key | [Environment variables and secrets](/guides/env) | | Drive Xeer from an AI agent | [Building with AI agents](/guides/agents) | | Look up a command or an error code | [CLI](/reference/cli) · [Diagnostics](/reference/diagnostics) | --- # Project structure `xeer new my-app` writes eleven files. There is no hidden configuration and no generated code you are not allowed to read. This is the default `notes` template; `--template todo`, `blog`, and `personal-site` differ in their manifest and source but have exactly this shape. `personal-site` declares no database, so it has no `capabilities` and no `database` block — everything else on this page still applies. ```text my-app/ ├── xeer.app.json the application manifest — the shape of your app ├── xeer.project.json the app's identity. commit this. ├── package.json ├── tsconfig.json ├── .gitignore ├── public/ │ └── favicon.svg anything here is served as a static asset ├── src/ │ ├── server.ts queries, mutations, endpoints │ ├── client.tsx your UI │ └── styles.css └── tests/ └── notes.test.ts runs under `xeer test` ``` ## `xeer.app.json` — the manifest The manifest declares everything about the app that is not code: its name, its entrypoints, its database tables and indexes, the [capabilities](/guides/capabilities) it is allowed to use, and its resource budgets. ```json { "$schema": "https://spec.xeer.dev/application-v0.schema.json", "format": "xeer.application-source.v0", "name": "my-app", "entrypoints": { "client": "src/client.tsx", "server": "src/server.ts" }, "app": { "spa": true, "title": "My App", "language": "en", "favicon": "/favicon.svg" }, "database": { "tables": { "notes": { "fields": { "text": { "type": "string", "maxLength": 500 }, "ownerId": { "type": "string", "maxLength": 128 } }, "indexes": { "by_owner": ["ownerId"] } } } }, "capabilities": ["database"] } ``` This is not a config file you tweak occasionally — it is the contract the rest of the project is generated from. Declare a table here and `ctx.db.table('notes')` becomes typed. Add a field and your editor knows about it on the next save. Get it wrong and `xeer check` tells you exactly where. Every field and every rule is in the [manifest reference](/reference/manifest). Two things are worth knowing up front: - **Unknown keys are errors, everywhere.** A typo in a field name is a failed build, not a silently ignored setting. - **The manifest is hashed into your build artifact's identity.** Two identical manifests plus identical source produce the identical artifact id, which is what makes [promote and rollback](/guides/deploy) exact rather than approximate. ## `xeer.project.json` — the app's identity ```json { "format": "xeer.project.v0", "appId": "app_…" } ``` `appId` is the permanent identity of the deployed app: its server, its stored data, and its per-app user ids all hang off it. It is not derived from your project name, so renaming the project or the directory changes nothing. **Commit this file.** It is what makes `git clone` followed by `xeer deploy` update the app you already deployed instead of creating a new, empty one. The id is a random identifier rather than a secret and grants nothing on its own — the platform binds each `appId` to the account that first deployed it and refuses deployments from anyone else. Use [`xeer link`](/reference/cli#xeer-link) to change what a checkout points at: ```sh xeer link # list the apps you own xeer link --app app_… # point this directory at one of them xeer link --new # deliberately fork: mint a new identity ``` It lives beside the manifest rather than inside it precisely because the manifest is hashed into artifact ids — pointing a checkout at a different app must not change your build hashes. ## `src/server.ts` — the server A single default-exported `defineServer({ … })` call. The compiler reads it **statically**, which is why the operations you declare can be typed all the way through to the client. ```ts import { defineServer, endpoint, mutation, query } from '@impetik/xeer/server'; export default defineServer({ queries: { /* read */ }, mutations: { /* write */ }, endpoints: { /* raw HTTP under /api */ }, authPolicies: { /* data-layer authorization */ }, }); ``` Full details in [Server functions and the database](/guides/server). ## `src/client.tsx` — the client A default-exported component. Xeer mounts it for you, wrapped in the provider that supplies auth and the live-update stream, so there is no `main.tsx` and no `createRoot` call to write. ```tsx import { useMutation, useQuery } from '@impetik/xeer/client'; export default function App() { const notes = useQuery('notes.list', {}); // … } ``` JSX is Preact, configured for you in `tsconfig.json` via `"jsxImportSource": "@impetik/xeer"`. Full details in [The client](/guides/client). ## The two zones, and the wall between them Your server code and your client code are compiled as **separate graphs**, and the compiler enforces what each may import. | Zone | May import | | --- | --- | | Server (`src/server.ts` and what it imports) | `@impetik/xeer/server`, `@impetik/xeer/shared` | | Client (`src/client.tsx` and what it imports) | `@impetik/xeer/client`, `@impetik/xeer/shared`, `preact`, `preact/hooks` | A client module that imports `@impetik/xeer/server` fails the build with `XE1202`; one that imports the server entrypoint fails with `XE1201`. This is not a lint rule you can turn off — it is the reason a secret read from `ctx.env` cannot end up in a browser bundle by accident. Code both zones need goes in a shared module, as `examples/expenses` and `examples/team-board` do with `src/shared/`. Plain TypeScript with no platform imports is importable from either side. ## `tests/*.test.ts` Anything matching `tests/**/*.test.ts` runs under [`xeer test`](/guides/testing), type-checked against the same generated contract your server and client see. ## `public/` Static files, served from the root of your app. `public/favicon.svg` is referenced as `/favicon.svg` in the manifest's `app.favicon`. ## `.xeer/` — disposable local state Generated build output, the generated type declarations, cached local database state, and the plaintext values `xeer env pull` writes. The scaffolded `.gitignore` excludes it, and it must stay excluded. Nothing in `.xeer/` is required to redeploy — your app's identity is in the checked-in `xeer.project.json` — so deleting the directory, or cloning a repository without it, is safe. `xeer state reset` clears the local database; `xeer doctor` reports on what is there. ## Bring your own tooling `package.json` is an ordinary `package.json`. Add dependencies, add scripts, add a formatter, add a linter. Xeer owns the compile-and-deploy path and the platform imports; it does not own your project. ```json { "scripts": { "dev": "xeer dev .", "check": "xeer check .", "build": "xeer build .", "test": "xeer test ." } } ``` --- # Capabilities A capability is a power your app **declares** in its manifest. If you have not declared it, it does not exist in your app's world — there is nothing to import, nothing bound at runtime, and nothing to misconfigure. ```jsonc { "capabilities": ["database", "storage"], "database": { "tables": { "notes": { "fields": { "text": { "type": "string" } } } } }, "storage": {} // {} means "the platform defaults" } ``` That is the whole pattern, and it is worth stating plainly because it drives everything below: 1. **Name the capability** in the `capabilities` array. 2. **Configure it** in the matching top-level block — `database` for `"database"`, `storage` for `"storage"`. 3. **Use it** through the typed surface that appears in your server code: `ctx.db`, `ctx.storage`. The two halves are checked against each other, in **both** directions. A `database` block with no `"database"` capability is an error; the `"database"` capability with no `database` block is an error too, and the message tells you to write `{}` if you meant the defaults. You cannot half-declare a capability, and you cannot end up with a configured-but-forbidden one. Naming the same capability twice is also an error. ## Deny by default `ctx` in a server handler carries `ctx.auth` and `ctx.env` — and then **only the capabilities you declared**. There is no `ctx.fetch` and no ambient client for anything. An app that declares no capabilities can still serve queries, mutations, and endpoints; it simply has no way to reach any resource. This is enforced in the type system, not just at runtime. `ctx.storage` in an app that has not declared `storage` is a **compile error** — the property is not on the context type at all, and `xeer check` reports `XE1205` against `src/server.ts`. So is calling `ctx.storage.put` from a *query*, because a query only ever gets the read-only half of the surface. This is deliberate, and it is what makes the manifest useful as a description rather than a suggestion. Reading one file tells you every external thing an app can touch. That holds for a person skimming a pull request and for an agent deciding whether a change is safe, and it holds because there is no second place to look. It also means adding a capability is a **visible, reviewable change to a checked-in file**, not a line of code buried in a handler. And because the manifest is hashed into every build artifact's identity, the version you can [roll back to](/guides/deploy#roll-back) is the version whose declared powers you already reviewed. There are two capabilities today: `database` and `storage`. ## `database` The one most apps start with. Declaring it gives you a typed, transactional, per-app database reachable as `ctx.db` in every server handler. ### Tables, fields, indexes ```json { "capabilities": ["database"], "database": { "tables": { "cards": { "fields": { "title": { "type": "string", "maxLength": 200 }, "status": { "type": "string", "maxLength": 32 }, "workspaceId": { "type": "string", "maxLength": 96 }, "dueAt": { "type": "datetime", "optional": true } }, "indexes": { "by_workspace": ["workspaceId"], "by_workspace_status": ["workspaceId", "status"] } } } } } ``` Field types are `string`, `number`, `boolean`, `datetime`, `bytes`, and `json`. Every field is required unless you write `"optional": true`. Every row additionally gets `id`, `createdAt`, and `updatedAt` from the runtime — you cannot declare those yourself, and you cannot write to them. **Indexes are not an optimisation here; they are the query surface.** A filter must match `{ id }` or a declared prefix of a declared index. With `by_workspace_status` above you can filter by `{ workspaceId }` or by `{ workspaceId, status }`, and nothing else. That constraint is why every query an app can express is one an index serves — there is no accidental table scan to discover in production, and no `EXPLAIN` to read. The complete rules — identifier patterns, `maxLength`, `database.version` — are in the [manifest reference](/reference/manifest#database). ### The typed surface ```ts import { defineServer, query } from '@impetik/xeer/server'; export default defineServer({ queries: { 'cards.list': query({ input: { workspaceId: 'string' }, handler: (ctx, input) => ctx.db.table('cards') .find({ where: { workspaceId: input.workspaceId } }), }), }, }); ``` `ctx.db.table('cards')` is generated from the manifest: the table name is checked, the row type has your fields, `where` accepts only your index prefixes. Seven methods, no query builder, no SQL. See [Server functions and the database](/guides/server#the-table-api). ### Policies: authorization in the data layer The database capability carries an authorization model with it. Declare a table owned-per-user or scoped-to-a-workspace once, in `defineServer`, and the rule is applied to **every** read and write of that table — not in each handler, where one omission is a data leak. ```ts import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server'; export default defineServer({ authPolicies: { drafts: ownedTable(), // rows belong to one user, via `ownerId` cards: workspaceTable(), // rows belong to a workspace, via `workspaceId` }, // … }); ``` Under `ownedTable()`, `ctx.db.table('drafts').all()` returns only the caller's rows, an insert stamps `ownerId` with the caller's own id and refuses any other value, and an update that would move a row to someone else is refused. `workspaceTable()` does the same against `ctx.auth.workspaceIds`, and a caller with no membership reads an empty list. Full details in [Auth](/guides/auth#table-policies). ### Local development, no provisioning `xeer dev` gives you a real database from the first run: no connection string, no migration command, no container. Change a table in the manifest and the local schema follows. `xeer state reset --state dev --confirm ` clears it; `xeer export` and `xeer import` move it in and out as a readable JSON document, so an incompatible schema change during development does not cost you your test data. Deploying carries the schema with the artifact. A compatible change — a new table, a new optional field, a widened `maxLength`, a required field made optional — is applied on deploy. An incompatible one is refused with both schema identities and the exact difference, rather than being applied and hoped for. ## `storage` Private file storage per app, for the bytes a database row is the wrong shape for: uploads, avatars, attachments, generated files. ```json { "capabilities": ["database", "storage"], "database": { "tables": { "photos": { "fields": { "key": { "type": "string", "maxLength": 512 } } } } }, "storage": {} } ``` There is **no bucket name, no region, no credential, and no tenancy parameter anywhere.** Isolation is implicit: one store per app, per environment. Your `xeer dev` objects, your preview deployment's, and production's never see each other, and no call you can write reaches another app's. ### The surface, in brief `ctx.storage` splits exactly the way `ctx.db` does — a query gets the read-only half, a mutation or an endpoint gets all of it: ```ts // queries, mutations, and endpoints get(key) // { key, size, contentType, uploadedAt, bytes } | null head(key) // the same without the body, and free of the read budget list({ prefix, cursor, limit }) // { objects, cursor } // mutations and endpoints only put(key, value, { contentType }) // Uint8Array | string delete(key) // deleting nothing is a success ``` Five methods, and a query that calls `put` is a compile error rather than a runtime one. Keys are slash-separated ASCII path segments, at most 512 bytes; the platform limits are `maxObjectBytes` (25 MiB), `readBytes`, and `writeBytes` (32 MiB each), and each may be lowered but never raised. A browser uploads by POSTing to an endpoint you wrote — **there is no signed-upload URL and no upload token** — which bounds an upload by your `requestBytes` [budget](/reference/manifest#budgets), 4 MiB at most. Objects are never publicly served, so a read goes through your own code and `ctx.auth` decides who sees what. **[Storage](/guides/storage) is the full guide**: the complete API, the key rules and the `appUserId` gotcha that trips most apps up, a browser-upload walkthrough end to end, paging, the limits at call time, local emulation, and what happens on deploy and on delete. ## Why not just add an SDK client? Every framework eventually faces the question of how an app reaches the outside world, and the usual answer is "import a client and put credentials in an environment variable." Xeer's answer is different on purpose, and the trade is explicit. What you give up: you cannot reach a resource the framework does not model. Today that means a database and an object store, plus whatever you can do over `fetch` from a handler using a key from [`ctx.env`](/guides/env). What you get: the manifest is a complete and trustworthy inventory of an app's powers. Local development needs no credentials for anything the framework provides. Authorization for a modelled resource is enforced where the data is, not where somebody remembered to check. And a reviewer — human or otherwise — can tell what an app is able to do by reading one file, in one place, every time. ## Next - **[Manifest reference](/reference/manifest)** — every field and every rule. - **[Server functions and the database](/guides/server)** — the table API in full. - **[Auth](/guides/auth)** — policies, guards, and how to test them. --- # Server functions and the database Your server is one file exporting one call: ```ts import { defineServer, endpoint, mutation, query } from '@impetik/xeer/server'; export default defineServer({ queries: { /* reads */ }, mutations: { /* writes */ }, endpoints: { /* raw HTTP under /api */ }, authPolicies: { /* data-layer authorization */ }, }); ``` All four keys are optional. The compiler reads this **statically** — it does not run your server to find out what it does — which is what lets it generate types the client and your tests both see. ## Queries A query reads. It gets a read-only view of the database and cannot write. ```ts 'notes.list': query({ input: {}, handler: (ctx) => ctx.db.table('notes').find({ where: { ownerId: ctx.auth.appUserId } }), }), ``` The name is the record key, and it becomes the string you pass to `useQuery` on the client and `.query()` in a test. **Operation names must be namespaced and lowercase**: a namespace, a dot, then the rest. ```text notes.list ✓ board.cards ✓ files.write ✓ notes.list_pinned ✓ underscores and dashes are fine listNotes ✗ no namespace, and uppercase cards.forWorkspace ✗ uppercase ``` Precisely: `^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_.-]*$`. Anything else fails the build with `XE1303`. The constraint exists so that an operation name is unambiguous in a URL, a log line, and a test transcript without a per-call-site escaping decision. ## Mutations A mutation writes. **The whole handler is one transaction**: every write it stages commits together at the end, or nothing does. There is no `begin`, no `commit`, and no way to get a half-applied change. ```ts 'notes.remove': mutation({ input: { id: 'string' }, handler: async (ctx, input) => { const note = await ctx.db.table('notes').get(input.id); // Ownership is enforced here, on the server. A test proves it. if (!note || note.ownerId !== ctx.auth.appUserId) throw new Error('Note not found.'); await ctx.db.table('notes').delete(input.id); return { id: input.id }; }, }), ``` Reads inside a mutation see that mutation's own uncommitted writes, so you can insert a row and then read it back in the same handler. ## Endpoints An endpoint handles a raw HTTP request, for the cases RPC does not fit: a webhook, a redirect, a non-JSON response. ```ts 'GET /api/status': endpoint(async (request, ctx) => { const note = await ctx.db.table('notes').first({ where: { ownerId: ctx.auth.appUserId } }); return Response.json({ ok: true, hasNote: note !== null }); }), ``` The key is `" "`. Methods are `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD`. The path must be `/api` or start with `/api/`; segments are either literal (`[A-Za-z0-9_.-]+`) or a parameter (`:name`). Two routes whose parameterized shapes would collide are a build error, not a runtime surprise. `endpoint()` takes the handler positionally, with an optional [auth requirement](/guides/auth#operation-guards) second — not an options object: ```ts 'POST /api/hooks/stripe': endpoint(handler, authenticated()), ``` An endpoint's `ctx` is write-capable regardless of method, so a `GET` endpoint *can* write. It gets one transaction per request exactly as a mutation does.

Endpoint writes do not refresh open clients. The live-update stream is driven by mutations. If your client writes by fetch-ing an endpoint, call invalidateQueries() yourself afterwards. See Live updates.

## Input types `input` is a tiny declarative schema, validated before your handler runs. Six type strings, plus nested plain objects: | Descriptor | Handler receives | | --- | --- | | `'string'` | `string` | | `'number'` | `number` — finite; `NaN` and `Infinity` are refused | | `'boolean'` | `boolean` | | `'datetime'` | `Date` — a real `Date`, over the wire and back | | `'bytes'` | `Uint8Array` | | `'json'` | `XeerValue` — any JSON-shaped value | | `{ a: 'string', b: { c: 'number' } }` | `{ a: string; b: { c: number } }` | ```ts input: { title: 'string', dueAt: 'datetime', meta: { tags: 'json', pinned: 'boolean' } }, ``` Two things this language deliberately does **not** have: - **No optional, nullable, array, or enum notation.** Every declared field is required, and the object is exact: a missing key and an unknown key are both refused before your handler runs. For a list or an optional value, declare `'json'` and narrow it in the handler. For an enum, take `'string'` and check it — which is what `examples/team-board` does with its lane names. - **No max/min/pattern.** Length limits belong on the table field (`maxLength`), and anything else is a line of handler code. `input: {}` means "no input". You still pass `{}` from the client. ## The table API `ctx.db.table(name)` returns a handle typed from your manifest. Seven methods, and that is all of them. ```ts interface QueryTable { all(): Promise; get(id: string): Promise; find(options: { where: Where; limit?: number }): Promise; first(options: { where: Where }): Promise; } interface MutationTable extends QueryTable { insert(value: Insert): Promise; update(id: string, patch: Update): Promise; delete(id: string): Promise; } ``` A `Row` is your declared fields plus `id: string`, `createdAt: Date`, `updatedAt: Date`. `Insert` omits those three — the runtime mints the id and both timestamps. `Update` makes every field optional and merges shallowly over the current row. ```ts const created = await ctx.db.table('notes').insert({ text: 'Water the plants', ownerId: ctx.auth.appUserId }); created.id; // string created.createdAt; // Date await ctx.db.table('notes').update(created.id, { text: 'Water the plants twice' }); await ctx.db.table('notes').delete(created.id); ``` `update` and `delete` on an id that does not exist throw `Record not found: /`. ### Filtering `where` is an equality record, and it must match either `{ id }` or **a declared prefix of a declared index**: ```json "indexes": { "by_workspace_status": ["workspaceId", "status"] } ``` ```ts ctx.db.table('cards').find({ where: { workspaceId: 'design' } }); // ok — prefix ctx.db.table('cards').find({ where: { workspaceId: 'design', status: 'doing' } }); // ok — full ctx.db.table('cards').find({ where: { status: 'doing' } }); // refused ``` The type system rejects the third one in your editor, and the runtime refuses it too: `Filter on cards must match the prefix of a declared index.` There are no comparison operators — no `gt`, `in`, `like`, `not`. There is no `orderBy`: results are always ordered by `createdAt` then `id`, ascending. There is no `count()`, and no offset or cursor pagination. `limit` on `find` is the only knob. That is a small API, and the reason to like it is that every query it can express is one an index already serves. If you need a shape it cannot express, the answer is a new index in the manifest — which is a reviewable change to a checked-in file, not a slow query nobody notices for a month. ### Row counts and budgets `find` returns at most `limit`, defaulting to 100. `all()` reads up to your `queryRows` [budget](/reference/manifest#budgets), which defaults to 1000. A `limit` outside `1 … queryRows` is refused. Raise the budget in the manifest if your app genuinely needs more. ## `ctx` `ctx.auth` and `ctx.env`, plus one property per [capability](/guides/capabilities) you declared — and nothing else: ```ts ctx.auth // the verified identity of the caller ctx.env // this app's environment values for this environment ctx.db // the database — only with `capabilities: ["database"]` ctx.storage // the object store — only with `capabilities: ["storage"]` ``` A capability you did not declare is not on the type, so touching it is a compile error (`XE1205`), not a runtime failure. There is no `ctx.fetch`, no ambient client, and no way to reach a resource the manifest does not name. ### `ctx.auth` ```ts interface AuthIdentity { readonly appUserId: string; // the caller, scoped to this app readonly kind: 'guest' | 'user'; readonly roles: readonly string[]; readonly permissions: readonly string[]; readonly workspaceIds: readonly string[]; readonly sessionId?: string; } ``` `appUserId` is the caller's identity **inside this app** — never a provider id, never an id shared with another app. It is what you store as `ownerId`. `kind` distinguishes a provisioned user from an unclaimed visitor. `workspaceIds` is the membership the platform asserted; your code cannot add to it. This object is derived from a cryptographically verified assertion on every request, not from anything the browser sent. See [Auth](/guides/auth).

ctx.auth.id exists as a deprecated alias of appUserId and is always equal to it. New code should use appUserId. ctx.auth.profile is declared in the type but is not populated by the runtime — do not build on it.

### `ctx.env` `Readonly>` — this app's own environment values, and nothing else. An unset variable is `undefined`, never `''`. See [Environment variables and secrets](/guides/env). ### `ctx.storage` The object store, when you have declared the `storage` capability. A query gets `get`, `head`, and `list`; a mutation or endpoint also gets `put` and `delete` — the same read/write split `ctx.db` has, and calling `put` from a query is a compile error rather than a runtime one. ```ts 'files.stat': query({ input: { key: 'string' }, handler: async (ctx, input) => { const object = await ctx.storage.head(input.key); // null when there is nothing there return object === null ? null : { size: object.size, contentType: object.contentType }; }, }), ``` An upload arrives as an ordinary endpoint body — `await request.arrayBuffer()` — because there is no signed-upload URL: every byte passes through a handler you wrote. The full surface, the key rules, a complete browser-upload walkthrough, and the byte budgets are in [Storage](/guides/storage). ## Refusing a call There is no error helper to import. Throw: ```ts if (!note || note.ownerId !== ctx.auth.appUserId) throw new Error('Note not found.'); ``` The caller gets a structured failure rather than a stack trace: | Where it threw | Status | `code` | | --- | --- | --- | | query or mutation handler | 400 | `operation_failed` | | endpoint handler | 500 | `endpoint_failed` | | a policy or guard denied it | 400 | `operation_failed` | | handler exceeded its deadline | 504 | `handler_deadline_exceeded` | | request body over `requestBytes` | 413 | `request_too_large` | On the client that arrives as a rejected promise carrying a `XeerRuntimeError`; in a test, `expectFailure()` hands you `{ status, code, message, errorId }`. Because a denial and a hand-written refusal look the same from outside, an attacker cannot tell "not yours" from "does not exist" — which is usually what you want, and is why the example above says *Note not found* rather than *not your note*. ## Authorization Two mechanisms, at two different levels. Use both. **Table policies** (`authPolicies`) apply to every read and write of a table, in the data layer: ```ts import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server'; export default defineServer({ authPolicies: { drafts: ownedTable(), cards: workspaceTable() }, // … }); ``` **Operation guards** (`auth`) run before a handler: ```ts import { authenticated, memberOf, mutation } from '@impetik/xeer/server'; 'cards.add': mutation({ input: { title: 'string', workspaceId: 'string' }, auth: [authenticated(), memberOf('workspaceId')], handler: (ctx, input) => ctx.db.table('cards').insert(input), }), ```

A policy filters; a guard refuses. Under workspaceTable() alone, a non-member reading a workspace gets an empty list. Add memberOf('workspaceId') and they get an error instead. Both are safe — neither leaks a row — but they are different products, so choose deliberately: guards suit writes, where “you may not do this” is the honest answer, and bare policies suit reads, where an empty list is usually what a UI wants.

Both are covered in [Auth](/guides/auth), which also shows how to prove they work. ## A complete server ```ts import { authenticated, defineServer, endpoint, memberOf, mutation, query, workspaceTable, } from '@impetik/xeer/server'; export default defineServer({ authPolicies: { cards: workspaceTable() }, queries: { 'board.cards': query({ input: { workspaceId: 'string' }, // No guard. `{ workspaceId }` is a prefix of `by_workspace_status`, and the workspace policy // narrows the result further — a non-member reads `[]`, never another workspace's rows. handler: (ctx, input) => ctx.db.table('cards') .find({ where: { workspaceId: input.workspaceId }, limit: 200 }), }), }, mutations: { 'cards.add': mutation({ input: { title: 'string', workspaceId: 'string' }, auth: [authenticated(), memberOf('workspaceId')], handler: (ctx, input) => ctx.db.table('cards') .insert({ title: input.title, status: 'todo', workspaceId: input.workspaceId }), }), 'cards.move': mutation({ input: { id: 'string', status: 'string' }, auth: authenticated(), handler: async (ctx, input) => { if (!['todo', 'doing', 'done'].includes(input.status)) throw new Error('Unknown lane.'); // The workspace policy already refuses a card the caller cannot see, so this is // authorization *and* a 404 in one line. return ctx.db.table('cards').update(input.id, { status: input.status }); }, }), }, endpoints: { 'GET /api/health': endpoint(async (_request, ctx) => Response.json({ ok: true, workspaces: ctx.auth.workspaceIds.length, })), }, }); ``` ## Next - **[The client](/guides/client)** — calling all of this from a component. - **[Auth](/guides/auth)** — policies, guards, and proving them. - **[Testing](/guides/testing)** — asserting the refusals above. - **[Capabilities](/guides/capabilities)** — what `ctx.db` is and why it is opt-in. --- # Storage Private file storage per app, for the bytes a database row is the wrong shape for: uploads, avatars, attachments, generated documents, exports. ```jsonc { "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. ```json { "capabilities": ["database", "storage"], "storage": { "maxObjectBytes": 1048576 } } ``` The two halves are checked against each other in **both** directions, so you cannot half-declare it: ```text "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 declared ``` Write `"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: ```text "storage": { "readBytes": 268435456 } XE1002 storage.readBytes: Too big: expected number to be <=134217728 ``` That 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: ```json "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. ```ts // 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; ``` 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: ```text XE1205 TS2339: Property 'put' does not exist on type 'ReadonlyAppStorage'. ``` And an app that never declared the capability has no `ctx.storage` at all: ```text 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](/guides/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. ```ts 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. ```text 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 charset ``` A refused key is a refusal from your own handler's frame, with the offending key quoted and truncated: ```text 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: ```ts // ✗ 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. ```ts /** A key-safe, stable scope for one caller. Stable across requests, and never leaks the id. */ async function scopeOf(appUserId: string): Promise { 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. ```json { "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](/guides/server#filtering). ### 2. The upload endpoint ```ts import { defineServer, endpoint, ownedTable } from '@impetik/xeer/server'; async function scopeOf(appUserId: string): Promise { 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, no `multipart/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. - **`ownerId` is passed explicitly.** Under `ownedTable()` the policy *refuses* any owner but the caller, so passing `ctx.auth.appUserId` is 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. ```tsx 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(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 (
{ const file = event.currentTarget.files?.[0]; if (file) void upload(file); }} /> {error &&

{error}

}
    {(files.data ?? []).map((file) => (
  • {file.name} {file.size} bytes
  • ))}
); } ``` `body: file` sends the `File` as-is; the browser streams it. The [`invalidateQueries`](/guides/client#invalidatequeries) call is not optional — [live updates](/guides/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. ```ts '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](/reference/manifest#budgets)** — 1 MiB by default, **4 MiB at most**. Over it, the request is refused before your handler is called at all: ```text 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: ```json "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: ```ts await ctx.storage.list({ prefix: 'uploads/42/' }); // everything "in" uploads/42 await ctx.storage.list({ prefix: 'uploads/4' }); // uploads/42 and uploads/47 alike ``` Keep calling with the `cursor` you were handed until it is `null`: ```ts '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 === null` is the only end condition.** Do not compare a page's length against your `limit` — 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. - `get` charges the object's size against `readBytes`, checked from the object's declared size *before* the body is transferred, so an object that cannot fit costs nothing to refuse. - `head` and `list` are **free** of the read budget. Metadata is cheap on purpose: check before you fetch. - `put` charges the byte length against `writeBytes` *before* 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: ```text 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](/guides/server#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. ```sh xeer dev # objects persist across restarts xeer preview # a separate store from dev's xeer test # a fresh, empty store every run, thrown away afterwards ``` - **`xeer dev` and `xeer preview` persist across restarts, and they do not share a store.** Stop the dev server, start it again, and yesterday's objects are still there. Upload under `xeer dev` and the same key is a 404 under `xeer preview`. - **`xeer test` starts 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: ```ts 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 ```sh xeer state reset --state dev --confirm ``` 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: ```text XE1812 dev state is owned by live dev process 43176 at …/.xeer/generated/.wrangler/state ``` Stop 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 promote` ships 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](/guides/deploy#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`. ```sh xeer delete --confirm my-app ``` It 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`](/guides/deploy#take-an-app-offline) instead — a disabled app's objects stay exactly as they are. ## Next - **[Capabilities](/guides/capabilities)** — the declaration model, and `database` alongside this. - **[Server functions and the database](/guides/server#ctx-storage)** — the rest of `ctx`, and endpoints. - **[Manifest reference](/reference/manifest#storage)** — every field and its exact rule. - **[Auth](/guides/auth#ownedtable)** — `ownedTable()`, which is what makes a stored object private. --- # The client Your client is a Preact component, default-exported from the file your manifest names. Xeer mounts it for you inside the provider that supplies identity and the live-update stream, so there is no entry script to write. ```tsx import { useMutation, useQuery, useState } from '@impetik/xeer/client'; import './styles.css'; export default function App() { const notes = useQuery('notes.list', {}); const createNote = useMutation('notes.create'); const [text, setText] = useState(''); return (
setText(event.currentTarget.value)} /> {notes.loading &&

Loading…

} {notes.error &&

{notes.error.message}

}
    {(notes.data ?? []).map((note) =>
  • {note.text}
  • )}
); } ``` JSX is Preact, wired up in the generated `tsconfig.json` with `"jsxImportSource": "@impetik/xeer"`. Note `class` rather than `className` — this is Preact, so plain HTML attribute names work. ## `useQuery` ```ts function useQuery(name, input): { data: Output | undefined; error: Error | undefined; loading: boolean; refetch(): void; } ``` The operation name and both its input and output types come from the server you wrote — misspell the name and your editor tells you, pass the wrong input shape and it tells you, and `notes.data` is typed as your handler's return value. `input` is a required second argument; pass `{}` for an input-less query. `data` is `undefined` on the first render and while an error is outstanding. `refetch()` is synchronous and returns nothing — it schedules a refetch rather than handing you a promise. You will rarely need `refetch`, because queries refresh themselves. See [Live updates](/guides/live-updates). ## `useMutation` ```ts function useMutation(name): (input) => Promise; ``` It returns a **callable**, not an object with a `.mutate`: ```tsx const createNote = useMutation('notes.create'); const removeNote = useMutation('notes.remove'); await createNote({ text: 'Water the plants' }); // resolves to the created row await removeNote({ id: note.id }); ``` On success it invalidates the queries that read whatever tables the mutation wrote, so your own UI updates without a round trip and without a cache key to name. On refusal it **rejects** with a `XeerRuntimeError` carrying `code`, `status`, `id`, and `message`. Handle it as you would any rejection: ```tsx const [problem, setProblem] = useState(null); const add = async () => { try { await createNote({ text }); setProblem(null); } catch (error) { setProblem(error instanceof Error ? error.message : 'Something went wrong.'); } }; ``` ## `useAuth` ```ts function useAuth(): { readonly auth: AuthIdentity | null; readonly loading: boolean; readonly error: Error | null; readonly isGuest: boolean; readonly isAuthenticated: boolean; refresh(): Promise; }; ``` `auth` is the same identity your server sees in `ctx.auth`: `appUserId`, `kind`, `roles`, `permissions`, `workspaceIds`. It is `null` until the first check resolves, which is what `loading` is for. ```tsx const { auth, loading, isAuthenticated } = useAuth(); if (loading) return

Checking your identity…

; if (!isAuthenticated) return ; return

Signed in as {auth.appUserId}

; ``` `isGuest` is true while `auth` is still `null`, so it means "not known to be a signed-in user" rather than "definitely a visitor". Gate on `isAuthenticated` when you want the stricter reading. Never use client-side identity as authorization. It decides what to *render*; the server decides what is *allowed*. See [Auth](/guides/auth). ## `signIn` and `signOut` ```ts function signIn(options?: { baseUrl?: string; returnTo?: string; provider?: 'google'; persona?: 'alice' | 'bob'; // local development only workspaceIds?: readonly string[]; // local development only }): void; function signOut(options?: { baseUrl?: string }): Promise; ``` `signIn` is a full-page navigation, so nothing after it in the same function runs. `signOut` is a request; it clears the session and invalidates every query, but it does not refresh `useAuth` by itself: ```tsx const { refresh } = useAuth(); ``` `persona` and `workspaceIds` are how you switch between local test identities under `xeer dev`. A deployed app ignores them and begins real sign-in on the same route, so the button you write for development is the button that works in production. ## Ready-made auth components For the common cases, so you do not have to build a sign-in flow to see one working: ```tsx import { AuthManagementPanel, SignInButton, UserButton } from '@impetik/xeer/client'; ``` - `SignInButton` — starts sign-in. Takes `children`, `className`, `returnTo`. - `UserButton` — shows the signed-in state and signs out on click; renders a `SignInButton` when there is nobody signed in. - `AuthManagementPanel` — a self-contained panel for the account actions a user is entitled to: revoking their sessions, exporting their data, deleting their account. All three are plain components you can style or replace. Nothing else in the framework depends on them. ## `invalidateQueries` ```ts function invalidateQueries(tables?: readonly string[]): void; ``` Refetch everything, or only the queries known to read one of the named tables. You need this in exactly one situation: **you wrote through an endpoint rather than a mutation.** ```tsx const shorten = async (url: string) => { await fetch('/api/links', { method: 'POST', body: JSON.stringify({ url }) }); invalidateQueries(['links']); // endpoint writes are not tracked }; ``` Mutations do this for you. See [Live updates](/guides/live-updates#what-is-not-covered). ## Preact hooks `useState` is re-exported from `@impetik/xeer/client` for convenience. Every other hook comes from Preact directly, which the compiler allows in client code: ```tsx import { useEffect, useMemo, useRef } from 'preact/hooks'; ``` ## What client code may import The compiler compiles your client and server as separate graphs and enforces the boundary: | Allowed in client code | Refused | | --- | --- | | `@impetik/xeer/client` | `@impetik/xeer/server` — fails with `XE1202` | | `@impetik/xeer/shared` | your `src/server.ts` — fails with `XE1201` | | `preact`, `preact/hooks` | | | your own modules with no server imports | | This is why a value read from `ctx.env` cannot reach a browser bundle by accident: there is no import path from the client graph to the code that can read it. ## Styling and assets Import CSS from your client and it is bundled: ```tsx import './styles.css'; ``` Anything in `public/` is served from the root of your app. There is no CSS framework, no component library, and no opinion about how your app should look. ## Next - **[Live updates](/guides/live-updates)** — why the list above refreshes itself. - **[Server functions and the database](/guides/server)** — the other half of every call here. - **[Auth](/guides/auth)** — what `useAuth` is reading, and how sign-in works. - **[Local development](/guides/local-development#local-personas)** — switching identity while you develop. --- # Live updates Two people have your app open. One adds a row. The other sees it — without a refresh, a polling interval, a subscription, a cache key, or a line of code from you. This page explains how, because a feature that works by magic is a feature you cannot debug. ## The mechanism It rests on one observation: **the platform already knows which tables a query read and which tables a mutation wrote**, because every handler runs against a database handle that records exactly that. 1. A query's response carries the list of tables its handler touched. The client remembers it, keyed by the operation name and its encoded input. 2. A mutation's response carries the list of tables it wrote, and the kinds of write. 3. When a mutation commits, the app pushes a small frame to every connected client over a [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream. 4. Each client refetches only the queries whose remembered read-set intersects the frame's table list. So a write to `notes` refreshes queries that read `notes`, and leaves the rest alone. ### What is on the wire The frame carries the table names, the write kinds, and a monotonic revision number. **It never carries record data, ids, or anything about who made the change.** A client learns "something in `cards` changed" and asks its own questions through the ordinary query path, where the ordinary [authorization](/guides/auth) applies. That is the property that makes it safe to broadcast to everyone at once: the notification is not a data channel. ### Three paths, not one Server push is the interesting one, but it is not the only one, and that is deliberate — invalidation still works with the stream down. | Path | Covers | Mechanism | | --- | --- | --- | | Same tab | Your own writes | `useMutation` invalidates from the write set in the response | | Same browser, other tabs | A second tab you have open | A `BroadcastChannel` message | | Everyone else | Other users, other devices | The server-sent events stream | Your own mutation therefore never pays a round trip to see its own effect. ## What you write Nothing: ```tsx const notes = useQuery('notes.list', {}); ``` That is the whole opt-in. The stream opens when the first query mounts and closes when the last one unmounts; connections are shared, so a page with twenty queries has one stream. On the server there is no `publish()`, no channel to name, and no invalidation call — reactivity is derived from what your handlers actually did, so it cannot be forgotten. Watch it work with two browser windows on `xeer dev`, or one normal window and one private window signed in as a different [persona](/guides/local-development#local-personas). ## Reliability Every failure mode collapses to "refetch more than strictly necessary", never to "show stale data forever". - **A query whose read-set is not yet known refetches on every frame.** Learning can only narrow the blast radius; it can never cause a miss. - **A dropped frame is caught by the revision number.** On reconnect the client compares the revision it last saw; if it has jumped, or the app instance changed, it invalidates everything. - **A dropped connection reconnects** with full-jitter exponential backoff, from 500 ms up to 30 seconds. - **Streams have a bounded lifetime** and reconnect on expiry. A stream never outlives the identity assertion behind it, so a revoked session stops receiving frames. - **There is a connection budget.** `liveConnections` in your manifest defaults to 100 simultaneous streams and accepts up to 1000. Past it, a client is told to retry after an interval and honours it. Raise the budget if your app needs more; see the [manifest reference](/reference/manifest#budgets). A heartbeat every 20 seconds keeps intermediaries from closing an idle stream. ## What is not covered **Writes made through an [`endpoint()`](/guides/server#endpoints) do not invalidate anything.** Only mutations report a write set. If your client writes by `fetch`-ing one of your endpoints, invalidate by hand: ```tsx import { invalidateQueries } from '@impetik/xeer/client'; await fetch('/api/links', { method: 'POST', body: JSON.stringify({ url }) }); invalidateQueries(['links']); ``` This is the one place the automatic story has a seam, and it is a seam on purpose: an endpoint is a raw HTTP handler, and the framework does not inspect what you did inside it. ## What this is not - **There is no `useSubscription`,** and no way to subscribe to a table directly. Invalidation is the only primitive; queries are the only way data reaches a client. - **It is not a real-time transport.** You cannot push a custom message to connected clients, and you cannot use it as a chat channel. Frames say only "these tables changed". - **It is not optimistic UI.** A mutation resolves when it has committed, and the refetch follows. If you want an optimistic render, hold the pending value in `useState` yourself. Those are real limits. The trade is that the thing you get instead — every query, on every client, correct after every write — needs no configuration and cannot drift out of sync with your schema, because it is derived from your schema. ## Next - **[The client](/guides/client)** — `useQuery`, `useMutation`, `invalidateQueries`. - **[Server functions and the database](/guides/server)** — the reads and writes being tracked. - **[Manifest reference](/reference/manifest#budgets)** — the `liveConnections` budget. --- # Auth There are **two completely separate identities** in a Xeer project, and almost every confusion about auth comes from running them together. They share nothing: not a store, not a credential, not a login.
### Door 1 — you You are a **builder**. You sign in so that you can deploy. ```sh xeer auth login ``` Your credential lives on your machine. It authorises `xeer deploy`, `xeer env set`, `xeer rollback` — the commands that change what is live. It never appears in your application's code, and your app never sees it.
### Door 2 — your app's users The people who visit the app you deployed are **app users**. They never sign in to Xeer; they sign in to *your app*, or they do not sign in at all. ```ts ctx.auth.appUserId ``` Their identity arrives at your server already verified, on every request. You do not implement sign-in, manage sessions, or check a token.
**Nothing crosses between them.** Your builder account is not in `ctx.auth` and cannot be. Signing in to your own deployed app makes you an ordinary app user of it, with an id unrelated to your builder account. Your users need no Xeer account of any kind — the closed beta gates *deploying*, not *visiting*. The rest of this page is Door 2, because that is the one you write code against. Door 1 is [one section at the end](#door-1-your-builder-account) and the [CLI reference](/reference/cli#sign-in). ## Door 2: your app's users ### Every visitor already has an identity This is the part that surprises people. **A deployed Xeer app gives every visitor a verified, stable identity on their very first request, before any sign-in.** They are a `kind: 'guest'`, and they have a real `appUserId` you can store as an owner. So this works with no auth UI at all, for anyone who opens your app: ```ts 'notes.list': query({ input: {}, handler: (ctx) => ctx.db.table('notes').find({ where: { ownerId: ctx.auth.appUserId } }), }), ``` That is why `xeer new` ships **no sign-in UI**: the scaffolded app already scopes every note to its caller. You add sign-in when you want a *named* account — something the person can return to from another device, or deliberately end. And when a guest does sign in, the platform **links** their existing identity to the new one, so the rows they created as a guest stay theirs. There is no "log in to keep your work" cliff to design around. ### `ctx.auth` ```ts interface AuthIdentity { readonly appUserId: string; // this caller, in this app readonly kind: 'guest' | 'user'; readonly roles: readonly string[]; readonly permissions: readonly string[]; readonly workspaceIds: readonly string[]; readonly sessionId?: string; } ``` That is the whole surface, and what is *absent* is deliberate. Your app never receives an email address, a display name, a provider name, a provider account id, a global cross-app identity, an OAuth token, or the session cookie. If you want a user's email, ask them for it in a form like any other data — the platform will not hand it to you. This is enforced structurally rather than by convention: identity reaches your code only as a cryptographically signed assertion your code cannot mint, and the platform strips every identity header and session cookie before your Worker sees the request. ### `appUserId` is pairwise `appUserId` identifies the caller **inside this app, and nowhere else**. - **Same person, same app, any session or device → the same id.** Stable, so ownership survives signing out and back in. - **Same person, two different Xeer apps → two unrelated ids.** They cannot be correlated. Your app cannot learn that its user is also a user of somebody else's app, and neither can anybody else's app learn that about yours. - **A guest who has not signed in gets a per-visit identity.** Signing in links it forward. Store it as your `ownerId`. Never store an email as the identity key. ### Signing in Sign-in lives on your app's own origin, at `/_xeer/auth/sign-in`. The provider that ships today is **Google**. The framework gives you three components so you do not have to build a flow to see one working: ```tsx import { SignInButton, UserButton, useAuth } from '@impetik/xeer/client'; function Account() { const { isAuthenticated, loading } = useAuth(); if (loading) return checking identity…; return isAuthenticated ? : Sign in; } ``` Or drive the route yourself: ```tsx import { signIn, signOut, useAuth } from '@impetik/xeer/client'; const { refresh } = useAuth(); signIn(); // full-page navigation signIn({ returnTo: '/dashboard' }); void signOut().then(() => refresh()); // signOut does not refresh useAuth for you ``` `useAuth()` returns `{ auth, loading, error, isGuest, isAuthenticated, refresh }`. Use it to decide what to **render**. Never use it as authorization — the server decides what is *allowed*, and a client can claim anything.

Under xeer dev the same button behaves differently, because there is no provider to talk to: /_xeer/auth/sign-in signs the session in as the local persona alice and clears any workspace membership. So locally the button reads as “become alice”, which is worth knowing before you conclude it is broken. See Local personas.

Signed-in users also get a platform-provided account panel — their sessions, a data export, and account deletion — which you can drop in as one component: ```tsx import { AuthManagementPanel } from '@impetik/xeer/client'; ``` Revoking sessions revokes the whole session family, so a refreshed descendant session dies with it. ## Authorization Identity tells you *who*. Authorization decides *what*. Xeer gives you two mechanisms at two levels, and you should use both. | | Table policies | Operation guards | | --- | --- | --- | | Declared in | `defineServer({ authPolicies })` | a query/mutation/endpoint's `auth` | | Applies to | every read and write of that table | one operation, before its handler runs | | Protects against | a handler that forgot to filter | a call that should not have been made | A table policy is the stronger guarantee, because it holds even in the handler you wrote at 2am. ### Table policies ```ts import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server'; export default defineServer({ authPolicies: { drafts: ownedTable(), cards: workspaceTable(), }, // … }); ``` #### `ownedTable()` Rows belong to one user, identified by a string field on the table. ```ts ownedTable() // ownerField: 'ownerId', allowGuests: true ownedTable({ ownerField: 'authorId' }) ownedTable({ allowGuests: false }) ``` - **Reads** are filtered to the caller in the database, so `ctx.db.table('drafts').all()` returns only their rows and there is no window in which more existed. - **Inserts** stamp the owner field with the caller's `appUserId` when you omit it, and **refuse** any other value. - **Updates and deletes** are refused unless the row already belongs to the caller, and refused if the change would hand it to someone else. - `allowGuests: false` refuses a `kind: 'guest'` caller outright. It defaults to `true`, because a guest is a real user with real rows. #### `workspaceTable()` Rows belong to a workspace, and the caller must be a member. ```ts workspaceTable() // workspaceField: 'workspaceId', allowGuests: false workspaceTable({ workspaceField: 'teamId' }) ``` - **Reads** are filtered to `ctx.auth.workspaceIds`. A caller with no membership reads an empty list immediately — no query runs, so this is fail-closed rather than fail-empty by luck. - **Inserts** fill the field automatically when the caller belongs to exactly one workspace. With several, the insert must name one they belong to, or it is refused. - **Updates and deletes** are refused for a row outside the caller's workspaces, and refused if the change would move it out. - Guests are refused by default, because a workspace is something you are put into. #### Validated at boot The field a policy names must be a declared **string** field on that table. If it is not, the app refuses to start rather than serving unprotected rows. A typo in a policy is a failed deployment, not a leak. ### Operation guards ```ts import { authenticated, hasPermission, hasRole, memberOf, owner } from '@impetik/xeer/server'; ``` | Guard | Passes when | | --- | --- | | `authenticated()` | `ctx.auth.kind === 'user'` | | `memberOf('workspaceId')` | the named **input field** is a string in `ctx.auth.workspaceIds` | | `hasRole('admin')` | `ctx.auth.roles` includes it | | `hasPermission('cards:write')` | `ctx.auth.permissions` includes it | | `owner()` | the input's `ownerId` (or `appUserId`) equals `ctx.auth.appUserId` | Attach one, or an array — an array requires **all** of them: ```ts 'cards.add': mutation({ input: { title: 'string', workspaceId: 'string' }, auth: [authenticated(), memberOf('workspaceId')], handler: (ctx, input) => ctx.db.table('cards').insert(input), }), ``` Guards run before your handler, so a refused call never touches the database. A denial reaches the caller as HTTP 400 with `code: 'operation_failed'` — identical to a refusal you threw yourself, so an attacker cannot distinguish "forbidden" from "does not exist". `owner()` reads the hard-coded input fields `ownerId` and `appUserId`; it is not configurable. `memberOf` reads a top-level string field of the input by name. ### A policy filters; a guard refuses This distinction catches people, so it is worth being explicit about. | The caller is not a member, and the query… | They get | | --- | --- | | relies on `workspaceTable()` alone | an **empty list** | | also carries `memberOf('workspaceId')` | a **refusal** (400 `operation_failed`) | Both are safe: neither can return another workspace's rows. They are different *products*, so pick on purpose. A bare policy suits reads, because an empty list is usually what a UI wants to render. A guard suits writes, because "you may not do this" is the honest answer to an attempted change — and it is cheaper, since a guard refuses before the handler runs at all. ## Prove it Every rule on this page is assertable, as three different users, in the runner that ships with the framework: ```ts import { as, expect, expectFailure, test } from '@impetik/xeer/test'; test('two members of a workspace see each other rows', async () => { const alice = as({ name: 'alice', workspaceIds: ['design'] }); const bob = as({ name: 'bob', workspaceIds: ['design'] }); const card = await alice.mutation('cards.add', { title: 'Ship it', workspaceId: 'design' }); // Membership, not ownership, scopes a workspaceTable(). Bob never touched this row. expect((await bob.query('board.cards', { workspaceId: 'design' })).map((row) => row.id)) .toContain(card.id); }); test('a non-member reads an empty board rather than another workspace', async () => { const alice = as({ name: 'alice', workspaceIds: ['design'] }); const outsider = as({ name: 'bob', workspaceIds: ['ops'] }); await alice.mutation('cards.add', { title: 'Design only', workspaceId: 'design' }); // `board.cards` has no guard, so the policy filters: an empty list, never a refusal. expect(await outsider.query('board.cards', { workspaceId: 'design' })).toEqual([]); // The guarded *write* is a refusal instead. const refused = await expectFailure( outsider.mutation('cards.add', { title: 'Sneaky', workspaceId: 'design' }), ); expect(refused.code).toBe('operation_failed'); }); ``` See [Testing](/guides/testing), and [Local personas](/guides/local-development#local-personas) for the same identities in a browser. ## Door 1: your builder account The other door, briefly. This is you, not your users. ```sh xeer auth login # opens a browser, prints a confirmation code xeer auth status # who you are, when the credential expires, which control plane xeer auth logout # revoke it, and delete it locally ``` `xeer auth login` prints a verification URL and a confirmation code, then opens your browser. **Approve only if the browser shows that exact code** — approval never happens on a plain page load. The credential is written to `~/.xeer/credentials/cli.json` with `0600` permissions and lasts 30 days; `xeer auth logout` revokes it immediately. Xeer is in closed beta, so builder accounts are by invitation. Signing in with an address that has not been invited creates nothing and tells the terminal to stop waiting, rather than leaving it polling. See the [FAQ](/faq#can-i-get-an-account).

xeer auth as and xeer auth clear are not this door. They sit under xeer auth for historical reasons, but they select the local development persona — a pretend app user — and touch no account and no credential. See Local personas.

## Next - **[Local development](/guides/local-development)** — personas, and exercising all of this offline. - **[Testing](/guides/testing)** — asserting the refusals above. - **[Server functions and the database](/guides/server)** — where `ctx.auth` is used. - **[The client](/guides/client#useauth)** — `useAuth`, `SignInButton`, `UserButton`. --- # Local development ```sh xeer dev ``` One command, and you have your client, your server, your database, and your object store running together — with a real verified identity for the caller. No account, no network, no containers, no credentials for anything the framework provides. ## What `xeer dev` gives you - **Your client and server, together.** Client edits hot-reload. Server edits recompile and restart behind a health check, with an automatic rollback if the new version does not come up — so a typo in a handler does not leave you with a dead dev server. - **A real database.** Not a mock, not an in-memory shim: the same transactional engine your deployed app uses, with the schema from your manifest. Change a table and the local schema follows. - **A real object store**, if you declared the [`storage` capability](/guides/storage). It exists on the first `put`, and `xeer dev`, `xeer preview`, and `xeer test` each get their own. - **Live updates.** Open two browser windows and watch a write in one appear in the other. - **An identity for every request**, without sign-in. See [local personas](#local-personas) below. The URL is printed on startup. Xeer picks a free port unless you pass `--port`. ```sh xeer dev --port 5173 # pin it xeer dev --json # newline-delimited events, for an agent ``` ## `xeer check` — the fast loop ```sh xeer check ``` Validates the manifest and analyses both source graphs without running anything: entrypoints resolve, the server export is statically readable, no client module imports server code, routes do not collide. Every failure carries a file, a line, and a suggested repair. This is the command to run on every edit; see the [diagnostics reference](/reference/diagnostics). `xeer doctor` is its counterpart for the machine rather than the code: Node version, platform support, whether the build dependencies resolve, local state. Run it when something works for a colleague and not for you. ## Local personas A deployed Xeer app gives every visitor a verified identity of its own — see [Auth](/guides/auth#door-2-your-app-s-users). Locally there is no identity service to talk to, so `xeer dev`, `xeer preview`, and `xeer test` mint **deterministic local personas** instead. `alice`, `bob`, and `guest` are three distinct application users with stable ids. That is what makes ownership and authorization something you can exercise on your laptop instead of hoping about in production. ```text appUserId under `xeer dev`: local:alice:1f0qk3 appUserId once deployed: xu_… (opaque, and different in every app) ``` The shapes differ visibly on purpose: a local id is never mistakable for a deployed one. ### Switching persona: two controls Mistaking the first for the second is the usual reason a persona switch appears to do nothing. **1. The live switch — the local sign-in route.** Takes effect immediately, and survives reloads: ```text http://127.0.0.1:5173/_xeer/auth/sign-in?persona=alice&returnTo=/ http://127.0.0.1:5173/_xeer/auth/sign-in?persona=bob&workspace=design&returnTo=/ ``` Paste it into the browser, or call it from your own UI: ```tsx import { signIn, signOut, useAuth } from '@impetik/xeer/client'; const { refresh } = useAuth(); signIn({ persona: 'bob' }); signIn({ persona: 'bob', workspaceIds: ['design'] }); void signOut().then(() => refresh()); ``` It sets an `httpOnly` cookie and redirects back. Because the selection is a cookie, **two browser profiles — or one normal and one private window — hold two personas against the same dev server at once.** That is the quickest way to watch per-user isolation and live updates actually happen. **2. The startup default — `xeer auth as`.** ```sh xeer auth as bob # writes .xeer/auth.local.json xeer auth as alice --workspace design # with workspace membership xeer auth as alice --workspace design,ops xeer auth clear # back to alice, no membership ``` This value is read when the dev server starts, and again when a server-code change restarts it. It is a **default only**: it never reaches a tab that has already chosen a persona, the cookie always wins, and `xeer test` ignores the file entirely because tests name their persona per call. Signing out clears the cookie, so the session falls back to this default.

xeer auth as has nothing to do with xeer auth login. login signs you in so you can deploy; as picks which pretend app user your local browser is. Two different doors — see Auth.

### Workspace membership locally A local persona carries the same `workspaceIds` a deployed assertion does, so `workspaceTable()` policies and `memberOf()` guards decide locally exactly as they will in production. Ids are the labels you declare — no derivation, so the same label means the same workspace on every machine — validated against the production charset (`A-Za-z0-9:_-`, 1–96 characters, at most 16 per persona). One rule worth internalising: **a persona holding membership is asserted as `kind: 'user'`; one holding none is a guest.** A deployed app only ever gives membership to a provisioned identity, so this keeps both `authenticated()` and `memberOf()` honest locally. Membership never changes `appUserId`, so joining a workspace does not orphan rows a persona already owns. `guest` is a third identity the sign-in route deliberately will not mint, and it can never be given membership. It exists so a test can prove an unclaimed visitor sees nothing Alice or Bob owns — a fail-closed control that cannot be accidentally upgraded. ### Personas are development-only, by construction The local identity adapter refuses any non-loopback origin, the local sign-in and sign-out routes exist only in the development identity mode, and a deployed app requires a cryptographically verified identity assertion for every single request. There is no flag that turns personas on in production, and `signIn({ persona })` is ignored there. ## Your local data Everything lives under a project-local `.xeer/`, which the scaffolded `.gitignore` excludes and which must stay excluded — it holds the plaintext values `xeer env pull` writes. Each mode has its own state, and they never mix: | Mode | Persists across restarts? | | --- | --- | | `xeer dev` | yes | | `xeer preview` | yes, separately from `dev` | | `xeer test` | no — fresh and discarded every run | ```sh xeer export --state dev . --out dev-backup.json # database records, as readable JSON xeer state reset . --state dev --confirm my-app # start clean (prints a recovery path) xeer import --state dev . dev-backup.json # put it back ``` That export-reset-import loop is what makes an **incompatible schema change survivable**: take the export first, then change the manifest. `import` validates the document against the schema currently in force and accepts it only when that schema is the export's own or a compatible successor, so it can never write rows your queries are not allowed to assume. Two things to know: `xeer state reset` clears stored objects along with the database for that mode, and `xeer export` carries **database records only** — stored objects are not in the document. Nothing in `.xeer/` is required to redeploy. Your app's identity is in the checked-in [`xeer.project.json`](/guides/project-structure#xeer-project-json-the-app-s-identity), so deleting the directory, or cloning a repository without it, is safe. ## Running the real artifact ```sh xeer build # produce a content-addressed artifact xeer preview # run that exact artifact ``` `xeer preview` verifies and runs the built artifact, **reading only the artifact's own contents and never your source**. It is the closest local approximation of production, and distinct from `xeer dev`, which recompiles from source on every change. Use it when you want to be sure what you are about to deploy actually works. ## Next - **[Testing](/guides/testing)** — the same personas, in a test runner. - **[Auth](/guides/auth)** — what identity looks like once deployed. - **[Deploy](/guides/deploy)** — shipping it. --- # Testing `xeer test` runs the tests you write for your own queries, mutations, and endpoints. There is no test framework to choose, no harness to build, and no mock database to keep in sync with the real one. ```sh xeer test # PASS/FAIL lines, then a summary; exit 1 on any failure xeer test --json # newline-delimited events, for an agent ``` ## What it does 1. Builds your project, exactly as `xeer build` would. 2. Type-checks `tests/**/*.test.ts` against the same generated contract your server and client see. A test that calls an operation you renamed is a compile error, not a runtime one. 3. Boots the verified build with **fresh, isolated state** that never touches your `xeer dev` or `xeer preview` data. 4. Runs every test, in registration order. 5. Tears the whole thing down. Your tests run against your real server, your real database, and your real authorization rules. Nothing is stubbed, because there is nothing to stub. ## The API Four imports. That is all of it. ```ts import { as, expect, expectFailure, test } from '@impetik/xeer/test'; ``` ### `test(name, run)` ```ts test('alice creates a note and reads it back', async () => { const alice = as('alice'); const created = await alice.mutation('notes.create', { text: 'Water the plants' }); const notes = await alice.query('notes.list', {}); expect(notes.map((note) => note.id)).toContain(created.id); }); ``` There is no `describe`, no `beforeEach`, no `.only`, and no mocking API.

State is shared across the tests in a run, not reset between them. There is one fresh database per xeer test invocation, and tests execute in registration order. Write tests that create the data they assert on, and do not assume an empty table.

### `as(persona)` — call as a specific user ```ts as('alice') as('bob') as('guest') as({ name: 'alice', workspaceIds: ['design'] }) ``` `alice`, `bob`, and `guest` are three **different application users** with stable ids. That is what makes authorization testable rather than assumable — and they are the same personas your browser uses under `xeer dev`. See [Local personas](/guides/local-development#local-personas). The client it returns has four methods: ```ts await alice.query('notes.list', {}); await alice.mutation('notes.create', { text: 'hello' }); await alice.endpoint('GET', '/api/status'); await alice.identity(); // { appUserId, kind, roles, workspaceIds } ``` `endpoint` takes an optional third argument: `{ json }` for a JSON body, `{ body }` for a raw one, `{ headers }` to add headers. It returns `{ status, ok, headers, body, json() }` — where `json()` is synchronous. Membership matters and is worth stating precisely: `as('alice')` is a guest with no workspaces, while `as({ name: 'alice', workspaceIds: ['design'] })` is the **same application user** asserted as a member of `design` — and therefore `kind: 'user'`. The id derives from the name alone, so ownership assertions stay valid whether or not membership is attached. `as({ name: 'guest', workspaceIds: [...] })` throws: `guest` is the fail-closed control and cannot be given membership. ### `expect(value)` — four matchers ```ts expect(value).toBe(expected); // Object.is expect(value).toEqual(expected); // deep: objects, arrays, Date, Uint8Array expect(value).toHaveLength(3); // string, array, Uint8Array, Set, Map expect(value).toContain(item); // string includes, or array membership (deep) ``` Each is also available negated as `.not.toBe(…)`, `.not.toEqual(…)`, and so on. That is the complete matcher set — there is no `toBeTruthy`, `toThrow`, `toMatchObject`, or `resolves`/`rejects`. Four matchers cover assertions about data, and everything else is an `if` and a thrown error. `toEqual` handles `Date` by time value and `Uint8Array` byte-wise, so comparing rows straight out of the database works without normalisation. ### `expectFailure(call)` — assert a refusal Pass the **un-awaited** promise: ```ts const refused = await expectFailure(bob.mutation('notes.remove', { id: note.id })); refused.status; // 400 refused.code; // 'operation_failed' refused.message; // 'Note not found.' refused.operation; // 'notes.remove' refused.persona; // 'bob' refused.errorId; // string | null ``` If the call *succeeds*, `expectFailure` fails the test — so "this should have been refused" cannot pass silently when you weaken a rule. A handler that threw, a table policy that filtered a row out, and an operation guard that denied the call all arrive as `operation_failed` with status 400. That is the same thing a real client would see, and it is why the assertions below are meaningful. ## Testing authorization This is the case worth writing carefully, because it is the one that costs you if it is wrong. ```ts import { as, expect, expectFailure, test } from '@impetik/xeer/test'; test('bob cannot read or remove a note owned by alice', async () => { const alice = as('alice'); const bob = as('bob'); const note = await alice.mutation('notes.create', { text: 'Only for alice' }); // Bob's list does not contain it. expect((await bob.query('notes.list', {})).map((row) => row.id)).not.toContain(note.id); // And he cannot delete it by guessing the id. const refused = await expectFailure(bob.mutation('notes.remove', { id: note.id })); expect(refused.code).toBe('operation_failed'); expect(refused.message).toContain('Note not found'); // Alice still has it — the refusal changed nothing. expect((await alice.query('notes.list', {})).map((row) => row.id)).toContain(note.id); }); ``` Three assertions, in the order that matters: not visible, not reachable by id, and unharmed by the attempt. The last one catches a class of bug where a refusal happens *after* a destructive write. For workspace rules, cover the matrix — member, other member, non-member — and note that a [policy filters while a guard refuses](/guides/auth#a-policy-filters-a-guard-refuses), so the two cases assert different things: ```ts test('two members of a workspace see each other rows', async () => { const alice = as({ name: 'alice', workspaceIds: ['design'] }); const bob = as({ name: 'bob', workspaceIds: ['design'] }); const card = await alice.mutation('cards.add', { title: 'Ship it', workspaceId: 'design' }); expect((await bob.query('board.cards', { workspaceId: 'design' })).map((row) => row.id)) .toContain(card.id); }); test('a non-member reads an empty board, and cannot write to it', async () => { const alice = as({ name: 'alice', workspaceIds: ['design'] }); const outsider = as({ name: 'bob', workspaceIds: ['ops'] }); await alice.mutation('cards.add', { title: 'Design only', workspaceId: 'design' }); // An unguarded read under workspaceTable() is filtered, not refused. expect(await outsider.query('board.cards', { workspaceId: 'design' })).toEqual([]); // The guarded write is refused. const refused = await expectFailure( outsider.mutation('cards.add', { title: 'Sneaky', workspaceId: 'design' }), ); expect(refused.code).toBe('operation_failed'); }); ``` Give each persona **one** session per test. `alice` and `bob` are the two names you have, so a test that needs both "bob in design" and "bob in ops" reads more clearly as two tests — which is also how the framework's own example suite is written. And prove the personas really are separate users, once, so the rest of the suite means what it says: ```ts test('every persona is a separate application user', async () => { const [alice, bob, guest] = await Promise.all([ as('alice').identity(), as('bob').identity(), as('guest').identity(), ]); expect(alice.appUserId).not.toBe(bob.appUserId); expect(bob.appUserId).not.toBe(guest.appUserId); expect(guest.appUserId).not.toBe(alice.appUserId); }); ``` `xeer new` scaffolds all of these, passing, into `tests/notes.test.ts`. ## Testing endpoints ```ts test('the status endpoint answers for the calling persona', async () => { await as('alice').mutation('notes.create', { text: 'Seen by the endpoint' }); const alice = await as('alice').endpoint('GET', '/api/status'); expect(alice.status).toBe(200); expect(alice.json()).toEqual({ ok: true, hasNote: true }); const guest = await as('guest').endpoint('GET', '/api/status'); expect(guest.json()).toEqual({ ok: true, hasNote: false }); }); ``` ## Under `--json` `xeer test --json` streams newline-delimited events rather than prose. A failure carries a stable code — `XE1904` for an assertion, `XE1905` for a thrown or refused call, `XE1906` for a timeout — plus the matcher, the expected and actual values, and the project-relative test location. See [Building with AI agents](/guides/agents). ## Local state, and getting it back `xeer test` state is created fresh and discarded, so it never collides with development. Your `xeer dev` data is separate and persistent, and you can move it around: ```sh xeer export --state dev . --out dev-backup.json # your dev database, as readable JSON xeer state reset . --state dev --confirm my-app # start clean xeer import --state dev . dev-backup.json # put it back ``` That is what makes an incompatible schema change during development survivable: export first, then change the manifest. ## Next - **[Auth](/guides/auth)** — the authorization rules these tests exercise. - **[Local development](/guides/local-development)** — the same personas in a browser. - **[Server functions and the database](/guides/server)** — what you are asserting on. - **[CLI reference](/reference/cli#xeer-test)** — `xeer test` options. --- # Environment variables and secrets Server code reads configuration from `ctx.env`. Values are stored per project and per environment, encrypted at rest, and injected into your deployed app. Nothing is baked into your build artifact, and nothing reaches the browser. ```sh xeer env set STRIPE_SECRET_KEY sk_live_… # production, by default printf %s "$SECRET" | xeer env set STRIPE_SECRET_KEY # read from stdin instead xeer env set API_BASE_URL http://localhost:4000 --environment dev xeer env ls # names and metadata; never values xeer env rm STRIPE_SECRET_KEY xeer env pull # write the dev values locally ``` ## Three environments | `--environment` | Used by | | --- | --- | | `prod` (default for `set`, `rm`, `ls`) | your production app | | `preview` | a [preview deployment](/guides/deploy#preview-a-change) | | `dev` (default for `pull`) | `xeer dev` and `xeer preview` on your machine | They are separate stores. A sandbox key in `dev` and `preview` and a live key in `prod` is the ordinary arrangement, and it is why [promoting a preview](/guides/deploy#promote-it) carries **code forward but never configuration** — a preview's own values can never reach production by being promoted. Every subcommand also accepts `--dir ` to target a project other than the current one, and `--json`. ## Reading a value ```ts import { defineServer, mutation } from '@impetik/xeer/server'; export default defineServer({ mutations: { 'billing.charge': mutation({ input: { amount: 'number' }, handler: async (ctx, input) => { const key = ctx.env.STRIPE_SECRET_KEY; if (!key) throw new Error('STRIPE_SECRET_KEY is not set for this environment.'); // … return { charged: input.amount }; }, }), }, }); ``` `ctx.env` is `Readonly>`. An unset variable is `undefined` — never the empty string — so a handler has to decide what to do about it rather than silently proceeding with `''`. It exposes this project's own values and nothing else; platform bindings are unreachable through it. ## Why a key cannot leak to the browser `ctx` exists only in the server graph, and the client graph **cannot import** `@impetik/xeer/server`. The compiler refuses it (`XE1202`), and refuses importing your server entrypoint from client code too (`XE1201`). There is no import path from a component to the code that can read a secret, so there is no bundle in which one can appear by mistake. See [the two zones](/guides/project-structure#the-two-zones-and-the-wall-between-them). ## Local development ```sh xeer env pull ``` Writes the `dev` values to `.xeer/env.dev.local.json` with `0600` permissions, and makes sure `.xeer/` is in your `.gitignore` first. `xeer dev` and `xeer preview` load that file and inject it exactly the way a deployment does, so `ctx.env` has the same shape locally and in production. Like a `.env` file, editing it takes effect on the next `xeer dev`. A value you only ever need locally can live in the `dev` store and nowhere else — there is no requirement to mirror names across environments. ## Deployed values need a redeploy Environment values are bindings on your deployed app, so **a running app keeps the values it was deployed with.** After changing a `prod` or `preview` value: ```sh xeer env set STRIPE_SECRET_KEY sk_live_… xeer deploy ``` `xeer env set` and `xeer env rm` say so, and a successful deploy reports how many values it injected — so you can tell that the change actually took effect rather than assuming it. A [rollback](/guides/deploy#roll-back) injects the values **as they are now**, not as they were when the artifact first shipped. Secrets get rotated for a reason; a rollback that restored withdrawn credentials would be a security regression dressed up as a repair. ## Values are write-only `xeer env ls` shows names, environments, sizes, and timestamps. It does not show values, and neither does anything else except `xeer env pull` — which writes them to a `0600` file on your machine and is audited. ```text ENVIRONMENT NAME BYTES UPDATED production STRIPE_SECRET_KEY 107 2026-07-25T09:14:02.118Z dev API_BASE_URL 27 2026-07-24T16:02:55.004Z ``` There is a browser dashboard for the same store, signed in with the account you use for `xeer auth login`. It can add and replace a value and can never display one, because it only ever reads the same metadata listing shown above. Every change there goes through the same endpoints the CLI calls, lands in the same audit trail, and needs the same `xeer deploy` to reach a running app. ## Rules and limits | | | | --- | --- | | Name | `UPPER_SNAKE_CASE`: an uppercase letter, then up to 62 uppercase letters, digits, or underscores | | Reserved | names beginning `XEER_` — the platform's own binding namespace | | Value size | up to 4096 bytes | | Count | up to 64 variables per environment, per project | A name or value outside these is refused when you set it, with the rule in the message. ## Next - **[Deploy, preview, promote, roll back](/guides/deploy)** — when values are injected. - **[Server functions and the database](/guides/server#ctx)** — the rest of `ctx`. - **[CLI reference](/reference/cli#xeer-env)** — every `xeer env` option. --- # Deploy, preview, promote, roll back ```sh xeer auth login # once xeer deploy # every time ``` That is the whole path to production. What follows is the rest of the toolkit: shipping to a side-by-side URL first, promoting the exact bundle you looked at, putting a previous version back, and taking an app offline.

Deploying needs a Xeer account, and accounts are currently by invitation. Everything local — xeer new, xeer dev, xeer test, xeer build, xeer preview — works with no account at all. See the FAQ.

## Sign in ```sh xeer auth login # opens a browser, prints a confirmation code xeer auth status # who you are, and when the credential expires xeer auth logout # revoke it, and delete it locally ``` The CLI prints a verification URL and a confirmation code, then opens your browser (`--no-open` leaves you to open it yourself). **Approve only if the browser shows that exact code** — approval never happens on a plain page load. The credential is written to `~/.xeer/credentials/cli.json` with `0600` permissions. This signs **you** in, not your app's users — they need no Xeer account at all. `xeer auth as` and `xeer auth clear` are a third thing again: the local development [persona](/guides/local-development#local-personas). See [Auth](/guides/auth) for the two doors. `xeer auth status` reports which control plane your stored credential is for, and says so explicitly when it is not the default. ## Deploy ```sh xeer deploy [directory] [--environment prod|preview] [--json] ``` The bare command targets **production**, and always has. What happens: your project is compiled to a content-addressed artifact, that artifact is verified, and it is uploaded. Then the CLI waits for the deployed app and checks it — platform health, your assets, and one authenticated request. **Only a verified authenticated request counts as success.** An app that serves static files but cannot answer a real request fails the deploy with `XE5104` rather than being reported as working. On success you get your application's name and its URL, of the form `https://xa-….xeer.run`. That URL is live on a global edge network, serving your client and your server, with its own database and real sign-in. Your app's identity comes from the checked-in [`xeer.project.json`](/guides/project-structure#xeer-project-json-the-app-s-identity), so deploying from a fresh clone updates the app you already have. Deploying an `appId` owned by someone else is refused (`XE5111`) — a clone of another person's project cannot silently fork or hijack it. Deployed apps require a cryptographically verified identity assertion for every request, which is why the Alice and Bob [personas](/guides/local-development#local-personas) exist only under `xeer dev`. Your deployed app gives every real visitor an identity of their own instead — see [Auth](/guides/auth). ## Look at a build before you ship it ```sh xeer build # produce the artifact xeer preview # run that exact artifact locally ``` `xeer preview` verifies and runs the built artifact, **reading only the artifact's own contents and never your source**. It is the closest local approximation of production, and it is distinct from `xeer dev`, which recompiles from source on every change. ## Preview a change ```sh xeer deploy --environment preview ``` A preview deployment is a **second, separate app instance at its own URL**. Production keeps serving exactly what it was serving. The preview gets the values you stored with `xeer env set --environment preview`. Nothing your users can reach changes until you promote. Two things to know: - **Preview data is separate from production's, and disposable.** It cannot read or corrupt production's records — and equally, it starts empty and is replaced by your next preview deploy. Do not treat it as a staging copy of your data. - **A preview deploy counts against your deploy allowance** — it is a real deployment. Having a preview slot does not count as a second app, though: the apps-per-account limit counts projects. `xeer rollback … --environment preview` rolls the preview slot back independently. ## Promote it ```sh xeer promote # make what preview is serving live xeer promote my-app --from-artifact sha256:… # or a specific version ``` Promotion makes the artifact your preview proved live on production. It is **the same bundle, not a rebuild**: every deployed payload is retained under its content address, so promoting reads that exact bundle back and activates it. The content address you looked at is the content address your users get. Three things worth knowing: - **The environment values are production's, not preview's.** A promotion carries code forward, never configuration, so a sandbox key in preview cannot end up in front of real users. The result says so. - **It is a production deployment.** It records a deployment, uploads, and counts against your allowance. - **The readiness gate is the deploy gate**, run against production: health, assets, and one authenticated request. A promotion that serves assets but fails authenticated requests fails with `XE5104`. An app with nothing on preview yet is refused with `XE5175`, naming the command to run first. ## Deployment history ```sh xeer deployments # the app this directory is linked to xeer deployments my-app # by name, appId, or deployed URL xeer deployments --limit 10 --json ``` ```text ENVIRONMENT STATUS CREATED ARTIFACT * production ready 2026-07-25T09:41:12.883Z sha256:9f2c… preview ready 2026-07-25T09:38:44.201Z sha256:9f2c… production failed 2026-07-24T18:02:10.664Z sha256:41ab… ``` A `*` marks the deployment currently serving each environment. There is no separate "active" pointer to consult: **the history is the record**, and active means the latest *completed* deployment for that environment. A failed or still-pending attempt is listed but never marked, even when it is the newest thing that happened. A `rollback` or `promote` note under a row says the deployment replayed a retained artifact and which one it displaced. The platform answers only the account that owns the app. An unknown app, someone else's app, and a deleted app are the **same refusal** (`XE5143`), so this command never confirms whether an app you do not own exists. ## Roll back ```sh xeer deployments my-app # copy an id from the ARTIFACT column xeer rollback my-app sha256:… # put that version back on production xeer rollback sha256:… # the app this directory is linked to ``` Rollback puts a previous version back online by **redeploying an artifact the app already deployed**. The retained bundle is read back and pushed through the same activation `xeer deploy` uses — so it is verified the same way, and a rollback that cannot answer an authenticated request fails (`XE5104`) rather than being called a success. A rollback is a **new deployment, not an edit of history**. After rolling v2 back to v1 you have three rows, the newest being v1, marked live and annotated with what it replaced. Nothing is rewritten. - **The environment values injected are the current ones**, not the ones the artifact first shipped with. - **It counts against your deploy allowance**, because it is a deployment. An artifact this app never successfully deployed — a typo, another project's, or one whose deployment failed — is refused with `XE5164` pointing back at `xeer deployments`. ## Take an app offline ```sh xeer disable # the app this directory is linked to xeer disable my-app xeer enable my-app ``` `xeer disable` switches an app off at the edge: requests get `410 Gone` on the very next request, and nothing else changes — the app, its data, its environment values, and its URL all stay exactly as they are. `xeer enable` puts it back. Both are idempotent, and both keep working when something else is broken — a broken app is precisely when you need to take it offline. A disabled app still appears in `xeer link` marked `(disabled)`, still lists its history, and can still be deployed to, so you can ship a fix before switching it back on. Disabling covers the preview slot too. ## Delete an app ```sh xeer delete my-app --confirm my-app ``` Permanent. It deletes the deployed app and its preview slot, **destroying their stored data irrecoverably**, removes every URL mapping, and retires the `appId` for good so it can never be deployed again. Because the `appId` is checked into `xeer.project.json`, a later `xeer deploy` from that checkout is refused with a repair hint rather than resurrecting a dead app; `xeer link --new` mints a fresh identity for the checkout. The confirmation is the application's exact name. Without `--confirm` the CLI refuses locally (`XE5150`) before it even reads your credential; with the wrong name the platform refuses it (`XE5151`) having changed nothing. ## Moving data in and out ```sh xeer export my-app --out backup.json # a deployed app you own xeer export --state dev . --out dev-backup.json # your local dev database xeer import --state dev . dev-backup.json # put it back locally ``` The file is a readable, diffable document: the schema that produced the rows plus every record. `import` **replaces** state wholesale, in one transaction, and validates the document against the schema currently in force first — accepting it only when that schema is the export's own or a compatible successor. There is no `--force`, because writing rows your schema does not describe would break what your own queries are allowed to assume. `xeer import` writes **local** dev and preview state only. To restore a deployed app, import locally, verify, and redeploy. ## Everything at a glance | Command | Changes what users see? | Counts as a deploy? | | --- | --- | --- | | `xeer deploy` | yes | yes | | `xeer deploy --environment preview` | no | yes | | `xeer promote` | yes | yes | | `xeer rollback` | yes | yes | | `xeer disable` / `xeer enable` | yes, immediately | no | | `xeer delete` | yes, permanently | no | | `xeer env set` | not until the next deploy | no | ## Next - **[Environment variables and secrets](/guides/env)** — what gets injected, and when. - **[CLI reference](/reference/cli)** — every command and option. - **[Diagnostics reference](/reference/diagnostics)** — every code mentioned above. --- # Building with AI agents Xeer is designed so that a program can drive it as competently as a person. That is not a compatibility layer bolted onto a human-first tool — it shaped the framework. It is why the app's entire shape lives in one declarative file, why the API surface is small enough to describe exhaustively, and why every failure carries a stable code and a suggested repair instead of a paragraph of prose. If you are pairing with an agent, this page is what to give it. ## Every command speaks JSON ```sh xeer check --json xeer build --json xeer deploy --json ``` `check`, `build`, `new`, `doctor`, `deploy`, `promote`, `rollback`, `link`, `env`, `deployments`, and the rest print exactly one envelope: ```json { "protocol": "xeer.command.v0", "command": "check", "ok": false, "diagnostics": [ { "code": "XE1002", "severity": "error", "message": "…", "file": "xeer.app.json", "span": { "line": 12, "column": 9, "length": 6 }, "hint": "…" } ] } ``` `dev`, `preview`, and `test` are long-lived, so they stream newline-delimited `xeer.dev.v0` events instead. `preview.ready` is the readiness handshake — an agent waits for that rather than sleeping and hoping. `test.case.fail` carries a `Diagnostic`-shaped failure. ## Diagnostics are the interface ```ts interface Diagnostic { code: string; // XE####, stable severity: 'error' | 'warning' | 'info'; message: string; // human wording, free to change file?: string; // project-relative POSIX path, never absolute span?: { line: number; column: number; length?: number }; // 1-based hint?: string; // suggested edit } ``` **Dispatch on `code`, never on `message`.** Codes are stable; wording is not. `file` and `span` locate the problem precisely enough to edit without searching, and `hint` is the platform's own suggestion for closing it. Every code is catalogued in the [diagnostics reference](/reference/diagnostics) — which is generated from the same catalogue the compiler emits from, so it cannot fall behind the codes you actually see. `xeer doctor --json` is the other half: it reports the environment (Node version, platform, resolvable dependencies, generated config, local state) as structured checks, which is how an agent distinguishes "your code is wrong" from "this machine is not set up". ## The MCP server [`@impetik/xeer-mcp`](https://www.npmjs.com/package/@impetik/xeer-mcp) exposes the whole loop as [Model Context Protocol](https://modelcontextprotocol.io) tools: ```jsonc // .mcp.json, or your harness's MCP configuration { "mcpServers": { "xeer": { "command": "npx", "args": ["--package=@impetik/xeer-mcp", "--", "xeer-mcp"], "env": { "XEER_MCP_ROOT": "." } } } } ``` | Tool | Does | | --- | --- | | `xeer_new` | Scaffold a project, optionally from a named `template` | | `xeer_check` | Validate the manifest and analyse the source | | `xeer_test` | Run the application's own test suite, with the full event stream | | `xeer_build` | Produce the content-addressed artifact | | `xeer_doctor` | Diagnose the local environment | | `xeer_deploy` | Deploy to production, or to the preview URL | | `xeer_promote` | Make a preview version live | | `xeer_auth_status` | Who the CLI is signed in as | | `xeer_inspect` | Read a running app's manifest, state, or logs | | `xeer_dev_start` / `_status` / `_stop` | A dev session, addressed by handle and resumed by cursor | | `xeer_diagnostics` | What an `XE####` code means, and the edit that closes it | Every tool returns the CLI's envelope unchanged, as both structured content and a JSON text block, so a client that ignores structured output still sees all of it. When the CLI crashes without printing an envelope the server synthesizes `XE0000` rather than failing the call in a different shape — the result of a tool call is always the same kind of thing. **`xeer link` and `xeer env` are deliberately absent.** Which hosted app a checkout deploys to is the owner's decision, so on an identity conflict the surface reports the diagnostic and its documented repair rather than performing it. `xeer env` reads and writes secrets — `xeer env pull` returns plaintext — so it is not reachable through a tool call at all. `XEER_MCP_ROOT` is a confinement root: a `directory` argument that escapes it is refused. ### Why `dev` is three tools A tool call is request/response; `xeer dev` is a long-lived stream. So the session is owned by the server and addressed by a `sessionId`, with the protocol's own sequence number as a resumable cursor: ```text xeer_dev_start → { sessionId, cursor, status: 'ready', preview: { url, … }, events } edit a file xeer_dev_status → { events: [compile.start, compile.diagnostic?, compile.ready?], openDiagnostics } xeer_dev_stop → { status: 'stopped' } ``` No event is summarised away — the agent reads the same stream a terminal would show. Repairing diagnostics needs none of this, because `xeer_check` runs the same compiler; the session exists for when the running app matters. Always stop a session you started: local state is leased per project, so a leaked session makes the next run fail with `XE1812`. ## The loop that works ```text xeer new → edit → xeer check → edit → xeer test → xeer build → xeer deploy ↑___________| ↑____________| ``` Two things make this reliable rather than hopeful: - **`xeer check` is fast and complete.** It validates the manifest and analyses both source graphs without running anything, so the edit-check loop is tight and every failure is located. - **`xeer test` proves behaviour, not compilation.** An agent that only checks has proved the code parses. An agent that tests has proved the app does what the tests say — including who is allowed to do what, because [tests run as three different users](/guides/testing). The step most worth insisting on is the second one. Xeer ships a test runner precisely so that "it compiles" is never mistaken for "it works", and an agent with no human reviewing every diff needs that distinction more than a person does. ## Why the framework's shape helps - **One declarative manifest.** An agent can read what an app *is* — its tables, its indexes, its [capabilities](/guides/capabilities) — without inferring it from code. And it can change what an app is by editing one file, which the compiler then validates. - **Deny by default.** `ctx` has three properties. There is no ambient resource an agent can reach for by accident, and no credential lying around to misuse. - **A closed, small API.** Seven database methods, six input types, four matchers. It fits in a context window, which means an agent is choosing between options it can actually enumerate rather than guessing at an API surface. - **Authorization in the data layer.** A [table policy](/guides/auth#table-policies) holds for every read and write of that table, so a handler an agent wrote cannot leak rows by forgetting a filter. - **Content-addressed builds.** The same source produces the same artifact id, and [promote and rollback](/guides/deploy) act on that id — so "ship exactly what was reviewed" is mechanical rather than a matter of trust. ## These docs, as plain text This site is not the only form the documentation comes in. Every page is also served as Markdown, so an agent never has to scrape HTML to read it: | | | | --- | --- | | [`/llms.txt`](/llms.txt) | The index: what Xeer is, and every page with its purpose and its Markdown URL. | | [`/llms-full.txt`](/llms-full.txt) | The entire documentation in one request. | | `.md` | Any page on its own — [`/guides/server.md`](/guides/server.md), [`/reference/cli.md`](/reference/cli.md), and so on. | The diagnostics reference in there is generated from the compiler's own catalogue, so the codes an agent reads cannot drift from the codes the platform emits. ## For Claude Code specifically The [repository](https://github.com/impetik/xeer) ships a skill under `.claude/skills/xeer/`, including the generated diagnostics catalogue. Copy it into your project's `.claude/skills/` or into `~/.claude/skills/` to have it load automatically. ## Next - **[Diagnostics reference](/reference/diagnostics)** — every code, what it means, how to repair it. - **[CLI reference](/reference/cli)** — every command and its `--json` shape. - **[Testing](/guides/testing)** — the step to insist on. --- # CLI reference ```sh npx @impetik/xeer --help npx @impetik/xeer --version ``` Inside a project that has run `npm install`, `npx xeer ` resolves the local copy, and the scaffolded `npm run dev` / `check` / `build` / `test` scripts work too. Prefer it on your `PATH`? `npm install --global @impetik/xeer`, and drop the `npx` from every command on this page. Every command accepts `--json`, which prints a single structured envelope instead of prose (or, for the long-running commands, a stream of newline-delimited events). See [Building with AI agents](/guides/agents). Where a command takes `[directory]`, it defaults to the current directory. Where it takes `[app|directory]`, you may name a deployed app by its **name**, its **appId**, or its **URL** — or omit it, in which case the app the current directory is linked to is used. ## Global flags | Flag | | | --- | --- | | `--json` | Structured output. | | `--version`, `-v` | Print the installed version. | | `--help`, `-h` | Print the command list. | | `--control-url ` | Talk to a different Xeer control plane. Must be HTTPS except on loopback. `XEER_CONTROL_PLANE_URL` does the same. | ## Build and check ### `xeer new` ```sh xeer new [--template notes|todo|blog|personal-site] [--json] ``` Scaffolds a complete working app: manifest, project identity, typed server, Preact client, styles, a favicon, and a passing test suite. The target directory must be empty or nonexistent. The project name is derived from the directory name, lowercased and slugified. `--template` defaults to `notes`: | Template | | | --- | --- | | `notes` | Per-user notes: one table, create and delete, ownership proven in tests. | | `todo` | A per-user task list: add, tick off, delete, and an open count read through an index. | | `blog` | Posts anyone can read and only their author can delete, with a list and a read view. | | `personal-site` | A few static pages from one shared content module, and no database at all. | Every template checks, tests, and builds clean, and **none of them scaffolds sign-in UI** — every visitor already has a verified identity, and the scaffolded README explains the opt-in path to a named account. See [Auth](/guides/auth). With `--json`, `result.template` reports which one was written. ### `xeer check` ```sh xeer check [directory] [--json] ``` Validates `xeer.app.json` and analyses both source graphs — entrypoints resolve, the server export is statically readable, no client module imports server code, routes do not collide. Runs nothing. This is the fast inner-loop command; exit 0 means the project is well-formed. ### `xeer build` ```sh xeer build [directory] [--json] ``` Compiles a content-addressed build artifact — client bundle, server bundle, assets, and a manifest — into `.xeer/build//`, then verifies it. An artifact the verification refuses is never reported as a successful build. Prints the `artifactId` (a `sha256:…` value); the same source and manifest always produce the same id. ### `xeer doctor` ```sh xeer doctor [directory] [--json] ``` Diagnoses your local environment rather than your code: Node version, platform support, whether the build dependencies resolve, generated configuration, and local state. Reports each check as pass/warn/fail/skip. Run this first when something works on one machine and not another. ## Run locally ### `xeer dev` ```sh xeer dev [directory] [--host ] [--port ] [--json] ``` Starts a development server with your client and server running together. Client edits hot-reload; server edits recompile and restart behind a health check with automatic rollback if the new version does not come up. Defaults to `127.0.0.1` and a free port — pass `--port` to pin it. Uses [local personas](/guides/local-development#local-personas) instead of real sign-in, and a local database that persists between runs. ### `xeer preview` ```sh xeer preview [directory] [--host ] [--port ] [--json] ``` Verifies and runs the artifact `xeer build` produced, **reading only the artifact's own contents and never your source**. The closest local approximation of production. Its state is separate from `xeer dev`'s. ### `xeer test` ```sh xeer test [directory] [--host ] [--json] ``` Builds the project, type-checks `tests/**/*.test.ts` against the generated contract, boots the verified artifact with fresh isolated state, runs every test in registration order, and tears down. Exit 1 on any failure. See [Testing](/guides/testing). ## Sign in These sign **you** in, so that you can deploy. They have nothing to do with your application's own users — see [Auth](/guides/auth). ### `xeer auth login` ```sh xeer auth login [--control-url ] [--no-open] [--json] ``` Browser-approved sign-in. Prints a verification URL and a confirmation code, then opens your browser (`--no-open` leaves that to you) and polls until you approve or the request expires. **Approve only if the browser shows that exact code.** The credential is written to `~/.xeer/credentials/cli.json` with `0600` permissions; `XEER_CONFIG_HOME` overrides the directory. ### `xeer auth status` ```sh xeer auth status [--json] ``` Verifies the stored credential and prints who you are signed in as, when it expires, and which control plane it is for: ```text Signed in as you@example.com. Credential expires 2026-08-24T09:41:12.883Z. Control plane: https://control.xeer.run ``` When the stored credential pins somewhere other than the default, the last line says so — which is how you find out why a deploy went somewhere unexpected: ```text Control plane: https://staging-control.xeer.run (non-default) ``` `xeer deploy` prints the same warning to stderr before it builds. Fails with `XE5002` when you are not signed in. ### `xeer auth logout` ```sh xeer auth logout [--json] ``` Revokes the credential at the control plane and deletes the local file. ### `xeer auth as` / `xeer auth clear` ```sh xeer auth as [directory] [--workspace ]… [--json] xeer auth clear [directory] [--json] ``` **Unrelated to signing in.** These select the **local development persona** — a pretend app user — `xeer dev` starts with, stored per project in `.xeer/auth.local.json`. They touch no account and no credential. `--workspace` is repeatable and each value may be comma-separated. It is a startup default only: a browser tab that has already chosen a persona keeps it, and `xeer test` ignores the file entirely because tests name their persona per call. See [Local personas](/guides/local-development#local-personas). ## Ship ### `xeer deploy` ```sh xeer deploy [directory] [--environment prod|preview] [--control-url ] [--json] ``` Builds, verifies, and deploys. Targets **production** unless you pass `--environment preview`, which deploys to a side-by-side URL with its own separate, disposable data. Waits for the deployed app and confirms platform health, assets, and one authenticated request before reporting success — a deploy that serves assets but fails authenticated requests fails with `XE5104`. `--environment` accepts `prod`, `production`, or `preview`. Reports how many environment values it injected. ### `xeer promote` ```sh xeer promote [app|directory] [--from-artifact ] [--control-url ] [--json] ``` Makes what preview is serving live on production — **the same bundle, not a rebuild** — using production's own environment values. `--from-artifact` promotes a specific retained version instead. Refused with `XE5175` when the app has nothing on preview yet. ### `xeer rollback` ```sh xeer rollback [app|directory] [--environment prod|preview] [--control-url ] [--json] ``` Redeploys an artifact the app already deployed successfully. Take the id from the `ARTIFACT` column of `xeer deployments`. Records a **new** deployment rather than rewriting history, and injects environment values **as they are now**, not as they were. Either argument order works — an artifact id is recognisable on sight. Refused with `XE5164` for an artifact this app never deployed. ### `xeer deployments` ```sh xeer deployments [app|directory] [--limit ] [--control-url ] [--json] ``` Deployment history for one app, newest first: environment, status (`pending`, `ready`, `failed`), timestamp, failure reason, and the full `artifactId`. A `*` marks the deployment currently serving each environment — the latest *completed* one. `--limit` takes a positive integer up to 200. ### `xeer link` ```sh xeer link [directory] [--app ] [--new] [--control-url ] [--json] ``` With no arguments, lists the apps you own. `--app` points this directory at one of them by writing `xeer.project.json` — **commit it**. `--new` deliberately forks the checkout to a brand-new app identity. `xeer deploy` never invents an identity when `xeer.project.json` is present but unusable; it fails with a hint pointing here. ### `xeer disable` / `xeer enable` ```sh xeer disable [app|directory] [--control-url ] [--json] xeer enable [app|directory] [--control-url ] [--json] ``` Takes an app offline immediately and reversibly: requests get `410 Gone` while the app, its data, and its environment values stay exactly as they are. Both are idempotent, and both keep working when the app itself is broken. ### `xeer delete` ```sh xeer delete [app|directory] --confirm [--control-url ] [--json] ``` Permanent. Destroys the deployed app and its preview slot along with **every byte of their stored data**, removes its URLs, and retires the `appId` for good. `--confirm` must be the application's exact name; without it the CLI refuses locally (`XE5150`) before reading your credential. ## Inspect ### `xeer inspect` / `xeer state` / `xeer logs` ```sh xeer inspect [--control-url ] [--json] xeer state [--control-url ] [--json] xeer logs [--after ] [--control-url ] [--json] ``` `inspect` reads the running app's manifest, `state` its stored records, `logs` its recent log entries (`--after` takes a cursor from a previous call to page forward). A local `xeer dev` or `xeer preview` URL is read directly. A **deployed** app — named by name, appId, or URL — is read through the control plane on your credential: a deployed inspector answers nobody else. ### `xeer state reset` ```sh xeer state reset [directory] --state --confirm [--json] ``` Clears local state for one mode. `--confirm` must be the exact application name. Prints a recovery path. Take an [export](#xeer-export-xeer-import) first if the data matters. ## Move data ### `xeer export` / `xeer import` ```sh xeer export [--out ] [--control-url ] [--json] xeer export --state [directory] [--out ] [--json] xeer import [--json] xeer import --state [directory] [--json] ``` `--state dev|preview` reads or writes the state of the `xeer dev` / `xeer preview` server running in that directory — no URL needed. Any other target is a deployed app, read on your credential. Without `--out`, the document goes to stdout. The file carries the schema that produced the rows plus every record, so it is readable and diffable on its own. `import` **replaces** state wholesale in one transaction, and only when the document's schema is the one currently in force or a compatible predecessor of it. There is no `--force`. `import` writes **local** dev and preview state only. `xeer test` state cannot be exported or imported — it is discarded after every run. ## Environment values ### `xeer env` ```sh xeer env set [value] [--environment prod|preview|dev] [--dir ] [--json] xeer env ls [--environment prod|preview|dev] [--dir ] [--json] xeer env rm [--environment prod|preview|dev] [--dir ] [--json] xeer env pull [--environment prod|preview|dev] [--dir ] [--json] ``` `set`, `rm`, and `ls` default to `prod`; `pull` defaults to `dev`. `--environment` accepts `prod`, `production`, `preview`, `dev`, or `development`. Omit the value on `set` to read it from stdin. `ls` shows names, sizes, and timestamps — never values. `pull` writes the values for one environment to `.xeer/env.dev.local.json` with `0600` permissions, adding `.xeer/` to `.gitignore` first. `env` uses `--dir` rather than a positional directory, because `set NAME [value]` already has an optional trailing argument and a value is indistinguishable from a path. Full details, including the naming rules and limits: [Environment variables and secrets](/guides/env). ## Environment variables the CLI reads | Variable | | | --- | --- | | `XEER_CONTROL_PLANE_URL` | Default control plane origin, same as `--control-url`. | | `XEER_CONFIG_HOME` | Where the CLI stores your credential. Defaults to `~/.xeer`. | ## Exit codes | Code | | | --- | --- | | `0` | Success. | | `1` | The operation failed — a diagnostic explains why. | | `2` | You asked wrongly: a missing argument, an unknown flag, an unusable value. | The split matters for scripting: exit 2 means "fix the command", exit 1 means "the command was fine and the platform refused or the project is broken". ## Requirements - **Node.js 22.12 or newer.** The CLI checks at startup and prints a clear error otherwise. - npm 11 and newer block native install scripts by default. If `xeer dev` or `xeer build` cannot find its runtime, run `npm approve-scripts esbuild workerd` in your project — or install globally with `npm install -g --allow-scripts=workerd,esbuild @impetik/xeer`. - `xeer doctor` reports on both. ## Next - **[Diagnostics reference](/reference/diagnostics)** — every `XE####` code. - **[Manifest reference](/reference/manifest)** — every `xeer.app.json` field. - **[Deploy, preview, promote, roll back](/guides/deploy)** — the shipping commands in context. --- # Manifest reference `xeer.app.json` is the whole shape of your app in one file. `xeer check` validates it and reports any problem with a file, a line, and a suggested repair. **Unknown keys are errors, everywhere.** Every object in this document is closed, so a misspelled field name is a failed build rather than a silently ignored setting. That is what makes the manifest trustworthy as a description of an app. ```json { "$schema": "https://spec.xeer.dev/application-v0.schema.json", "format": "xeer.application-source.v0", "name": "team-board", "entrypoints": { "client": "src/client.tsx", "server": "src/server.ts" }, "app": { "spa": true, "title": "Team Board", "language": "en", "description": "A shared board for a small team.", "favicon": "/favicon.svg" }, "database": { "version": 1, "tables": { "cards": { "fields": { "title": { "type": "string", "maxLength": 200 }, "status": { "type": "string", "maxLength": 32 }, "workspaceId": { "type": "string", "maxLength": 96 }, "dueAt": { "type": "datetime", "optional": true } }, "indexes": { "by_workspace": ["workspaceId"], "by_workspace_status": ["workspaceId", "status"] } } } }, "capabilities": ["database"], "budgets": { "queryRows": 500, "liveConnections": 200 } } ``` ## Top level | Field | Required | Rule | | --- | --- | --- | | `format` | yes | Exactly `"xeer.application-source.v0"`. | | `name` | yes | `^[a-z][a-z0-9-]{1,62}$` — a lowercase slug, 2 to 63 characters, starting with a letter. | | `entrypoints` | yes | `{ client, server }`, both required. | | `$schema` | no | Any valid URL. An editor hint; it is not fetched and does not affect validation. | | `app` | no | Document metadata. [Below](#app). | | `database` | no | Tables and indexes. [Below](#database). | | `storage` | no | Object-store limits. [Below](#storage). | | `capabilities` | no | Declared powers. [Below](#capabilities). | | `budgets` | no | Resource ceilings. [Below](#budgets). | There is no `version`, `runtime`, `env`, `auth`, `routes`, or `assets` field. Routes are declared in your server code; environment values live [outside the repository](/guides/env); assets are whatever is in `public/`. ### `entrypoints` ```json "entrypoints": { "client": "src/client.tsx", "server": "src/server.ts" } ``` Paths are relative to the manifest and must use forward slashes. Each must exist, must be a file, and must resolve **inside** the project root — a path that escapes it, including by symlink, is refused (`XE1101`). See [the two zones](/guides/project-structure#the-two-zones-and-the-wall-between-them) for what each side may import. ## `app` All optional. Controls the generated HTML document. | Field | Type | Default | Rule | | --- | --- | --- | --- | | `spa` | boolean | `true` | Serve the app shell for unmatched paths. | | `title` | string | the app's `name` | 1–120 characters, no leading or trailing whitespace, no control characters. | | `language` | string | `"en"` | A constrained BCP 47 tag: `^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$`, up to 35 characters. | | `description` | string | `""` | Up to 300 characters, no control characters. | | `favicon` | string | none | A root-relative path into `public/`, 2–256 characters, starting with `/`. No `//`, no `.` or `..` segments. | ## `database` Present when — and only when — `capabilities` includes `"database"`. See [Capabilities](/guides/capabilities#database). | Field | Required | Rule | | --- | --- | --- | | `tables` | yes | A record of table name to table definition. | | `version` | no | A positive integer, default `1`. Sequences [schema changes](#schema-changes); excluded from the structural schema hash. | ### Tables ```json "cards": { "fields": { "title": { "type": "string", "maxLength": 200 } }, "indexes": { "by_title": ["title"] } } ``` Table names, field names, index names, and the field names inside an index are all **identifiers**: `^[A-Za-z][A-Za-z0-9_]*$`. No dashes, no leading digit. A table has exactly two keys, `fields` and `indexes`; there is nothing else to configure — per-user and per-workspace scoping is declared in [server code](/guides/auth#table-policies), not here. ### Fields ```json { "type": "string", "optional": true, "maxLength": 200 } ``` | Key | Required | | | --- | --- | --- | | `type` | yes | One of `string`, `number`, `boolean`, `datetime`, `bytes`, `json`. | | `optional` | no | `true` makes the field nullable. Fields are **required** by default. | | `maxLength` | no | A positive integer. Valid **only** on `string` and `bytes`. | Those three keys are all a field has. There is no `default`, no `unique`, no foreign key or relation, no `min`, and no enum — validate anything else in your handler, where the rule is visible and testable. Field types map to TypeScript as `string`, `number`, `boolean`, `Date`, `Uint8Array`, and a JSON-shaped value respectively. ### Reserved fields Every row automatically has: ```ts { id: string; createdAt: Date; updatedAt: Date } ``` `id` is minted by the runtime on insert; both timestamps are set for you and `updatedAt` is maintained. **Declaring any of the three is an error**, and writing to them is refused at runtime. ### Indexes ```json "indexes": { "by_workspace_status": ["workspaceId", "status"] } ``` Each index is an ordered list of at least one declared field. A field that does not exist on the table, or appears twice in one index, is an error. Indexes are the **query surface**, not merely an optimisation. A `where` filter must match `{ id }` or a declared *prefix* of a declared index. Given the index above you may filter by `{ workspaceId }` or by `{ workspaceId, status }` — and by nothing else. Anything wider is rejected by the generated types and refused at runtime. See [the table API](/guides/server#filtering). There is no cap on the number of tables, fields, or indexes. ### Schema changes Changing the manifest changes your app's schema. On deploy, a **compatible** change is applied: a new table, a new optional field, a widened `maxLength`, a required field made optional. An incompatible one is refused, with both schema identities and the exact difference — rather than being applied and hoped for. Locally, `xeer export` and `xeer import` make an incompatible change survivable: export first, change the manifest, then import into the new schema if it is a compatible successor. There is no forced import. ## `storage` Present when — and only when — `capabilities` includes `"storage"`. Write `{}` for the platform defaults. All three fields are optional, and each may be **lowered but never raised** past its ceiling — a larger value is a build error, not a silent clamp. | Field | Default | Maximum | | --- | --- | --- | | `maxObjectBytes` | 25 MiB (`26214400`) | 25 MiB — already the ceiling, so it can only be lowered | | `readBytes` | 32 MiB (`33554432`) | 128 MiB (`134217728`) | | `writeBytes` | 32 MiB (`33554432`) | 128 MiB (`134217728`) | `readBytes` and `writeBytes` are per handler invocation and reset on every call. There is nothing else to configure: no bucket name, no region, no credential, no tenancy parameter. See [Storage](/guides/storage#declaring-it) for the `ctx.storage` surface, the object-key rules, and how uploads work. ## `capabilities` ```json "capabilities": ["database", "storage"] ``` An array of the powers this app is allowed to use. The values are `"database"` and `"storage"`, and each must be present **exactly when** its config block is — declaring one without the other is an error in either direction, and naming the same capability twice is an error too. An undeclared capability does not appear on `ctx` at all, so reaching for it is a compile error (`XE1205`) rather than a runtime surprise. See [Capabilities](/guides/capabilities). ## `budgets` Ceilings the runtime enforces. All optional; each has a default and a maximum. | Field | Default | Maximum | | | --- | --- | --- | --- | | `queryRows` | 1000 | 10000 | Rows one query may read. `find`'s `limit` must fall within it; `all()` is bounded by it. | | `mutationWrites` | 100 | 1000 | Writes one mutation may make. | | `requestBytes` | 1 MiB | 4 MiB | Request body size. Over it: `413 request_too_large`. | | `responseBytes` | 1 MiB | 4 MiB | Response body size. | | `liveConnections` | 100 | 1000 | Simultaneous [live-update streams](/guides/live-updates) the app will hold. Past it, a client is told to retry after an interval and honours it. | Budgets exist so that a runaway query fails loudly and locally rather than becoming a bill or an outage. Raise one when your app genuinely needs it; the value is in the manifest, so the change is reviewable. ## Normalisation Before your app is built, the manifest is normalised: defaults are filled in, `capabilities` is sorted, and `$schema` is dropped. The normalised form is hashed into your build artifact's identity — which is why two identical projects produce the identical `artifactId`, and why [promote and rollback](/guides/deploy) act on an exact version rather than an approximate one. It is also why the app's identity lives in a **separate** file, [`xeer.project.json`](/guides/project-structure#xeer-project-json-the-app-s-identity): pointing a checkout at a different app must not change your build hashes. ## Next - **[Capabilities](/guides/capabilities)** — the declare-then-use model. - **[Server functions and the database](/guides/server)** — using what you declared here. - **[Diagnostics reference](/reference/diagnostics)** — the `XE10xx` and `XE11xx` families cover this file. --- # Diagnostics reference Every diagnostic Xeer emits carries a stable `XE####` code. There are 106 of them, and this page is generated from the same catalogue the compiler and CLI emit from, so it cannot fall behind. **Codes are stable; messages are not.** Dispatch on `code`, read `message` and `span` for the location, and treat `hint` as the platform's own suggested edit. ```ts interface Diagnostic { code: string; // XE####, stable severity: 'error' | 'warning' | 'info'; message: string; // human wording, free to change file?: string; // project-relative POSIX path, never absolute span?: { line: number; column: number; length?: number }; // 1-based hint?: string; // suggested edit } ``` Every command prints these as JSON with `--json`; see [Building with AI agents](/guides/agents). The "Seen in" column lists the commands whose output can carry the code. `check` diagnostics also appear in `build` and `dev`, because both run the same manifest and analysis stages first. ## CLI (XE00xx) The command itself failed. Not an application defect. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE0000` | any | The CLI crashed before it could report a structured result. Written to stderr, so it is the one code that can arrive outside a JSON envelope. | Report it: an internal error is a platform bug, not a project defect. stderr carries the stack. | | `XE0001` | any | Unknown or unimplemented command. | Read `xeer --help` and use a documented command. | ## Manifest (XE10xx) xeer.app.json is missing, unparseable, or violates the application-source schema — including the database schema, indexes, capabilities, and budgets it declares. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1001` | check, build, dev, test | xeer.app.json was not found in the target directory. | Run `xeer new `, or point the command at the project root that holds the manifest. | | `XE1002` | check, build, dev, test | A manifest value is invalid: bad JSON, a failed schema rule, or an entrypoint that does not exist. Also covers the declared database schema — unknown field types, an index naming a field the table does not declare, reserved field names, and tables declared without the "database" capability — and every capability declaration: an unknown capability name, one declared twice, a config block whose capability is not in `capabilities` (or the reverse), and a declared limit above the platform ceiling. | Read the message: it names the failing manifest path (for example database.tables.notes.indexes.by_owner) and the rule. Correct that one value. A capability and its config block are one declaration in two halves — `"capabilities": ["storage"]` and a `"storage": {}` block — so either add the missing half or remove the present one. | | `XE1003` | check, build, dev, test | The manifest carries a property the application-source schema does not define. | Remove the property, or fix its spelling. The manifest is closed: unknown keys are never ignored. | ## Manifest paths (XE11xx) A manifest path does not resolve to a file inside the project root. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1101` | check, build, dev, test | An entrypoint path is absolute or escapes the project root. | Use a forward-slash path relative to xeer.app.json that stays inside the project. | ## Module graph and zones (XE12xx) The client and server graphs are walked separately. Each zone has its own package allowlist, every import must be a static literal resolving inside the project, and shared code lives under src/shared/. Type errors in the reachable graph land here too. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1201` | check, build, dev, test | A source import breaks the zone boundary: it is absolute, leaves the project root, reaches the opposite entrypoint, or the module is reachable from both graphs while living outside src/shared/. | Keep imports relative and inside the project. Move genuinely shared code to src/shared/ and update both importers; never import one entrypoint from the other. | | `XE1202` | check, build, dev, test | A package import is not on the allowlist for that zone. | The client zone may import @impetik/xeer/client, /shared, the JSX runtimes, and preact; the server zone may import @impetik/xeer/server and /shared. Node builtins are never importable. Move the code to the zone that owns it instead of widening the import. | | `XE1203` | check, build, dev, test | A dynamic import() specifier is not a string literal. | Use a literal specifier so the module graph stays statically knowable, or a static import. | | `XE1204` | check, build, dev, test | A relative source import does not resolve to a file. | Write the specifier without an extension, or with the file's real one: the resolver appends candidate extensions rather than rewriting them, so ./shared/title.js does not find shared/title.ts. Directory imports resolve to index.*. | | `XE1205` | check, build, dev, test | TypeScript reported an error in a file reachable from an entrypoint. The message starts with the TS#### code and span points at the exact position. | Fix the type error. Operation input and result types come from the generated contract, so a mismatch between a client call and its server handler surfaces here. | | `XE1206` | check, build, dev, test | A stylesheet is imported outside the client graph. | Import .css only from client modules; the server bundle has no styling stage. | ## Operations (XE13xx) The default defineServer({...}) export is read statically, so operation and endpoint registration must be a literal object with valid, unambiguous names. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1300` | check, build, dev, test | The default server export is not a statically inspectable defineServer({...}) call, or an operation group is not a literal object. | Export `default defineServer({ queries: {...}, mutations: {...}, endpoints: {...} })` with literal object members: no spreads, shorthand, computed keys, or wrappers. | | `XE1301` | check, build, dev, test | Two operations in the same group share a name. | Rename one of them, or delete the duplicate registration if it was a copy-paste. | | `XE1302` | check, build, dev, test | An endpoint key is not `METHOD /api/path`. | Use an uppercase HTTP method, one space, and a literal /api path; dynamic segments are :name. | | `XE1303` | check, build, dev, test | A query or mutation name is not namespaced. | Use a dotted lowercase name such as notes.list. | | `XE1304` | check, build, dev, test | Two endpoints reduce to the same route shape, so dispatch would be ambiguous. | Change the method or a static path segment. Parameter names do not distinguish routes: GET /api/notes/:id and GET /api/notes/:slug are the same route. | ## Budgets and assets (XE14xx) A module or public asset breaks a v0 size limit, collides with generated output, or claims a platform-reserved path. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1401` | build, dev, test | A compiled module (10 MiB) or an asset (25 MiB) exceeds the v0 limit. | Shrink the module or asset. There is no flag that raises a v0 limit. | | `XE1402` | build, dev, test | A file in public/ collides with generated output. | Rename the public file; imported client CSS owns /assets/app.css. | | `XE1403` | build, dev, test | A file in public/ claims a platform-reserved path. | Move it: /_xeer/* and /__xeer/* belong to the platform. | ## Bundling (XE15xx) The client or server bundle failed after analysis passed. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1501` | build, dev, test | Bundling failed after the module graph and types were accepted. | Read the bundler message in `message`. It usually names a syntax construct the target does not support, rather than a Xeer rule. | ## Development loop (XE16xx) A watch rebuild failed. The last-good preview stays active and is rolled back to; these codes arrive as compile.diagnostic events, never as a dead server. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1602` | dev | A watch rebuild failed. The previously accepted generation is still serving. | Fix the edit named by `file`. The next quiet-window rebuild promotes automatically; no restart. | | `XE1603` | dev | A rebuilt candidate compiled but failed to take over — usually a manifest or schema change the running state cannot accept — and the last-good generation was restored. | Fix the manifest or schema change. To adopt an incompatible schema locally, stop dev and run `xeer state reset . --state dev --confirm `. | | `XE1604` | dev | The manifest could not be watched for changes, so edits to it will not rebuild the preview. Everything already serving keeps serving. | On Linux this is normally an exhausted inotify allowance: raise `fs.inotify.max_user_watches` and `fs.inotify.max_user_instances`. Restart `xeer dev` afterwards. | ## Artifact preview (XE17xx) xeer preview verifies the content-addressed artifact and its blobs before booting. These codes mean the build output is missing, malformed, or does not match its own identity. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1700` | build, preview, test, deploy, state | No verifiable build artifact was found for preview. | Run `xeer build` first; preview never compiles. | | `XE1701` | build, preview, test, deploy, state | The artifact, its latest pointer, or its server source map is malformed. | Rebuild with `xeer build`. Do not hand-edit anything under .xeer/build/. If a fresh build reports this itself, it is a compiler defect: report it with the diagnostic. | | `XE1702` | build, preview, test, deploy, state | The artifact does not match its own content-addressed identity or schema identity. | Rebuild. A mismatch means the output was mutated after it was written. | | `XE1703` | build, preview, test, deploy, state | An artifact blob is missing, the wrong size, or hashes differently than its receipt. | Rebuild. The build output is incomplete or corrupted. | | `XE1704` | preview | The artifact preview server failed to start or crashed. | Read `message`. Re-run `xeer build`, then preview again. | ## Local state and state transfer (XE18xx) Local dev/preview/test state is leased per project and mode, destructive resets are confirmed by exact application name, and `xeer export`/`xeer import` move it as a versioned xeer.state-export.v0 document that must fit the schema currently in force. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1801` | state | A state command was invoked without a valid --state selector. | Pass `--state dev`, `--state preview`, or `--state test`. The three modes never share a root. | | `XE1810` | state | A destructive state command was invoked without --confirm. | Pass `--confirm ` using the manifest `name` exactly. | | `XE1811` | state | The --confirm value does not match the application name. | Use the manifest `name` verbatim; the guard is exact-match by design. | | `XE1812` | dev, preview, test, state | The local state lease for that project and mode could not be acquired, or is held by a live process. Two concurrent `xeer test` runs in one project report this rather than racing. | Stop the other `xeer dev`/`xeer preview`/`xeer test` for this project and retry. | | `XE1813` | dev, preview, test, state | The resolved state directory escapes the project or traverses a link. | Remove the link under .xeer/ and retry. State must stay inside the project. | | `XE1814` | dev, preview, test, state | A local state operation failed for an underlying filesystem reason. | Read `message`. Usually permissions or a partially removed .xeer/ directory. | | `XE1820` | export, import | No `xeer dev`/`xeer preview` server owns that mode's state in that directory, or it has not published its address yet. Local state lives in a Durable Object, so only the running runtime can read or write it. | Start `xeer dev` (or `xeer preview`) in that directory and re-run, or pass a deployed application name, appId, or URL instead. | | `XE1821` | export, import | The export file could not be read or written. | Read `message`: usually a missing path or permissions. `--out` creates parent directories. | | `XE1822` | export, import | The document is not a usable xeer.state-export.v0 export: wrong protocol, or it contradicts itself (counts, schema, or an encoded value). | Import the unmodified file `xeer export --out` produced. `detail.code` names the exact defect. | | `XE1823` | import | `xeer import` was pointed at a deployed application. It writes local dev and preview state only; a deployed import needs a write-scoped grant and a pre-import snapshot. | Import into `xeer dev`/`xeer preview` state (`--state dev`), verify there, and `xeer deploy`. | | `XE1824` | export, import | The runtime refused the transfer. `detail.code` is the reason: state_import_schema_mismatch, state_import_application_mismatch, state_import_record_invalid, or state_export_too_large. | For a schema mismatch, `detail.expected` and `detail.found` carry both schema identities: check out the schema the export came from, import there, and let the compatible-change path carry the data forward, or re-export from the current schema. There is no forced import. | | `XE1825` | export, import | The command was invoked without a usable target, without the export file, or with a --state value other than dev or preview. | Pass `` or `--state dev\|preview [directory]`; `xeer import` also needs the file. | ## Tests (XE19xx) xeer test builds the project, type-checks tests/**/*.test.ts against the generated contract, and runs each test against a fresh isolated state. A failure carries the standard Diagnostic fields plus kind, matcher, expected, and actual, so the same parser handles it. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE1900` | test | xeer test found no test files. | Create tests/.test.ts importing test, as, and expect from @impetik/xeer/test. `xeer new` scaffolds a passing suite to copy. | | `XE1901` | test | A test file could not be compiled. | Read `message`: it is the bundler error for that file. Test files may import @impetik/xeer/test, @impetik/xeer/shared, and project-relative modules. | | `XE1902` | test | The test runtime failed to start. | Not a test failure. Run `xeer build` and `xeer doctor` — the artifact or the local workerd toolchain is the problem. | | `XE1903` | test | A test file threw while loading, so none of its tests ran. Other files still run. | Move work out of module scope and into a test body; only test registration belongs at the top level. | | `XE1904` | test | An assertion failed. The failure carries `matcher`, `expected`, `actual`, and the project-relative test location. | Decide which side is wrong. If the application is wrong, fix it and re-run; if the expectation is wrong, fix the test. Never delete the assertion to go green. | | `XE1905` | test | A test threw, including a call that was refused when the test did not expect it. `operation` names the persona, operation, status, and runtime error code. | When the refusal is the point, wrap the call in expectFailure(). A refused workspace-scoped call usually means the persona's declared membership does not contain the workspace the operation names: pass it with as({ name, workspaceIds }). | | `XE1906` | test | A test exceeded its 20 second budget. The remaining tests still run. | Remove the wait: tests run against a local runtime, so a timeout means an unresolved promise or an operation that never returns, not a slow machine. | ## Inspector (XE20xx) An inspector request failed. Locally the target is a preview URL; for a deployed app the request is routed through the control plane and requires builder ownership. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE2001` | inspect, state | An inspector command was invoked without a target. | Pass the preview URL from the dev/preview `preview.ready` event, or a deployed application name, appId, or URL. | | `XE2002` | inspect, state | The inspector request itself failed: a local dev/preview inspector that answered a non-2xx status or no JSON, or a control plane that refused the proxied read, in which case the read never reached the application. An application that refused the read is XE2004. | Confirm the preview is still running and the URL matches the current `preview.ready` event. For a deployed read, `message` carries the control plane's own refusal. | | `XE2003` | inspect, state | No deployed application matched the inspector target for the signed-in builder. | Pass the application name from xeer.app.json, its appId, or its deployed URL, and confirm with `xeer auth status` that you are signed in as its owner. | | `XE2004` | inspect, state, export | The deployed application refused the read itself, and the control plane forwarded its answer. The application's own runtime code, its errorId, and the cause are in `message`, and in `detail` as `code`, `errorId`, and `appId`. | Dispatch on `detail.code`, not on the wording. `state_bootstrap_failed` means the application state never bootstrapped, so the read never ran: fix the cause named in `message` and redeploy. `state_unavailable` means the read never reached the state object at all — most often a brand-new application whose state namespace is not dispatchable yet — so there is nothing to fix: wait out the advertised retry and read again. Quote `detail.errorId` when correlating with the Worker's console output. | ## Scaffold (XE30xx) xeer new could not create the project. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE3001` | new | xeer new could not scaffold the project — most often a non-empty target directory. | Choose an empty or non-existent directory. | | `XE3002` | new | `--template` named a scaffold that does not exist. Refused before the target directory is read, so nothing was written. | Use one of the names the message lists, or omit --template for the default. `xeer --help` describes each template. | ## Environment (XE40xx) xeer doctor found a broken toolchain or generated-file state. The failing check id and details are in result.checks. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE4001` | doctor | A doctor check failed. `hint` carries the check details as JSON, and result.checks lists every check with its id and status. | Fix the environment problem the failing check names. Doctor never edits the project. | ## Builder authentication (XE50xx) Builder sign-in against the control plane. Sign-in is a human step; an agent reads these codes and asks for it. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE5000` | auth | An auth subcommand was invoked with wrong arguments. | Use `xeer auth `; `auth as` takes alice or bob. | | `XE5001` | auth, deploy | The --control-url value is not an exact HTTP(S) origin, or is plain HTTP off localhost. | Pass an origin such as https://control.example.com with no path. | | `XE5002` | auth, deploy | No valid builder credential is stored. | A human must run `xeer auth login` and approve the shown code. An agent cannot complete sign-in. | | `XE5004` | auth | The control plane returned a response the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5005` | auth | The device authorization expired before sign-in completed. | Run `xeer auth login` again and approve promptly. | | `XE5008` | auth, deploy | The stored CLI credential file is unreadable or invalid. | Run `xeer auth logout` and sign in again. | | `XE5009` | auth | An auth command failed without a more specific code. | Read `message`; it is the underlying error verbatim. | ## Deployment, project identity, and environment variables (XE51xx) The deployment itself; the checked-in xeer.project.json appId that decides which hosted app a checkout deploys to, where the repair is always xeer link rather than a new appId; the per-project encrypted environment store behind xeer env; and the deployment history behind xeer deployments. | Code | Seen in | Means | Repair | | --- | --- | --- | --- | | `XE5101` | deploy | The deployment Worker module could not be constructed from the artifact. | Re-run `xeer build` and inspect the artifact; the build output is incomplete. | | `XE5102` | deploy | The control plane rejected the deployment or answered without a usable JSON envelope. | Read `message`: it carries the control-plane error. Check sign-in and that the project name is claimable. | | `XE5103` | deploy | The deployment was accepted but the deployed runtime never became ready. | The upload succeeded; the runtime did not. Retry, then report it — the artifact is already recorded. | | `XE5104` | deploy | The deployment serves assets but authenticated requests fail. | A platform identity problem, not an application one. Report it with the diagnostic message. | | `XE5105` | deploy | The --environment value is not a deployable scope. | Pass prod, production, or preview. `dev` is a local-only scope that `xeer env pull` writes to a gitignored file; no deployment ever injects it. | | `XE5106` | deploy, promote, rollback | A capability the manifest declares could not be provisioned. Either the control plane could not create or adopt the backing resource (a per-app R2 bucket for "storage"), it is not configured to provision at all, or it does not support that capability name. Nothing was activated: the previous version is still serving. | Retry the deployment — provisioning is adopt-or-create, so a half-finished attempt heals on the next one. A capability the control plane does not recognise is not retryable: remove it from `capabilities` in xeer.app.json, or deploy against a control plane that supports it. `message` carries the control plane's own reason. | | `XE5109` | deploy | Deployment failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5111` | deploy | The appId declared in xeer.project.json is owned by a different builder, so the control plane refused the deployment. | Run `xeer link --new` to fork this checkout into a new app you own, or `xeer link` to attach it to one of yours. A clone of someone else's project cannot silently take over their app. | | `XE5112` | deploy | The builder is at their apps-per-builder quota, so the control plane refused to create the app this deployment would have claimed. | This quota is a standing cap and does not lift on its own. Run `xeer link` to deploy into an app you already own, or ask the Xeer team to raise it. `message` states the quota and your usage. | | `XE5113` | deploy | The builder has spent their hourly or daily deploy quota. | Retry after the reset `message` states — it is the exact moment a slot frees, not a fixed backoff. Failed deployments count toward the quota, so a fix-and-retry loop spends it too. | | `XE5120` | deploy, link, dev, preview, test, doctor | xeer.project.json exists but is unusable: not JSON, not an object, the wrong `format`, or without a valid `appId`. | Run `xeer link --app ` to rewrite it, or `xeer link --new` to mint a fresh identity. A present-but-broken file is never repaired by guessing, because that would fork the app. | | `XE5121` | deploy, link, dev, preview, test, doctor | The checked-in xeer.project.json and the local .xeer/project.json cache name different appIds. | The checked-in file wins. `xeer link --app ` re-points the cache, or `xeer link --new` deliberately forks this checkout. | | `XE5122` | link, deploy | An appId is malformed, or `xeer link` was given both --app and --new. | Run `xeer link` with no flags to list the appIds you own, then pass exactly one of --app or --new. | | `XE5123` | link | The signed-in builder does not own a project with that appId. | Run `xeer link` to list your projects, or `xeer link --new` to create a new app for this checkout. | | `XE5124` | link | The control plane returned an invalid project or project list. | Retry; if it persists the control plane is unreachable or misconfigured. Not a project defect. | | `XE5129` | link | xeer link failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5130` | env | An `xeer env` invocation is wrong: an unknown --environment, a missing name or value, or no usable xeer.app.json in the target directory. | Read `message` and the usage it quotes. --environment takes prod, preview, or dev, and the command must run inside a Xeer project. | | `XE5131` | env | The control plane rejected the environment-variable request. | Read `message`: it carries the control-plane error and hint. Check sign-in and that you own the project. | | `XE5132` | env | The control plane returned an environment payload the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5133` | env | A closed-beta quota refused the environment-variable request: `xeer env set` claims the project on first use, so it is capped by the same apps-per-builder quota as `xeer deploy`. | Set the value on an app you already own — `xeer link` lists them — or ask the Xeer team to raise the quota. `message` states the quota, your usage, and any reset. | | `XE5139` | env | xeer env failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5140` | deployments | An `xeer deployments` invocation is wrong: an unusable --limit, or a directory that declares no app identity. | Read `message`. --limit takes a positive integer up to 200. In a directory with no appId, run `xeer deploy` or `xeer link` first, or name the app: `xeer deployments `. | | `XE5141` | deployments | The control plane rejected the deployment-history request. | Read `message`: it carries the control-plane error and hint. Check sign-in and that you own the app. | | `XE5142` | deployments | The control plane returned a deployment listing the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5143` | deployments | No app matched the reference for the signed-in builder. | Pass the application name from xeer.app.json, its appId, or its deployed URL, and confirm with `xeer auth status` that you are signed in as its owner. | | `XE5149` | deployments | xeer deployments failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5160` | rollback | An `xeer rollback` invocation is wrong: a missing or malformed artifact id, an unknown --environment, or a directory that declares no app identity. | An artifact id is `sha256:` followed by 64 hex characters — copy it from the ARTIFACT column of `xeer deployments `. --environment takes prod or preview; `dev` is never deployed. | | `XE5161` | rollback | The control plane refused the rollback. | Read `message` and `hint`: they carry the control-plane error verbatim. A rollback is a deployment, so it can also be refused by the deploy quota. | | `XE5162` | rollback | The control plane returned a rollback result the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5163` | rollback | No app matched the reference for the signed-in builder, or it has already been deleted. | Pass the application name from xeer.app.json, its appId, or its deployed URL, and confirm with `xeer auth status` that you are signed in as its owner. | | `XE5164` | rollback | This application has no completed deployment of that artifact — it belongs to another project, was never deployed here, or its deployment failed. | Run `xeer deployments ` and roll back to an artifact listed there. Only a deployment that completed can be rolled back to. | | `XE5169` | rollback | xeer rollback failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5170` | token | An `xeer token` invocation is wrong: an unknown action, a missing or unusable --name, a token id that is not an `xstid_` value, or no interactively signed-in builder. | Issuing a service token requires `xeer auth login` first — the browser approval is the root of every credential an account holds, so a service token cannot mint another. Take ids from `xeer token ls`; the id is the `xstid_` value, never the secret. | | `XE5171` | token | The control plane refused the service-token request. | Read `message` and `hint`: they carry the control-plane error verbatim. A service token cannot issue another one, and a disabled builder is refused every credential they hold. | | `XE5172` | token | The control plane returned a service-token response the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5173` | promote | No app matched the reference for the signed-in builder, or it has already been deleted. | Pass the application name from xeer.app.json, its appId, or its deployed URL, and confirm with `xeer auth status` that you are signed in as its owner. | | `XE5174` | promote | This application has no completed deployment of that artifact — it belongs to another project, was never deployed here, or its deployment failed. | Run `xeer deployments ` and promote an artifact listed there. Only a deployment that completed can be promoted. | | `XE5175` | promote | The application has no completed preview deployment, so there is nothing to promote. | Run `xeer deploy --environment preview` to put a version on the preview origin first, or name the version explicitly with `--from-artifact sha256:…`. | | `XE5179` | token | xeer token failed without a more specific code. | Read `message`; it is the underlying error verbatim. | | `XE5150` | disable, enable, delete | A lifecycle invocation is wrong: `xeer delete` without --confirm, or a directory that declares no app identity. | Deleting an application is permanent, so it requires its exact name: `xeer delete --confirm `. In a directory with no appId, name the app instead of relying on the directory. | | `XE5151` | disable, enable, delete | The control plane refused the lifecycle change — most often because --confirm does not match the application name, or because it cannot delete the deployed Worker. | Read `message` and `hint`: they carry the control-plane error verbatim. A confirmation must equal the name in xeer.app.json exactly. | | `XE5152` | disable, enable, delete | The control plane returned a lifecycle result the CLI will not trust. | Retry; if it persists the control plane is misconfigured or unreachable. Not a project defect. | | `XE5153` | disable, enable, delete | No app matched the reference for the signed-in builder, or it has already been deleted. | Pass the application name from xeer.app.json, its appId, or its deployed URL, and confirm with `xeer auth status` that you are signed in as its owner. A deleted app never resolves again. | | `XE5159` | disable, enable, delete | A lifecycle command failed without a more specific code. | Read `message`; it is the underlying error verbatim. | --- # FAQ ## What is Xeer, in one sentence? A full-stack framework where you declare your app's shape in one manifest, write typed server functions and a reactive client against it, test it with a runner that ships in the box, and deploy it to a real URL with one command. ## Can I get an account? Xeer is in **closed beta**, and deploying needs an invitation. Everything local does not: `xeer new`, `xeer dev`, `xeer test`, `xeer build`, and `xeer preview` need nothing but Node.js — no account, no network, no credentials. The framework, the compiler, the dev server, and the test runner are MIT-licensed and run entirely on your machine. You can build a complete app and never sign in. If you run `xeer auth login` without an invitation, you get a page saying so, nothing is created, and the terminal stops waiting rather than hanging until the request expires. To ask for one, open an issue or a discussion on [GitHub](https://github.com/impetik/xeer). ## Do my app's users need a Xeer account? **No.** The closed beta gates *deploying*, not *visiting*. Anyone can use an app you deployed. In fact they need no sign-in at all to start: every visitor to a Xeer app gets a verified, stable identity on their first request, so your app can own data per user immediately. If you add sign-in, the only provider that ships today is Google — and signing in *links* the visitor's existing identity, so rows they created beforehand stay theirs. See [Auth](/guides/auth). Your builder account and your app's users are two entirely separate systems. Signing in to your own deployed app makes you an ordinary user of it, with an id unrelated to your builder account. ## Do I have to use the hosted platform? To deploy with `xeer deploy`, yes — that is what it deploys to. There is no self-hosting path today, and we would rather say so plainly than imply one exists. What you keep regardless: your source, your data, and a way out. `xeer export` writes your app's records as a readable, diffable document with the schema that produced them, from a deployed app or a local one. Your build artifacts are content-addressed and inspectable. Nothing about your project is stored in a format only we can read. ## What is it actually running on? Xeer apps run on **Cloudflare's infrastructure**. You never configure it, never see a dashboard, and never hold a Cloudflare credential — the CLI talks to Xeer's control plane and nothing else, and your app's database, sign-in, and live-update streams are all provided by the framework. But you deserve to know where your code executes, so: that is where. Practically, it means your app runs close to your users rather than in one region, cold starts are not something you tune, and your database lives with your app rather than a network hop away. ## Which Node.js version? **22.12 or newer.** The CLI checks at startup and prints a clear error otherwise. `xeer doctor` reports your version and platform support. ## Is it TypeScript-only? Effectively yes. The generated types are the point — your manifest types your database, your server types your client, and your server types your tests. Plain JavaScript would run, but you would be giving up most of what the framework is for. ## Why is the package called `@impetik/xeer`? npm's automated anti-typosquatting check rejected the unscoped name `xeer` as too similar to existing packages. The installed **command** is still just `xeer`. `npx @impetik/xeer ` works — npx resolves the scoped package and runs its `xeer` binary. What does *not* work is dropping the scope: a bare `npx xeer` looks for a package literally named `xeer`, and `npx x` is an unrelated package entirely. ## React, or Preact? Preact, with JSX configured for you. In practice that means components, hooks, and `class` instead of `className`. Use `preact/hooks` for anything beyond the `useState` that `@impetik/xeer/client` re-exports. ## Can I use my own UI library, or Tailwind, or a component kit? `package.json` is an ordinary `package.json` and you can add whatever you like. Xeer owns the compile-and-deploy path and the platform imports; it does not own your dependency list or your styling. There is no bundled CSS framework and no opinion about how your app should look. ## Why can't I write the query I want? Because a `where` filter must match a declared index prefix, and there is no `orderBy`, no comparison operator, and no offset pagination. See [the table API](/guides/server#filtering). The trade is deliberate: every query the API can express is one an index already serves. There is no accidental full-table scan to discover in production. When you need a different shape, you add an index to the manifest — a reviewable change to a checked-in file. If your app needs relational joins, window functions, or full-text search, Xeer is not the right tool today, and it is better to know that now. ## Where do I put an API key? `xeer env set NAME value`, then read it as `ctx.env.NAME` in a handler. Values are stored per environment, encrypted at rest, and injected into your deployed app. They cannot reach the browser: client code **cannot import** server code, and the compiler enforces it. See [Environment variables and secrets](/guides/env). ## How do live updates work, and can I turn them off? Every query records which tables it read and every mutation reports which it wrote, so a commit refreshes exactly the queries that care — across tabs and across users. There is nothing to configure and nothing to switch off; a query that is not mounted does not refetch. See [Live updates](/guides/live-updates). ## Is there file or blob storage? Yes. Declare the `storage` capability and `ctx.storage` appears in your server handlers: `put`, `get`, `head`, `list`, `delete` over an object store that belongs to your app alone. No bucket name, no credential, no region — and it is fully emulated by `xeer dev`. See [Storage](/guides/storage). The one thing to plan around: **there is no signed-upload URL.** A browser uploads by POSTing to one of your endpoints, which means an upload is bounded by your `requestBytes` [budget](/reference/manifest#budgets) — 4 MiB at most. An object larger than that has to be assembled server-side. That is a real limit today, not a detail we are glossing over. ## Are there limits during the beta? Yes, and they are hard caps rather than billing. The starting allowances are **10 apps per account**, **30 deploys per hour**, and **200 per day**. Preview deploys, promotions, and rollbacks all count as deploys, because they all upload. Your app itself is bounded by its [manifest budgets](/reference/manifest#budgets), which you control. ## Is preview a copy of production's data? No, and this is worth being clear about. A preview deployment is a **separate instance with its own data**. It cannot read or corrupt production's records, and it starts empty and is replaced by your next preview deploy. Treat it as a place to look at a build, not a staging copy of your database. ## What happens if a deploy goes wrong? Nothing your users can reach changes until a deploy passes its readiness gate: platform health, assets, and one authenticated request. A build that serves static files but cannot answer a real request fails the deploy rather than replacing what was working. If something gets through anyway, `xeer rollback ` redeploys a previous version by its content address, and `xeer disable` takes the app offline in one request while you work out what happened. See [Deploy](/guides/deploy). ## Can I take my data out? ```sh xeer export my-app --out backup.json ``` Schema plus every record, as readable JSON. From a deployed app you own, or from your local dev database with `--state dev`. ## Is it stable? Can I build something real on it? It is a `0.x` release in closed beta, and the honest answer is: build something real, but expect the framework to change under you. What is stable today: diagnostic codes, the command envelope shapes, the manifest format identifier, and the artifact-identity model. What may still move: parts of the SDK surface, and anything marked deprecated. ## How do I report a bug or ask for something? [github.com/impetik/xeer](https://github.com/impetik/xeer). Issues and discussions both work. A `--json` transcript of the failing command is the most useful thing you can attach — it carries the diagnostic code, the file, and the span. ## Is it open source? The framework is MIT-licensed: the CLI, the compiler, the SDK, the runtime, the local development server, the test runner, and the MCP server. The hosted platform that `xeer deploy` talks to is not. ## Why does an agent keep coming up in the docs? Because it shaped the design. Every command emits structured output, every failure carries a stable code and a suggested repair, and the whole app is described by one declarative file — which makes the framework drivable by a program. It also, not coincidentally, makes it small enough for a person to hold in their head. See [Building with AI agents](/guides/agents).