@vibes.diy/prompts 12.1.0 → 12.1.2

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.
Files changed (2) hide show
  1. package/llms/backend.md +120 -22
  2. package/package.json +4 -4
package/llms/backend.md CHANGED
@@ -81,6 +81,44 @@ than `forbidden` — keep scheduled writes small and well-guarded. `fetch` and
81
81
  `onChange` never get admin mode: they're user-triggerable, and a handler any
82
82
  visitor can invoke must not run owner-privileged.
83
83
 
84
+ ### Taking on the authorization job: `config.fetch.unfilteredReads`
85
+
86
+ Because the `fetch` lane runs as the calling user, a `fetch` handler by default
87
+ reads nothing for a caller with no session, and cannot read an access-fn-bound
88
+ database without an opt-in. Some routes legitimately need to: a calendar feed at
89
+ `GET /_api/faves.ics?t=<token>` is fetched by a calendar app that carries no
90
+ login, and the docs it serves are private to one user. For those, `backend.js`
91
+ can opt in — per database, `fetch` lane only:
92
+
93
+ ```js
94
+ export const config = {
95
+ fetch: {
96
+ unfilteredReads: {
97
+ dbs: ["faves"],
98
+ why: "serves per-user .ics calendar feeds authorized by an unguessable token",
99
+ },
100
+ },
101
+ };
102
+ ```
103
+
104
+ This is an **obligation, not a convenience**. Inside that handler, for those
105
+ databases, you are taking on the authorization job the platform normally does:
106
+ both the anonymous refusal and the access-function scoping come off, so
107
+ `ctx.db.query`/`ctx.db.get` return **every** document in the named databases —
108
+ all users, all channels — to whoever called the route. The legitimate pattern is
109
+ a **capability**: an unguessable, per-user, revocable token in the URL or a
110
+ header, checked before you read, with the response scoped to exactly that
111
+ token's owner. "The URL is hard to guess" only holds if you mint the token
112
+ yourself with real entropy and never print it anywhere else.
113
+
114
+ Two things to design around. The opt-in is scoped to the **lane, not the
115
+ route**: `backend.js` exports one `fetch` handler that routes internally, so
116
+ every path inside it can read those databases — keep the check at the top and
117
+ name as few databases as the feature needs. And it is a **static declaration**:
118
+ `dbs` must be plain string literals and `why` a non-empty string literal saying
119
+ who is authorized and how, or the push is rejected. There is no call-site flag,
120
+ and no other lane can opt in.
121
+
84
122
  ## ctx — what a handler gets
85
123
 
86
124
  ```js
@@ -89,7 +127,8 @@ ctx.userInfo; // { userHandle } or null — who the handler is acting as
89
127
  ctx.secrets; // Record<string,string> — the owner's per-vibe secrets; frozen, {} when none
90
128
  await ctx.db.put(doc, { db: "notes", id: "optional-id" }); // resolves to the doc id AFTER commit
91
129
  await ctx.db.delete(docId, { db: "notes" });
92
- const docs = await ctx.db.query({ db: "notes" }); // latest non-deleted docs (each with _id)
130
+ const docs = await ctx.db.query({ db: "notes", field: "type", key: "note", limit: 100 }); // a filtered page
131
+ const doc = await ctx.db.get("state:cursor", { db: "notes" }); // one doc by _id, or null
93
132
  const text = await ctx.callAI("prompt", { model: "openrouter/auto", max_tokens: 500 }); // server-side AI call
94
133
  const res = await ctx.fetch("https://api.example.com/x", { headers }); // outbound HTTP — use this, not bare fetch()
95
134
  ```
@@ -99,13 +138,57 @@ const res = await ctx.fetch("https://api.example.com/x", { headers }); // outbou
99
138
  database that triggered the event is the default.
100
139
  - Always `await` db calls; they resolve only after the write commits (or throw
101
140
  when the access function denies it).
