@uniweb/runtime 0.17.1 → 0.18.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.23.0",
39
+ "@uniweb/core": "^0.24.0",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.40.0"
47
+ "@uniweb/build": "0.41.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -12,11 +12,22 @@
12
12
  *
13
13
  * ⇒ So the composition is not duplicated here either. This walks the site's
14
14
  * `config.queries`, hands each one to `resolveFetchConfigs` — **the same rule a
15
- * page render uses**, applying each saved query's own `scope` / `where` / `sort`
16
- * / `limit` — and asks through the same client. What differs from a page is two
17
- * fields and nothing else: `depth: 'brief'` (an index wants what a list shows)
18
- * and `exhaustive: true` (a corpus is not a page, so it follows `cursors` to the
19
- * end; the records contract bounds a single answer at 100).
15
+ * page render uses**, applying each saved query's own `scope` and `where` and
16
+ * asks through the same client.
17
+ *
18
+ * ## `limit` IS DROPPED, and it is the one place a corpus must diverge
19
+ *
20
+ * A saved query's `limit` is the LIST PAGE's presentation: `limit: 20` means the
21
+ * page shows twenty. **Its detail pages still exist for every record matching
22
+ * `scope` + `where`** — so a corpus that honoured `limit` would index twenty and
23
+ * miss every page beyond them, which is worse than indexing nothing because the
24
+ * gap is invisible.
25
+ *
26
+ * ⚠️ **This shipped wrong in 0.17.0 and was found by the consumer, not by us**
27
+ * (2026-09-06): the config was passed through unchanged, `limit` crossed as the
28
+ * question's own, and the corpus was capped. The claim that this surface met
29
+ * "the population its detail pages can reach" was made *"read charitably"* — a
30
+ * phrase doing work that one `sed` would have done better.
20
31
  *
21
32
  * ## What the caller supplies
22
33
  *
@@ -46,13 +57,29 @@ import { createDefaultFetcher } from './default-fetcher.js'
46
57
  * @param {Function} options.fetch - the transport, `(url, init) => Response`
47
58
  * @param {AbortSignal} [options.signal]
48
59
  * @param {string[]} [options.only] - restrict to these query names
60
+ * @param {'brief'|'full'} [options.depth='brief'] - what to ask for. `brief` is
61
+ * what a list shows; **`full` is what an index wants** — a brief index cannot
62
+ * match body text the record's own detail page displays, and a reader who
63
+ * finds a word on the page and not in search meets the inconsistency two
64
+ * rankings would produce. The cost is the caller's and is bounded by `maxPages`.
65
+ * @param {number} [options.maxPages] - the caller's own bound on the walk. The
66
+ * default is a bound, not a target; a caller that knows its per-request budget
67
+ * passes its own.
49
68
  * @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
50
69
  * `records` is keyed by query NAME, each a flat array; `errors` is keyed the
51
- * same and is null when nothing failed; `meta[name]` carries `{ depth, pages,
52
- * truncated? }` `truncated` meaning the loop hit its own bound, never that
53
- * the site has more.
70
+ * same and is null when nothing failed; `meta[name]` carries
71
+ * `{ depth, pages, partial?, bound? }`.
72
+ *
73
+ * ⭐ **`partial` means NOT THE WHOLE POPULATION**, and a key can be in BOTH
74
+ * `records` and `errors`: a walk that failed or was aborted with pages already
75
+ * in hand keeps them, marked. ⛔ Losing them would be indistinguishable from
76
+ * "this query has no records", and on an abort every in-flight key fails at
77
+ * once — so discarding would lose the corpus, not a key.
54
78
  */
