@vibes.diy/prompts 14.3.6 → 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.
- package/llms/backend.js.map +1 -1
- package/llms/backend.md +140 -4
- package/llms/bluesky.md +19 -12
- package/llms/callai.initial.md +3 -1
- package/llms/callai.md +3 -1
- package/llms/fireproof.initial.md +20 -16
- package/llms/fireproof.md +20 -16
- package/llms/spotify.md +4 -3
- package/package.json +4 -4
package/llms/backend.js.map
CHANGED
|
@@ -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;
|
|
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
|
|
1018
|
-
|
|
1019
|
-
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
the throw with `ctx.log` so an operator
|
|
129
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
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 && (
|
package/llms/callai.initial.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
|
|
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,
|
|
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
|
-
|
|
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())
|
|
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,
|
|
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
|
-
|
|
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())
|
|
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
|
|
157
|
-
the app
|
|
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
|
|
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.
|
|
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.
|
|
41
|
-
"@vibes.diy/identity": "14.3.
|
|
42
|
-
"@vibes.diy/use-vibes-types": "14.3.
|
|
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
|
},
|