Fold API
How to define a fold's server: fold(), the schema, queries, mutations, actions, endpoints, and the ctx every handler receives.
All server code imports from @aozumi/fold/server. The default export of server/index.ts must be a fold({...}) call.
A complete server
import { action, boolean, endpoint, fold, json, mutation, query, string, table, text, userId } from "@aozumi/fold/server";
import { cleanBody } from "../shared/notes";
// shared/notes.ts: export function cleanBody(value: string): string { return value.trim().slice(0, 500); }
export default fold({
name: "notes",
auth: { requireSignIn: false },
schema: {
notes: table({
body: string(),
pinned: boolean().default(false),
color: string().optional(),
ownerId: userId()
}).index("by_owner", ["ownerId"])
},
queries: {
myNotes: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.notes
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.take(100);
})
},
mutations: {
addNote: mutation(async (ctx, body: string) => {
const { userId } = ctx.auth.requireIdentity();
const clean = cleanBody(body);
if (!clean) {
throw new Error("Write something first.");
}
return ctx.db.notes.insert({ body: clean, ownerId: userId });
}),
removeNote: mutation(async (ctx, id: string) => {
const { userId } = ctx.auth.requireIdentity();
const note = await ctx.db.notes.get(id);
if (!note || note.ownerId !== userId) {
throw new Error("Note not found.");
}
await ctx.db.notes.delete(id);
})
},
actions: {
noteCount: action(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
const total = await ctx.db.notes.withIndex("by_owner", (q) => q.eq("ownerId", userId)).count();
return { total };
})
},
endpoints: {
health: endpoint({ method: "GET", path: "/api/health" }, () => text("ok")),
createNote: endpoint({ method: "POST", path: "/api/notes" }, async (ctx, req) => {
const auth = ctx.auth.requireSignedIn();
const payload = await req.json<{ body: string }>();
const note = await ctx.db.notes.insert({ body: cleanBody(payload.body), ownerId: auth.userId });
return json({ id: note.id }, { status: 201 });
})
}
});
fold(definition)
fold() accepts only these keys. Any other key throws Unknown fold() option.
| Key | Type | Meaning | |
|---|---|---|---|
name | string | App title. The first deploy uses it to pick the subdomain. Defaults to "AOZUMI Fold". | |
favicon | string | Path to an .svg or .ico file in the fold. Without it, favicon.svg or favicon.ico at the fold root is used. Max 64 KiB. | |
auth | `{ requireSignIn?: boolean; onGuestUpgrade?: (ctx, upgrade) => void \ | Promise<void> }` | See Auth. |
schema | Record<string, TableDefinition> | Tables. See below. | |
queries | Record<string, handler> | Read-only, live handlers. | |
mutations | Record<string, handler> | Transactional writes. | |
actions | Record<string, handler> | One-shot, read-only calls with size and time caps. | |
endpoints | Record<string, EndpointDefinition> | HTTP routes for webhooks and other clients. |
Schema
schema: {
lists: table({ title: string(), ownerId: userId() }).index("by_owner", ["ownerId"]),
items: table({
listId: id("lists"),
text: string(),
done: boolean().default(false),
dueAt: number().optional()
})
.index("by_list", ["listId"])
.index("by_list_due", ["listId", "dueAt"])
}
Field builders:
| Builder | Stores |
|---|---|
string() | A string. No NUL characters or unpaired surrogates. |
number() | A finite number. NaN and Infinity are rejected. |
boolean() | true or false. |
id("table") | The id string of a row in another table. The referenced table must exist in the schema. |
userId() | A Fold user ID. Use it for every owner field. When a guest signs in, these values move to the account. |
Modifiers, chainable on any field:
.optional(): the field may be absent. Inserts may omit it or passnull..default(value): used when an insert omits the field, and returned for older rows that lack it.
Rules:
- Table, field, and index names start with a letter and use only letters, digits, and
_. id,createdAt, andupdatedAtare reserved. Fold sets them on every row (createdAtandupdatedAtare ISO strings)..index(name, fields)declares an index. Fields may includeid,createdAt, andupdatedAt. The nameby_creationis reserved: every table already has it.- Every index sorts by its fields, then
createdAt, thenid.
Only string, number, boolean, id, and userId exist. Store a timestamp as number() (epoch milliseconds). Store a list as rows in another table. See Database for queries.
Queries
query(async (ctx, args: { listId: string }) => {
return ctx.db.items.withIndex("by_list", (q) => q.eq("listId", args.listId)).collect();
})
- Read-only database. Any write throws
Query handlers have a read-only database capability. - Live: a client subscribed with
useQuerygets a new result whenever a table the query read changes. - Give a query at most one argument and make it an object. The client sends it as JSON.
- The result must be JSON-compatible.
Mutations
mutation(async (ctx, listId: string, text: string) => {
const { userId } = ctx.auth.requireIdentity();
const list = await ctx.db.lists.get(listId);
if (!list || list.ownerId !== userId) {
throw new Error("List not found.");
}
return ctx.db.items.insert({ listId, text });
})
- Read and write. All writes in one call commit together or not at all. A thrown error rolls back every write.
- A mutation may take several positional arguments. Each must be JSON-compatible.
- Await or return every database call.
- Each call counts toward the daily mutation limit when hosted.
Actions
action(async (ctx, search: string) => {
const matches = await ctx.db.items.withIndex("by_creation").order("desc").take(200);
return matches.filter((item) => String(item.text).includes(search)).length;
})
- Read-only database, like a query, but not live: the client calls it once with
useAction. - Arguments up to 16 KiB, result up to 48 KiB, both JSON. Runs for at most 5 seconds.
- Each call counts toward the daily request and mutation limits when hosted.
Endpoints
endpoint({ method: "POST", path: "/api/items" }, async (ctx, req) => {
const body = await req.json<{ listId: string; text: string }>();
const item = await ctx.db.items.insert({ listId: body.listId, text: body.text });
return json(item, { status: 201 });
})
endpoint(route, handler):
route.method: an uppercase HTTP method, such as"GET"or"POST".route.path: an absolute path such as/api/items. No query string, fragment, or backslash. Paths are matched exactly. Reserved:/,/index.html,/client.js,/auth/...,/__fold/...,/__span/.... When hosted,/storageand/storage/...are taken by file storage, so do not use them.route.readOnly: defaults totrueforGETandHEAD,falsefor other methods. A read-only endpoint gets a read-onlyctx.db. A writable one runs in a transaction like a mutation and counts toward the daily mutation limit when hosted.
The request (EndpointRequest):
| Member | Type |
|---|---|
method, path, url | string |
headers | get(name), has(name), entries() |
query | URLSearchParams |
text() | Promise<string> |
json<T>() | Promise<T> |
bytes() | Promise<Uint8Array> |
Body readers can be called more than once. The request body may be up to 2 MiB.
Return a response with a helper:
| Helper | Response |
|---|---|
json(value, { status?, headers? }) | JSON body |
text(value, { status?, headers? }) | Plain text body |
empty({ status?, headers? }) | No body |
redirect(url, { status?, headers? }) | Redirect to url |
Endpoint callers authenticate with the same-origin guest cookie, or with a Google identity token in the X-Fold-Token header. Authorization is left for your app. See Auth.
ctx
Every handler gets ctx as its first argument:
| Field | Type | Notes | |
|---|---|---|---|
ctx.auth | AuthContext | The caller. userId, isGuest, isSignedIn, displayName, and the guards requireIdentity() and requireSignedIn(). See Auth. | |
ctx.db | ReadDatabase or WriteDatabase | One table API per schema table. Writable only in mutations and writable endpoints. | |
ctx.env | `Record<string, string \ | undefined>` | Values from .env.fold.server in local dev. Empty in anonymous deploys. |
ctx.log | { info, warn, error } | Each takes (message: string, data?: unknown). Readable with logs --port in local dev. Hosted logs are coming soon. |
Handler capabilities
| Query | Mutation | Action | Endpoint | |
|---|---|---|---|---|
| Called from | useQuery, usePaginatedQuery | useMutation | useAction | Any HTTP client |
| Database | Read | Read and write, one transaction | Read | Read (GET, HEAD), or read and write |
| Live updates | Yes | No | No | No |
| Time limit, hosted | 10 s | 10 s | 5 s | 10 s |
| Counts as, hosted | Request | Request and mutation | Request and mutation | Request, plus mutation if writable |
Outbound fetch | Local dev only | Local dev only | Local dev only | Local dev only |
ctx.env values | Local dev only | Local dev only | Local dev only | Local dev only |
Outbound fetch and server env need a claimed deploy. Claiming is coming soon, so today they work only in npx @aozumi/fold dev. An anonymous deploy rejects any server or shared file that mentions fetch.
Fold has no scheduler. To ingest data on a schedule, have an outside service call a writable endpoint. See Dashboard counts.
Errors
Throw an Error to fail a call. The message reaches the caller: useMutation and useAction promises reject with it, useQuery throws it during render (catch it with ErrorBoundary), and endpoints answer 500 with { "error": "<message>" }. Do not put secrets in error messages.