Browse the docs
Build
Database
Tables, field types, indexes, sort rules, collect vs paginate, count, relationships, user references, consistency, schema changes, and a schedule-driven ingest example.
Every fuast app has its own database. Calls are async, reads go through declared indexes, and every handler runs inside one transaction. The API matches Lakebed's database API v1, plus a json() field type.
Tables and fields
import { boolean, capsule, id, json, number, string, table, userId } from "fuast/server";
export default capsule({
schema: {
rooms: table({ name: string() }),
messages: table({
roomId: id("rooms"),
authorId: userId(),
body: string(),
pinned: boolean().default(false),
editedAt: number().optional(),
meta: json().optional()
})
.index("by_room", ["roomId"])
.index("by_room_pinned", ["roomId", "pinned"])
.index("by_author", ["authorId"])
}
});Field types
| Helper | Row type | Notes |
|---|---|---|
string() |
string |
No NUL characters or unpaired surrogates. text() is an alias. |
number() |
number |
Any finite float64. NaN and Infinity are rejected on write. |
boolean() |
boolean |
|
id("table") |
string |
A row id from the named table. |
userId() |
string |
A fuast user reference. Follows a guest to their account on sign-in. See user references. |
json() |
any JSON value | fuast extra. Objects, arrays, and scalars. Cannot be indexed. |
Chain .optional() to let a field be absent and .default(value) to fill it when an insert omits it. They chain in either order.
Every row also has id (string), createdAt, and updatedAt (epoch milliseconds), set by fuast.
A single field value is at most 64 KiB. Put larger payloads in storage and keep the key in the row.
Numbers and time
number() sorts numerically in indexes: -10 before 2 before 10. Store timestamps as epoch milliseconds from Date.now() or Date.parse(value), and reject a non-finite result before writing. Numeric ranges then compare times without string formats or time zones.
Optional fields
todos: table({
text: string(),
dueAt: number().optional(),
priority: number().optional().default(0)
}).index("by_due", ["dueAt"])An absent optional field has no key in the stored row, so it reads as undefined. Pass null to insert or update to mean "no value". An update with null removes the field. Stored rows never contain null for an optional field.
An optional field with a default always has a value. Clearing it with null restores the default.
Reading and writing
const id = await ctx.db.todos.insert({ text: "milk", ownerId: userId }); // returns the new id
const todo = await ctx.db.todos.get(id); // row or null
await ctx.db.todos.update(id, { done: true }); // merge a patch
await ctx.db.todos.replace(id, { text: "oat milk", ownerId: userId }); // replace all fields
await ctx.db.todos.delete(id);Await or return every call. A database call that is not awaited may run after the transaction ends and fail.
Validation errors use these exact messages:
Expected <table>.<field> to be a <type>.Missing value for <table>.<field>Unknown field <table>.<field>Value for <table>.<field> exceeds 65536 bytes.
Indexes
Every read uses an index. Declare one per access pattern with .index(name, fields):
const recent = await ctx.db.messages
.withIndex("by_room", (q) => q.eq("roomId", roomId))
.order("desc")
.take(20);A range is zero or more eq clauses in index field order, then at most one range field with gt or gte and/or lt or lte:
ctx.db.scores.withIndex("by_game_points", (q) => q.eq("gameId", g).gte("points", 100).lt("points", 500))Out-of-order clauses throw Index range must follow index field order. A missing index throws No index named <name> on <table>.
Every index has an implicit createdAt, id suffix, so rows with equal field values come back in a stable creation order. Every table has a built-in by_creation index with no declared fields. Range on it with createdAt:
ctx.db.events.withIndex("by_creation", (q) => q.gte("createdAt", since))ctx.db.todos.order("desc").take(10), .collect(), .take(), and .first() directly on a table use by_creation.
Indexes do not enforce uniqueness. To keep one row per key, look it up with first() and insert only when it is missing. Writes on one app are serialized, so that check is safe as long as every writer does it.
Terminals
After withIndex(...) and an optional .order("asc" | "desc") (default asc), call exactly one:
| Terminal | Returns |
|---|---|
collect() |
Every matching row. |
take(n) |
At most n rows. |
first() |
The first row, or null. Use .order("desc").first() for the latest. |
paginate({ cursor, numItems }) |
{ page, isDone, continueCursor }. |
count() |
The number of matching rows, without loading them. |
Sort rules
Within one index field, values sort in this order:
- Absent (an optional field with no value). First in
asc, last indesc. false, thentrue.- Strings, by UTF-8 byte order. Uppercase sorts before lowercase.
- Numbers, by value.
-0and0are the same key.
q.eq("priority", null) matches rows where the field is absent. gt, gte, lt, and lte only match present values.
Collect or paginate
collect() returns every match, as long as the handler stays inside its budget. A handler can return at most 1000 rows across all its database calls. One collect() that finds more throws collect() exceeded the 1000 row return limit. Use paginate(). Several calls that add up to more throw Database rowsReturned limit exceeded (1000). Either one rolls back the transaction.
Use collect() only for result sets your app keeps small. For a list that grows, use take(n) for a fixed window or paginate() for scrolling. A handler can also read at most 5000 rows and 4 MiB, so a broad scan can fail before it returns 1000 rows. Pagination loops inside one handler share the same budget and do not bypass it.
Count
const openCount = await ctx.db.todos
.withIndex("by_owner_done", (q) => q.eq("ownerId", userId).eq("done", false))
.count();count() charges one row read per match against the 5000-row read budget and nothing against rows returned or bytes read. If the remaining budget is not enough, it throws instead of returning a partial count. For totals larger than that, keep summary rows. See dashboard counts.
Pagination
Server: accept an argument with a pagination property and pass it to paginate().
import { query, type PaginationOptions } from "fuast/server";
messages: query(async (ctx, args: { roomId: string; pagination: PaginationOptions }) =>
ctx.db.messages
.withIndex("by_room", (q) => q.eq("roomId", args.roomId))
.order("desc")
.paginate(args.pagination)
)Client: pass the other arguments. The hook adds pagination.
const messages = client.usePaginatedQuery("messages", { roomId }, { initialNumItems: 25 });
return (
<>
{messages.page.map((m) => <p key={m.id}>{m.body}</p>)}
{messages.isDone ? null : <Button onClick={messages.loadMore}>Load more</Button>}
</>
);The hook returns { page, isDone, continueCursor, loadMore(), reset(), status }. page holds the items of every loaded page. status is one of:
status |
Meaning |
|---|---|
"loading" |
The first page has not arrived yet. page is empty. |
"canLoadMore" |
Pages are loaded and there are more. loadMore() fetches the next one. |
"loadingMore" |
loadMore() was called and the next page has not arrived yet. |
"exhausted" |
The last page is loaded (isDone is true). loadMore() does nothing. |
{messages.status === "loading" ? <Spinner /> : null}
{messages.status === "canLoadMore" ? <Button onClick={messages.loadMore}>Load more</Button> : null}
{messages.status === "loadingMore" ? <Button disabled>Loading…</Button> : null}numItems must be an integer from 1 to 1000, or the call throws pagination numItems must be an integer between 1 and 1000. cursor is null for the first page. Cursors are signed and bound to the app, schema, table, index, range, order, query name, and arguments. A cursor that does not match throws Malformed pagination cursor. Each page is its own snapshot. The hook keeps every loaded page subscribed and starts over from the first page when the data changes underneath it.
Dashboard counts
To show totals over more rows than one handler can read, store a count per UTC day next to the source rows and update both in the same transaction. Because fuast has schedules, the ingest can run on a timer inside the app. No external scheduler and no browser involved.
This app pulls merged pull requests from GitHub every 10 minutes, one page per run, and keeps daily counts:
import { capsule, number, query, schedule, string, table } from "fuast/server";
import type { ServerContext } from "fuast/server";
const DAY = 86_400_000;
const PAGE_SIZE = 25;
const REPO = "fuast/fuast";
export default capsule({
name: "Merged PRs",
auth: { requireSignIn: false },
schema: {
prs: table({ number: number(), mergedAt: number() })
.index("by_number", ["number"])
.index("by_merged", ["mergedAt"]),
days: table({ day: number(), count: number() }).index("by_day", ["day"]),
cursors: table({ name: string(), page: number(), lastRunAt: number() }).index("by_name", ["name"])
},
schedules: {
ingest: schedule("*/10 * * * *", (ctx) => ingestPage(ctx))
},
queries: {
daily: query(async (ctx, start: number) => {
if (!Number.isSafeInteger(start) || start % DAY !== 0) throw new Error("Use a UTC day boundary.");
return ctx.db.days
.withIndex("by_day", (q) => q.gte("day", start).lt("day", start + 14 * DAY))
.take(14);
}),
lastRun: query(async (ctx) =>
ctx.db.cursors.withIndex("by_name", (q) => q.eq("name", "github")).first()
)
}
});
async function ingestPage(ctx: ServerContext) {
const token = ctx.env.GITHUB_TOKEN;
if (!token) throw new Error("Set GITHUB_TOKEN in .env.fuast.server.");
// ServerContext is not tied to this schema, so check stored fields before using them.
const cursor = await ctx.db.cursors.withIndex("by_name", (q) => q.eq("name", "github")).first();
const page = typeof cursor?.page === "number" ? cursor.page : 1;
const res = await fetch(
`https://api.github.com/repos/${REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=${PAGE_SIZE}&page=${page}`,
{ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } }
);
if (!res.ok) throw new Error(`GitHub returned ${res.status}.`);
const items: unknown = await res.json();
if (!Array.isArray(items) || items.length > PAGE_SIZE) throw new Error("Invalid GitHub page.");
let inserted = 0;
for (const item of items) {
if (typeof item !== "object" || item === null) throw new Error("Invalid PR.");
const { number: prNumber, merged_at: mergedAtRaw } = item as { number?: unknown; merged_at?: unknown };
if (mergedAtRaw === null) continue;
if (typeof prNumber !== "number" || !Number.isSafeInteger(prNumber) || typeof mergedAtRaw !== "string") {
throw new Error("Invalid merged PR.");
}
const mergedAt = Date.parse(mergedAtRaw);
if (!Number.isFinite(mergedAt)) throw new Error("Invalid merge time.");
const existing = await ctx.db.prs.withIndex("by_number", (q) => q.eq("number", prNumber)).first();
if (existing) continue;
const day = Math.floor(mergedAt / DAY) * DAY;
const bucket = await ctx.db.days.withIndex("by_day", (q) => q.eq("day", day)).first();
await ctx.db.prs.insert({ number: prNumber, mergedAt });
if (bucket) {
if (typeof bucket.count !== "number") throw new Error("Invalid daily count.");
await ctx.db.days.update(bucket.id, { count: bucket.count + 1 });
}
else await ctx.db.days.insert({ day, count: 1 });
inserted += 1;
}
// Walk older pages during the first backfill, then keep re-reading page 1.
const nextPage = items.length === PAGE_SIZE && page < 20 ? page + 1 : 1;
if (cursor) await ctx.db.cursors.update(cursor.id, { page: nextPage, lastRunAt: Date.now() });
else await ctx.db.cursors.insert({ name: "github", page: nextPage, lastRunAt: Date.now() });
ctx.log.info("GitHub ingest", { page, inserted, nextPage });
}How it fits the limits:
- The schedule is one transaction. If it throws, no rows or counts change, and the next run retries the same page.
- Each PR costs at most two indexed lookups and two writes, so a 25-item page stays far inside 100 scans and 1000 writes per handler.
- The
fetchholds the app's write lock while it waits. One small page per run keeps other writes fast. - Re-reading a page never double-counts, because the
by_numberlookup skips PRs already stored. lastRunlets the UI say when data was last refreshed.
Set GITHUB_TOKEN in .env.fuast.server locally and in the dashboard Env tab for the hosted app. On the client, pass a UTC midnight to client.useQuery("daily", start), fill missing days with zero, and draw the counts. Change start when the visible window moves, because the clock alone does not re-run a subscription.
To test the ingest without waiting for the clock, call the same ingestPage helper from a mutation or a protected endpoint while you develop. See schedules.
This keeps PR numbers and merge times forever. For a long-running app, add retention: delete old source rows and their expired day buckets in the same transaction. A query cannot read unlimited rows by selecting fewer fields, because there is no projection API.
Relationships
Use id("table") fields and traverse them in bounded loops. There are no joins.
const page = await ctx.db.messages.withIndex("by_room", (q) => q.eq("roomId", roomId)).take(20);
const withRooms = [];
for (const m of page) withRooms.push({ ...m, room: await ctx.db.rooms.get(m.roomId) });A handler can make at most 1000 direct get calls. Model many-to-many relationships with a join table indexed in both directions.
User references
Use userId() for a fuast user and id("table") for a row in your own table, even if your app has a table named users.
todos: table({ text: string(), ownerId: userId() }).index("by_owner", ["ownerId"])userId() stores an opaque string and marks the field for transfer: when a guest signs in on a public app, every userId() value equal to the guest's id becomes the account's id, in one transaction. Plain strings, JSON values, and text that contains an id do not transfer.
The field does not authorize anything. Get the caller with ctx.auth.requireIdentity(), filter by its userId, and check ownership before updates and deletes. Always write the caller's current userId, never a stored or client-supplied one. See auth.
Consistency
- A query sees one repeatable snapshot and cannot write.
- A mutation, writable endpoint, or schedule runs in one write transaction. Its writes commit together or not at all, and it reads its own writes.
- Writes are serialized per app.
- An action reads with read-only access and does not hold the write lock.
- A throw, a timeout, or a limit error rolls the transaction back.
- After a write commits, every subscribed query that read a table the write touched re-runs, and clients receive the new result only if it changed. The client that made the write gets its updated queries before the mutation's promise resolves.
Schema changes
Each deploy compares the new schema with the stored rows:
- Tables that remain keep their rows. Removing a field does not delete stored values.
- A new index is backfilled from existing rows.
- A new required field without a default on a table with rows rejects the deploy with
Missing value for <table>.<field>. Make it.optional()or give it a.default(). - Changing a field's type on a populated table is allowed only if every stored row validates against the new type.
- Removing or renaming a table that has rows rejects the deploy with code
DESTRUCTIVE_SCHEMA_CHANGEand lists every such table with its row count. Empty tables can be dropped freely.
To delete populated tables on purpose, confirm each one with its exact count:
fuast deploy --delete-table archived=12 --delete-table drafts=3The confirmations must match the current omitted tables and counts exactly, or the deploy is rejected with fresh counts. A rejected deploy changes nothing. Reverting the code after a confirmed deletion does not bring the rows back, so export first with fuast db export --out before.json.
fuast dev applies the same rules on hot reload and keeps the previous build running when a change is rejected. fuast dev --reset wipes local data instead.
Rename a table
fuast does not guess renames. Migrate in steps:
- Add the new table next to the old one, with an indexed
sourceId: string()field. - Deploy handlers that stop writes to the old table.
fuast db export --out before.json.- Copy rows in bounded mutations. Look up
sourceIdbefore each insert so retries are safe. - Rewrite references to the new ids, then switch reads and writes to the new table.
- Remove the old table from the schema and deploy with
--delete-table old=COUNT.
Export and import
fuast db export --out backup.json # every table, paged, written atomically
fuast db import backup.json # owner only, into empty tables onlyThe export is { fuastBackup: 1, appId, schemaHash, consistency: "per_page", ordering: { tables: "name_asc", rows: "by_creation_asc" }, tables: [...] }. Each page is a separate snapshot, so a running app can change between pages. Stop writes first when you need an exact copy.