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.
{
"$schema": "https://docs.xeer.run/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. New projects point at Xeer's live JSON Schema for completion and inline editor feedback; the compiler does not fetch it and validation does not depend on network access. |
app | no | Document metadata. Below. |
database | no | Tables and indexes. Below. |
storage | no | Object-store limits. Below. |
capabilities | no | Declared powers. Below. |
client | no | Which UI library the client is written against. Below. |
untypedDependencies | no | Dependencies acknowledged as shipping no type declarations. Below. |
budgets | no | Resource ceilings. Below. |
There is no version, runtime, env, auth, routes, or assets field. Routes are declared in your
server code; environment values live outside the repository; assets are whatever is in
public/.
#entrypoints
"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 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.
| Field | Required | Rule |
|---|---|---|
tables | yes | A record of table name to table definition. |
version | no | A positive integer, default 1. Sequences schema changes; excluded from the structural schema hash. |
#Tables
"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.
| Key | Required | |
|---|---|---|
fields | yes | Field name to field definition. |
indexes | no | Index name to index definition. |
unique | no | Composite UNIQUE constraints, each a list of declared field names. |
checks | no | Named table-level CHECK expressions. The name is what SQLite reports on failure. |
idSource | no | "runtime" (default) or "application". See Who chooses the id. |
Per-user and per-workspace scoping is declared in server code, not here.
"cards": {
"fields": { "workspaceId": { "type": "ref", "table": "workspaces" }, "slug": { "type": "string" } },
"unique": [["workspaceId", "slug"]],
"checks": { "slug_not_blank": "length(\"slug\") > 0" }
}#Fields
{ "type": "string", "optional": true, "maxLength": 200 }| Key | Required | |
|---|---|---|
type | yes | One of string, number, boolean, datetime, bytes, json, ref. |
optional | no | true lets the field be absent. Fields are required by default. See optional fields. |
maxLength | no | A positive integer. string and bytes only. |
unique | no | A single-column UNIQUE. Optional columns may still repeat NULL. |
enum | no | Permitted values, enforced as a CHECK. string only; 1–1000 entries, no duplicates. |
default | no | Applied when the field is omitted. See defaults. |
collate | no | binary, nocase, or rtrim. string only. |
table | for ref | The declared table whose id this column points at. |
onDelete | no | noAction, restrict, cascade, setNull, setDefault. ref only. |
onUpdate | no | Same values as onDelete. ref only. |
generated | no | Makes the column computed. See generated columns. |
Field types map to TypeScript as string, number, boolean, Date, Uint8Array, a JSON-shaped
value, and — for ref — the referenced row's id. An optional field is typed field?: T.
#Optional fields
An optional field has one absent state, not two. Omitting the key, writing undefined, and writing
null all store the same absence, and a row read back from any of them omits the key:
const post = await ctx.db.table('posts').insert({ title: 'Draft', subtitle: null });
post.subtitle; // undefined
'subtitle' in post; // falseIt is never read back as null. Collapsing the three at write time is what makes the record insert
returns equal to the record a later get returns — otherwise a handler would see { subtitle: null }
from its own write and {} from the read after it. Read one with ?? or a truthiness test, not a
comparison against null.
The one place the column shows through is raw SQL: xeer db exec reports stored columns rather than
records, so it prints NULL where a handler sees no key at all.
#Defaults
A default is written in the JavaScript type its field is written in, and is checked against the rest of the field at build time:
| Type | Default is |
|---|---|
string | a string, within maxLength and among enum if declared |
number, boolean | a number or boolean |
datetime | an ISO-8601 UTC timestamp |
bytes | an even number of lowercase hex digits |
json | a string containing valid JSON |
SQLite accepts a DEFAULT that every CHECK on the column rejects and only fails on the first insert
that uses it, so this is checked here instead.
#Generated columns
"total": { "type": "number", "generated": { "expression": "\"qty\" * \"unitPrice\"", "stored": true } }stored: true writes the value to disk; the default recomputes it on read. A generated column may not
also carry a default.
#Referencing another table
"workspaceId": { "type": "ref", "table": "workspaces", "onDelete": "cascade" }A ref field points at another declared table's id. table is required. Omitting onDelete gives
SQLite's default, NO ACTION.
If the referenced table declares "idSource": "application", that id is a value your application
already knows — a workspace id from ctx.auth.workspaceIds, say — so a ref to it is both a foreign
key and a column a table policy can scope by.
#Reserved fields
Every row automatically has:
{ 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 as a field is an error, and writing to them is refused at runtime.
#Who chooses the id
By default the runtime mints each row's id as an unguessable uuid, and an insert that tries to
supply one is refused. A table may invert that, and only that:
"workspaces": {
"idSource": "application",
"fields": { "name": { "type": "string", "maxLength": 80 } }
}insert({ id, ... }) is now required for this table — the generated Insert type says so, so a
handler that forgets the id does not compile. Nothing else changes: id is still not a declared
field, still cannot be written by update, and is still TEXT PRIMARY KEY.
Declare it when a row is something an outside system already names. The case it exists for is a
workspaces table whose row id is the identity provider's workspace id, which is what makes
workspaceTable({ workspaceField: "id" }) and a ref to workspaces both work without a second
"external key" column to join through.
An id must be 1–256 characters and carry no control characters. It is otherwise unconstrained, because these ids come from systems you do not control.
Two rows cannot hold one id. A second insert at a taken id is refused with the unique_violation
code and HTTP 409 — a first-class answer to handle ("this workspace is already registered"), not a
crash. get(id) first if a repeat should read the existing row instead.
Turning idSource on or off later is a compatible schema change: it governs what
the next insert may say and nothing about the rows already stored.
#Indexes
"indexes": { "by_workspace_status": ["workspaceId", "status"] }The shorthand 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.
The full form adds uniqueness, ordering, collation, expression terms, and a predicate:
"indexes": {
"by_slug": {
"columns": [{ "column": "slug", "collate": "nocase" }, { "column": "createdAt", "desc": true }],
"unique": true,
"where": "\"deletedAt\" IS NULL"
}
}| Key | Required | |
|---|---|---|
columns | yes | Field names, or { column, collate?, desc? }, or { expression, desc? }. |
unique | no | Makes the index a UNIQUE constraint. |
where | no | A predicate. The index then covers only the rows it matches. |
An upsert targeting a unique partial index must repeat the predicate —
ON CONFLICT ("slug") WHERE "deletedAt" IS NULL DO UPDATE … — or SQLite answers "ON CONFLICT clause
does not match any PRIMARY KEY or UNIQUE constraint". The predicate is part of the constraint's identity.
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, a changed default,
or idSource switched either way. 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 for the ctx.storage surface, the object-key rules, and how
uploads work.
#capabilities
"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.
#client
"client": { "runtime": { "provider": "react" } }Which UI library your client is written against. The whole block is optional, and so is every field
inside it: omitting any level normalises to preact on the platform's own copy, which is what every
app built before this field existed already used. xeer new --ui react writes the block; --ui preact
writes nothing at all, so a Preact project is byte-identical to one scaffolded without the flag.
| Field | Type | Default | Rule |
|---|---|---|---|
runtime.provider | string | "preact" | "preact" or "react". A closed enum — any other value is an error, not a fallback. |
runtime.source | string | "platform" | "platform" is the only accepted value: the renderer is the platform's, at the platform's version. |
The provider is what @impetik/xeer/client resolves to — the same import gives you the Preact adapter
or the React one — and what the JSX runtime, the editor's types, the dev server's Fast Refresh, and the
production bundle all resolve to. It is recorded in the build artifact as the app's clientRuntime, so
a deployed build states which renderer it was built against rather than implying one.
Your source does not change with it. tsconfig.json says "jsxImportSource": "@impetik/xeer" under
both providers, and a client that imports @impetik/xeer/client, @impetik/xeer/client/core, and
@impetik/xeer/shared compiles unchanged under either — which is why switching is one edit to this
field, plus one generated file. That file is tsconfig.json, whose paths table is where the
editor learns which renderer those names mean; xeer check reads the provider from this manifest
and never from that file, so an editor left on the old table reports against the renderer you
switched away from while the build uses the one you switched to. Copy the paths block from a
xeer new --ui <provider> scaffold, or scaffold one to compare against. What does change is which renderer packages the client zone admits: under react those are
react, react-dom, and scheduler, and preact is refused (XE1202); under preact it is
preact and its entrypoints, with react and react-dom admitted as aliases onto the platform's
preact/compat. One physical renderer either way. See
what client code may import.
#untypedDependencies
"untypedDependencies": ["slug-case"]Installed dependencies that ship no TypeScript declarations, named one package at a time. Xeer
generates ambient declarations for each — the package and every subpath under it — into
.xeer/generated/, which your tsconfig.json already includes, so the editor and xeer check agree
about the package. Withdrawing an entry deletes the declarations with it.
Package names only: slug-case, not slug-case/extra.js. One entry covers the whole package, so
reaching a new subpath never needs another. The list is sorted and deduplicated during
normalisation, and it is deliberately absent from the build artifact — an acknowledgment is
type-space only and changes no byte of what ships.
An acknowledged package is typed as any, and that is reported rather than assumed. Every check
emits XE1214 at info severity naming each acknowledged package, so the hole stays visible for as
long as it stands.
Two entries are not honoured, and both say so:
XE1213 | The package already resolves to declarations — its own, or an installed @types/… companion. The acknowledgment is ignored rather than applied, and its real types still answer. Generating the declaration would not be redundant: an ambient declaration overrides module resolution, so it would replace those types with any and a call with the wrong arguments would compile clean. |
XE1212 | The package is not declared in package.json dependencies, so the acknowledgment describes a package this application never said it uses. |
Try the types before acknowledging. See dependencies without type declarations.
#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. Over it: response_too_large, as a 400 from a query or mutation and a 500 from an endpoint. |
liveConnections | 0 | 1000 | Simultaneous server→client streams the app will hold, of both kinds: live-update streams and streamed endpoint responses share this one ceiling. 0 means server push is off, which is the default: declare a positive value to turn it on — streaming an endpoint response needs it too. Past a declared budget, a client is told to retry after an interval and honours it. |
streamedBytes | 1 MiB | 64 MiB | Bytes one endpoint response declared with stream() may send. Over it the body is cut off mid-flight: a 200 has already gone out, so the client sees a failed read rather than an error envelope. Not an exemption from responseBytes — a response you did not declare is still bounded by that. |
clientBundleBytes | 1 MiB | 10 MiB | Minified pre-gzip size of the client JavaScript bundle. Over it the build fails (XE1405), naming the packages that contributed the bytes. A fixed 400 KiB advisory tier warns (XE1406) before that, measured over the application's own share — the bundle minus the renderer your client.runtime.provider selected, which the platform ships and your code cannot shrink. |
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 and
untypedDependencies are sorted, and $schema is dropped. A field you never declared stays absent
rather than being materialised at its default, which is what keeps your artifactId unchanged when
the platform adds one. 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 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: pointing a checkout at
a different app must not change your build hashes.
#Next
- Capabilities — the declare-then-use model.
- Server functions and the database — using what you declared here.
- Diagnostics reference — the
XE10xxandXE11xxfamilies cover this file.