@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/dist/ssr.js +39 -23
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +48 -15
- package/src/default-fetcher.js +68 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
47
|
+
"@uniweb/build": "0.41.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/collect-records.js
CHANGED
|
@@ -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`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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(
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
package/src/default-fetcher.js
CHANGED
|
@@ -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
|
|
234
|
-
// the config asked for, echoed:
|
|
235
|
-
// a per-record source,
|
|
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
|
|
238
|
-
return
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
392
|
-
?
|
|
393
|
-
: (
|
|
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
|
-
|
|
404
|
-
|
|
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
|
|
408
|
-
entry.resolve({ data: acc, meta: withMeta({
|
|
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
|
-
|
|
448
|
+
whole,
|
|
415
449
|
bound,
|
|
416
|
-
// ⭐
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
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 })
|