55
- export async function collectSiteRecords(content, { locale, fetch, signal, only = null } = {}) {
79
+ export async function collectSiteRecords(
80
+ content,
81
+ { locale, fetch, signal, only = null, whole = false, maxPages } = {},
82
+ ) {
56
83
  const config = content?.config
57
84
  const services = config?.services ?? null
58
85
  const queries = config?.queries ?? null
@@ -82,12 +109,18 @@ export async function collectSiteRecords(content, { locale, fetch, signal, only
82
109
  // `path` has no live lane, and reading that file is the caller's business,
83
110
  // not ours — it is in the site's own URL space and they already serve it.
84
111
  if (!cfg.ask) return
85
- const result = await fetcher.resolve({ ...cfg, depth: 'brief', exhaustive: true }, { signal })
86
- if (result?.error) {
87
- errors[name] = result.error
88
- return
89
- }
90
- records[name] = Array.isArray(result?.data) ? result.data : []
112
+ // `limit` is the list page's, never the corpus's see the header.
113
+ const { limit, ...population } = cfg
114
+ const asked = { ...population, whole, exhaustive: true }
115
+ if (typeof maxPages === 'number' && maxPages > 0) asked.maxPages = maxPages
116
+
117
+ const result = await fetcher.resolve(asked, { signal })
118
+ if (result?.error) errors[name] = result.error
119
+ // ⭐ Data and an error are not exclusive: a partial walk reports both, and
120
+ // the caller decides whether partial is usable. Only a walk that collected
121
+ // nothing leaves the key out of `records` entirely.
122
+ if (Array.isArray(result?.data)) records[name] = result.data
123
+ else if (!result?.error) records[name] = []
91
124
  if (result?.meta) meta[name] = result.meta
92
125
  }))
93
126
 
@@ -230,12 +230,13 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
230
230
  // it returned.
231
231
  data = applyOperators(data, request, { dev })
232
232
 
233
- // ⭐ Say what depth was delivered, so the record index can file it — what
234
- // the config asked for, echoed: a list at brief depth when the query has
235
- // a per-record source, a record in full. (The service reports `depths` per
233
+ // ⭐ Say whether WHOLE records were delivered, so the record index can
234
+ // file them — what the config asked for, echoed: briefs when the query
235
+ // has a per-record source, whole records otherwise. (The service reports
236
+ // `whole` per
236
237
  // key and overrides this with what it actually served.)
237
- const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
238
- return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
238
+ const whole = typeof request.whole === 'boolean' ? request.whole : undefined
239
+ return whole === undefined ? { data: data ?? [] } : { data: data ?? [], meta: { whole } }
239
240
  } catch (error) {
240
241
  if (error?.name === 'AbortError') {
241
242
  return { data: [], error: 'aborted' }
@@ -270,12 +271,16 @@ function toQuestion(request) {
270
271
  const sort = sortToWire(request.sort)
271
272
  if (sort) q.sort = sort
272
273
  if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
273
- if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
274
+ // ⛔ ONLY WHEN TRUE. The brief is the default and absent means the brief, so
275
+ // sending `whole: false` would be noise on every list question the client makes.
276
+ if (request.whole === true) q.whole = true
274
277
  // ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
275
278
  // it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
276
279
  // `exhaustive` deliberately does NOT cross — it is a client instruction about
277
280
  // how many times to ask, not part of the question being asked.
278
281
  if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
282
+ // ⛔ `maxPages` does not cross either, for the same reason `exhaustive` does not:
283
+ // both say how many times to ask, never what is being asked.
279
284
  return q
280
285
  }
281
286
 
@@ -310,7 +315,7 @@ function renameOperators(where) {
310
315
  *
311
316
  * Two behaviours, deliberately not one:
312
317
  *
313
- * - **a page render REPORTS** — `meta.truncated` and `meta.bound` ride the
318
+ * - **a page render REPORTS** — `meta.partial` and `meta.bound` ride the
314
319
  * answer, and nothing pages automatically. Auto-paging here would put
315
320
  * unbounded round trips in front of paint for a section that may only show
316
321
  * ten rows.
@@ -320,8 +325,16 @@ function renameOperators(where) {
320
325
  * a service that always answers with a cursor cannot spin.
321
326
  */
322
327
 
323
- /** Pages an exhaustive request will follow before giving up and reporting truncation. */
324
- const MAX_PAGES = 50
328
+ /**
329
+ * Pages an exhaustive request follows before stopping and reporting the answer
330
+ * as partial. A caller sets its own with `request.maxPages`.
331
+ *
332
+ * ⚖️ **20 is a bound, not a target** — it is the value a real caller chose for a
333
+ * real per-request budget (a search index, 20 × 100), taken as the default
334
+ * because any bound prevents a runaway and a low one fails visibly rather than
335
+ * expensively. A caller that knows its budget passes its own.
336
+ */
337
+ const DEFAULT_MAX_PAGES = 20
325
338
  async function flushAsked(url, queue, doFetch) {
326
339
  // One shared page loop: the batch is sent, and any entry that asked to be
327
340
  // exhaustive and came back with a cursor is re-sent alone until it is done.
@@ -362,24 +375,42 @@ async function flushAsked(url, queue, doFetch) {
362
375
  parsed = await response.json()
363
376
  } catch (error) {
364
377
  const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
365
- for (const entry of queue) entry.resolve({ data: null, error: message })
378
+ // AN ABORT KEEPS WHAT ARRIVED. On an exhaustive walk every in-flight key
379
+ // fails at once here, so discarding held pages loses the whole corpus rather
380
+ // than one key — the "rejection that loses every key" a caller cannot have.
381
+ for (const entry of queue) {
382
+ const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
383
+ entry.resolve(held
384
+ ? { data: held, error: message, meta: withMeta({ partial: true, pages: entry.page }) }
385
+ : { data: null, error: message })
386
+ }
366
387
  return
367
388
  }
368
389
  const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
369
390
  const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
370
- const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
391
+ // `whole[key]` is present only for keys delivered as WHOLE entities so a
392
+ // key's ABSENCE is the brief, and a key present that never asked is the
393
+ // brief-less Model saying so. Absent entirely when every key is briefs.
394
+ const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
371
395
  // Both absent when empty, never `{}` (the records contract §5).
372
396
  const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
373
397
  const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
374
398
  queue.forEach((entry, i) => {
375
399
  const key = keys[i]
400
+ // ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
401
+ // that fails on page 7 has six pages in hand, and a caller that asked for a
402
+ // corpus would rather have them marked partial than lose the key — losing it
403
+ // is indistinguishable from "this query has no records".
404
+ const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
376
405
  if (key in errors) {
377
406
  // A per-key error is `{ code, detail }` — `schema_not_found`,
378
407
  // `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
379
408
  // rides beside it for a reader that wants to branch on it.
380
409
  const e = errors[key]
381
410
  const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
382
- const out = { data: null, error: detail }
411
+ const out = held
412
+ ? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) }
413
+ : { data: null, error: detail }
383
414
  if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
384
415
  entry.resolve(out)
385
416
  return
@@ -388,9 +419,9 @@ async function flushAsked(url, queue, doFetch) {
388
419
  entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
389
420
  return
390
421
  }
391
- const depth = depths[key] === 'brief' || depths[key] === 'full'
392
- ? depths[key]
393
- : (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
422
+ const whole = typeof wholes[key] === 'boolean'
423
+ ? wholes[key]
424
+ : (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
394
425
 
395
426
  const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
396
427
  const bound = typeof limits[key] === 'number' ? limits[key] : undefined
@@ -400,23 +431,36 @@ async function flushAsked(url, queue, doFetch) {
400
431
  if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
401
432
  const acc = entry.collected ? entry.collected.concat(rows) : rows.slice()
402
433
  const page = (entry.page || 1) + 1
403
- if (page <= MAX_PAGES) {
404
- pending.set(entry, { cursor, collected: acc, page, depth, bound })
434
+ const cap = typeof entry.request.maxPages === 'number' && entry.request.maxPages > 0
435
+ ? entry.request.maxPages
436
+ : DEFAULT_MAX_PAGES
437
+ if (page <= cap) {
438
+ pending.set(entry, { cursor, collected: acc, page, whole, bound })
405
439
  return
406
440
  }
407
- // The loop's own bound, not the service's: report rather than spin.
408
- entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) })
441
+ // The caller's own bound, not the service's: report rather than spin.
442
+ entry.resolve({ data: acc, meta: withMeta({ whole, bound, partial: true, pages: cap }) })
409
443
  return
410
444
  }
411
445
 
412
446
  const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
413
447
  const meta = withMeta({
414
- depth,
448
+ whole,
415
449
  bound,
416
- // ⭐ A cursor IS the truncation signal, and it is the only one for a query
417
- // that declared no `limit`: `limits` is reported only when an author's own
418
- // limit was clamped (the records contract §5).
419
- truncated: cursor ? true : undefined,
450
+ // ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** asked for by name, so
451
+ // a caller has one boolean to branch on rather than three signals to
452
+ // combine. It is set in every case that means it:
453
+ // · a cursor came back and this caller does not page (a page render);
454
+ // · a cursor came back and the caller's `maxPages` stopped the walk;
455
+ // · the walk failed or aborted with pages already in hand;
456
+ // · the service reported it BOUNDED the answer and offered no cursor.
457
+ // ⚠️ The last is why `limits` is read at all: a cursor is the signal for a
458
+ // query that declared no `limit`, and `limits` is the signal for one whose
459
+ // author limit was clamped (the records contract §5). Neither alone covers
460
+ // both. ⛔ Named `truncated` when it shipped in 0.17.0 this morning; renamed
461
+ // the same day, before any consumer adopted it, because "truncated" says
462
+ // something was cut and this also means "there is more you did not ask for".
463
+ partial: (cursor || bound !== undefined) ? true : undefined,
420
464
  pages: entry.page && entry.page > 1 ? entry.page : undefined,
421
465
  })
422
466
  entry.resolve(meta ? { data: collected, meta } : { data: collected })