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.
{
"capabilities": ["database", "storage"],
"database": {
"tables": { "notes": { "fields": { "text": { "type": "string" } } } }
},
"storage": {} // {} means "the platform defaults"
}That is the whole pattern:
- Name the capability in the
capabilitiesarray. - Configure it in the matching top-level block —
databasefor"database",storagefor"storage". - 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.
So reading one file tells you every external thing an app can touch — 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 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
{
"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, json, and ref — a foreign key
into another declared table. 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. One table-level key changes half of that: "idSource": "application" makes id a value your insert supplies, so a row can be something an outside
system already names. See Who chooses the id.
A field can also carry unique, enum, default, and — on a ref — onDelete and onUpdate; a
table can carry composite unique constraints and named checks. Each becomes a real constraint in the
database rather than a validation your handlers have to remember.
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, generated columns, partial and expression
indexes, database.version — are in the manifest reference.
#The typed surface
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. Nine methods, no query builder, no SQL — the same
where also drives page for cursor pagination and count for a bounded row count. See
Server functions and the database.
#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.
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 — those are your identity provider's workspace ids, so a
workspaces table declaring "idSource": "application" can be scoped by its own primary key with
workspaceTable({ workspaceField: 'id' }). Full details in Auth.
#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 <app> 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, a changed default, or idSource
switched either way — 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.
{
"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:
// 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 successFive 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, 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 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.
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 — every field and every rule.
- Server functions and the database — the table API in full.
- Auth — policies, guards, and how to test them.