Browse the docs
Examples
Live board example
A shared sticky-note board that syncs between windows. One live query per board, optimistic updates, a typing indicator, and the limits that make a public guest app safe to leave open.
bunx @revnu/fuast new my-board --template live-demoA shared checklist of sticky notes. Every window on the same board sees adds, checks, and deletes as they happen, plus "pane 2 is typing…". It is the app embedded twice, side by side, on the fuast.com homepage.
What it shows
- One
boardquery per window. It returns the room's notes and who is typing, so a single subscription keeps the whole screen live. - Optimistic updates on every mutation, so your own window changes before the server answers.
- A typing indicator made of ordinary rows: one write when typing starts, one every 4 seconds while it continues, none when the note is sent.
- Data scoped by a
roomid from the URL instead of by user, because guests on a public link have no lasting identity. - Limits for a public app: 140-character plain text, 30 notes per room, a per-visitor write budget, and a
sweepschedule that deletes everything older than an hour. - An
embed=1mode that hides the page chrome for use in an iframe.
URL parameters
| Parameter | Meaning |
|---|---|
room |
The board: 8 to 64 letters, digits, -, or _. Without it the app starts a new board with a random id. |
who |
1 or 2. Which pane this window is. It picks the note colour and the typing label, nothing else. |
embed=1 |
Fill the frame and hide the title and footer. |
Server pattern
The room id is the only key to a board. Every handler validates it and reads through the room's index, and an id from the client only counts inside that room:
board: query(async (ctx, room: string) => {
const key = requireRoom(room); // 8-64 url-safe characters, or it throws
const items = await ctx.db.items.withIndex("by_room", (q) => q.eq("room", key)).take(MAX_ITEMS + 5);
const typing = await ctx.db.typing.withIndex("by_room", (q) => q.eq("room", key)).take(4);
return { items: items.map(/* id, text, done, who, createdAt */), typing: typing.map(/* who, at */) };
})const item = await ctx.db.items.get(id);
if (!item || item.room !== key) throw new Error("That note is gone.");add inserts, then trims the room to the newest 30 in the same transaction, so a room can never hold more:
const all = await ctx.db.items.withIndex("by_room", (q) => q.eq("room", key)).take(MAX_ITEMS + 10);
for (const old of all.slice(0, Math.max(0, all.length - MAX_ITEMS))) await ctx.db.items.delete(old.id);fuast has no built-in per-user rate limit for handlers, so the write budget is a row per visitor, checked first in every mutation:
const row = await ctx.db.writers.withIndex("by_user", (q) => q.eq("user", caller)).first();
// new minute: reset the count. Over 60 this minute: throw. Otherwise count + 1.It stops a runaway client, not a determined one: a guest who drops their cookie gets a new identity and a new budget. The room cap, the hourly sweep, and the app's daily quotas are what bound abuse.
The cleanup is a schedule that does one bounded batch per run:
sweep: schedule("*/5 * * * *", async (ctx) => {
const old = await ctx.db.items.withIndex("by_creation", (q) => q.lt("createdAt", Date.now() - ROOM_TTL_MS)).take(500);
for (const row of old) await ctx.db.items.delete(row.id);
})Client pattern
Each mutation predicts its result on the cached board query. See optimistic updates.
const setDone = client.useMutation("setDone").withOptimisticUpdate((store, room: string, id: string, done: boolean) => {
store.setQuery("board", [room], (current) =>
current && { ...current, items: current.items.map((item) => (item.id === id ? { ...item, done } : item)) });
});New notes get a temporary id ("optimistic:…"), and their buttons stay disabled until the real row arrives.
The typing label is timed from when the other pane's ping arrives, not from the ping's timestamp, so the two clocks never need to agree. It hides 7 seconds after the last ping, which also covers a tab that closed while typing.
What it costs
Every write to a table re-runs the live queries that read that table, for every open board, and each re-run counts toward the app's daily requests. That is why the board has one query, typing sends a ping every few seconds instead of every keystroke, and nothing is written until a visitor does something.
Try it
bunx @revnu/fuast devOpen http://localhost:3000/, then use "Open a second window to see it sync". Check the limits from the terminal:
bunx @revnu/fuast run add '"my-board-01"' 1 '"hello"' --port 3000
bunx @revnu/fuast run board '"my-board-01"' --port 3000
bunx @revnu/fuast run schedule sweep --port 3000Deploy and make it public with bunx @revnu/fuast share --visibility public. A public app can be framed by the fuast site it is hosted on and by no other site. See embedding.