@uniweb/runtime 0.23.0 → 0.25.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 +85 -64
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +26 -23
- package/src/default-fetcher.js +138 -90
- package/src/prefetch.js +25 -16
- package/src/prepare-props.js +53 -21
- package/src/ssr-renderer.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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.29.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.49.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/collect-records.js
CHANGED
|
@@ -15,19 +15,21 @@
|
|
|
15
15
|
* page render uses**, applying each saved query's own `scope` and `where` — and
|
|
16
16
|
* asks through the same client.
|
|
17
17
|
*
|
|
18
|
-
* ##
|
|
18
|
+
* ## ⭐ Each query is asked AS SAVED — its `limit` included
|
|
19
19
|
*
|
|
20
|
-
* A
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* A query defines its SET: the records its `scope` and `where` select, in its
|
|
21
|
+
* `sort`, up to its `limit` (ruled 2026-09-14 [Diego]). A parametric page has a URL
|
|
22
|
+
* for each record of its route query's set and no other, so the set is exactly the
|
|
23
|
+
* population a site's pages reach — a field note older than a query's 100 most recent
|
|
24
|
+
* has no page to index. Asked with no `narrow`, the service answers the set whole in
|
|
25
|
+
* one answer, so a walk has nothing to page; it still follows a cursor if one ever
|
|
26
|
+
* comes back, bounded by `maxPages`.
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* ⛔ **Until 2026-09-14 this dropped the query's `limit`**, on the rule it replaced — a
|
|
29
|
+
* `limit` was the list page's presentation and every record matching `scope` + `where`
|
|
30
|
+
* had a page — which was right under that rule. ⚠️ And that rule's own history: the
|
|
31
|
+
* first version, in 0.17.0, passed `limit` through while claiming the corpus was the
|
|
32
|
+
* population detail pages reach, and the consumer found it (2026-09-06).
|
|
31
33
|
*
|
|
32
34
|
* ## What the caller supplies
|
|
33
35
|
*
|
|
@@ -57,18 +59,20 @@ import { createDefaultFetcher } from './default-fetcher.js'
|
|
|
57
59
|
* @param {Function} options.fetch - the transport, `(url, init) => Response`
|
|
58
60
|
* @param {AbortSignal} [options.signal]
|
|
59
61
|
* @param {string[]} [options.only] - restrict to these query names
|
|
60
|
-
* @param {
|
|
61
|
-
*
|
|
62
|
-
* match body text the record's own detail page displays, and a reader who
|
|
62
|
+
* @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
|
|
63
|
+
* list shows; **`true` — whole records — is what an index wants**: a brief index
|
|
64
|
+
* cannot match body text the record's own detail page displays, and a reader who
|
|
63
65
|
* 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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
66
|
+
* rankings would produce. The cost is the caller's, and bounded by each query's
|
|
67
|
+
* own `limit`. An external query is never collected — it has no records service
|
|
68
|
+
* to ask.
|
|
69
|
+
* @param {number} [options.maxPages] - the caller's own bound on a walk that pages. A
|
|
70
|
+
* question with no `narrow` is answered whole with no cursor, so today's walk is one
|
|
71
|
+
* request per query; the bound holds should a service page anyway.
|
|
68
72
|
* @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
|
|
69
73
|
* `records` is keyed by query NAME, each a flat array; `errors` is keyed the
|
|
70
|
-
* same and is null when nothing failed; `meta[name]
|
|
71
|
-
* `{
|
|
74
|
+
* same and is null when nothing failed; `meta[name]`, when there is anything to
|
|
75
|
+
* say, carries `{ whole?, pages?, partial?, unavailable? }`.
|
|
72
76
|
*
|
|
73
77
|
* ⭐ **`partial` means NOT THE WHOLE POPULATION**, and a key can be in BOTH
|
|
74
78
|
* `records` and `errors`: a walk that failed or was aborted with pages already
|
|
@@ -109,9 +113,8 @@ export async function collectSiteRecords(
|
|
|
109
113
|
// `path` has no live lane, and reading that file is the caller's business,
|
|
110
114
|
// not ours — it is in the site's own URL space and they already serve it.
|
|
111
115
|
if (!cfg.ask) return
|
|
112
|
-
//
|
|
113
|
-
const {
|
|
114
|
-
const asked = { ...population, whole, exhaustive: true }
|
|
116
|
+
// The query as saved — the set its pages reach, its `limit` included; see the header.
|
|
117
|
+
const asked = { ...cfg, whole, exhaustive: true }
|
|
115
118
|
if (typeof maxPages === 'number' && maxPages > 0) asked.maxPages = maxPages
|
|
116
119
|
|
|
117
120
|
const result = await fetcher.resolve(asked, { signal })
|
package/src/default-fetcher.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* question the page asked, answered per key (the records contract, as this
|
|
16
16
|
* client reads it).
|
|
17
17
|
*
|
|
18
|
-
* `where:` / `sort:` / `limit:`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* that per site: the
|
|
18
|
+
* A query's set — `scope:` / `where:` / `sort:` / `limit:` — and the `narrow`
|
|
19
|
+
* a fetch takes of it are evaluated HERE, locally, over what the first lane
|
|
20
|
+
* returns — with `@uniweb/core`'s one evaluator (`evaluateQuery`), the same the
|
|
21
|
+
* build uses — and at the source when asked. Nothing decides that per site: the
|
|
22
|
+
* LANE decides.
|
|
22
23
|
*
|
|
23
24
|
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
24
25
|
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
@@ -52,8 +53,7 @@
|
|
|
52
53
|
|
|
53
54
|
import {
|
|
54
55
|
substitutePlaceholders,
|
|
55
|
-
|
|
56
|
-
applyScope,
|
|
56
|
+
evaluateQuery,
|
|
57
57
|
sortRecords,
|
|
58
58
|
sortToWire,
|
|
59
59
|
deriveCacheKey,
|
|
@@ -209,7 +209,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
209
209
|
// (filtering/sorting/limiting a single record doesn't make sense).
|
|
210
210
|
// For non-arrays, operators are ignored — the source returned what
|
|
211
211
|
// it returned.
|
|
212
|
-
data = applyOperators(data, request, { dev })
|
|
212
|
+
data = applyOperators(data, request, { dev, locale: sortLocale(request, ctx) })
|
|
213
213
|
|
|
214
214
|
// ⭐ Say whether WHOLE records were delivered, so the record index can
|
|
215
215
|
// file them — what the config asked for, echoed: briefs when the query
|
|
@@ -230,14 +230,27 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
232
|
* One question of a batch, in the records service's own vocabulary
|
|
233
|
-
* (the records contract, §2)
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
233
|
+
* (the records contract, §2).
|
|
234
|
+
* ⭐ TWO LEVELS, ruled 2026-09-14 [Diego]:
|
|
235
|
+
*
|
|
236
|
+
* - **the top level is the query as saved** — `schema` (required), `scope` a bare
|
|
237
|
+
* path, `where`, `sort` one key spelled `date` / `-date`, `limit` — and `whole`
|
|
238
|
+
* when the whole record is wanted. Its records are the set, and the top level is
|
|
239
|
+
* the same on every question about it;
|
|
240
|
+
* - **`narrow`** — how this page takes less than the set: `where`, `match` (a
|
|
241
|
+
* parametric page's record), `sort`, `cursor` (to resume a previous answer) and
|
|
242
|
+
* `limit`. Absent when the question asks for the set as it is.
|
|
243
|
+
*
|
|
244
|
+
* ⛔ Until 2026-09-14 the two were one flat question — the page's `where` joined the
|
|
245
|
+
* query's, its `sort` and `limit` replaced the query's — and `match` and `cursor` sat
|
|
246
|
+
* at the top level, where the service no longer takes them. There is no fallback:
|
|
247
|
+
* the service answers an unknown or misplaced field with a `400` for the request.
|
|
248
|
+
*
|
|
249
|
+
* ⭐ A where-object crosses exactly as authored: the evaluator and the service speak
|
|
250
|
+
* one language (`@uniweb/core`'s where evaluator). ⛔ `nin` was respelled `not_in`
|
|
251
|
+
* here until 2026-09-13, when the evaluator took `not_in` as its only spelling.
|
|
252
|
+
* Anything the service does not answer (a retired operator) is sent as written and
|
|
253
|
+
* answered there: never approximated.
|
|
241
254
|
*/
|
|
242
255
|
function toQuestion(request) {
|
|
243
256
|
const q = { schema: request.schema }
|
|
@@ -248,50 +261,87 @@ function toQuestion(request) {
|
|
|
248
261
|
const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
249
262
|
if (scope) q.scope = scope
|
|
250
263
|
if (where) q.where = where
|
|
251
|
-
|
|
252
|
-
// key and the URL's value, beside the author's `where`, never merged into it
|
|
253
|
-
// (`buildDetailConfig`; the records contract as we read it, §1d).
|
|
254
|
-
if (request.match && typeof request.match === 'object') q.match = request.match
|
|
255
|
-
const sort = sortToWire(request.sort)
|
|
264
|
+
const sort = wireSort(request.sort)
|
|
256
265
|
if (sort) q.sort = sort
|
|
257
266
|
if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
|
|
258
267
|
// ⛔ ONLY WHEN TRUE. The brief is the default and absent means the brief, so
|
|
259
268
|
// sending `whole: false` would be noise on every list question the client makes.
|
|
260
269
|
if (request.whole === true) q.whole = true
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// `exhaustive` deliberately
|
|
264
|
-
// how many times to ask,
|
|
265
|
-
if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
|
|
266
|
-
// ⛔ `maxPages` does not cross either, for the same reason `exhaustive` does not:
|
|
267
|
-
// both say how many times to ask, never what is being asked.
|
|
270
|
+
const narrow = toNarrow(request.narrow)
|
|
271
|
+
if (narrow) q.narrow = narrow
|
|
272
|
+
// ⛔ `exhaustive` and `maxPages` deliberately do NOT cross — they are client
|
|
273
|
+
// instructions about how many times to ask, never part of what is being asked.
|
|
268
274
|
return q
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/**
|
|
278
|
+
* A question's `narrow`, with only the fields the service takes inside it — any other
|
|
279
|
+
* is a `400` for the whole request there — or `null` when nothing is left.
|
|
280
|
+
*
|
|
281
|
+
* - `where` crosses as authored;
|
|
282
|
+
* - `match` — the record of a parametric page: one key and the URL's value, beside
|
|
283
|
+
* the author's `where`, never merged into it (`buildDetailConfig`);
|
|
284
|
+
* - `sort` in the service's spelling;
|
|
285
|
+
* - ⭐ `cursor` — the ONLY field in a question that is not the author's: opaque, from a
|
|
286
|
+
* previous answer's `cursors`, sent back on the same question and nothing else
|
|
287
|
+
* changed. A cursor is a narrowing like the rest, so the top level stays the query as
|
|
288
|
+
* saved on every page;
|
|
289
|
+
* - `limit`, when it cuts.
|
|
290
|
+
*/
|
|
291
|
+
function toNarrow(narrow) {
|
|
292
|
+
if (!narrow || typeof narrow !== 'object') return null
|
|
293
|
+
const n = {}
|
|
294
|
+
if (narrow.where && typeof narrow.where === 'object') n.where = narrow.where
|
|
295
|
+
if (narrow.match && typeof narrow.match === 'object') n.match = narrow.match
|
|
296
|
+
const sort = wireSort(narrow.sort)
|
|
297
|
+
if (sort) n.sort = sort
|
|
298
|
+
if (typeof narrow.cursor === 'string' && narrow.cursor) n.cursor = narrow.cursor
|
|
299
|
+
if (typeof narrow.limit === 'number' && narrow.limit > 0) n.limit = narrow.limit
|
|
300
|
+
return Object.keys(n).length > 0 ? n : null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A `sort` in the service's spelling — `date` / `-date` — or, for one outside the
|
|
305
|
+
* language, the author's text AS WRITTEN, which the service answers by its own rule
|
|
306
|
+
* (ignored: the author's arrangement). ⛔ Never a throw: this runs while a page's whole
|
|
307
|
+
* batch is being composed, before any request, and until 2026-09-14 a sort `parseSort`
|
|
308
|
+
* refuses — two keys, a bad direction — threw here and left every question of the batch
|
|
309
|
+
* pending forever.
|
|
310
|
+
*/
|
|
311
|
+
function wireSort(sort) {
|
|
312
|
+
try {
|
|
313
|
+
return sortToWire(sort)
|
|
314
|
+
} catch {
|
|
315
|
+
return typeof sort === 'string' && sort.trim() ? sort : null
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
271
319
|
/**
|
|
272
320
|
* Send one batch to the records service and hand each question its own answer.
|
|
273
321
|
*
|
|
274
|
-
* The response is `{ data,
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* never
|
|
322
|
+
* The response is `{ data, whole?, cursors?, partial? }` (backend's records-query
|
|
323
|
+
* contract rev D, 2026-09-13), each map absent when empty: `data` answers exactly the
|
|
324
|
+
* keys sent — `[]` when nothing matched, and `[]` too for a question an author got
|
|
325
|
+
* wrong, since the service answers a mistake rather than refusing it; `whole[key]` says
|
|
326
|
+
* a key was served as whole records; `cursors[key]` that more records remain past the
|
|
327
|
+
* question's `narrow.limit` — a query's own `limit` defines the set and never produces
|
|
328
|
+
* one; `partial[key]` that a source of the key did not answer. A key
|
|
329
|
+
* missing from `data` is a protocol violation and is reported as an error, never as
|
|
330
|
+
* silence. ⛔ `errors` and `limits` are not read: the service stopped sending them in
|
|
331
|
+
* the same revision, and this client read both until 2026-09-14.
|
|
280
332
|
*
|
|
281
|
-
* ⭐ `cursors`
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* the service
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* a bound from the end of the data. **The silent class, on a visitor's page.**
|
|
333
|
+
* ⭐ `cursors` ARE READ — 2026-09-06 [Diego], reversing the ruling that had them
|
|
334
|
+
* "received and IGNORED, because framework has no paging concept." ⛔ **That ruling
|
|
335
|
+
* described our client and was silently wrong about our USERS:** a `cursors` entry is
|
|
336
|
+
* how the service says there is more, and discarding it meant a hosted list could
|
|
337
|
+
* render part of its records with no way for an author to tell that from the end of
|
|
338
|
+
* the data. **The silent class, on a visitor's page.**
|
|
288
339
|
*
|
|
289
340
|
* Two behaviours, deliberately not one:
|
|
290
341
|
*
|
|
291
|
-
* - **a page render REPORTS** — `meta.partial` and
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* ten rows.
|
|
342
|
+
* - **a page render REPORTS** — `meta.partial` rides the answer, and nothing pages
|
|
343
|
+
* automatically. Auto-paging here would put unbounded round trips in front of
|
|
344
|
+
* paint for a section that may only show ten rows.
|
|
295
345
|
* - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
|
|
296
346
|
* until the service stops issuing them. `collectSiteRecords` is the caller
|
|
297
347
|
* that wants it (a corpus is not a page), and `maxPages` bounds the loop so
|
|
@@ -360,14 +410,14 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
360
410
|
return
|
|
361
411
|
}
|
|
362
412
|
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
363
|
-
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
364
413
|
// ⭐ `whole[key]` is present only for keys delivered as WHOLE entities — so a
|
|
365
414
|
// key's ABSENCE is the brief, and a key present that never asked is the
|
|
366
415
|
// brief-less Model saying so. Absent entirely when every key is briefs.
|
|
367
416
|
const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
|
|
368
|
-
// Both absent when empty, never `{}` (the records contract §5).
|
|
369
417
|
const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
|
|
370
|
-
|
|
418
|
+
// ⭐ `partial[key]` — `{ source, code, detail }`: a source of that key did not answer,
|
|
419
|
+
// and the key carries what the others returned. Unread, a degraded key looked complete.
|
|
420
|
+
const degraded = parsed && typeof parsed.partial === 'object' && parsed.partial ? parsed.partial : {}
|
|
371
421
|
queue.forEach((entry, i) => {
|
|
372
422
|
const key = keys[i]
|
|
373
423
|
// ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
|
|
@@ -375,21 +425,11 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
375
425
|
// corpus would rather have them marked partial than lose the key — losing it
|
|
376
426
|
// is indistinguishable from "this query has no records".
|
|
377
427
|
const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
|
|
378
|
-
if (key in errors) {
|
|
379
|
-
// A per-key error is `{ code, detail }` — `schema_not_found`,
|
|
380
|
-
// `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
|
|
381
|
-
// rides beside it for a reader that wants to branch on it.
|
|
382
|
-
const e = errors[key]
|
|
383
|
-
const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
|
|
384
|
-
const out = held
|
|
385
|
-
? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
386
|
-
: { data: null, error: detail }
|
|
387
|
-
if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
|
|
388
|
-
entry.resolve(out)
|
|
389
|
-
return
|
|
390
|
-
}
|
|
391
428
|
if (!(key in data)) {
|
|
392
|
-
|
|
429
|
+
const error = `the records service answered without the key "${key}"`
|
|
430
|
+
entry.resolve(held
|
|
431
|
+
? { data: held, error, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
432
|
+
: { data: null, error })
|
|
393
433
|
return
|
|
394
434
|
}
|
|
395
435
|
const whole = typeof wholes[key] === 'boolean'
|
|
@@ -397,7 +437,6 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
397
437
|
: (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
|
|
398
438
|
|
|
399
439
|
const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
|
|
400
|
-
const bound = typeof limits[key] === 'number' ? limits[key] : undefined
|
|
401
440
|
const rows = Array.isArray(data[key]) ? data[key] : data[key]
|
|
402
441
|
|
|
403
442
|
// An exhaustive caller collects the page and asks for the next one.
|
|
@@ -408,32 +447,30 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
408
447
|
? entry.request.maxPages
|
|
409
448
|
: DEFAULT_MAX_PAGES
|
|
410
449
|
if (page <= cap) {
|
|
411
|
-
pending.set(entry, { cursor, collected: acc, page, whole
|
|
450
|
+
pending.set(entry, { cursor, collected: acc, page, whole })
|
|
412
451
|
return
|
|
413
452
|
}
|
|
414
453
|
// The caller's own bound, not the service's: report rather than spin.
|
|
415
|
-
entry.resolve({ data: acc, meta: withMeta({ whole,
|
|
454
|
+
entry.resolve({ data: acc, meta: withMeta({ whole, partial: true, pages: cap }) })
|
|
416
455
|
return
|
|
417
456
|
}
|
|
418
457
|
|
|
419
458
|
const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
|
|
420
459
|
const meta = withMeta({
|
|
421
460
|
whole,
|
|
422
|
-
bound,
|
|
423
461
|
// ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
|
|
424
|
-
// a caller has one boolean to branch on rather than
|
|
462
|
+
// a caller has one boolean to branch on rather than several signals to
|
|
425
463
|
// combine. It is set in every case that means it:
|
|
426
464
|
// · a cursor came back and this caller does not page (a page render);
|
|
427
465
|
// · a cursor came back and the caller's `maxPages` stopped the walk;
|
|
428
466
|
// · the walk failed or aborted with pages already in hand;
|
|
429
|
-
// ·
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
partial: (cursor || bound !== undefined) ? true : undefined,
|
|
467
|
+
// · a source of the key did not answer (`partial[key]`), and `unavailable`
|
|
468
|
+
// carries the service's own account of which one.
|
|
469
|
+
// ⛔ Named `truncated` when it shipped in 0.17.0; renamed the same day, before
|
|
470
|
+
// any consumer adopted it, because "truncated" says something was cut and this
|
|
471
|
+
// also means "there is more you did not ask for".
|
|
472
|
+
partial: (cursor || key in degraded) ? true : undefined,
|
|
473
|
+
unavailable: key in degraded ? degraded[key] : undefined,
|
|
437
474
|
pages: entry.page && entry.page > 1 ? entry.page : undefined,
|
|
438
475
|
})
|
|
439
476
|
entry.resolve(meta ? { data: collected, meta } : { data: collected })
|
|
@@ -442,10 +479,13 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
442
479
|
if (pending.size === 0) return
|
|
443
480
|
// Re-ask each unfinished key on its own — the cursor is per key, so a batch
|
|
444
481
|
// would have to correlate several independent positions through one body.
|
|
482
|
+
// ⭐ The cursor rides inside `narrow` and NOTHING ELSE in the question changes: the
|
|
483
|
+
// service resumes only the question a cursor came from, and answers a cursor sent
|
|
484
|
+
// with anything else changed with a `400`.
|
|
445
485
|
await Promise.all([...pending].map(([entry, state]) => {
|
|
446
486
|
const next = {
|
|
447
487
|
...entry,
|
|
448
|
-
request: { ...entry.request, cursor: state.cursor },
|
|
488
|
+
request: { ...entry.request, narrow: { ...(entry.request.narrow || {}), cursor: state.cursor } },
|
|
449
489
|
collected: state.collected,
|
|
450
490
|
page: state.page,
|
|
451
491
|
}
|
|
@@ -487,22 +527,30 @@ function withMeta(fields) {
|
|
|
487
527
|
return Object.keys(out).length ? out : undefined
|
|
488
528
|
}
|
|
489
529
|
|
|
530
|
+
/**
|
|
531
|
+
* The locale a list's texts are collated in: the page's (ruled 2026-09-14 [Diego]).
|
|
532
|
+
* A request asked in a locale carries it; otherwise the caller's — the website's
|
|
533
|
+
* active locale in the browser, the locale a host's prerender renders.
|
|
534
|
+
*/
|
|
535
|
+
function sortLocale(request, ctx) {
|
|
536
|
+
if (typeof request?.locale === 'string' && request.locale) return request.locale
|
|
537
|
+
if (typeof ctx?.locale === 'string' && ctx.locale) return ctx.locale
|
|
538
|
+
const active = ctx?.website?.getActiveLocale?.()
|
|
539
|
+
return typeof active === 'string' && active ? active : null
|
|
540
|
+
}
|
|
541
|
+
|
|
490
542
|
/**
|
|
491
543
|
* Evaluate the query over what the source returned — the ONE evaluator,
|
|
492
|
-
* `@uniweb/core`'s
|
|
493
|
-
*
|
|
544
|
+
* `@uniweb/core`'s `evaluateQuery`, so the browser orders and filters exactly as
|
|
545
|
+
* the build does: the query's set first (`scope` over each record's placement,
|
|
546
|
+
* `where`, `sort`, `limit`), then the fetch's `narrow` of it. Only the sort is this
|
|
547
|
+
* lane's own, for what it does with a bad `sort:` in production.
|
|
494
548
|
*/
|
|
495
|
-
function applyOperators(data, request, { dev = false } = {}) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
|
|
501
|
-
if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
|
|
502
|
-
if (request.where) result = matchWhere(request.where, result)
|
|
503
|
-
if (request.sort) result = applySort(result, request.sort, dev)
|
|
504
|
-
if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
|
|
505
|
-
return result
|
|
549
|
+
function applyOperators(data, request, { dev = false, locale = null } = {}) {
|
|
550
|
+
return evaluateQuery(data, request, {
|
|
551
|
+
locale,
|
|
552
|
+
sort: (items, sortExpr, options) => applySort(items, sortExpr, dev, options.locale),
|
|
553
|
+
})
|
|
506
554
|
}
|
|
507
555
|
|
|
508
556
|
/**
|
|
@@ -513,9 +561,9 @@ function applyOperators(data, request, { dev = false } = {}) {
|
|
|
513
561
|
* logged once — a wrong order is not worth a broken page for a visitor.
|
|
514
562
|
*/
|
|
515
563
|
const warnedBadSorts = new Set()
|
|
516
|
-
function applySort(items, sortExpr, dev) {
|
|
564
|
+
function applySort(items, sortExpr, dev, locale) {
|
|
517
565
|
try {
|
|
518
|
-
return sortRecords(items, sortExpr)
|
|
566
|
+
return sortRecords(items, sortExpr, { locale })
|
|
519
567
|
} catch (err) {
|
|
520
568
|
if (dev) throw err
|
|
521
569
|
const key = String(sortExpr)
|
package/src/prefetch.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* `{base}/…` from the payload, or the records service the host itself
|
|
51
51
|
* published at `config.services.records`.
|
|
52
52
|
*/
|
|
53
|
-
import { resolveFetchConfigs, pageRouteQuery, routeSelection,
|
|
53
|
+
import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, siteReaches } from '@uniweb/core/fetch-config'
|
|
54
54
|
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
55
55
|
import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
|
|
56
56
|
import { buildDetailConfig } from '@uniweb/core/detail-url'
|
|
@@ -149,7 +149,6 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
149
149
|
site: siteFetch,
|
|
150
150
|
})
|
|
151
151
|
: null
|
|
152
|
-
const routeKey = chosen?.key ?? null
|
|
153
152
|
// A nested page's sections receive its capturing page's route binding, which the
|
|
154
153
|
// one-parent cascade may not reach — the entity store adds it the same way.
|
|
155
154
|
const routeSource = chosen?.nested ? chosen.config : null
|
|
@@ -159,33 +158,42 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
159
158
|
const key = deriveCacheKey(cfg)
|
|
160
159
|
if (!out.has(key)) out.set(key, cfg)
|
|
161
160
|
}
|
|
161
|
+
// ⭐ EVERY fetch of every level, each resolved on its own — not the first per key. A section
|
|
162
|
+
// receives the keys its component declares, and two fetches of one `as` at two levels can
|
|
163
|
+
// fill two of them (`fillDeclaredKeys`, 2026-09-14), which a prefetch holding no
|
|
164
|
+
// component's meta cannot rule out. A superset, which a prefetch can afford.
|
|
162
165
|
const add = (sources) => {
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
const resolved = sources.flatMap((source) => fetchEntries(source).map((entry) => resolveFetchConfigs([entry], options).get(entry.as)))
|
|
167
|
+
for (const cfg of resolved.filter(Boolean)) {
|
|
168
|
+
// how the fetch uses the page's record — by the query it names, as the entity store asks
|
|
169
|
+
const current = currentFor(cfg, chosen)
|
|
165
170
|
if (current === 'exclude') {
|
|
166
171
|
// the list one longer, from which the render removes the page's record
|
|
167
172
|
put(othersView(cfg))
|
|
168
173
|
continue
|
|
169
174
|
}
|
|
170
175
|
if (current !== 'only') {
|
|
171
|
-
//
|
|
176
|
+
// no part for the page's record, or `current: include` — the list as the fetch describes it
|
|
172
177
|
put(cfg)
|
|
173
178
|
continue
|
|
174
179
|
}
|
|
175
180
|
// ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
|
|
176
181
|
// source (the records service, a `deferred:` query's per-record file) that
|
|
177
|
-
// record is a request of its own. Built for EVERY config the
|
|
178
|
-
//
|
|
179
|
-
// question
|
|
180
|
-
//
|
|
182
|
+
// record is a request of its own. Built for EVERY config that takes the page's
|
|
183
|
+
// record, by the one rule the entity store uses (`buildDetailConfig`), so
|
|
184
|
+
// the question prefetched is the question the render asks — and since that
|
|
185
|
+
// question drops a fetch's own narrowing, sections that narrow differently ask
|
|
186
|
+
// one record question between them.
|
|
181
187
|
if (cfg.detail && binding.paramValue !== undefined) {
|
|
182
188
|
const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
|
|
183
189
|
if (detailCfg) put(detailCfg)
|
|
184
190
|
}
|
|
185
|
-
// On the records service the
|
|
186
|
-
//
|
|
187
|
-
// `
|
|
188
|
-
|
|
191
|
+
// ⭐ On the records service the record question checks the query's set on its
|
|
192
|
+
// own, so nothing else is asked. Off it, the record is FOUND in the set —
|
|
193
|
+
// the query as saved, without the fetch's `narrow` (`routeSelection`) — which is
|
|
194
|
+
// all the render reads for it. ⛔ Until 2026-09-14 the service's list was asked
|
|
195
|
+
// beside the record, and the page embedded the whole set to show one record.
|
|
196
|
+
if (!cfg.ask) put(routeSelection(cfg))
|
|
189
197
|
}
|
|
190
198
|
}
|
|
191
199
|
// The cascade a block sees: its own fetch, page, parent, a nested page's route
|
|
@@ -216,7 +224,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
216
224
|
* @param {boolean} [opts.dev]
|
|
217
225
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
218
226
|
*/
|
|
219
|
-
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
|
|
227
|
+
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
|
|
220
228
|
if (prerender !== 'author' && prerender !== 'always') {
|
|
221
229
|
throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
222
230
|
}
|
|
@@ -225,7 +233,8 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
225
233
|
dev,
|
|
226
234
|
fetch,
|
|
227
235
|
})
|
|
228
|
-
|
|
236
|
+
// The page's locale, which a list's texts are collated in when the runtime sorts it.
|
|
237
|
+
const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
|
|
229
238
|
// Dispatched together, not one after another: the records service batches the
|
|
230
239
|
// requests issued in one tick into one POST, and a page's configs are
|
|
231
240
|
// independent of each other. Order is preserved in the result.
|
|
@@ -244,5 +253,5 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
244
253
|
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
|
245
254
|
export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
246
255
|
const configs = resolvePageFetchConfigs(content, route, { locale })
|
|
247
|
-
return executeFetchConfigs(configs, { content, fetch, dev, prerender })
|
|
256
|
+
return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
|
|
248
257
|
}
|