@vibes.diy/prompts 14.3.77 → 14.3.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,869 +0,0 @@
1
- # Fireproof Database API Guide
2
-
3
- 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 on ingest and can still reject it (a conflict, or a write the platform does not admit); 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.
4
-
5
- ## Key Features
6
-
7
- - **Apps run anywhere:** Bundle UI, data, and logic together.
8
- - **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. `useLiveQuery` keeps 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 (a refusal, a conflict): the local optimistic revision converges **server-wins**, and the platform surfaces a reason only when it has one; a reason-less rejection converges silently. 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.
9
- - **Unified API:** TypeScript works with Deno, Bun, Node.js, and the browser.
10
- - **React Hooks:** Leverage `useLiveQuery` and `useDocument` for live collaboration. Note: these are NOT top-level exports — they are returned by the `useFireproof()` hook. Always destructure from `const { useLiveQuery, useDocument, database } = useFireproof("dbName")`. A child component that was handed `database` may call `useFireproof(database)` instead of repeating the name; it is the same database.
11
-
12
- **File structure:** A vibe's source is one or more files. `/App.jsx` is the entry point (React component).
13
-
14
- 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, validates and routes it on ingest, and then syncs it to viewers. Because every synced write is validated server-side on ingest, it can be rejected.
15
-
16
- ## Installation
17
-
18
- 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.
19
-
20
- 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.
21
-
22
- 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.
23
-
24
- `useLiveQuery` populates and refreshes the UI reactively as data arrives, so you usually render empty states rather than loading spinners. Writes are optimistic and local: `put()`/`save()`/`del()` land in the local replica instantly, and transport or offline failures queue and retry automatically. If the server rejects a synced write, the local optimistic revision converges **server-wins** (it is overwritten by the server's version) — and the **platform** surfaces the reason in a write-fail toast whenever it has one: the app's own `forbidden("…")` text, or the platform's message when a signed-in visitor has no write access to the app (both also reach the app as `writeRejection`, below); a reason-less rejection converges silently. So do **not** wrap plain writes in `try/catch` to show denial errors to the user (a save carrying `_files` is the one exception, covered with the image uploader below); keep the UI reactive to the store (`useLiveQuery`/`useDocument`) so the converged state is the feedback, and gate write surfaces up front with `useVibe(dbName).can.create/edit/delete` so a disallowed action never renders in the first place.
25
-
26
- When the server later refuses a write the visitor can act on — a signed-in visitor saving to a private app, say — the platform shows a notice and `useFireproof` hands the app the same reason as `writeRejection` (`{ docId, message }`, or `null` until a save is refused). Render `writeRejection.message` beside the form it came from and call `clearWriteRejection()` when the visitor dismisses it:
27
-
28
- ```jsx
29
- function NoteForm({ text }) {
30
- const { database, writeRejection, clearWriteRejection } = useFireproof("notes");
31
- return (
32
- <div>
33
- <button onClick={() => database.put({ type: "note", text })}>Save</button>
34
- {writeRejection && (
35
- <p role="status">
36
- {writeRejection.message} <button onClick={clearWriteRejection}>Dismiss</button>
37
- </p>
38
- )}
39
- </div>
40
- );
41
- }
42
- ```
43
-
44
- ### Basic Example
45
-
46
- Wire these visible actions in the first working app, using its 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.
47
-
48
- 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.
49
-
50
- ### `App.jsx`
51
- ```jsx
52
- import React, { useState } from "react";
53
- import { callAI } from "call-ai";
54
- import { useFireproof } from "use-fireproof";
55
- import { useVibe } from "use-vibes";
56
-
57
- const taskSchema = {
58
- type: "object",
59
- properties: {
60
- title: { type: "string", description: "The task in the person's words" },
61
- details: { type: "string", description: "Supporting details in the person’s words" },
62
- },
63
- required: ["title", "details"],
64
- };
65
-
66
- function normalizeTask(raw) {
67
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
68
- const title = typeof raw.title === "string" ? raw.title.trim() : "";
69
- const details = typeof raw.details === "string" ? raw.details.trim() : "";
70
- return title.length > 0 ? { title, details } : undefined;
71
- }
72
-
73
- // Single-record extraction and edit-by-sentence share a schema and normalizer.
74
- async function readTask(sentence, current) {
75
- const facts = current === undefined ? {} : { title: current.title, details: current.details };
76
- const response = await callAI(
77
- `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}`,
78
- { schema: taskSchema }
79
- );
80
- const task = normalizeTask(JSON.parse(response));
81
- if (task === undefined) throw new Error("Add a task in your own words and try again.");
82
- return task;
83
- }
84
-
85
- // A pasted list uses an array of the same flat document fields.
86
- async function readTasks(sentence) {
87
- const response = await callAI(
88
- `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}`,
89
- { schema: { properties: { items: { type: "array", items: taskSchema } }, required: ["items"] } }
90
- );
91
- const raw = JSON.parse(response);
92
- const items = Array.isArray(raw?.items) ? raw.items.map(normalizeTask) : [];
93
- if (items.length === 0 || items.some((item) => item === undefined)) {
94
- throw new Error("Describe the tasks you want to add and try again.");
95
- }
96
- return items;
97
- }
98
-
99
- function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
100
- const { useDocument } = useFireproof(database);
101
- const { doc, merge, save } = useDocument(task);
102
- const [editing, setEditing] = useState(false);
103
- return <article>
104
- <h2>{task.title}</h2><p>{task.details}</p><p>{task.dueDate}</p>
105
- {can.edit(task).ok && <>
106
- <button type="button" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</button>
107
- <button type="button" disabled={disabled} onClick={() => setEditing(true)}>Edit details</button>
108
- {editing && <form onSubmit={async (event) => { event.preventDefault(); if (disabled) return; await save(); onSaved(task); setEditing(false); }}>
109
- <input disabled={disabled} aria-label="Task" value={doc.title} onChange={(event) => merge({ title: event.target.value })} />
110
- <textarea disabled={disabled} aria-label="Details" value={doc.details} onChange={(event) => merge({ details: event.target.value })} />
111
- <input disabled={disabled} aria-label="Due date" type="date" value={doc.dueDate} onChange={(event) => merge({ dueDate: event.target.value })} />
112
- <button type="submit" disabled={disabled}>Save details</button>
113
- </form>}
114
- </>}
115
- </article>;
116
- }
117
-
118
- export default function App() {
119
- const { database, useLiveQuery, writeRejection, clearWriteRejection } = useFireproof("tasks");
120
- const { me, can, ready } = useVibe("tasks");
121
- const { docs } = useLiveQuery("type", { key: "task" });
122
- const [sentence, setSentence] = useState("");
123
- const [current, setCurrent] = useState(undefined);
124
- const [pending, setPending] = useState(undefined);
125
- const [answer, setAnswer] = useState("");
126
- const [busy, setBusy] = useState(false);
127
- const [message, setMessage] = useState("");
128
- // Creation, sentence correction and detail editing replace this one Undo receipt after a successful write.
129
- const [lastWrite, setLastWrite] = useState(undefined);
130
- async function submitSentence(event) {
131
- event.preventDefault();
132
- if (busy || !ready) return;
133
- setBusy(true); setMessage("");
134
- try {
135
- // A failed local batch resumes its unsaved tail without extracting or duplicating earlier records.
136
- let operation = pending;
137
- if (operation === undefined) {
138
- const target = current === undefined ? undefined : docs.find((doc) => doc._id === current._id);
139
- if (current !== undefined && target === undefined) throw new Error("Choose a saved task to correct.");
140
- const items = target === undefined ? await readTasks(sentence) : [await readTask(sentence, target)];
141
- const remaining = items.map((item) => target === undefined
142
- ? { _id: crypto.randomUUID(), type: "task", title: item.title, details: item.details, dueDate: "", authorHandle: me?.userHandle }
143
- : { ...target, title: item.title, details: item.details });
144
- operation = { remaining, created: [], replaced: target };
145
- }
146
- if (operation.remaining.some((doc) => operation.replaced === undefined ? can.create(doc).ok === false : can.edit(doc).ok === false)) {
147
- throw new Error("This task is not editable right now.");
148
- }
149
- setPending(operation);
150
- while (operation.remaining.length > 0) {
151
- const doc = operation.remaining[0];
152
- await database.put(doc);
153
- operation = { ...operation, remaining: operation.remaining.slice(1), created: operation.replaced === undefined ? [...operation.created, doc._id] : [] };
154
- setPending(operation);
155
- setLastWrite({ created: operation.created, replaced: operation.replaced });
156
- }
157
- setPending(undefined);
158
- setSentence(""); setCurrent(undefined);
159
- } catch (error) {
160
- // Preserve the words and any unsaved tail on local errors. Synced refusals converge through useLiveQuery.
161
- setMessage(error.reason === "refusal" ? "Try describing the task another way." : error.message);
162
- } finally { setBusy(false); }
163
- }
164
- async function askRecords() {
165
- if (busy || !ready || pending !== undefined || sentence.trim().length === 0) return;
166
- setBusy(true); setMessage("");
167
- try {
168
- const response = await callAI(
169
- `Answer from these records. Today: ${new Date().toISOString().slice(0, 10)}. Records: ${JSON.stringify(docs.map(({ title, details, dueDate }) => ({ title, details, dueDate })))}. Question: ${sentence}`,
170
- { schema: { properties: { answer: { type: "string" } }, required: ["answer"] } }
171
- );
172
- const raw = JSON.parse(response);
173
- if (typeof raw?.answer !== "string") throw new Error("Ask your question again.");
174
- setAnswer(raw.answer); setSentence("");
175
- } catch (error) {
176
- setMessage(error.message);
177
- } finally { setBusy(false); }
178
- }
179
- // A new record is one del away; a corrected one is one put of its previous version away.
180
- async function undoLast() {
181
- if (busy || lastWrite === undefined) return;
182
- setBusy(true); setMessage("");
183
- try {
184
- for (const id of lastWrite.created) await database.del(id);
185
- if (lastWrite.replaced !== undefined) await database.put(lastWrite.replaced);
186
- setLastWrite(undefined); setPending(undefined); setCurrent(undefined);
187
- } catch (error) {
188
- setMessage(error.message);
189
- } finally { setBusy(false); }
190
- }
191
- return <main>
192
- <h1>Tasks in your own words</h1>
193
- <form onSubmit={submitSentence}>
194
- <textarea aria-label="Describe tasks, a correction, or ask a question" value={sentence} disabled={busy || pending !== undefined} onChange={(event) => setSentence(event.target.value)} />
195
- {current !== undefined && <p>Changing: {current.title} <button type="button" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</button></p>}
196
- <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>
197
- <button type="button" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</button>
198
- </form>
199
- <p>{answer}</p>
200
- <p role="status">{message}</p>
201
- {writeRejection && <p role="status">{writeRejection.message} <button type="button" onClick={clearWriteRejection}>Dismiss</button></p>}
202
- {lastWrite !== undefined && <button type="button" disabled={busy} onClick={undoLast}>Undo</button>}
203
- {pending !== undefined && !busy && <button type="button" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</button>}
204
- {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(""); }} />)}
205
- </main>;
206
- }
207
- ```
208
-
209
- ### Editing Documents
210
-
211
- 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, save and reset. 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.
212
-
213
- ## Required starting records
214
-
215
- When a request names records the app must already have — the three projects in a
216
- studio, a template's starter rows, the rooms in a planner — those records are
217
- created by the app's own first render through the ordinary write path. That is
218
- the mechanism, and it is the same one described below for starter content.
219
- `seed.json` is a separate thing: an optional import a person runs by hand from
220
- the Data tab. Its sample records also populate the temporary, view-only preview
221
- while saving is being prepared; they become real saved records only through an
222
- explicit import. In that file, give each item a stable `key`; its derived identity
223
- is `seed:<key>`. References to another item use that derived identity. An `_id`
224
- that repeats the item's own derived identity is equivalent to leaving it out.
225
- Explicit singleton identities such as `config:board` remain available.
226
-
227
- Four rules make it hold:
228
-
229
- - **Each required record gets a fixed `_id` you choose** (`"project:atlas"`), so
230
- a second write of it lands on the same document instead of minting another.
231
- - **Create them after `ready` and after `can.create` says yes**, so a write the
232
- platform does not admit yet is skipped quietly and retried on a later run.
233
- - **Write the object before the records that belong to it**, so a dependent
234
- document never lands against a container that does not exist yet.
235
-
236
- Fixed ids are also what makes two tabs safe. Two browsers opening the app for the
237
- first time at the same moment both see an empty query and both write; with fixed
238
- ids those are two writes of the same document, and last-writer-wins converges on
239
- one copy. Reading the query first and then deciding whether to write is the shape
240
- that races — the read is empty in both tabs before either write lands.
241
-
242
- ### How records are shaped
243
-
244
- Every record a person writes carries two things beyond its own content: **the id of the object it belongs to**, and **`authorHandle`, the handle of whoever wrote it**. The object itself carries **`creatorHandle`**. Write them at create and leave them alone afterwards — a later editor's handle then always finds the ids already on the doc rather than supplying one.
245
-
246
- Each signed-in person also gets **an object of their own with no doc to create**: its id is derived from their handle, `` `default-${me.userHandle}` ``. So someone who has just arrived types once and their first record lands, with nothing for them to set up first — and an app one person keeps for themselves is just this, with one object in it.
247
-
248
- ```jsx
249
- import { useFireproof } from "use-fireproof";
250
- import { useVibe } from "use-vibes";
251
-
252
- function Board({ pickedBoardId }) {
253
- const { useLiveQuery, database } = useFireproof("boards");
254
- const { me, can } = useVibe("boards");
255
- // No board doc needed to start: every person has one derived from their handle.
256
- const boardId = pickedBoardId ?? `default-${me?.userHandle}`;
257
- const { docs: cards } = useLiveQuery("boardId", { key: boardId });
258
-
259
- // A card names its board and its author, both at create.
260
- const addCard = (text) => database.put({ type: "card", boardId, authorHandle: me?.userHandle, text });
261
- // A board someone explicitly makes names the person who made it.
262
- const addBoard = (title) => database.put({ type: "board", creatorHandle: me.userHandle, title });
263
-
264
- return (
265
- <div>
266
- {cards.map((card) => (
267
- <p key={card._id}>{card.text}</p>
268
- ))}
269
- {can.create({ type: "card", boardId, authorHandle: me?.userHandle }).ok && <AddCardForm onAdd={addCard} />}
270
- </div>
271
- );
272
- }
273
- ```
274
-
275
- Queries read off those same ids — `useLiveQuery("boardId", { key: boardId })` above — so the fields earn their place immediately rather than being bookkeeping for later.
276
-
277
- ### Match sample records to the maker's opening query
278
-
279
- The initial build's preview-maker context supplies the actual maker handle.
280
- Use it to evaluate the opening query's group ID when writing `seed.json`.
281
- For a maker named `mira`, the runtime expression below selects `default-mira`,
282
- so every sample card in this opening board carries that exact `boardId`:
283
-
284
- ```jsx
285
- const boardId = pickedBoardId ?? `default-${me?.userHandle}`;
286
- const { docs: cards } = useLiveQuery("boardId", { key: boardId });
287
- ```
288
-
289
- ```json
290
- {
291
- "boards": [
292
- { "key": "tent", "type": "card", "boardId": "default-mira", "text": "Blue tent, sleeps four" },
293
- { "key": "stove", "type": "card", "boardId": "default-mira", "text": "Two-burner camp stove" },
294
- { "key": "lantern", "type": "card", "boardId": "default-mira", "text": "Two rechargeable lanterns" },
295
- { "key": "cooler", "type": "card", "boardId": "default-mira", "text": "Wheeled cooler" }
296
- ]
297
- }
298
- ```
299
-
300
- Substitute the supplied maker handle in the sample values; keep the app's query
301
- and real writes driven by the current viewer and selected object. The database
302
- name, record type, parent references and query filters agree across the sample
303
- file and opening view. Later edits preserve those parent references.
304
- The platform labels the temporary preview as sample data; those records reach
305
- persistent storage only through the person's explicit import.
306
-
307
- ### Seeding starter data — give each seed a deterministic `_id`
308
-
309
- When an app ships with default content — starter slides, template rows, example cards — and writes it into the database on first load, give **each seed document a deterministic `_id`** (`"seed:intro"`, `"seed:" + key`). This makes the seeding write **idempotent**: if it ever runs again, it overwrites the same documents instead of creating fresh copies.
310
-
311
- The trap is the live-query empty state. `useLiveQuery` renders empty before data arrives, so `docs.length === 0` is briefly true on **every** fresh load — and a `useRef`/`useState` "already seeded" flag only guards the current session, never the next reload, a second device, or another collaborator. With **auto-generated** `_id`s, each of those re-seeds the full set and duplicate copies pile up without bound. Deterministic `_id`s collapse every re-seed back onto the same N documents.
312
-
313
- ```jsx
314
- import React from "react";
315
- import { useFireproof } from "use-fireproof";
316
- import { useVibe } from "use-vibes";
317
-
318
- // Stable _id per seed, and no Date.now()/random fields — keep the payload
319
- // constant so a redundant seed is a true no-op, not a churning rewrite.
320
- const SEED = [
321
- { _id: "seed:intro", type: "slide", title: "Welcome", position: 1000 },
322
- { _id: "seed:problem", type: "slide", title: "The problem", position: 2000 },
323
- { _id: "seed:ask", type: "slide", title: "The ask", position: 3000 },
324
- ];
325
-
326
- export default function App() {
327
- const { useLiveQuery, database } = useFireproof("deck");
328
- const { ready, can } = useVibe("deck");
329
- const { docs: slides } = useLiveQuery("position");
330
-
331
- React.useEffect(() => {
332
- if (slides.length > 0) return; // already has content — don't seed
333
- // Ask `can.create` before writing. A doc type the platform doesn't admit
334
- // yet (e.g. one this very edit just introduced — the update binds only
335
- // after the whole turn lands) would be rejected: skip quietly now and seed
336
- // on a later run instead of spamming errors.
337
- if (!ready || !can.create(SEED[0]).ok) return;
338
- // Deterministic _id → idempotent. Even if this runs again (another load or
339
- // device before the query resolves), it overwrites the same docs instead of
340
- // minting new ones, so seed slides never duplicate.
341
- SEED.forEach((s) => database.put(s).catch((e) => console.error(e)));
342
- }, [slides.length, ready, can, database]);
343
-
344
- return <Deck slides={slides} />;
345
- }
346
- ```
347
-
348
- Don't stamp `Date.now()` or other changing values into seed documents — that makes each re-seed rewrite the doc with new content, churning revisions even though the `_id` is stable. Resource-like docs — categories, profiles, **seeds** — get deterministic `_id`s; event/content docs let `_id` auto-generate.
349
-
350
- ### Updating Documents in Event Handlers
351
-
352
- 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:
353
-
354
- ### `App.jsx`
355
- ```jsx
356
- <<<<<<< SEARCH
357
- const { useDocument, useLiveQuery } = useFireproof("myLedger");
358
- =======
359
- const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
360
- >>>>>>> REPLACE
361
- ```
362
-
363
- ### `App.jsx`
364
- ```jsx
365
- <<<<<<< SEARCH
366
- <li key={doc._id}>{doc.text}</li>
367
- =======
368
- <li key={doc._id}>
369
- {doc.text}
370
- <button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
371
- {doc.favorite ? "★" : "☆"}
372
- </button>
373
- </li>
374
- >>>>>>> REPLACE
375
- ```
376
-
377
- Never call hooks inside handlers — `const { doc, save } = useDocument({ _id: id })` inside an onClick BREAKS the Rules of Hooks.
378
-
379
- ### Continuous Controls — `merge()` on every event, `save()` once on commit
380
-
381
- 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()` locally on each event to keep the UI live, and `save()` once when the interaction commits (`onPointerUp`, `onBlur`, `onChange` for a range input's final value, or a debounced trailing call). Editing a single doc with a slider:
382
-
383
- ### `App.jsx`
384
- ```jsx
385
- <<<<<<< SEARCH
386
- const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
387
- =======
388
- const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
389
-
390
- // One shared mix doc; slider updates the UI on every event, persists on commit
391
- const { doc: mix, merge: mergeMix, save: saveMix } = useDocument({ _id: "mix:current", level: 50 });
392
- >>>>>>> REPLACE
393
- ```
394
-
395
- ### `App.jsx`
396
- ```jsx
397
- <<<<<<< SEARCH
398
- <h3>Recent Documents</h3>
399
- =======
400
- <input
401
- type="range"
402
- min="0"
403
- max="100"
404
- value={mix.level}
405
- onChange={(e) => mergeMix({ level: Number(e.target.value) })} // live, no write
406
- onPointerUp={() => saveMix().catch((err) => console.error("save failed", err))} // one write on commit
407
- />
408
-
409
- <h3>Recent Documents</h3>
410
- >>>>>>> REPLACE
411
- ```
412
-
413
- 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.
414
-
415
- ### Query Data
416
-
417
- 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.
418
-
419
- #### Query by Key Range
420
-
421
- 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:
422
-
423
- ### `App.jsx`
424
- ```jsx
425
- <<<<<<< SEARCH
426
- const { docs } = useLiveQuery("_id", { descending: true, limit: 100 });
427
- =======
428
- // all docs where doc.agentName === "agent-1", sorted by _id
429
- const { docs } = useLiveQuery("agentName", { key: "agent-1" });
430
- >>>>>>> REPLACE
431
- ```
432
-
433
- Or query a numeric range:
434
-
435
- ### `App.jsx`
436
- ```jsx
437
- <<<<<<< SEARCH
438
- const { docs } = useLiveQuery("agentName", { key: "agent-1" });
439
- =======
440
- // docs with agentRating between 3 and 5
441
- const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
442
- >>>>>>> REPLACE
443
- ```
444
-
445
- #### Counter Pattern
446
-
447
- 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:
448
-
449
- ### `App.jsx`
450
- ```jsx
451
- <<<<<<< SEARCH
452
- const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
453
- =======
454
- // The counter's scope: the board being viewed and who is counting (useVibe from "use-vibes", as above).
455
- const { me } = useVibe("boards");
456
- const boardId = `default-${me?.userHandle}`;
457
- // Keyed on the board AND the event name, so one board's count never includes another board's events.
458
- const { docs } = useLiveQuery((doc) => [doc.boardId, doc.counter], { key: [boardId, "my-event-name"] });
459
- const counterValue = docs.length;
460
-
461
- // Each counted event is its own record, and it names the board it belongs to and who wrote it.
462
- function countEvent() {
463
- database.put({ type: "count", counter: "my-event-name", boardId, authorHandle: me?.userHandle });
464
- }
465
- >>>>>>> REPLACE
466
- ```
467
-
468
- This pattern ensures the count is accurate even during sync — each event is its own document, so concurrent writes never conflict.
469
-
470
- ### Custom Indexes
471
-
472
- 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:
473
-
474
- ### `App.jsx`
475
- ```jsx
476
- <<<<<<< SEARCH
477
- // The counter's scope: the board being viewed and who is counting (useVibe from "use-vibes", as above).
478
- const { me } = useVibe("boards");
479
- const boardId = `default-${me?.userHandle}`;
480
- // Keyed on the board AND the event name, so one board's count never includes another board's events.
481
- const { docs } = useLiveQuery((doc) => [doc.boardId, doc.counter], { key: [boardId, "my-event-name"] });
482
- const counterValue = docs.length;
483
-
484
- // Each counted event is its own record, and it names the board it belongs to and who wrote it.
485
- function countEvent() {
486
- database.put({ type: "count", counter: "my-event-name", boardId, authorHandle: me?.userHandle });
487
- }
488
- =======
489
- const { docs } = useLiveQuery(
490
- (doc) => {
491
- if (doc.type == "listing_v1") return doc.sellerId;
492
- else if (doc.type == "listing") return doc.userId;
493
- },
494
- { key: routeParams.sellerId }
495
- );
496
- >>>>>>> REPLACE
497
- ```
498
-
499
- #### Array Indexes and Prefix Queries
500
-
501
- 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:
502
-
503
- ### `App.jsx`
504
- ```jsx
505
- <<<<<<< SEARCH
506
- const { docs } = useLiveQuery(
507
- (doc) => {
508
- if (doc.type == "listing_v1") return doc.sellerId;
509
- else if (doc.type == "listing") return doc.userId;
510
- },
511
- { key: routeParams.sellerId }
512
- );
513
- =======
514
- const { docs } = useLiveQuery(
515
- (doc) => {
516
- const date = new Date(doc.date);
517
- if (Number.isNaN(date.getTime())) return; // return nothing to skip docs without a valid date
518
- return [date.getFullYear(), date.getMonth(), date.getDate()];
519
- },
520
- { prefix: [2024, 11] } // everything from November 2024
521
- );
522
- >>>>>>> REPLACE
523
- ```
524
-
525
- #### Sortable Lists
526
-
527
- Sortable lists are a common pattern. Use evenly spaced positions and insert between items using midpoint calculation:
528
-
529
- ### `App.jsx`
530
- ```jsx
531
- <<<<<<< SEARCH
532
- const { docs } = useLiveQuery(
533
- (doc) => {
534
- const date = new Date(doc.date);
535
- if (Number.isNaN(date.getTime())) return;
536
- return [date.getFullYear(), date.getMonth(), date.getDate()];
537
- },
538
- { prefix: [2024, 11] }
539
- );
540
- =======
541
- // Query items on list xyz, sorted by position
542
- // Note: useLiveQuery('listId', { key:'xyz' }) would be the same docs, sorted chronologically by _id
543
- const { docs } = useLiveQuery((doc) => [doc.listId, doc.position], { prefix: ["xyz"] });
544
-
545
- // Each item names the list it belongs to and who wrote it, both at create.
546
- async function initializeList() {
547
- await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 1000 });
548
- await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 2000 });
549
- await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 3000 });
550
- }
551
-
552
- async function insertBetween(beforeDoc, afterDoc) {
553
- const newPosition = (beforeDoc.position + afterDoc.position) / 2;
554
- await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: newPosition });
555
- }
556
- >>>>>>> REPLACE
557
- ```
558
-
559
- #### Emit a Row Value
560
-
561
- 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.
562
-
563
- ### `App.jsx`
564
- ```jsx
565
- <<<<<<< SEARCH
566
- const { docs } = useLiveQuery((doc) => [doc.listId, doc.position], { prefix: ["xyz"] });
567
- =======
568
- // Rows carry just what this list renders; docs still holds the whole documents
569
- const { rows } = useLiveQuery((doc, emit) => emit([doc.listId, doc.position], { label: doc.label }), {
570
- prefix: ["xyz"],
571
- });
572
- const labels = rows.map((row) => row.value.label);
573
- >>>>>>> REPLACE
574
- ```
575
-
576
- ## Offline writes are on by default (`offlineQueue`)
577
-
578
- 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. A refusal or validation rejection is **not** queued: it converges **server-wins** (the local optimistic revision is overwritten by the server's version); the platform surfaces the reason only when it has one, and a reason-less rejection converges silently — so keep the UI reactive to the store rather than depending on a toast. Only transport failures queue and retry.
579
-
580
- 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.
581
-
582
- ```js
583
- // Default (single-user apps): durable offline writes, no config needed.
584
- const { useLiveQuery } = useFireproof("todos");
585
-
586
- // Collaborative app: fail fast instead of queueing a stale write.
587
- const { useLiveQuery } = useFireproof("shared-board", { offlineQueue: false });
588
- ```
589
-
590
- ## Anonymous writes (local-first by default)
591
-
592
- 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. On first sign-in that local state drains automatically into the user's cloud database 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.
593
-
594
- 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.
595
-
596
- ---
597
-
598
- ## Architecture: Where's My Data?
599
-
600
- 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 on ingest, persisted, and then synced to every viewer who may read it. A write that fails validation or hits a conflict is rejected on ingest: the local optimistic revision converges **server-wins**, and the platform surfaces the reason only when it has one — a reason-less rejection converges silently, 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.
601
-
602
- ## Using Fireproof in JavaScript
603
-
604
- You can use the core API outside a React component — in a plain script or a helper module. Instead of hooks, import the core API directly:
605
-
606
- ### `App.jsx`
607
- ```jsx
608
- <<<<<<< SEARCH
609
- import React from "react";
610
- import { useFireproof } from "use-fireproof";
611
-
612
- export default function App() {
613
- const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
614
- =======
615
- import { fireproof } from "use-fireproof";
616
-
617
- const database = fireproof("myLedger");
618
-
619
- // The document API is async — reads and writes both await the server.
620
- // Writes can fail (refused, conflict, network), so wrap them.
621
- async function main() {
622
- try {
623
- const ok = await database.put({ text: "Sample Data" });
624
- const doc = await database.get(ok.id);
625
- const latest = await database.query("_id", { limit: 10, descending: true });
626
- console.log("Latest documents:", latest.docs);
627
- } catch (err) {
628
- console.error("write failed (refused, conflict, or network):", err);
629
- }
630
- }
631
- >>>>>>> REPLACE
632
- ```
633
-
634
- ### Working with Files
635
-
636
- 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.
637
-
638
- 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).
639
-
640
- 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.
641
-
642
- Building an image uploader with `_files`:
643
-
644
- ### `App.jsx`
645
- ```jsx
646
- import React from "react";
647
- import { useFireproof } from "use-fireproof";
648
- import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
649
-
650
- export default function App() {
651
- const { useDocument, useLiveQuery } = useFireproof("imageUploads");
652
-
653
- const { doc, merge, save, reset } = useDocument({
654
- _files: {},
655
- caption: "",
656
- type: "upload",
657
- createdAt: Date.now(),
658
- });
659
-
660
- const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
661
- const [saveError, setSaveError] = React.useState("");
662
-
663
- const onPickFile = (e) => {
664
- const f = e.target.files?.[0];
665
- if (f) merge({ _files: { photo: f } });
666
- };
667
-
668
- const onSubmit = async (e) => {
669
- e.preventDefault();
670
- if (!doc._files?.photo) return;
671
- setSaveError("");
672
- try {
673
- // The save uploads the photo first, so it can reject; show why.
674
- await save();
675
- reset();
676
- } catch (err) {
677
- setSaveError(err.message);
678
- }
679
- };
680
-
681
- const c = {
682
- page: "bg-background text-foreground font-sans",
683
- meta: "text-muted-foreground",
684
- };
685
-
686
- return (
687
- <div className={`min-h-screen p-6 max-w-lg mx-auto ${c.page}`}>
688
- <h2 className="text-2xl font-bold mb-4">Image Uploader</h2>
689
- <form onSubmit={onSubmit} className="space-y-3">
690
- <Label htmlFor="photo">Photo</Label>
691
- <Input id="photo" type="file" accept="image/*" onChange={onPickFile} className="w-full" />
692
- <Input
693
- type="text"
694
- placeholder="Caption"
695
- value={doc.caption}
696
- onChange={(e) => merge({ caption: e.target.value })}
697
- className="w-full"
698
- />
699
- <Button type="submit" size="lg">
700
- Upload
701
- </Button>
702
- {saveError && <p role="alert" className="text-destructive">{saveError}</p>}
703
- </form>
704
-
705
- <h3 className="text-lg font-bold mt-8">Recent Uploads</h3>
706
- <div className="grid grid-cols-2 gap-4 mt-2">
707
- {docs.map((d) => (
708
- <Card key={d._id}>
709
- <CardContent>
710
- {d._files?.photo?.url && <img src={d._files.photo.url} alt={d.caption || "upload"} className="w-full h-auto rounded-md" />}
711
- <p className={`${c.meta} mt-2`}>{d.caption || "No caption"}</p>
712
- </CardContent>
713
- </Card>
714
- ))}
715
- </div>
716
- </div>
717
- );
718
- }
719
- ```
720
-
721
- For multi-file uploads (e.g. `<input multiple>`), build the `_files` map keyed by filename and iterate with `Object.entries(doc._files)` to render each entry.
722
-
723
- Adding multi-file support:
724
-
725
- ### `App.jsx`
726
- ```jsx
727
- <<<<<<< SEARCH
728
- const onPickFile = (e) => {
729
- const f = e.target.files?.[0];
730
- if (f) merge({ _files: { photo: f } });
731
- };
732
-
733
- const onSubmit = async (e) => {
734
- e.preventDefault();
735
- if (!doc._files?.photo) return;
736
- =======
737
- const onPickFile = (e) => {
738
- const next = {};
739
- for (const f of e.target.files) next[f.name] = f;
740
- merge({ _files: next });
741
- };
742
-
743
- const onSubmit = async (e) => {
744
- e.preventDefault();
745
- if (!Object.keys(doc._files || {}).length) return;
746
- >>>>>>> REPLACE
747
- ```
748
-
749
- ### Form Validation
750
-
751
- 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:
752
-
753
- ### `App.jsx`
754
- ```jsx
755
- <<<<<<< SEARCH
756
- const onSubmit = async (e) => {
757
- e.preventDefault();
758
- if (!Object.keys(doc._files || {}).length) return;
759
- =======
760
- const [errors, setErrors] = React.useState({});
761
-
762
- function validateForm() {
763
- const newErrors = {};
764
- if (!doc.caption.trim()) newErrors.caption = "Caption is required.";
765
- if (!Object.keys(doc._files || {}).length) newErrors.file = "Pick a file.";
766
- setErrors(newErrors);
767
- return Object.keys(newErrors).length === 0;
768
- }
769
-
770
- const onSubmit = async (e) => {
771
- e.preventDefault();
772
- if (!validateForm()) return;
773
- >>>>>>> REPLACE
774
- ```
775
-
776
- ## Example React Application
777
-
778
- Code listing for todo tracker App.jsx. Note the code ordering: hooks, then handlers, then classNames right before JSX.
779
-
780
- ```js
781
- import React from "react";
782
- import { useFireproof } from "use-fireproof";
783
- import { useVibe } from "use-vibes";
784
- import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
785
-
786
- export default function App() {
787
- // 1. Hooks and document shapes
788
- const { useLiveQuery, useDocument, database } = useFireproof("todoList");
789
- const { me } = useVibe("todoList");
790
-
791
- // Every person has a list of their own without creating one first.
792
- const listId = `default-${me?.userHandle}`;
793
-
794
- const {
795
- doc: newTodo,
796
- merge: mergeNewTodo,
797
- submit: submitNewTodo,
798
- } = useDocument({
799
- todo: "",
800
- type: "todo",
801
- listId, // the list this todo belongs to, on the todo itself
802
- authorHandle: me?.userHandle,
803
- completed: false,
804
- createdAt: Date.now(),
805
- });
806
-
807
- const { docs: todos } = useLiveQuery("listId", {
808
- key: listId,
809
- descending: true,
810
- });
811
-
812
- // 2. Event handlers
813
- const handleInputChange = (e) => {
814
- mergeNewTodo({ todo: e.target.value });
815
- };
816
-
817
- const handleSubmit = (e) => {
818
- e.preventDefault();
819
- submitNewTodo();
820
- };
821
-
822
- // 3. ClassNames — right before JSX, for the plain elements only; the look's
823
- // components carry their own surface, ink and shape.
824
- const c = {
825
- page: "bg-background text-foreground font-sans",
826
- meta: "text-muted-foreground",
827
- };
828
-
829
- // 4. JSX return
830
- return (
831
- <div className={`min-h-screen max-w-md mx-auto p-6 ${c.page}`}>
832
- <h2 className="text-2xl font-bold mb-4">Todo List</h2>
833
- <form onSubmit={handleSubmit} className="mb-4">
834
- <Label htmlFor="todo" className="mb-2 block">
835
- Todo
836
- </Label>
837
- <Input className="w-full" id="todo" type="text" onChange={handleInputChange} value={newTodo.todo} />
838
- </form>
839
- <ul className="space-y-3">
840
- {todos.map((doc) => (
841
- <li key={doc._id}>
842
- <Card>
843
- <CardContent className="flex flex-col items-start">
844
- <div className="flex items-center justify-between w-full">
845
- <div className="flex items-center">
846
- <input
847
- className="mr-2"
848
- type="checkbox"
849
- checked={doc.completed}
850
- onChange={() => database.put({ ...doc, completed: !doc.completed })}
851
- />
852
- <span className="font-medium">{doc.todo}</span>
853
- </div>
854
- <Button variant="destructive" size="sm" onClick={() => database.del(doc._id)}>
855
- Delete
856
- </Button>
857
- </div>
858
- <div className={`${c.meta} mt-1`}>{new Date(doc.createdAt).toISOString()}</div>
859
- </CardContent>
860
- </Card>
861
- </li>
862
- ))}
863
- </ul>
864
- </div>
865
- );
866
- }
867
- ```
868
-
869
- 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.