Server functions and the database
Your server is one file exporting one call:
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.
'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.
notes.list ✓
board.cards ✓
files.write ✓
notes.list_pinned ✓ underscores and dashes are fine
listNotes ✗ no namespace, and uppercase
cards.forWorkspace ✗ uppercasePrecisely: ^[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.
'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.
'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 "<METHOD> <path>". 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 second — not an options object:
'POST /api/hooks/stripe': endpoint(handler, authenticated()),#Path parameters
ctx.params holds the segments the dispatcher matched. Pass the route key as a type argument and the
parameters are typed from the key itself, so there is no pattern to write out a second time:
'GET /api/posts/:slug/comments/:id': endpoint<'GET /api/posts/:slug/comments/:id'>(
async (_request, ctx) => {
ctx.params; // { slug: string; id: string }
ctx.params.slug; // string
ctx.params.other; // compile error: this route declares no :other
return Response.json({ slug: ctx.params.slug, id: ctx.params.id });
},
),Segments arrive percent-decoded. A route with no dynamic segment has params: {}, so reading a
parameter it cannot produce is a compile error rather than undefined at run time.
A parameter's value is caller-controlled. One percent-encoded segment can decode to /, \,
.., or a NUL byte — /api/posts/..%2F..%2Fetc matches this route and hands the handler
ctx.params.slug === '../../etc'. Validate a parameter before using it as a storage key, a path,
or any other identifier; matching the route proves where the value sat in the URL, not what it
contains.
defineServer checks the type argument against the key the handler is registered under, so
endpoint<'GET /api/posts/:id'>(…) registered as 'GET /api/posts/:slug' fails to compile — the two
cannot drift apart. The type argument is optional: an endpoint declared as endpoint(handler) still
gets ctx.params, typed as Readonly<Record<string, string>>.
Params<Key> and EndpointContext<Key> are exported for a handler written out separately from its
endpoint() call, which is what a server split into feature modules does. A registry annotated by
hand should use AnyEndpointDefinition — a keyed endpoint is not assignable to the bare
EndpointDefinition, whose open ctx.params promises parameters a keyed handler never declared:
import { type AnyEndpointDefinition } from '@impetik/xeer/server';
export const postEndpoints: Record<string, AnyEndpointDefinition> = { /* … */ };import { endpoint, type EndpointContext } from '@impetik/xeer/server';
// src/server/posts.ts
export const postShow = endpoint<'GET /api/posts/:slug'>(async (_request, ctx) =>
Response.json({ slug: ctx.params.slug }));
// or, with the context written out by hand
async function showPost(_request: Request, ctx: EndpointContext<'GET /api/posts/:slug'>) {
return Response.json({ slug: ctx.params.slug });
}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.
#Streaming a response
An endpoint may return a streaming body — a proxied fetch, server-sent events, a model's tokens —
if you declare it with stream() and the handler wrote nothing:
import { endpoint, stream } from '@impetik/xeer/server';
// Server-sent events, from a body you produce.
'GET /api/events': endpoint(async () => {
const body = new ReadableStream<Uint8Array>({ /* enqueue frames over time */ });
return stream(body, { headers: { 'content-type': 'text/event-stream' } });
}),
// A proxied upstream. `stream(upstream)` carries its status and headers across with it.
'GET /api/feed': endpoint(async () => stream(await fetch('https://example.test/feed'))),The write condition is not a style preference. A Xeer handler's writes commit only after its response
is known to have succeeded, which is what makes a failed handler leak nothing. Bytes already sent
cannot be unsent — so a handler that streams and writes would need the platform to deliver a
response before knowing whether the write survived. There is no correct answer to that, so the
platform refuses it: a handler that wrote to ctx.db or ctx.storage and returned a stream() gets
500 stream_not_admitted. A handler that wrote nothing has nothing to commit, so there is nothing the
sent bytes can contradict, and it streams.
Three things follow:
- A plain
Responseis buffered, bounded byresponseBytes, exactly as it always was. That includesnew Response(upstream.body)— a body that is merely slow is still a buffered body, and overresponseBytesit is refused withresponse_too_largerather than truncated part-way.stream()is the only way to say otherwise. The platform does not guess, because whether draining a body has to wait depends on how fast the upstream is, so a guess would make the same handler behave one way inxeer devand another way in production. - A streamed response needs a
liveConnectionsslot, which is0by default. It shares that ceiling with live updates, because both hold a connection open from the same place. streamedBytesbounds the body, and a stream also has a lifetime. Past either, the body is cut off — the status line already went out, so your client sees the read fail part-way rather than an error response.
If you need to stream and persist what you streamed, write it from the client in a separate mutation once the stream finishes. A post-commit streaming phase that can do both is a planned extension, not something to work around today.
#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 } } |
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 whatexamples/team-boarddoes 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.
#Result types
Whatever a handler returns is what the client and your tests receive, typed from the handler through
the generated contract — you never declare it twice. The one rule is that the platform has to be able
to encode it: null, boolean, number, string, Date, Uint8Array, arrays, and plain objects
of those. A handler that only writes may return nothing; the client sees undefined.
Declare result shapes with a type alias, not an interface.
type PublicPost = { title: string; tags: string[] }; // ✓
interface PublicPost { title: string; tags: string[] } // ✗This is TypeScript's rule rather than ours. An interface never gets an implicit index signature, so
it is not assignable to the encodable-value type even when every one of its members is encodable —
while the character-identical type alias is. The same rule refuses readonly T[], which is not
assignable to a mutable array; write T[]. Class instances, Map, and Set are refused because they
genuinely do not survive the wire.
Get it wrong and check reports XE1205 on the handler, naming the type and the reason:
XE1205 src/server.ts:9 TS2769: … Type 'PublicPost' is not assignable to type 'XeerValue'.
Index signature for type 'string' is missing in type 'PublicPost'.Changing interface to type is the whole fix. The client call sites that read the result stay
typed from the handler while you do it, so the error count does not move away from the file that is
actually wrong.
#The table API
ctx.db.table(name) returns a handle typed from your manifest. Nine methods, and that is all of them.
interface QueryTable {
all(): Promise<Row[]>;
get(id: string): Promise<Row | null>;
find(options: { where: Where; limit?: number }): Promise<Row[]>;
first(options: { where: Where }): Promise<Row | null>;
page(options: { where: Where; limit?: number; cursor?: string }): Promise<{ rows: Row[]; cursor: string | null }>;
count(options: { where: Where; limit?: number }): Promise<number>;
}
interface MutationTable extends QueryTable {
insert(value: Insert): Promise<Row>;
update(id: string, patch: Update): Promise<Row>;
delete(id: string): Promise<void>;
}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.
An optional field that holds no value is absent from the row, not null: row.field is
undefined and 'field' in row is false, whether it was omitted, written as undefined, or
written as null. Test it with ?? or truthiness rather than === null. (table.get() and
table.first() returning null is a different thing — that is a missing row.)
The exception to runtime-owned ids is a table declaring
"idSource": "application", where Insert requires
id because the runtime will not mint one. Update never accepts it: an id is chosen once, or every
ref naming that row silently stops naming it.
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: <table>/<id>.
#Filtering
where is an equality record, and it must match either { id } or a declared prefix of a declared
index:
"indexes": { "by_workspace_status": ["workspaceId", "status"] }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' } }); // refusedThe 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, and there is no descending direction. count is
the only aggregate; there is no sum or group.
Every query this API can express is one an index already serves. If you need a shape it cannot express, add an index to the manifest.
#Paging
page takes the same where as find and returns one page plus the token that continues it:
let cursor: string | undefined;
do {
const result = await ctx.db.table('cards').page({ where: { workspaceId: 'design' }, limit: 50, cursor });
for (const card of result.rows) render(card);
cursor = result.cursor ?? undefined;
} while (cursor);Two rules, both of which bite if you guess:
The cursor is opaque. Pass it back exactly as you got it. Do not parse it, build one, or store one across a schema change. A token this platform did not issue is refused rather than treated as absent — so a bad cursor is an error you can see, not a list that quietly restarts at the top.
cursor === null is the only end condition. Do not compare a page's length against your limit. A
page can be exactly limit long and still not be the last one.
This is keyset paging, not offset, and that is the feature. The cursor names a position in the
(createdAt, id) ordering rather than a number of rows to skip, so writes that land while you are
paging do not shift what is left. Relative to that position you get no duplicates and no skips: a row
deleted behind the cursor does not pull an unread row past you, a row inserted behind it does not
repeat one you have seen, and rows inserted ahead of it are simply picked up in their place. An
offset gives you none of that.
#Counting
count answers the question (await find({ where, limit: 1000 })).length used to answer, without the
cost that made it a bad idea:
const comments = await ctx.db.table('comments').count({ where: { postId } });
const anyDraft = await ctx.db.table('cards').count({ where: { workspaceId, status: 'draft' }, limit: 1 });It counts inside the database over the index your where had to name, materializes no rows, and
charges one row of queryRows however many rows it counted. Counting 5,000 comments costs the same
as counting five.
It is bounded, and the bound has two modes:
- No
limit— the count is exact, or it is refused. If the match would exceed yourqueryRowsbudget you getcount on comments matches more than 1000 rows…, because the ceiling is not a total this platform will claim. Narrow the filter, or opt into saturating. - With
limit— the count saturates there deliberately, returningmin(matches, limit). That is what makeslimit: 1an existence check that stops at the first match.
#Row counts and budgets
find returns at most limit, defaulting to 100 or your queryRows budget, whichever is lower.
all() reads up to your queryRows
budget, which defaults to 1000. A limit outside 1 … queryRows is
refused. Raise the budget in the manifest if your app genuinely needs more.
page charges for the rows it read plus one: detecting the last page costs one row beyond limit, so
a limit equal to your whole budget leaves no room for it and the budget refuses the call. count
charges one row per call, whatever it counted.
#ctx
ctx.auth and ctx.env, plus one property per capability you declared — and
nothing else:
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
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.
ctx.auth.id exists as a deprecated alias of appUserId and is always equal to
it. New code should use appUserId. Provider profile data is deliberately absent: the app
never receives a display name, email address, avatar, or provider identity.
#ctx.env
Readonly<Record<string, string | undefined>> — this app's own environment values, and nothing else. An
unset variable is undefined, never ''. See Environment variables and
secrets.
#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.
'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.
#Refusing a call
There is no error helper to import. Throw:
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 |
response over responseBytes | 400 / 500 | response_too_large |
stream() from a handler that wrote | 500 | stream_not_admitted |
no free stream slot under liveConnections | 503 | stream_connection_budget_exceeded |
| insert chose a row id already taken | 409 | unique_violation |
The denial row holds on every surface, endpoints included: a guard doing its job is not a server
fault, so an unauthorized call never answers 5xx. The response_too_large row keeps the status of
the surface it came from — 400 from a query or a mutation, 500 from an endpoint, because exceeding a
budget the app declared is the app's own fault — and the code is what tells it apart from a handler
that crashed.
unique_violation holds on every surface too, and takes a status of its own: only a table with
"idSource": "application" can raise it, and it is the one fault here the caller fixes by sending a
different id. A UNIQUE you declared on a column of your own is not reclassified — it stays a handler
fault carrying the database's own sentence.
The two stream rows are streaming (see Streaming a response).
stream_connection_budget_exceeded is the one endpoint refusal that carries Retry-After: a stream
is only ever refused a slot after passing the admission test that proved the handler wrote nothing,
so retrying it cannot repeat a write.
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:
import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server';
export default defineServer({
authPolicies: { drafts: ownedTable(), cards: workspaceTable() },
// …
});Operation guards (auth) run before a handler:
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, which also shows how to prove they work.
#A complete server
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 — calling all of this from a component.
- Auth — policies, guards, and proving them.
- Testing — asserting the refusals above.
- Capabilities — what
ctx.dbis and why it is opt-in.