102
- - `ctx.db.query({ db })` returns the whole database's latest docs (capped at
103
- 2000) filter and sort in the handler. It is read-ACL-gated as the acting
104
- identity and anonymous `fetch` callers are denied. In `scheduled` (admin
105
- mode) the read is **unfiltered** access-fn-bound databases return every
106
- doc, across all users and channels. In `fetch`/`onChange`, databases bound
107
- to an access function cannot be queried (keep those lanes' read databases on
108
- plain ACLs). Made for `scheduled` sweeps: read, decide, then `put`/`delete`.
141
+ - `ctx.db.get(id, { db })` resolves the single document with that `_id`, or
142
+ `null` when it is missing or deleted. It is the right read whenever you can
143
+ name the document a tick's state doc, a token lookup, a singleton. Never
144
+ scan a database looking for a doc whose id you already know.
145
+ - `ctx.db.query({ db, field, key, keys, range, prefix, limit, after })` reads a
146
+ **page** of the database's latest docs. Every filter is optional and they
147
+ combine: `field` names the document field to match on, then `key` (one exact
148
+ value), `keys` (any of several), `range` (`[from, to]`) or `prefix` (a string,
149
+ or an array prefix — charwise-encoded, same semantics the client's queries
150
+ use) narrows it. Three edges to get right: **no `field` means no filtering**
151
+ (you get a raw page of everything), a bare `field` with no selector still
152
+ drops docs that lack that field, and `prefix` takes the **raw** value — a
153
+ plain string or array, never anything pre-encoded (the encoding is internal).
154
+ `limit` only clamps the page **down**; the 2000-doc cap stays. Ask for what
155
+ the handler actually needs — reading the whole database and filtering in the
156
+ handler wastes the cap and goes blind as the app grows. Sorting still happens
157
+ in the handler.
158
+ - The result is a real `Array` of docs that **also** destructures — both
159
+ `const docs = await ctx.db.query(...)` and
160
+ `const { docs, next } = await ctx.db.query(...)` work.
161
+ - **Paginate on `next`, never on emptiness.** `next` is computed from the raw
162
+ page *before* filtering, and only when that page came back full — so a
163
+ narrowly filtered page can be **empty and still carry `next`**. Stopping at
164
+ the first empty page silently truncates the read:
165
+
166
+ ```js
167
+ let after;
168
+ do {
169
+ const page = await ctx.db.query({ db: "faves", field: "type", key: "favorite", limit: 500, after });
170
+ for (const doc of page) {
171
+ // …
172
+ }
173
+ after = page.next;
174
+ } while (after);
175
+ ```
176
+
177
+ `next` rides on the array itself, so read it **before** transforming: any
178
+ array operation (`.filter()`, `.map()`, spread) returns a plain array with no
179
+ `next`. On the last page the property is absent, not undefined — `"next" in
180
+ page` is `false`.
181
+ - Both reads are read-ACL-gated as the acting identity, through the same gate:
182
+ anonymous `fetch` callers are denied, and in `fetch`/`onChange` a database
183
+ bound to an access function cannot be read at all (keep those lanes' read
184
+ databases on plain ACLs). In `scheduled` (admin mode) reads are
185
+ **unfiltered** — access-fn-bound databases return every doc, across all users
186
+ and channels. Made for `scheduled` sweeps: read, decide, then `put`/`delete`.
187
+ - The one exception to that last sentence is the declared opt-in above,
188
+ `config.fetch.unfilteredReads` — a `fetch` handler that names a database there
189
+ reads it unfiltered, anonymous callers included, and owes the authorization
190
+ check itself. Nothing changes for writes, or for any lane or database you
191
+ didn't name.
109
192
  - `ctx.secrets` carries the owner's per-vibe secrets, set in the vibe's settings
110
193
  page or with `vibes-diy secrets set KEY`. Use it to verify inbound credentials —
111
194
  a webhook's shared secret or token — or to authenticate outbound calls
@@ -177,8 +260,8 @@ const SNAPSHOT = [
177
260
  ];
178
261
 
179
262
  async function ensureSnapshot(ctx) {
180
- const docs = await ctx.db.query({ db: "nodeReleases" });
181
- if (docs.some((doc) => doc.type === "release")) return;
263
+ // Point-read by id the snapshot has a known `_id`, so there's nothing to scan for.
264
+ if (await ctx.db.get(SNAPSHOT[0]._id, { db: "nodeReleases" })) return;
182
265
  for (const doc of SNAPSHOT) await ctx.db.put(doc, { db: "nodeReleases" });
183
266
  }
184
267
 
@@ -190,18 +273,19 @@ function unchanged(existing, next) {
190
273
  return Object.keys(next).every((k) => existing[k] === next[k]);
191
274
  }
192
275
 
