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()),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 } } |
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.
#The table API
ctx.db.table(name) returns a handle typed from your manifest. Seven 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>;
}
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.
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. 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, 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 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. ctx.auth.profile is declared in the type but
is not populated by the runtime — do not build on it.
#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. The
full surface, the key rules, and how uploads work are in
Capabilities → 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 |
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.