Database
The ctx.db API: insert, get, update, delete, and index queries with ranges, order, take, first, collect, count, and paginate.
Every call is async. Await or return each one. ctx.db has one property per table in your schema. Queries, actions, and GET/HEAD endpoints get read methods only. Mutations and writable endpoints also get insert, update, and delete.
The examples use this schema:
schema: {
tasks: table({
title: string(),
done: boolean().default(false),
priority: number().default(0),
dueAt: number().optional(),
ownerId: userId()
})
.index("by_owner", ["ownerId"])
.index("by_owner_done", ["ownerId", "done"])
.index("by_owner_due", ["ownerId", "dueAt"])
}
Rows
Every row has the fields you declared plus three Fold sets:
| Field | Value |
|---|---|
id | A unique string. Use it with get, update, delete, and id() fields. |
createdAt | ISO timestamp string, set on insert. |
updatedAt | ISO timestamp string, set on insert and every update. |
You cannot set or update these three yourself.
insert(value)
const task = await ctx.db.tasks.insert({ title: "Ship it", ownerId: userId });
// task.id, task.createdAt, task.done === false, task.priority === 0
Returns the new row with defaults filled in. Required fields must be present. Fields with .default() or .optional() may be left out. Pass null for an optional field to leave it empty. An unknown field throws Unknown field for tasks: <name>.
get(id)
const task = await ctx.db.tasks.get(taskId);
if (!task) {
throw new Error("Task not found.");
}
Returns the row, or null.
update(id, patch)
const updated = await ctx.db.tasks.update(taskId, { done: true });
await ctx.db.tasks.update(taskId, { dueAt: null }); // clear an optional field
Changes only the fields in patch. Returns the updated row, or null if no row has that ID. Passing null clears an optional field.
delete(id)
const removed = await ctx.db.tasks.delete(taskId); // true if a row was deleted
Check ownership before writing
A row ID is not a credential. Before you update or delete a row on behalf of a user, load it and compare its owner:
mutation(async (ctx, taskId: string) => {
const { userId } = ctx.auth.requireIdentity();
const task = await ctx.db.tasks.get(taskId);
if (!task || task.ownerId !== userId) {
throw new Error("Task not found.");
}
await ctx.db.tasks.delete(taskId);
})
Queries: withIndex
Every list read goes through an index:
ctx.db.<table>.withIndex(<index name>, (q) => <range>).order(<"asc" | "desc">).<terminal>()
by_creation exists on every table and sorts by creation time. Use it for unfiltered reads:
const newest = await ctx.db.tasks.withIndex("by_creation").order("desc").take(20);
Filter by declaring an index on the fields you filter on, then pass a range:
const mine = await ctx.db.tasks
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
There is no full-table filter. where(), orderBy(), limit(), and all() throw. Filter small results in JavaScript after an index query if you need to.
Range operators
| Operator | Meaning |
|---|---|
q.eq(field, value) | Field equals value. eq(field, null) on an optional field matches rows where it is absent. |
q.gt(field, value) | Greater than |
q.gte(field, value) | Greater than or equal |
q.lt(field, value) | Less than |
q.lte(field, value) | Less than or equal |
Rules:
- Clauses follow the index's field order. Start with
eqon the first field, theneqon the next, and so on. - After the
eqclauses, you may add at most one lower bound (gtorgte) and one upper bound (ltorlte) on the next field. - Range bounds only match rows where the field has a value.
- The range callback must return the builder it was given.
// Open tasks for this user
ctx.db.tasks.withIndex("by_owner_done", (q) => q.eq("ownerId", userId).eq("done", false)).collect();
// Due in the next 7 days, soonest first
const now = Date.now();
ctx.db.tasks
.withIndex("by_owner_due", (q) => q.eq("ownerId", userId).gte("dueAt", now).lt("dueAt", now + 7 * 86_400_000))
.order("asc")
.collect();
Wrong order throws, for example Index tasks.by_owner_done expected field ownerId, received done.
order(direction)
"asc" (default) or "desc". Rows are sorted by the index fields, then createdAt, then id. So withIndex("by_owner", ...).order("desc") returns a user's newest rows first.
Terminals
| Call | Returns | Notes | |
|---|---|---|---|
.collect() | Promise<Row[]> | All matches. Throws collect() exceeded the 1000 row return limit. Use paginate(). past 1,000 rows. | |
.take(n) | Promise<Row[]> | At most n rows. n is an integer from 0 to 1,000. | |
.first() | `Promise<Row \ | null>` | The first match. |
.count() | Promise<number> | Number of matches, without reading row bodies. Each counted entry still counts toward the 5,000 rows-read budget. | |
.paginate({ cursor, numItems }) | Promise<{ page, continueCursor, isDone }> | One page. numItems is 1 to 1,000. Pass cursor: null for the first page. |
Pagination
import { fold, query, type PaginationOptions } from "@aozumi/fold/server";
queries: {
feed: query(async (ctx, args: { pagination: PaginationOptions }) => {
return ctx.db.tasks.withIndex("by_creation").order("desc").paginate(args.pagination);
})
}
Pass continueCursor back as the next cursor until isDone is true. A cursor only works for the same query shape, and stops working after a schema change (Pagination cursor belongs to a stale schema.). On the client, usePaginatedQuery handles cursors for you. See Client.
Read and write budgets
Each handler call has a budget. Hitting one throws Database <name> limit exceeded (<limit>). These apply in local dev and hosted.
| Budget | Limit per call |
|---|---|
| Rows read (including counted entries) | 5,000 |
| Rows returned | 1,000 |
| Bytes read | 4 MiB |
get() calls | 1,000 |
| Index queries | 100 |
| Writes | 1,000 |
| One field value | 64 KiB |
| One index key | 2,048 bytes |
A loop over paginate() inside one handler shares the same budget. It does not get around these limits.
Dashboard counts
For counts and totals, keep a summary row up to date when you write, and read the summary instead of scanning.
import { fold, mutation, number, query, string, table } from "@aozumi/fold/server";
export default fold({
name: "clicks",
schema: {
events: table({ kind: string(), at: number() }).index("by_kind_at", ["kind", "at"]),
counters: table({ key: string(), total: number().default(0) }).index("by_key", ["key"])
},
mutations: {
record: mutation(async (ctx, kind: string) => {
const at = Date.now();
await ctx.db.events.insert({ kind, at });
const counter = await ctx.db.counters.withIndex("by_key", (q) => q.eq("key", kind)).first();
if (counter) {
await ctx.db.counters.update(counter.id, { total: Number(counter.total) + 1 });
} else {
await ctx.db.counters.insert({ key: kind, total: 1 });
}
})
},
queries: {
totals: query(async (ctx) => ctx.db.counters.withIndex("by_key").collect()),
lastHour: query(async (ctx, args: { kind: string }) => {
const since = Date.now() - 3_600_000;
return ctx.db.events.withIndex("by_kind_at", (q) => q.eq("kind", args.kind).gte("at", since)).count();
})
}
});
Guidance:
- Store times as
number()epoch milliseconds and index them after the fields you filter on, as inby_kind_at. count()is fine for a few thousand matching rows. Past 5,000 it hits the rows-read budget. Use summary rows for anything that can grow past that.- The mutation updates the event and the counter in one transaction, so totals stay exact.
- A live
useQuery("totals")on the client updates as soon asrecordruns. - Stored data in an anonymous deploy is capped at 1 MB. Delete or roll up old rows if events pile up.
- To feed a dashboard from another system, have that system
POSTto a writable endpoint that calls the same insert-and-count logic, one small batch per request. Anonymous deploys cannotfetchout, andctx.envis empty, so they cannot pull data or check a secret from server env. Protect an ingest endpoint withctx.auth.requireSignedIn()or keep it to data that is safe for anyone to send.
Types
Inside fold({ schema, ... }), rows are typed from the schema: task.title is a string, task.dueAt is number | undefined. On the client, createClient<typeof app>() gives each query result the same JSON shape. The Id<"tasks"> type from @aozumi/fold/server marks an ID string by table.