193
- async function putIfChanged(ctx, byId, next) {
194
- if (unchanged(byId.get(next._id), next)) return;
276
+ // The diff source is a POINT-READ per incoming id, not a query over the stored
277
+ // set: the work is bounded by what the feed sent (≤20 docs), never by how big
278
+ // the database has grown — so a db that outgrows a query page can never
279
+ // silently eject rows from the diff and turn this tick into a rewrite loop.
280
+ async function putIfChanged(ctx, next) {
281
+ const existing = await ctx.db.get(next._id, { db: "nodeReleases" });
282
+ if (unchanged(existing, next)) return;
195
283
  await ctx.db.put(next, { db: "nodeReleases" });
196
284
  }
197
285
 
198
286
  export async function scheduled(event, ctx) {
199
287
  await ensureSnapshot(ctx);
200
288
 
201
- // Read current docs once so each write can diff against what's already stored.
202
- const current = await ctx.db.query({ db: "nodeReleases" });
203
- const byId = new Map(current.map((doc) => [doc._id, doc]));
204
-
205
289
  // One request, no retry storm; ctx.fetch enforces 15s/10MB and egress caps.
206
290
  const response = await ctx.fetch("https://api.github.com/repos/nodejs/node/releases?per_page=20", {
207
291
  headers: { accept: "application/vnd.github+json" },
@@ -215,7 +299,7 @@ export async function scheduled(event, ctx) {
215
299
  if (denied?.vibesEgressDenied === true) {
216
300
  // A real state change — putIfChanged writes once on entering denial, then
217
301
  // nothing while it persists. No per-tick timestamp, so no churn.
218
- await putIfChanged(ctx, byId, {
302
+ await putIfChanged(ctx, {
219
303
  _id: "refresh:status",
220
304
  type: "refreshStatus",
221
305
  state: "egress-denied",
@@ -233,7 +317,7 @@ export async function scheduled(event, ctx) {
233
317
  for (const row of rows.slice(0, 20)) {
234
318
  if (!row?.id || !row?.html_url || !row?.published_at) continue;
235
319
  // Stable per-item `_id` + diff: a row whose content is identical is skipped.
236
- await putIfChanged(ctx, byId, {
320
+ await putIfChanged(ctx, {
237
321
  _id: `release:github-${row.id}`,
238
322
  type: "release",
239
323
  title: String(row.name || row.tag_name || "Node.js release"),
@@ -243,7 +327,7 @@ export async function scheduled(event, ctx) {
243
327
  });
244
328
  }
245
329
 
246
- await putIfChanged(ctx, byId, {
330
+ await putIfChanged(ctx, {
247
331
  _id: "refresh:status",
248
332
  type: "refreshStatus",
249
333
  state: "fresh",
@@ -294,15 +378,29 @@ down. Catch that exact 403 shape and show the saved snapshot with honest copy
294
378
  render a broken screen, or silently label saved data as fresh.
295
379
 
296
380
  **Zero-churn discipline for scheduled ticks.** A cron runs forever, so it must
297
- not write forever. Read the current docs first (`ctx.db.query`), give every item
298
- a stable `_id`, and `put` **only** the rows whose content actually changed — the
299
- `putIfChanged` diff above. Never stamp a per-tick `checkedAt`/`updatedAt`/run
381
+ not write forever. Give every item a stable `_id` and `put` **only** the rows
382
+ whose content actually changed — the `putIfChanged` diff above, which
383
+ point-reads each id it is about to write. Diff against **point-reads of the
384
+ incoming ids**, so the work is bounded by what arrived this tick, not by how
385
+ big the database has grown; reach for a keyed `ctx.db.query` only when the
386
+ stored set itself is the target (and then walk `next` to exhaustion before
387
+ diffing — a one-page diff source re-puts everything the page missed, forever). Never stamp a per-tick `checkedAt`/`updatedAt`/run
300
388
  counter onto a doc: it makes every put look new, and each spurious put mints a
301
389
  fresh revision and a sync-feed row that re-tickles every replica on every tick.
302
390
  A failed or empty upstream fetch aborts with no writes, preserving the last-good
303
391
  rows. Only a genuine content change — a new or updated row, a real state
304
392
  transition — earns a write.
305
393
 
394
+ **A tick's own memory is a document it reads by id.** Whatever a scheduled
395
+ handler needs to know about what it already did — a cursor, a last-synced
396
+ marker, a per-run state — lives in one doc with a fixed `_id`, read with
397
+ `ctx.db.get(id, { db })`. Never re-derive that answer by scanning the database:
398
+ a sweep that diffs against its own whole-db read goes blind the moment the
399
+ database outgrows one page, and then rewrites everything on every tick, forever.
400
+ Keep a copy of the state in module scope as well, and read a **missing or failed**
401
+ state read as *"don't know — skip this tick"*, never as *"nothing has been done
402
+ yet"*: that second reading is exactly what restarts the endless rewrite.
403
+
306
404
  ## fetch — the app's HTTP endpoint
307
405
 
308
406
  Runs for requests to the app's `/_api` route. The request path is rooted after
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "12.1.0",
3
+ "version": "12.1.2",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "description": "",
@@ -24,9 +24,9 @@
24
24
  "license": "Apache-2.0",
25
25
  "dependencies": {
26
26
  "@adviser/cement": "~0.5.34",
27
- "@vibes.diy/call-ai-v2": "^12.1.0",
28
- "@vibes.diy/identity": "^12.1.0",
29
- "@vibes.diy/use-vibes-types": "^12.1.0",
27
+ "@vibes.diy/call-ai-v2": "^12.1.2",
28
+ "@vibes.diy/identity": "^12.1.2",
29
+ "@vibes.diy/use-vibes-types": "^12.1.2",
30
30
  "arktype": "~2.2.3",
31
31
  "json-schema-faker": "~0.6.2"
32
32
  },