Fireproof Database API Guide
prompts/pkg/llms/fireproof.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Fireproof is a document database with live sync, designed to make browser apps easy. On vibes.diy it runs against Firefly: each app holds a local replica (IndexedDB) that IS the database, so writes succeed locally and instantly and then sync in the background. The Firefly server validates every synced write with the app's access rules on ingest and can still reject it (access denied, conflict); accepted writes stream live to every viewer. Use it in any JavaScript environment with a unified API that works both in React (with hooks) and as a standalone core API.
Key Features
- Apps run anywhere: Bundle UI, data, and logic together.
- Real-Time, local-first: Writes land in the local replica instantly and sync in the background; the server validates each on ingest and streams the accepted ones live to every viewer.
useLiveQuerykeeps the UI in sync as data arrives, so you render empty states rather than loading spinners — but a synced write can still be rejected on ingest (access denied, conflicts): the local optimistic revision converges server-wins — and the platform tells the visitor about every refusal, in the app's ownforbidden("…")words when it gave some. Keep the UI reactive to the store rather than depending on a toast; don't assume every write lands, but don't hand-roll denial errors either. - Unified API: TypeScript works with Deno, Bun, Node.js, and the browser.
- React Hooks: Leverage
useLiveQueryanduseDocumentfor live collaboration. Note: these are NOT top-level exports — they are returned by theuseFireproof()hook. Always destructure fromconst { useLiveQuery, useDocument, database } = useFireproof("dbName"). A child component that was handeddatabasemay calluseFireproof(database)instead of repeating the name; it is the same database.
File structure: A vibe's source is one or more files. /App.jsx is the entry point (React component).
Fireproof enforces cryptographic causal consistency and ledger integrity using hash history, providing git-like versioning with lightweight blockchain-style verification. On vibes.diy, a write commits to the local replica immediately and syncs to the Firefly server in the background; the server is the authority on acceptance: it stores each document in a per-document append-only sequence, applies the app's access rules to validate and route it on ingest, and then syncs it to viewers. Because every synced write is validated server-side on ingest, it is subject to access rules and can be rejected.
Installation
The use-fireproof package provides both the core API and React hooks. React hooks are the recommended way to use Fireproof in LLM code generation contexts. Fireproof databases persist data through the Firefly server and sync it live to every viewer. Each database is identified by a string name, and you can have multiple databases per application—often one per collaboration session, as they are the unit of sharing. Database names are the app's own to choose — pick a plain camelCase name for every database the app creates; prefix-namespaced names like media:* are platform-reserved for the databases platform components bring with them.
Each document has an _id, which can be auto-generated or set explicitly. Auto-generation is recommended to ensure uniqueness and avoid conflicts. The server keeps a per-document sequence, so two clients writing the same _id at the same time can collide and one write will be rejected — see the note on continuous updates below. Prefer one document per event over many rapid writes to a single hot document.
Use granular documents, e.g. one document per user action, so saving a form or clicking a button should typically create or update a single document, or just a few documents. Avoid patterns that require a single document to grow without bound.
useLiveQuery populates and refreshes the UI reactively as data arrives, so render an inviting empty state while a list has nothing in it. Writes are optimistic and local: put()/save()/del() land in the local replica instantly, and transport or offline failures queue and retry on their own. The server's access rules have the final word: a refused write converges server-wins to the server's version, and the platform tells the visitor — in the app's own forbidden("…") words when it gave some, its own line otherwise, and "saved on this device" when a signed-out visitor's save waits for sign-in. So write plainly and let the store be the feedback: render from useLiveQuery/useDocument, gate write surfaces up front with useVibe(dbName).can.create/edit/delete so only allowed actions render, and keep try/catch for a save carrying _files (covered with the image uploader below). A toggle shows the whole shape — flip it at once, follow its receipt beside the control, and the list re-reads itself:
jsxconst { database, useLiveQuery } = useFireproof("tasks");
const { docs } = useLiveQuery("type", { key: "task" });
async function toggle(task) {
const { synced } = await database.put({ ...task, done: !task.done }); // the row flips now
synced.on((s) => setNote(s.status === "refused" && s.message ? s.message : ""));
}
// each row can show its own state: task._synced.status === "pending" → a quiet "saving" mark
put() and del() save locally at once and resolve right away, so a resolved put means the record is saved on this device — never treat it as proof the server has it. Most apps need nothing more, because the platform shows refusals. Every result also carries synced, a receipt for this one write: synced.status is "pending", then "accepted" (with synced.seq), "refused" (with synced.code and, when the app's rules gave one, synced.message), "held" (kept on this device until the visitor signs in, then it goes out), or "superseded" (a later save to the same record went out instead). It never rejects. Use synced.on(...) for feedback beside the control that caused the write: it calls your function at once and on each change, lets go after the last one, and returns an unsubscribe. Every doc from useLiveQuery carries the same shape as doc._synced for per-row state, such as a quiet "saving" mark while doc._synced.status === "pending"; it is not part of the document, so spreading or saving the doc leaves it behind:
jsxfunction NoteForm({ text }) {
const { database } = useFireproof("notes");
const [note, setNote] = useState("");
async function save() {
const { synced } = await database.put({ type: "note", text });
synced.on((s) => setNote(s.status === "refused" && s.message ? s.message : ""));
}
return (
<Stack gap={2}>
<Row>
<Button type="button" onClick={save}>Save</Button>
</Row>
{note && <p role="status">{note}</p>}
</Stack>
);
}
While the app's first build is still finishing, the preview runs on its example records, and saving opens up the moment the build completes. A save in that window resolves with ok: false and a receipt that is already refused, with code: "draft-read-only" and a friendly ready-made message. Treat it as a calm status line: the same quiet role="status" note as above (or nothing at all), with the visitor's typed text and selections kept in component state so the next save carries them. The next save that lands clears the note:
jsxfunction AddItem() {
const { database } = useFireproof("items");
const [text, setText] = useState("");
const [note, setNote] = useState("");
async function add() {
const typed = text;
setText(""); // the field is ready for the next item the moment they press Add
const { ok, synced } = await database.put({ type: "item", text: typed });
setNote(synced.code === "draft-read-only" ? synced.message : "");
if (!ok) setText((now) => now || typed); // hand their words back while the field is still empty
}
return (
<Stack gap={2}>
<Field label="Item">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={text} onChange={setText} />}</Field>
<Row>
<Button type="button" onClick={add}>Add</Button>
</Row>
{note && <p role="status">{note}</p>}
</Stack>
);
}
Clear the field as the person submits, before the put: capture what they typed, empty the field, then write the captured value. The field is ready for their next entry the moment they press Add, and anything they type while the write settles stays theirs for the next save. When the result comes back ok: false, hand the words back with an updater that keeps newer typing, setText((now) => now || typed). useDocument's submit() follows the same shape on its own: it hands back a fresh draft as the person submits, saves the submitted one, and restores it when that save does not land while the form is still untouched.
Basic Example
When the composer is on screen, wire these visible actions in the same build, using the app's own record names. One composer has Add and Ask actions. Add extracts and normalizes facts, saves each record immediately, and shows saved records with edit actions; one item and a pasted batch share this action. Each saved row has Change by sentence: selecting it changes Add to Apply correction, which saves onto that same record id. Ask sends the typed question with the relevant saved records and displays a read-only answer beside the composer. Edit opens the selected row’s populated fields. One parent-owned Undo restores the latest successful creation batch or the previous document from either a sentence correction or a field edit.
With the callai skill available, submitting words extracts and normalizes every task they name, then immediately saves one document per task. The saved task is the confirmation and remains editable, with Undo beside the intake after each save. The parent keeps the ids from the last creation or the previous version from an edit, so intake and saved-detail editors share one Undo action. useLiveQuery keeps saved tasks visible; useDocument supplies the detail editor only after a record exists. For a plain message whose text is already the whole record, save that text directly.
App.jsx
jsximport React, { useState } from "react";
import { callAI } from "call-ai";
import { useFireproof } from "use-fireproof";
import { useVibe } from "use-vibes";
import { Button, Field, Heading, Input, Row, RowCard, Stack, Text, Textarea } from "@vibes.diy/pouf";
const taskSchema = {
type: "object",
properties: {
title: { type: "string", description: "The task in the person's words" },
details: { type: "string", description: "Supporting details in the person’s words" },
},
required: ["title", "details"],
};
function normalizeTask(raw) {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const title = typeof raw.title === "string" ? raw.title.trim() : "";
const details = typeof raw.details === "string" ? raw.details.trim() : "";
return title.length > 0 ? { title, details } : undefined;
}
// Single-record extraction and edit-by-sentence share a schema and normalizer.
async function readTask(sentence, current) {
const facts = current === undefined ? {} : { title: current.title, details: current.details };
const response = await callAI(
`Extract one task’s title and supporting details from the person's words. Update only what the correction mentions when a current task is supplied. Today: ${new Date().toISOString().slice(0, 10)}. Current: ${JSON.stringify(facts)}. Person: ${sentence}`,
{ schema: taskSchema }
);
const task = normalizeTask(JSON.parse(response));
if (task === undefined) throw new Error("Add a task in your own words and try again.");
return task;
}
// A pasted list uses an array of the same flat document fields.
async function readTasks(sentence) {
const response = await callAI(
`Extract every task from these words as a separate item, including when the person names just one task. Keep their title and supporting details. Today: ${new Date().toISOString().slice(0, 10)}. List: ${sentence}`,
{ schema: { properties: { items: { type: "array", items: taskSchema } }, required: ["items"] } }
);
const raw = JSON.parse(response);
const items = Array.isArray(raw?.items) ? raw.items.map(normalizeTask) : [];
if (items.length === 0 || items.some((item) => item === undefined)) {
throw new Error("Describe the tasks you want to add and try again.");
}
return items;
}
function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
const { useDocument } = useFireproof(database);
const { doc, merge, save } = useDocument(task);
const [editing, setEditing] = useState(false);
return <RowCard><Stack gap={2}>
<Text>{task.title}</Text><Text muted>{task.details}</Text><Text muted>{task.dueDate}</Text>
{can.edit(task).ok && <>
<Row gap={2} wrap>
<Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</Button>
<Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => setEditing(true)}>Edit details</Button>
</Row>
{editing && <form onSubmit={async (event) => { event.preventDefault(); if (disabled) return; await save(); onSaved(task); setEditing(false); }}>
<Stack gap={3}>
<Field label="Task">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} value={doc.title} onChange={(title) => merge({ title })} />}</Field>
<Field label="Details">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} disabled={disabled} value={doc.details} onChange={(details) => merge({ details })} />}</Field>
<Field label="Due date">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} type="date" value={doc.dueDate} onChange={(dueDate) => merge({ dueDate })} />}</Field>
<Row><Button type="submit" size="sm" variant="quiet" disabled={disabled}>Save details</Button></Row>
</Stack>
</form>}
</>}
</Stack></RowCard>;
}
export default function App() {
const { database, useLiveQuery } = useFireproof("tasks");
const { me, can, ready } = useVibe("tasks");
const { docs } = useLiveQuery("type", { key: "task" });
const [sentence, setSentence] = useState("");
const [current, setCurrent] = useState(undefined);
const [pending, setPending] = useState(undefined);
const [answer, setAnswer] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
// Creation, sentence correction and detail editing replace this one Undo receipt after a successful write.
const [lastWrite, setLastWrite] = useState(undefined);
async function submitSentence(event) {
event.preventDefault();
if (busy || !ready) return;
setBusy(true); setMessage("");
try {
// A failed local batch resumes its unsaved tail without extracting or duplicating earlier records.
let operation = pending;
if (operation === undefined) {
const target = current === undefined ? undefined : docs.find((doc) => doc._id === current._id);
if (current !== undefined && target === undefined) throw new Error("Choose a saved task to correct.");
const items = target === undefined ? await readTasks(sentence) : [await readTask(sentence, target)];
const remaining = items.map((item) => target === undefined
? { _id: crypto.randomUUID(), type: "task", title: item.title, details: item.details, dueDate: "", authorHandle: me?.userHandle }
: { ...target, title: item.title, details: item.details });
operation = { remaining, created: [], replaced: target };
}
if (operation.remaining.some((doc) => operation.replaced === undefined ? can.create(doc).ok === false : can.edit(doc).ok === false)) {
throw new Error("This task is not editable right now.");
}
setPending(operation);
while (operation.remaining.length > 0) {
const doc = operation.remaining[0];
await database.put(doc);
operation = { ...operation, remaining: operation.remaining.slice(1), created: operation.replaced === undefined ? [...operation.created, doc._id] : [] };
setPending(operation);
setLastWrite({ created: operation.created, replaced: operation.replaced });
}
setPending(undefined);
setSentence(""); setCurrent(undefined);
} catch (error) {
// Preserve the words and any unsaved tail on local errors. Synced refusals converge through useLiveQuery.
setMessage(error.reason === "refusal" ? "Try describing the task another way." : error.message);
} finally { setBusy(false); }
}
async function askRecords() {
if (busy || !ready || pending !== undefined || sentence.trim().length === 0) return;
setBusy(true); setMessage("");
try {
const response = await callAI(
`Answer from these records. Today: ${new Date().toISOString().slice(0, 10)}. Records: ${JSON.stringify(docs.map(({ title, details, dueDate }) => ({ title, details, dueDate })))}. Question: ${sentence}`,
{ schema: { properties: { answer: { type: "string" } }, required: ["answer"] } }
);
const raw = JSON.parse(response);
if (typeof raw?.answer !== "string") throw new Error("Ask your question again.");
setAnswer(raw.answer); setSentence("");
} catch (error) {
setMessage(error.message);
} finally { setBusy(false); }
}
// A new record is one del away; a corrected one is one put of its previous version away.
async function undoLast() {
if (busy || lastWrite === undefined) return;
setBusy(true); setMessage("");
try {
for (const id of lastWrite.created) await database.del(id);
if (lastWrite.replaced !== undefined) await database.put(lastWrite.replaced);
setLastWrite(undefined); setPending(undefined); setCurrent(undefined);
} catch (error) {
setMessage(error.message);
} finally { setBusy(false); }
}
return <main className="min-h-screen bg-background text-foreground font-sans p-6"><Stack gap={5}>
<Heading level={1}>Tasks in your own words</Heading>
<form onSubmit={submitSentence}>
<Stack gap={3}>
<Field label="Describe tasks, a correction, or ask a question">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={sentence} disabled={busy || pending !== undefined} onChange={setSentence} />}</Field>
{current !== undefined && <Row gap={2} align="center" wrap><Text muted>Changing: {current.title}</Text><Button type="button" size="sm" variant="quiet" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</Button></Row>}
<Row gap={2} wrap>
<Button type="submit" disabled={!ready || busy || sentence.trim().length === 0}>{busy ? "Adding your changes…" : pending !== undefined ? "Retry remaining tasks" : current === undefined ? "Add tasks" : "Apply correction"}</Button>
<Button type="button" variant="quiet" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</Button>
</Row>
</Stack>
</form>
<Text>{answer}</Text>
<p role="status">{message}</p>
<Row gap={2} wrap>
{lastWrite !== undefined && <Button type="button" size="sm" variant="quiet" disabled={busy} onClick={undoLast}>Undo</Button>}
{pending !== undefined && !busy && <Button type="button" size="sm" variant="quiet" tone="down" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</Button>}
</Row>
{docs.map((task) => <TaskCard key={task._id} task={task} database={database} can={can} disabled={busy || pending !== undefined} onSaved={(previous) => setLastWrite({ created: [], replaced: previous })} onCorrect={(doc) => { if (busy || pending !== undefined) return; setCurrent(doc); setSentence(""); }} />)}
</Stack></main>;
}
Editing Documents
Address an existing record by its _id. useDocument(task) above holds the selected record's editable fields: merge(updates) changes the draft, save() writes it, and reset() restores its initial state. submit(event) combines prevent-default, reset and save: the form resets as the person submits, the submitted draft is saved, and edits made while the save settles stay in the form. Choose the operation that matches the editor's lifecycle. For correction by sentence, pass the current editable fields and the person's correction to the same extraction schema, normalize the result and immediately update the existing document in that submit action with its trusted _id and identity fields intact. The callai skill's Text to records example includes that path and pasted lists.
Required starting records
A person's own action — a tap, a submit, a pick — is what starts a write; nothing in the app writes on load or on mount. Starting state comes in three kinds, and none of them is written by the app's own code:
- A person's own starting state needs no record. Their records live on the channel named by their handle (see
access.md), so the app opens on their tree with nothing to create first, and the view renders its defaults — a name, a setting, an empty list's invitation — from constants until the person's own action saves one. - App-wide named containers or participants the request requires — the pottery studio and its named apprentice, the three projects in a studio, the rooms in a planner — are written by the agent building the app, with
put_docas the owner, once the build whose rules admit them is saved, then read back. That save is the owner's own action. The app's opening view shows them when they are there and an inviting add flow until they are. - Example content stays the optional
seed.jsonimport — believable entries the screenshot renders and the owner adds with one ask. A name present only inseed.jsondescribes an offered import, not an existing record or grant. In that file, give each item a stablekey; its derived identity isseed:<key>. References to another item use that derived identity. An_idthat repeats the item's own derived identity is equivalent to leaving it out. Explicit singleton identities such asconfig:boardremain available. The file also populates the temporary, view-only preview while saving is being prepared.
Four rules make the required records hold:
- Each required record gets a fixed
_idyou choose ("project:atlas"), so a second save of it lands on the same document instead of minting another. - Gate their creation on a role in
access.js—ctx.requireRole("owner"), or a role the owner hands out with a role record — when the records describe the app itself rather than a visitor's own work. - List parents and membership or role grants before the records that depend on them, so a dependent document never lands against a container or a grant that does not exist yet.
- Leave the app's render paths write-free. A write in an effect that fires when a query reads empty runs on every fresh load, on every device and for every visitor, because the live query renders empty before data arrives.
Fixed ids are also what makes a repeated save safe: two devices saving the same record write the same document, and last-writer-wins converges on one copy.
Match sample records to the owner's opening query
The initial build's <app-owner> context supplies the app owner's handle. A
sample record in the owner's own tree names that tree with "@owner" as the whole
field value, which the platform fills in for whoever applies the seed. The opening
view reads the signed-in person's own tree by their handle, so every sample card
carries "ownerHandle": "@owner":
jsxconst { docs: cards } = useLiveQuery("ownerHandle", { key: me?.userHandle });
json{
"boards": [
{ "key": "tent", "type": "card", "ownerHandle": "@owner", "authorHandle": "@owner", "text": "Blue tent, sleeps four" },
{ "key": "stove", "type": "card", "ownerHandle": "@owner", "authorHandle": "@owner", "text": "Two-burner camp stove" },
{ "key": "lantern", "type": "card", "ownerHandle": "@owner", "authorHandle": "@owner", "text": "Two rechargeable lanterns" },
{ "key": "cooler", "type": "card", "ownerHandle": "@owner", "authorHandle": "@owner", "text": "Wheeled cooler" }
]
}
An id that only contains the handle (settings-mira) is not a whole value, so
write the supplied owner handle into it literally; keep the app's query and real
writes driven by the current viewer and selected object. The database name,
record type, parent references and query filters agree across the sample file and
opening view. Later access rules preserve those parent references. The platform
labels the temporary preview as sample data; those records reach persistent
storage only through the person's explicit import.
Seeding starter data — give each seed a deterministic _id
When an app ships with default content — starter slides, template rows, example cards — the content is a set of seed.json items, each with a deterministic identity (a stable key, or _id: "seed:intro"). This makes adding it idempotent: a second add overwrites the same documents instead of creating fresh copies.
The app itself writes no copy. useLiveQuery renders empty before data arrives, so docs.length === 0 is briefly true on every fresh load, and a "seed when empty" effect re-runs on every reload, every device and for every visitor. The empty view instead invites the person's own first action, and that action writes the record it checked:
App.jsx
jsxfunction Deck() {
const { useLiveQuery, database } = useFireproof("deck");
const { ready, can } = useVibe("deck");
const { docs: slides } = useLiveQuery("position");
// The person's own tap writes the first slide: the control checks this exact record.
const first = { type: "slide", title: "Welcome", position: 1000 };
const allowed = ready && can.create(first).ok;
if (slides.length === 0) {
return (
<Stack gap={2}>
<Text>Your deck is ready for its first slide.</Text>
<Button type="button" disabled={!allowed} onClick={() => allowed && database.put(first)}>
Add the first slide
</Button>
</Stack>
);
}
return <Slides slides={slides} />;
}
Don't stamp Date.now() or other changing values into seed documents — that makes each re-add rewrite the doc with new content, churning revisions even though the _id is stable. Resource-like docs — channels, profiles, seeds — get deterministic _ids; event/content docs let _id auto-generate.
Updating Documents in Event Handlers
To update an existing document from a click handler or callback, use database.put() directly. Never call useDocument inside an event handler — that violates React's Rules of Hooks. Adding a toggle to list items:
App.jsx
jsx<<<<<<< SEARCH
const { useDocument, useLiveQuery } = useFireproof("myLedger");
=======
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
>>>>>>> REPLACE
App.jsx
jsx<<<<<<< SEARCH
<li key={doc._id}>{doc.text}</li>
=======
<li key={doc._id}>
{doc.text}
<IconButton
size="sm"
variant={doc.favorite ? "solid" : "quiet"}
tone="warn"
aria-pressed={Boolean(doc.favorite)}
label={doc.favorite ? "Unfavorite" : "Favorite"}
icon={<Icon name="star" size="sm" />}
onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
/>
</li>
>>>>>>> REPLACE
Never call hooks inside handlers — const { doc, save } = useDocument({ _id: id }) inside an onClick BREAKS the Rules of Hooks.
Continuous Controls — merge() on every event, save() once on commit
A continuous control (slider, drag, color picker, live-typed text bound to one doc) fires many events in quick succession. Never call database.put() or save() on every onChange — that floods the server with rapid concurrent writes to the same _id, which collide on the per-document sequence and get rejected (Failed to put document …). Instead, merge() on each event to keep the UI live (it updates the local draft and, when the doc has an _id, broadcasts the merged snapshot to connected peers, but persists nothing), and save() once when the interaction commits (onPointerUp, onBlur, or a debounced trailing call; a Slider's onChange fires on every step, so it is not a commit, and the wrapper around it hears the release). Editing a single doc with a slider:
App.jsx
jsx<<<<<<< SEARCH
import { useFireproof } from "use-fireproof";
=======
import { useFireproof } from "use-fireproof";
import { Slider } from "@vibes.diy/pouf/interactive";
>>>>>>> REPLACE
App.jsx
jsx<<<<<<< SEARCH
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
=======
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
// One shared mix doc; slider updates the UI on every event, persists on commit
const { doc: mix, merge: mergeMix, save: saveMix } = useDocument({ _id: "mix:current", level: 50 });
const SLIDER_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"]);
>>>>>>> REPLACE
App.jsx
jsx<<<<<<< SEARCH
<h3>Recent Documents</h3>
=======
<div
onPointerUp={() => saveMix().catch((err) => console.error("save failed", err))} // one write on commit
onKeyUp={(e) => {
// only the keys that move the slider commit; Tab or focusing it writes nothing
if (SLIDER_KEYS.has(e.key)) saveMix().catch((err) => console.error("save failed", err));
}}
>
{/* live, no write */}
<Slider label="Level" min={0} max={100} value={[mix.level]} onChange={([level]) => mergeMix({ level })} />
</div>
<h3>Recent Documents</h3>
>>>>>>> REPLACE
If you genuinely need to persist mid-drag, debounce the save() so at most one write is in flight, and still .catch() the rejection. Better yet, prefer one document per event (see the Counter Pattern) over hammering a single hot document.
Strokes — merge() the gesture live, save its cells once on pointer-up
A drag that touches many documents (painting pixels, drawing on a shared board) splits the same way: the stroke is live, the cells are durable. Keep the in-progress stroke in a per-person draft (_id: "stroke:" + handle) and merge() each new cell into it on onPointerMove; peers see it as it is drawn, because merge() broadcasts the draft and their useLiveQuery returns it as a row. On onPointerUp, put() one document per touched cell, then merge() the stroke back to empty. Never put() from onPointerMove: one quick drag becomes dozens of writes in flight.
Keep each cell its own document with a deterministic _id (pixel-<x>-<y>). Never fold the canvas into one document every visitor saves: the server keeps whichever save arrives last, so one visitor's save erases everyone else's strokes. An unsaved draft drops out for peers about 12 seconds after it stops updating.
A draft reaches only the peers already subscribed to its channel, and a channel's grant comes from a saved document. The painter's own pointer-down saves a fixed-id room record on that channel before the first stroke is drawn, so the stroke is seen live; nothing writes on load or on mount.
App.jsx
jsximport React, { useEffect, useRef } from "react";
import { useFireproof } from "use-fireproof";
import { useViewer, useVibe } from "use-vibes";
import { Button } from "@vibes.diy/pouf";
const SIZE = 32;
const CELL = 16;
const COLOR = "#3a86ff";
export default function App() {
const { viewer, isViewerPending, requestLogin } = useViewer();
if (isViewerPending) return null;
if (!viewer) return <Button type="button" onClick={requestLogin}>Sign in to paint</Button>;
return <Canvas key={viewer.userHandle} me={viewer.userHandle} />; // fixed draft _id per painter
}
function Canvas({ me }) {
// Many people write the same pixel docs: fail fast rather than replay a stale queued write.
const { database, useLiveQuery, useDocument } = useFireproof("pixels", { offlineQueue: false });
const { ready, can } = useVibe("pixels");
const canPaint = ready && can.create({ _id: "pixel-0-0", type: "pixel", x: 0, y: 0, color: COLOR, paintedBy: me }).ok;
const { docs: pixels } = useLiveQuery("type", { key: "pixel" });
const { docs: strokes } = useLiveQuery("type", { key: "stroke" });
const { doc: stroke, merge: mergeStroke } = useDocument({ _id: "stroke:" + me, type: "stroke", userHandle: me, color: COLOR, cells: [] });
const cellsRef = useRef([]);
const canvasRef = useRef(null);
useEffect(() => {
const ctx = canvasRef.current.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, SIZE * CELL, SIZE * CELL);
const fill = (x, y, color) => {
ctx.fillStyle = color;
ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
};
for (const p of pixels) fill(p.x, p.y, p.color);
for (const s of strokes) if (s.userHandle !== me) for (const [x, y] of s.cells ?? []) fill(x, y, s.color);
for (const [x, y] of stroke.cells) fill(x, y, stroke.color);
}, [pixels, strokes, stroke, me]);
function startStroke(e) {
// The painter's own pointer-down saves the room record that grants the channel; the
// fixed id makes every later save the same document.
const room = { _id: "canvas:main", type: "canvas" };
if (ready && can.create(room).ok) database.put(room);
extendStroke(e);
}
function extendStroke(e) {
if (!canPaint) return;
const r = canvasRef.current.getBoundingClientRect();
const x = Math.floor(((e.clientX - r.left) / r.width) * SIZE);
const y = Math.floor(((e.clientY - r.top) / r.height) * SIZE);
if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) return;
if (cellsRef.current.some((c) => c[0] === x && c[1] === y)) return;
cellsRef.current = [...cellsRef.current, [x, y]];
mergeStroke({ cells: cellsRef.current }); // live for everyone, no write
}
async function endStroke() {
const cells = cellsRef.current;
cellsRef.current = [];
const writes = [];
for (const [x, y] of cells) writes.push(database.put({ _id: `pixel-${x}-${y}`, type: "pixel", x, y, color: COLOR, paintedBy: me }));
await Promise.all(writes); // the durable commit: one write per touched cell
if (cellsRef.current.length === 0) mergeStroke({ cells: [] }); // unless a newer stroke began
}
return (
<div>
<canvas
ref={canvasRef}
width={SIZE * CELL}
height={SIZE * CELL}
className="w-full max-w-lg aspect-square touch-none"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
startStroke(e);
}}
onPointerMove={(e) => e.buttons && extendStroke(e)}
onPointerUp={endStroke}
onPointerCancel={endStroke}
/>
</div>
);
}
The access rules see the broadcast draft too, so stroke needs its own branch that binds the _id to the sender:
access.js
jsexport default function (doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to paint" };
const inGrid = (n) => Number.isInteger(n) && n >= 0 && n < 32;
if (doc.type === "canvas") {
if (doc._id !== "canvas:main") throw { forbidden: "one canvas" };
return { channels: ["canvas"], grant: { public: ["canvas"] } };
}
if (doc.type === "stroke") {
if (doc._id !== "stroke:" + user.userHandle || doc.userHandle !== user.userHandle) throw { forbidden: "not your stroke" };
if (!Array.isArray(doc.cells) || doc.cells.length > 1024 || !doc.cells.every((c) => Array.isArray(c) && c.length === 2 && inGrid(c[0]) && inGrid(c[1]))) throw { forbidden: "bad stroke" };
return { channels: ["canvas"], grant: { public: ["canvas"] } };
}
if (doc.type === "pixel") {
if (!inGrid(doc.x) || !inGrid(doc.y) || doc._id !== `pixel-${doc.x}-${doc.y}`) throw { forbidden: "pixel out of bounds" };
if (doc.paintedBy !== user.userHandle) throw { forbidden: "not your paint" };
return { channels: ["canvas"], grant: { public: ["canvas"] } };
}
throw { forbidden: "unknown document type" };
}
Query Data
Data is queried by sorted indexes defined by the application. Sort by strings, numbers, or booleans, as well as arrays for grouping. Use numbers when possible for sorting continuous data. You can use the _id field for temporal sorting so you don't have to write code to get simple recent document lists, as in the basic example above.
Query by Key Range
Passing a string to useLiveQuery will index by that field. Use the key argument to filter by a specific value, or range for bounded queries. Switching from temporal to filtered query:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery("_id", { descending: true, limit: 100 });
=======
// all docs where doc.agentName === "agent-1", sorted by _id
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
>>>>>>> REPLACE
Or query a numeric range:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
=======
// docs with agentRating between 3 and 5
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
>>>>>>> REPLACE
Counter Pattern
Documents can be updated by multiple clients, and synced later. To create an event counter, don't increment a number on a single doc, instead write a small document per counted event, and query them with an index:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
=======
const { docs } = useLiveQuery("counter", { key: "my-event-name" });
const counterValue = docs.length;
function countEvent() {
database.put({ counter: "my-event-name" });
}
>>>>>>> REPLACE
This pattern ensures the count is accurate even during sync — each event is its own document, so concurrent writes never conflict.
Custom Indexes
Use a custom index function to normalize and transform document data, for instance if you have both new and old document versions in your app:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery("counter", { key: "my-event-name" });
const counterValue = docs.length;
function countEvent() {
database.put({ counter: "my-event-name" });
}
=======
const { docs } = useLiveQuery(
(doc) => {
if (doc.type == "listing_v1") return doc.sellerId;
else if (doc.type == "listing") return doc.userId;
},
{ key: routeParams.sellerId }
);
>>>>>>> REPLACE
Array Indexes and Prefix Queries
When you want to group rows easily, you can use an array index key. This is great for grouping records by year/month/day or other paths. The prefix query is a shorthand for a key range:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery(
(doc) => {
if (doc.type == "listing_v1") return doc.sellerId;
else if (doc.type == "listing") return doc.userId;
},
{ key: routeParams.sellerId }
);
=======
const { docs } = useLiveQuery(
(doc) => {
const date = new Date(doc.date);
if (Number.isNaN(date.getTime())) return; // return nothing to skip docs without a valid date
return [date.getFullYear(), date.getMonth(), date.getDate()];
},
{ prefix: [2024, 11] } // everything from November 2024
);
>>>>>>> REPLACE
Sortable Lists
Sortable lists are a common pattern. Use evenly spaced positions and insert between items using midpoint calculation:
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery(
(doc) => {
const date = new Date(doc.date);
if (Number.isNaN(date.getTime())) return;
return [date.getFullYear(), date.getMonth(), date.getDate()];
},
{ prefix: [2024, 11] }
);
=======
// Query items on list xyz, sorted by position
// Note: useLiveQuery('list', { key:'xyz' }) would be the same docs, sorted chronologically by _id
const { docs } = useLiveQuery((doc) => [doc.list, doc.position], { prefix: ["xyz"] });
async function initializeList() {
await database.put({ list: "xyz", position: 1000 });
await database.put({ list: "xyz", position: 2000 });
await database.put({ list: "xyz", position: 3000 });
}
async function insertBetween(beforeDoc, afterDoc) {
const newPosition = (beforeDoc.position + afterDoc.position) / 2;
await database.put({ list: "xyz", position: newPosition });
}
>>>>>>> REPLACE
Emit a Row Value
An index function can call emit(key, value) with a second argument to store a value beside the key. rows[].value is the value you emitted when you pass one, and the whole document when you call emit(key) alone. Emit a small summary when a long list renders only a field or two, and read docs when you want the documents themselves — docs holds the indexed documents whatever the rows carry.
App.jsx
jsx<<<<<<< SEARCH
const { docs } = useLiveQuery((doc) => [doc.list, doc.position], { prefix: ["xyz"] });
=======
// Rows carry just what this list renders; docs still holds the whole documents
const { rows } = useLiveQuery((doc, emit) => emit([doc.list, doc.position], { label: doc.label }), {
prefix: ["xyz"],
});
const labels = rows.map((row) => row.value.label);
>>>>>>> REPLACE
Offline writes are on by default (offlineQueue)
For signed-in users, writes are local-first by default: a put/del that fails on the network is durably queued on the device (it resolves, stays visible, and syncs to the cloud when you're back online) instead of rolling back. You don't opt in — every signed-in vibe gets this. An access-denied/validation rejection is not queued: it converges server-wins (the local optimistic revision is overwritten by the server's version) and the platform tells the visitor, so keep the UI reactive to the store rather than depending on a toast. Only transport failures queue and retry.
Pass { offlineQueue: false } for server-first, fail-fast writes: a put resolves only when the server accepts it, and a network failure rejects immediately with nothing queued. Choose this for collaborative multi-writer apps where two people may edit the same doc — sync is blind last-arrival-wins (Firefly has no _rev), so a stale write replayed on reconnect can silently overwrite a newer one. When a lost write is safer than a surprise overwrite, opt out.
js// Default (single-user apps): durable offline writes, no config needed.
const { useLiveQuery } = useFireproof("sightings");
// Collaborative app: fail fast instead of queueing a stale write.
const { useLiveQuery } = useFireproof("shared-board", { offlineQueue: false });
Anonymous writes (local-first by default)
Letting a logged-out visitor try the app and build a little state before signing in needs no opt-in — it is the universal default on every vibe. While logged out, put/del/useLiveQuery/useDocument run against the local replica with the identical API — no auth branching in your code — so favorites, a draft, or a scratch list just work. When the app's rules keep writes for signed-in people, such a save's synced.status reads "held" until then. On first sign-in that local state drains automatically into the user's cloud database, each document exactly as stored (an author field left empty while signed out stays empty), and then syncs live; nothing is lost if a drain is interrupted. The sign-in and drain-confirmation toast ("saved to your account") is platform-provided chrome — do not build your own "saved"/"synced" notice for it.
The old { anonymousLocal: true } option (and its migrate hook) is deprecated and ignored — its behavior is now the default, so the runtime warns once and drains any legacy data on its own. Do not emit the flag in new code.
Reading Resolved Grants and worked access examples
useFireproof() returns an access property (resolved roles/channels for display). The full worked access examples — function signature, AccessDescriptor, channels, grants, and the round-trip UI that reads them back — live in the access skill, which is included whenever the app is permission-shaped (privacy, sharing, teams, members, roles, approval). Gate every write surface with useVibe(dbName).can.
Architecture: Where's My Data?
Data lives in a local replica (IndexedDB) that the app reads and writes instantly; that replica syncs to the Firefly server in the background. The server is the authority on acceptance — each synced write is validated by the app's access rules on ingest, persisted, and then synced to all users who have read access. A write that fails validation or hits a conflict is rejected on ingest: the local optimistic revision converges server-wins, and the platform tells the visitor about it, so keep the UI reactive to the store rather than depending on a toast. Don't assume the write always lands, but don't hand-roll your own denial error UI — let it converge.
Using Fireproof in JavaScript
You can use the core API in HTML or on the backend. Instead of hooks, import the core API directly:
App.jsx
jsx<<<<<<< SEARCH
import React from "react";
import { useFireproof } from "use-fireproof";
export default function App() {
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
=======
import { fireproof } from "use-fireproof";
const database = fireproof("myLedger");
// The document API is async — reads and writes both await the server.
// Writes can fail (access denied, conflict, network), so wrap them.
async function main() {
try {
const ok = await database.put({ text: "Sample Data" });
const doc = await database.get(ok.id);
const latest = await database.query("_id", { limit: 10, descending: true });
console.log("Latest documents:", latest.docs);
} catch (err) {
console.error("write failed (access denied, conflict, or network):", err);
}
}
>>>>>>> REPLACE
To subscribe to real-time updates, use the subscribe method. This is useful for building backend event handlers or other server-side logic — for instance to send an email when the user confirms a sighting:
jsdatabase.subscribe((changes) => {
changes.forEach((change) => {
if (change.confirmed) {
sendEmail(change.email, "Sighting confirmed", "You have confirmed a sighting.");
}
});
}, true);
Working with Files
Fireproof documents carry attachments under _files. Save a File (or Blob) by assigning it to a key on _files, and Fireproof handles upload, durable storage, and URL minting for you. After a doc round-trips through the database, each _files.<key> entry carries a stable url you can drop straight into <img>, <video>, <audio>, CSS background-image, etc.
Each _files.<key> entry shape after save + round-trip: { url: string, type: string, size: number, lastModified?: number, file: () => Promise<File> }. The platform-minted url is stable for the lifetime of that file, so the browser cache works normally. For plain <img> rendering, prefer meta.url — it skips one fetch and lets the browser handle cache and decoding. Use meta.file() only when you need the bytes themselves (transcoding, hashing, ML features).
A save that carries _files uploads the bytes before the doc lands, so unlike a plain write it rejects instead of converging. Await it inside try/catch and show err.message next to the upload button: the message says in plain words how to get the photo saved (for example, on a private app, sign in as the owner or ask them for write access). Gate the uploader with useVibe(dbName).can.create as you would any write surface.
Building an image uploader with _files:
App.jsx
jsximport React from "react";
import { useFireproof } from "use-fireproof";
import { Button, Card, ErrorNote, Field, Figure, Grid, Heading, Input, Stack, Text } from "@vibes.diy/pouf";
export default function App() {
const { useDocument, useLiveQuery } = useFireproof("imageUploads");
const { doc, merge, submit } = useDocument({
_files: {},
caption: "",
type: "upload",
createdAt: Date.now(),
});
const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
const [saveError, setSaveError] = React.useState("");
const onPickFile = (e) => {
const f = e.target.files?.[0];
if (f) merge({ _files: { photo: f } });
};
const onSubmit = async (e) => {
e.preventDefault();
if (!doc._files?.photo) return;
setSaveError("");
try {
// The save uploads the photo first, so it can reject; show why.
// submit() hands back a fresh form at once and saves the submitted one; a failed upload
// puts the photo back in the form while it is still untouched.
await submit();
} catch (err) {
setSaveError(err.message);
}
};
const c = {
page: "min-h-screen p-6 max-w-lg mx-auto bg-background text-foreground font-sans",
};
return (
<div className={c.page}>
<Stack gap={5}>
<Heading level={1}>Image Uploader</Heading>
<form onSubmit={onSubmit}>
<Stack gap={3}>
{/* A file picker hands over files, not a value, so it stays the browser's own control inside the Field. */}
<Field label="Photo">{(id, describedBy) => <input id={id} aria-describedby={describedBy} type="file" accept="image/*" onChange={onPickFile} />}</Field>
<Field label="Caption">
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={doc.caption} onChange={(caption) => merge({ caption })} />}
</Field>
<Button type="submit" size="lg">
Upload
</Button>
{saveError && <ErrorNote>{saveError}</ErrorNote>}
</Stack>
</form>
<Heading level={2}>Recent Uploads</Heading>
<Grid cols={2} gap={4}>
{docs.map((d) => (
<Card key={d._id}>
<Stack gap={2}>
{d._files?.photo?.url && <Figure src={d._files.photo.url} alt={d.caption || "upload"} width={400} height={300} />}
<Text muted>{d.caption || "No caption"}</Text>
</Stack>
</Card>
))}
</Grid>
</Stack>
</div>
);
}
For multi-file uploads (the file picker with multiple), build the _files map keyed by filename and iterate with Object.entries(doc._files) to render each entry.
Adding multi-file support:
App.jsx
jsx<<<<<<< SEARCH
const onPickFile = (e) => {
const f = e.target.files?.[0];
if (f) merge({ _files: { photo: f } });
};
const onSubmit = async (e) => {
e.preventDefault();
if (!doc._files?.photo) return;
=======
const onPickFile = (e) => {
const next = {};
for (const f of e.target.files) next[f.name] = f;
merge({ _files: next });
};
const onSubmit = async (e) => {
e.preventDefault();
if (!Object.keys(doc._files || {}).length) return;
>>>>>>> REPLACE
Form Validation
You can use React's useState to manage validation states and error messages. Validate inputs at the UI level before allowing submission. Adding validation to the uploader:
App.jsx
jsx<<<<<<< SEARCH
const onSubmit = async (e) => {
e.preventDefault();
if (!Object.keys(doc._files || {}).length) return;
=======
const [errors, setErrors] = React.useState({});
function validateForm() {
const newErrors = {};
if (!doc.caption.trim()) newErrors.caption = "Caption is required.";
if (!Object.keys(doc._files || {}).length) newErrors.file = "Pick a file.";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
const onSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
>>>>>>> REPLACE
Example React Application
Code listing for a bird sightings log App.jsx. Note the code ordering: hooks, then handlers, then classNames right before JSX.
App.jsx
jsimport React from "react";
import { useFireproof } from "use-fireproof";
import { Button, Field, Heading, Input, Row, RowCard, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Checkbox } from "@vibes.diy/pouf/interactive";
export default function App() {
// 1. Hooks and document shapes
const { useLiveQuery, useDocument, database } = useFireproof("birdLog");
const {
doc: newSighting,
merge: mergeNewSighting,
submit: submitNewSighting,
} = useDocument({
species: "",
type: "sighting",
confirmed: false,
createdAt: Date.now(),
});
const { docs: sightings } = useLiveQuery("type", {
key: "sighting",
descending: true,
});
// 2. Event handlers
const handleInputChange = (species) => {
mergeNewSighting({ species });
};
const handleSubmit = (e) => {
e.preventDefault();
submitNewSighting();
};
// 3. ClassNames — right before JSX, for the plain elements only; the pouf
// components carry their own surface, ink and shape.
const c = {
page: "min-h-screen max-w-md mx-auto p-6 bg-background text-foreground font-sans",
};
// 4. JSX return
return (
<div className={c.page}>
<Stack gap={5}>
<Heading level={1}>Bird Sightings</Heading>
<form onSubmit={handleSubmit}>
<Row gap={2} align="top">
<Field label="Species">
{(id, describedBy) => <Input id={id} describedBy={describedBy} onChange={handleInputChange} value={newSighting.species} />}
</Field>
<Button type="submit">Add</Button>
</Row>
</form>
<Stack gap={3}>
{sightings.map((doc) => (
<RowCard key={doc._id}>
<Row gap={3} align="center">
<Checkbox
label={doc.species}
checked={doc.confirmed}
onChange={(confirmed) => database.put({ ...doc, confirmed: confirmed === true })}
/>
<Spacer />
<Button type="button" variant="quiet" tone="down" size="sm" onClick={() => database.del(doc._id)}>
Delete
</Button>
</Row>
<Text size="sm" muted>
{new Date(doc.createdAt).toISOString()}
</Text>
</RowCard>
))}
</Stack>
</Stack>
</div>
);
}
IMPORTANT: Don't use useState() on form data, instead use merge() and submit() from useDocument. Only use useState for ephemeral UI state (active tabs, open/closed panels, cursor positions). Keep your data model in Fireproof.