@vibes.diy/prompts 14.3.5 → 14.3.7

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 +1 @@
1
- {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,uXAAuX;IAMzX,IAAI,EAAE,CAAC,QAAQ,EAAE,YAAY,CAAC;CAC/B,CAAC"}
1
+ {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,uXAAuX;IAOzX,IAAI,EAAE,CAAC,QAAQ,EAAE,YAAY,CAAC;CAC/B,CAAC"}
package/llms/backend.md CHANGED
@@ -141,6 +141,7 @@ ctx.appInfo; // { ownerHandle, appSlug } — this app's identity
141
141
  ctx.userInfo; // { userHandle } or null — who the handler is acting as
142
142
  ctx.secrets; // Record<string,string> — the owner's per-vibe secrets; frozen, {} when none
143
143
  await ctx.db.put(doc, { db: "notes", id: "optional-id" }); // resolves to the doc id AFTER commit
144
+ await ctx.db.put(doc, { db: "jobs", id: doc._id, ifRev: doc._rev }); // commits only if nobody wrote the doc since you read it
144
145
  await ctx.db.delete(docId, { db: "notes" });
145
146
  const docs = await ctx.db.query({ db: "notes", field: "type", key: "note", limit: 100 }); // a filtered page
146
147
  const doc = await ctx.db.get("state:cursor", { db: "notes" }); // one doc by _id, or null
@@ -184,6 +185,31 @@ const gh = await ctx.github.fetch("/repos/acme/site/issues", { method: "POST", b
184
185
  database that triggered the event is the default.
185
186
  - Always `await` db calls; they resolve only after the write commits (or throw
186
187
  when the access function denies it).
188
+ - **Every read carries the doc's revision as `_rev`.** `ctx.db.get`, each
189
+ `ctx.db.query` row and the `onChange` event's `doc` include `_rev` beside
190
+ `_id`. It is a reserved field the platform fills in on read and never stores,
191
+ so spreading a read straight into a put is safe.
192
+ - **`ifRev` makes a write a compare-and-set.** `ctx.db.put(doc, { db, id, ifRev: doc._rev })`
193
+ commits only if the doc is still at the revision you read. Pass `null` to
194
+ write only if the doc does not exist yet — a deleted doc counts as not
195
+ existing, just as `get` returns `null` for it. If anyone else wrote the doc in
196
+ between, even with identical content, the put throws with
197
+ `err.code === "conflict"` and `err.currentRev` (the revision there now, `null`
198
+ when the doc is gone), so of several racing handlers exactly one succeeds.
199
+ The access function still runs on every put, so its `oldDoc.status` check
200
+ plus `ifRev` is an exclusive claim. A conflict is an answer, not a failure:
201
+ someone else has the doc, so return. Leave `ifRev` out and a put is
202
+ last-writer-wins, as always. This is `backend.js` only: `useFireproof`
203
+ writes in the browser are local-first and keep last-writer-wins.
204
+
205
+ ```js
206
+ try {
207
+ await ctx.db.put({ ...event.doc, status: "running" }, { db: "jobs", id: event.docId, ifRev: event.doc._rev });
208
+ } catch (err) {
209
+ if (err.code === "conflict") return; // another run claimed it first
210
+ throw err;
211
+ }
212
+ ```
187
213
  - `ctx.db.get(id, { db })` resolves the single document with that `_id`, or
188
214
  `null` when it is missing or deleted. It is the right read whenever you can
189
215
  name the document — a tick's state doc, a token lookup, a singleton. Never
@@ -638,6 +664,39 @@ From `App.jsx`, call it with a relative fetch — no host needed:
638
664
  const res = await fetch("/_api/rsvp", { method: "POST", body: JSON.stringify({ name }) });
639
665
  ```
640
666
 
667
+ The platform's answers are worth recognising. `404 backend.js _api: not found`
668
+ means the app has no `fetch` handler to run. When a handler throws, the caller
669
+ gets a `500` whose body names only the error class — `backend.js threw:
670
+ TypeError. Details are in this app's backend log: …` — and the full message is
671
+ written to the app's log, where `vibes-diy app logs <owner>/<slug>` shows it. A
672
+ `502 backend.js failed to run` means the module never started, most often an
673
+ error at the top level of `backend.js`. So a route that expects trouble — an
674
+ upstream that can be down, input that can be malformed — catches it and answers
675
+ `{ ok: false, reason }` with a status the app can show, and the platform's 500
676
+ stays the signal for a bug to fix.
677
+
678
+ ### Who is calling: `ctx.userInfo` comes from the request, never a cookie
679
+
680
+ Cookies are not read. `ctx.userInfo` is filled in two ways:
681
+
682
+ - **A caller outside the app** — a script, another agent, a server — sends the
683
+ person's vibes.diy token as `Authorization: Bearer <token>`. A missing,
684
+ expired or unrecognised token is not an error: the handler runs with
685
+ `ctx.userInfo === null`, so a route that needs a person answers `401` itself.
686
+ - **The builder's own `call_own_api` check** (see the debugging skill) arrives
687
+ already verified, as the app's owner, with no bearer header.
688
+
689
+ A plain `fetch("/_api/...")` from `App.jsx` sends neither, so for that call the
690
+ handler sees `ctx.userInfo === null`. That is exactly right for the routes the
691
+ app calls for server work — reading a page, calling a service with a secret —
692
+ which should not depend on who is asking. For anything that depends on the
693
+ signed-in person (their own records, their permissions), build the screen on
694
+ the database hooks, which already know who is signed in; a button that "tests
695
+ the API" from inside the app is testing the anonymous path.
696
+
697
+ Writes a handler makes with `ctx.db.put` still pass through `access.js` as the
698
+ caller, so the app's rules hold for API writes too.
699
+
641
700
  ### Bulk transformations run as a route
642
701
 
643
702
  A change that touches many stored records is written once, as a named `/_api` route, and run
@@ -755,7 +814,7 @@ backend writes alike). The event:
755
814
 
756
815
  ```js file=backend.js
757
816
  export async function onChange(event, ctx) {
758
- // event: { dbName, docId, doc, oldDoc, seq, deleted }
817
+ // event: { dbName, docId, doc, oldDoc, seq, deleted } — doc carries _id and _rev
759
818
  if (event.dbName !== "votes" || event.deleted) return;
760
819
  // Maintain a server-authoritative tally the UI reads but users can't forge.
761
820
  await ctx.db.put({ _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq }, { db: "tallies" });
@@ -772,6 +831,81 @@ Two rules keep change-reactions sane:
772
831
  same-database ping-pong is still wasted work. Different db + dbName guard
773
832
  makes loops structurally impossible.
774
833
 
834
+ ### Jobs that finish even when a run crashes
835
+
836
+ **A job that survives a crash carries its status, its claim and its lease on the document.**
837
+ When each request is a unit of work ("resize every upload", "a queue of jobs
838
+ that keeps going if one fails"), give each job a doc with a `status`, let
839
+ `onChange` claim and finish it, and let a `scheduled` sweep take back expired
840
+ leases. The status guard is what makes writing back to the same database safe:
841
+ the handler acts only on `pending`, so every chain ends after one step.
842
+
843
+ Every transition is the same move: read the doc (the event's `doc`, or a
844
+ sweep's `query` row), then `put` the next status with `ifRev: doc._rev`. A
845
+ `conflict` means someone else moved the job first, so step aside.
846
+
847
+ - `access.js` holds the transition table, keyed on `oldDoc.status`:
848
+ `ctx.requireBackend("onChange")` for `pending → running` and
849
+ `running → done`; `ctx.requireBackend("scheduled")` for
850
+ `running → pending` (only once `oldDoc.leaseUntil < Date.now()`) and
851
+ `→ failed`; anything else throws.
852
+ - The claim puts `running` with `ifRev: event.doc._rev`, so exactly one run
853
+ claims each job. It also writes a fresh `claimId`, `leaseUntil` and
854
+ `attempts + 1`.
855
+ - The finish carries the same `claimId`, and `access.js` refuses a `done`
856
+ whose `claimId` differs from `oldDoc.claimId`. A slow run's lease can be
857
+ taken back and the job claimed again while it works, and `onChange` cannot
858
+ read the job back, so the token is how the finish knows it still owns it.
859
+ - The sweep releases an expired lease with `ifRev: job._rev` from its query,
860
+ so it never overwrites a job that finished between the read and the write.
861
+ - A lost claim returns rather than throws: a redelivered event carries the doc
862
+ as it first fired, so its `_rev` is stale and the claim conflicts.
863
+ - Key the work's own writes by `jobId:attempt`, since a job can run twice.
864
+ - Keep each run short; a run past 15 minutes is delivered again while it is
865
+ still going, so chunk long work into `scheduled` ticks.
866
+
867
+ ```js file=backend.js
868
+ export const config = { scheduled: { interval: "30s" } };
869
+ const DB = "jobs", LEASE_MS = 30_000, MAX_ATTEMPTS = 3;
870
+ const rid = () => Math.random().toString(36).slice(2, 10);
871
+
872
+ // One transition: commit `doc` only if the job is still at revision `ifRev`.
873
+ async function tryPut(ctx, doc, ifRev) {
874
+ try { await ctx.db.put(doc, { db: DB, id: doc._id, ifRev }); return true; }
875
+ catch (err) {
876
+ if (err.code === "conflict") return false; // someone else moved this job first
877
+ ctx.log("info", "transition refused", { id: doc._id, reason: String(err?.message || err) });
878
+ return false;
879
+ }
880
+ }
881
+
882
+ export async function onChange(event, ctx) {
883
+ if (event.dbName !== DB || event.deleted || event.doc?.status !== "pending") return;
884
+ const job = { ...event.doc, _id: event.docId };
885
+ const claimed = { ...job, status: "running", attempts: (job.attempts || 0) + 1,
886
+ claimId: rid(), leaseUntil: Date.now() + LEASE_MS };
887
+ if (!(await tryPut(ctx, claimed, job._rev))) return; // lost the claim — ack, don't retry
888
+ const output = await doTheWork(job, `${job._id}:${claimed.attempts}`); // idempotent per jobId:attempt
889
+ await tryPut(ctx, { ...claimed, status: "done", output }); // same claimId, or access.js refuses it
890
+ }
891
+
892
+ export async function scheduled(event, ctx) {
893
+ ctx.log("debug", "lease sweep", { at: event.scheduledTime });
894
+ let after;
895
+ do {
896
+ const page = await ctx.db.query({ db: DB, field: "status", key: "running", limit: 500, after });
897
+ for (const job of page) {
898
+ if (!(job.leaseUntil < Date.now())) continue;
899
+ await tryPut(ctx, { ...job, status: job.attempts >= MAX_ATTEMPTS ? "failed" : "pending" }, job._rev);
900
+ }
901
+ after = page.next;
902
+ } while (after);
903
+ }
904
+ ```
905
+
906
+ The full walkthrough, the matching `access.js`, and timings measured on
907
+ production are at https://good.vibes.diy/docs/patterns/doc-state-machines.
908
+
775
909
  ## scheduled — periodic work
776
910
 
777
911
  Requires a `config` export with a **static string-literal** interval between
@@ -1014,9 +1148,11 @@ a description of the series, never a prediction of the next member of it.
1014
1148
  **Claim the unit of work before you spend on it, not after.** Write the key
1015
1149
  first, then do the expensive, externally visible part: an isolate that dies
1016
1150
  between a finished draft and an unwritten claim comes back and buys the same day
1017
- again. Claiming first means a crashed run is skipped rather than retried, and for
1018
- work like a daily digest that is the better side of the trade — a missing day the
1019
- status doc names beats a double charge nobody notices.
1151
+ again. Claiming first means a crashed run is skipped rather than retried. That
1152
+ is the named exception to the lease-and-retry jobs under `onChange` above: when a
1153
+ duplicate costs more than a gap — a paid call, a post, a message to a person —
1154
+ skip rather than retry, because a missing day the status doc names beats a double
1155
+ charge nobody notices.
1020
1156
 
1021
1157
  Then say what happened. Write the run's outcome to a status doc the app reads,
1022
1158
  and put the failure detail in `ctx.log("error", …)` for whoever inspects the
package/llms/bluesky.md CHANGED
@@ -64,7 +64,7 @@ This is the trap that turns a handled refusal into a dead app. The `403` from
64
64
  before any `if (body.error)` line can run, and — because an uncaught throw in a
65
65
  handler renders as an opaque `404` — the app tells its user it does not exist.
66
66
  A 400 from a real XRPC route (a bad `uri`, an unknown actor) *is* JSON, shaped
67
- `{ "error": "NotFound", "message": … }`, which is worth passing to the screen.
67
+ `{ "error": "InvalidRequest", "message": "Profile not found" }` for an unknown actor, which is worth passing to the screen.
68
68
 
69
69
  So: status first, JSON second, and a stated reason either way.
70
70
 
@@ -80,7 +80,10 @@ async function bsky(ctx, method, params) {
80
80
  }
81
81
  const body = await res.json().catch(() => null);
82
82
  if (!res.ok || !body) {
83
- return { ok: false, reason: body?.error ?? "http-error", message: body?.message ?? `HTTP ${res.status}` };
83
+ // A missing, renamed or deactivated actor is a 400 `InvalidRequest` whose
84
+ // message says "not found"; name it once here so the screen can say so.
85
+ const notFound = /not found/i.test(body?.message ?? "");
86
+ return { ok: false, reason: notFound ? "not-found" : (body?.error ?? "http-error"), message: body?.message ?? `HTTP ${res.status}` };
84
87
  }
85
88
  return { ok: true, body };
86
89
  }
@@ -121,16 +124,17 @@ implying it is complete.
121
124
 
122
125
  ## 5. Every `/_api` route keeps its reason
123
126
 
124
- An uncaught throw in a handler is folded into an opaque `404 backend.js _api: not found`, so every `/_api` route wraps its work in try/catch and answers with a stated reason.
127
+ An uncaught throw in a handler reaches the app as a bare `500 backend.js threw: <ErrorClass>` — the full message goes to the app's log, never to the caller — so every `/_api` route wraps its work in try/catch and answers with a stated reason.
125
128
 
126
- That fold is why a transient upstream blip reads to the user as "this app does
127
- not exist". Wrap the body of each route, answer `{ ok: false, reason }`, and log
128
- the throw with `ctx.log` so an operator can tell "the request never arrived"
129
- from "the request arrived and died".
129
+ A transient upstream blip is something the person can wait out, and the screen
130
+ can say so only when the route says so. Wrap the body of each route, answer
131
+ `{ ok: false, reason }`, and log the throw with `ctx.log` so an operator reading
132
+ `vibes-diy app logs` sees the route's own account alongside the platform's.
130
133
 
131
134
  A `not-found` from Bluesky is worth its own reason too: an actor who does not
132
135
  exist, a renamed handle, and a deactivated account all come back as a 400 with
133
- `error: "NotFound"`, and "we could not find @name on Bluesky" is an answer the
136
+ `error: "InvalidRequest"` and a message saying the profile was not found — the
137
+ helpers below name that `reason: "not-found"` — and "we could not find @name on Bluesky" is an answer the
134
138
  person can act on — "no data" is not.
135
139
 
136
140
  ## Complete backend.js
@@ -150,7 +154,10 @@ async function bsky(ctx, method, params) {
150
154
  }
151
155
  const body = await res.json().catch(() => null);
152
156
  if (!res.ok || !body) {
153
- return { ok: false, reason: body?.error ?? "http-error", message: body?.message ?? `HTTP ${res.status}` };
157
+ // A missing, renamed or deactivated actor is a 400 `InvalidRequest` whose
158
+ // message says "not found"; name it once here so the screen can say so.
159
+ const notFound = /not found/i.test(body?.message ?? "");
160
+ return { ok: false, reason: notFound ? "not-found" : (body?.error ?? "http-error"), message: body?.message ?? `HTTP ${res.status}` };
154
161
  }
155
162
  return { ok: true, body };
156
163
  }
@@ -241,7 +248,7 @@ export async function fetch(request, ctx) {
241
248
 
242
249
  return new Response("not found", { status: 404 });
243
250
  } catch (err) {
244
- // Without this, the throw is folded into an opaque 404 and the app claims it does not exist.
251
+ // Without this, the app gets a bare platform 500 naming only the error class — no reason to show.
245
252
  ctx.log("error", "bluesky route failed", { path: url.pathname, message: String(err) });
246
253
  return json({ ok: false, reason: "handler-failed", message: "Bluesky could not be read just now." }, 500);
247
254
  }
@@ -277,7 +284,7 @@ export default function App() {
277
284
  fetch(`/_api/feed?actor=${actor}`).then((r) => r.json()),
278
285
  ]);
279
286
  if (!p.ok) {
280
- setProblem(p.reason === "NotFound" ? `We could not find @${who} on Bluesky.` : p.message);
287
+ setProblem(p.reason === "not-found" ? `We could not find @${who} on Bluesky.` : p.message);
281
288
  return;
282
289
  }
283
290
  setProfile(p.profile);
@@ -288,7 +295,7 @@ export default function App() {
288
295
  return (
289
296
  <div>
290
297
  <input value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="alice.bsky.social" />
291
- <button onClick={load}>Look up</button>
298
+ <button onClick={() => load()}>Look up</button>
292
299
  {problem && <p role="alert">{problem}</p>}
293
300
 
294
301
  {profile && (
@@ -198,7 +198,7 @@ async function askRecords(question) {
198
198
 
199
199
  Send an image by passing the prompt as an array of content parts: an `image_url` part carrying the picture beside a `text` part asking the question.
200
200
 
201
- The prompt is the content of one user message, so a plain string means exactly one text part and the array is that same message's parts. Read the picture the user picked with a `FileReader` and hand over the data URL it produces:
201
+ The prompt is the content of one user message, so a plain string means exactly one text part and the array is that same message's parts. Read the picture the user picked with a `FileReader` and hand over the data URL it produces, straight from the camera or photo library — `callAI` shrinks a large photo to a model-friendly size (1568px on its long edge) before sending it, so the app passes the whole picture and leaves the resizing to the platform:
202
202
 
203
203
  ```javascript
204
204
  import { callAI } from "call-ai";
@@ -237,6 +237,8 @@ async function readFlyer(file) {
237
237
 
238
238
  A string prompt stays the common path for text-only work and behaves exactly as it always has. A model that reads text alone answers an image request with an error naming that model, so an app that asks about a picture hears when the picture went unread — surface that message the way you surface any other `callAI` error.
239
239
 
240
+ An image that stays too large even after shrinking is answered at once with an error whose `reason` is `"image-too-large"`; show its message and ask the person to pick a smaller photo.
241
+
240
242
  ## Error Handling
241
243
 
242
244
  ```javascript
package/llms/callai.md CHANGED
@@ -198,7 +198,7 @@ async function askRecords(question) {
198
198
 
199
199
  Send an image by passing the prompt as an array of content parts: an `image_url` part carrying the picture beside a `text` part asking the question.
200
200
 
201
- The prompt is the content of one user message, so a plain string means exactly one text part and the array is that same message's parts. Read the picture the user picked with a `FileReader` and hand over the data URL it produces:
201
+ The prompt is the content of one user message, so a plain string means exactly one text part and the array is that same message's parts. Read the picture the user picked with a `FileReader` and hand over the data URL it produces, straight from the camera or photo library — `callAI` shrinks a large photo to a model-friendly size (1568px on its long edge) before sending it, so the app passes the whole picture and leaves the resizing to the platform:
202
202
 
203
203
  ```javascript file=App.jsx
204
204
  import { callAI } from "call-ai";
@@ -237,6 +237,8 @@ async function readFlyer(file) {
237
237
 
238
238
  A string prompt stays the common path for text-only work and behaves exactly as it always has. A model that reads text alone answers an image request with an error naming that model, so an app that asks about a picture hears when the picture went unread — surface that message the way you surface any other `callAI` error.
239
239
 
240
+ An image that stays too large even after shrinking is answered at once with an error whose `reason` is `"image-too-large"`; show its message and ask the person to pick a smaller photo.
241
+
240
242
  Inside `backend.js`, `ctx.callAI` takes a string prompt.
241
243
 
242
244
  ## Error Handling
@@ -21,7 +21,7 @@ Each document has an `_id`, which can be auto-generated or set explicitly. Auto-
21
21
 
22
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
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 only when it has one; a reason-less rejection converges silently. So do **not** wrap writes in `try/catch` to show denial errors to the user; 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.
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 only when it has one; 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
25
 
26
26
  ### Basic Example
27
27
 
@@ -611,6 +611,8 @@ Fireproof documents carry attachments under `_files`. Save a `File` (or `Blob`)
611
611
 
612
612
  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).
613
613
 
614
+ 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.
615
+
614
616
  Building an image uploader with `_files`:
615
617
 
616
618
  App.jsx
@@ -623,7 +625,7 @@ import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
623
625
  export default function App() {
624
626
  const { useDocument, useLiveQuery } = useFireproof("imageUploads");
625
627
 
626
- const { doc, merge, submit } = useDocument({
628
+ const { doc, merge, save, reset } = useDocument({
627
629
  _files: {},
628
630
  caption: "",
629
631
  type: "upload",
@@ -631,16 +633,24 @@ export default function App() {
631
633
  });
632
634
 
633
635
  const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
636
+ const [saveError, setSaveError] = React.useState("");
634
637
 
635
638
  const onPickFile = (e) => {
636
639
  const f = e.target.files?.[0];
637
640
  if (f) merge({ _files: { photo: f } });
638
641
  };
639
642
 
640
- const onSubmit = (e) => {
643
+ const onSubmit = async (e) => {
641
644
  e.preventDefault();
642
645
  if (!doc._files?.photo) return;
643
- submit();
646
+ setSaveError("");
647
+ try {
648
+ // The save uploads the photo first, so it can reject; show why.
649
+ await save();
650
+ reset();
651
+ } catch (err) {
652
+ setSaveError(err.message);
653
+ }
644
654
  };
645
655
 
646
656
  const c = {
@@ -664,6 +674,7 @@ export default function App() {
664
674
  <Button type="submit" size="lg">
665
675
  Upload
666
676
  </Button>
677
+ {saveError && <p role="alert" className="text-destructive">{saveError}</p>}
667
678
  </form>
668
679
 
669
680
  <h3 className="text-lg font-bold mt-8">Recent Uploads</h3>
@@ -695,11 +706,9 @@ App.jsx
695
706
  if (f) merge({ _files: { photo: f } });
696
707
  };
697
708
 
698
- const onSubmit = (e) => {
709
+ const onSubmit = async (e) => {
699
710
  e.preventDefault();
700
711
  if (!doc._files?.photo) return;
701
- submit();
702
- };
703
712
  =======
704
713
  const onPickFile = (e) => {
705
714
  const next = {};
@@ -707,11 +716,9 @@ App.jsx
707
716
  merge({ _files: next });
708
717
  };
709
718
 
710
- const onSubmit = (e) => {
719
+ const onSubmit = async (e) => {
711
720
  e.preventDefault();
712
721
  if (!Object.keys(doc._files || {}).length) return;
713
- submit();
714
- };
715
722
  >>>>>>> REPLACE
716
723
  ```
717
724
 
@@ -723,11 +730,9 @@ App.jsx
723
730
 
724
731
  ```jsx
725
732
  <<<<<<< SEARCH
726
- const onSubmit = (e) => {
733
+ const onSubmit = async (e) => {
727
734
  e.preventDefault();
728
735
  if (!Object.keys(doc._files || {}).length) return;
729
- submit();
730
- };
731
736
  =======
732
737
  const [errors, setErrors] = React.useState({});
733
738
 
@@ -739,10 +744,9 @@ App.jsx
739
744
  return Object.keys(newErrors).length === 0;
740
745
  }
741
746
 
742
- const onSubmit = (e) => {
747
+ const onSubmit = async (e) => {
743
748
  e.preventDefault();
744
- if (validateForm()) submit();
745
- };
749
+ if (!validateForm()) return;
746
750
  >>>>>>> REPLACE
747
751
  ```
748
752
 
package/llms/fireproof.md CHANGED
@@ -21,7 +21,7 @@ Each document has an `_id`, which can be auto-generated or set explicitly. Auto-
21
21
 
22
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
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's access rules reject 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 **only when the app's access rules provide one** (`forbidden("…")`); a reason-less rejection converges silently. So do **not** wrap writes in `try/catch` to show denial errors to the user; 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.
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's access rules reject 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 **only when the app's access rules provide one** (`forbidden("…")`); 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
25
 
26
26
  ### Basic Example
27
27
 
@@ -585,6 +585,8 @@ Fireproof documents carry attachments under `_files`. Save a `File` (or `Blob`)
585
585
 
586
586
  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).
587
587
 
588
+ 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.
589
+
588
590
  Building an image uploader with `_files`:
589
591
 
590
592
  App.jsx
@@ -597,7 +599,7 @@ import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
597
599
  export default function App() {
598
600
  const { useDocument, useLiveQuery } = useFireproof("imageUploads");
599
601
 
600
- const { doc, merge, submit } = useDocument({
602
+ const { doc, merge, save, reset } = useDocument({
601
603
  _files: {},
602
604
  caption: "",
603
605
  type: "upload",
@@ -605,16 +607,24 @@ export default function App() {
605
607
  });
606
608
 
607
609
  const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
610
+ const [saveError, setSaveError] = React.useState("");
608
611
 
609
612
  const onPickFile = (e) => {
610
613
  const f = e.target.files?.[0];
611
614
  if (f) merge({ _files: { photo: f } });
612
615
  };
613
616
 
614
- const onSubmit = (e) => {
617
+ const onSubmit = async (e) => {
615
618
  e.preventDefault();
616
619
  if (!doc._files?.photo) return;
617
- submit();
620
+ setSaveError("");
621
+ try {
622
+ // The save uploads the photo first, so it can reject; show why.
623
+ await save();
624
+ reset();
625
+ } catch (err) {
626
+ setSaveError(err.message);
627
+ }
618
628
  };
619
629
 
620
630
  const c = {
@@ -638,6 +648,7 @@ export default function App() {
638
648
  <Button type="submit" size="lg">
639
649
  Upload
640
650
  </Button>
651
+ {saveError && <p role="alert" className="text-destructive">{saveError}</p>}
641
652
  </form>
642
653
 
643
654
  <h3 className="text-lg font-bold mt-8">Recent Uploads</h3>
@@ -669,11 +680,9 @@ App.jsx
669
680
  if (f) merge({ _files: { photo: f } });
670
681
  };
671
682
 
672
- const onSubmit = (e) => {
683
+ const onSubmit = async (e) => {
673
684
  e.preventDefault();
674
685
  if (!doc._files?.photo) return;
675
- submit();
676
- };
677
686
  =======
678
687
  const onPickFile = (e) => {
679
688
  const next = {};
@@ -681,11 +690,9 @@ App.jsx
681
690
  merge({ _files: next });
682
691
  };
683
692
 
684
- const onSubmit = (e) => {
693
+ const onSubmit = async (e) => {
685
694
  e.preventDefault();
686
695
  if (!Object.keys(doc._files || {}).length) return;
687
- submit();
688
- };
689
696
  >>>>>>> REPLACE
690
697
  ```
691
698
 
@@ -697,11 +704,9 @@ App.jsx
697
704
 
698
705
  ```jsx
699
706
  <<<<<<< SEARCH
700
- const onSubmit = (e) => {
707
+ const onSubmit = async (e) => {
701
708
  e.preventDefault();
702
709
  if (!Object.keys(doc._files || {}).length) return;
703
- submit();
704
- };
705
710
  =======
706
711
  const [errors, setErrors] = React.useState({});
707
712
 
@@ -713,10 +718,9 @@ App.jsx
713
718
  return Object.keys(newErrors).length === 0;
714
719
  }
715
720
 
716
- const onSubmit = (e) => {
721
+ const onSubmit = async (e) => {
717
722
  e.preventDefault();
718
- if (validateForm()) submit();
719
- };
723
+ if (!validateForm()) return;
720
724
  >>>>>>> REPLACE
721
725
  ```
722
726
 
package/llms/spotify.md CHANGED
@@ -153,8 +153,9 @@ A failure keeps its reason all the way to the screen. "No data" tells the owner
153
153
  nothing they can act on, and the five cases above each have a different fix.
154
154
 
155
155
  Every `_api` route wraps its handler in `try`/`catch` and answers with a stated
156
- reason, because an uncaught throw leaves the route rendering as an opaque 404 and
157
- the app looks broken rather than misconfigured.
156
+ reason, because an uncaught throw reaches the app as a bare `500 backend.js threw`
157
+ naming only the error class, and the app can show the person what to fix only
158
+ when the route names it.
158
159
 
159
160
  ## Complete backend.js
160
161
 
@@ -243,7 +244,7 @@ export async function fetch(request, ctx) {
243
244
  return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
244
245
  }
245
246
 
246
- // An uncaught throw renders as an opaque 404, so every route answers with a reason.
247
+ // An uncaught throw reaches the app as a bare 500 naming only the error class, so every route answers with a reason.
247
248
  try {
248
249
  if (url.pathname === "/search") {
249
250
  const q = (url.searchParams.get("q") ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "14.3.5",
3
+ "version": "14.3.7",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {
@@ -37,9 +37,9 @@
37
37
  "license": "Apache-2.0",
38
38
  "dependencies": {
39
39
  "@adviser/cement": "~0.5.34",
40
- "@vibes.diy/call-ai-v2": "14.3.5",
41
- "@vibes.diy/identity": "14.3.5",
42
- "@vibes.diy/use-vibes-types": "14.3.5",
40
+ "@vibes.diy/call-ai-v2": "14.3.7",
41
+ "@vibes.diy/identity": "14.3.7",
42
+ "@vibes.diy/use-vibes-types": "14.3.7",
43
43
  "arktype": "~2.2.3",
44
44
  "json-schema-faker": "~0.6.3"
45
45
  },