@uniweb/runtime 0.23.0 → 0.24.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 +27 -32
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +4 -3
- package/src/default-fetcher.js +52 -52
- package/src/prefetch.js +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.28.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.48.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/collect-records.js
CHANGED
|
@@ -57,11 +57,12 @@ import { createDefaultFetcher } from './default-fetcher.js'
|
|
|
57
57
|
* @param {Function} options.fetch - the transport, `(url, init) => Response`
|
|
58
58
|
* @param {AbortSignal} [options.signal]
|
|
59
59
|
* @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
|
|
60
|
+
* @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
|
|
61
|
+
* list shows; **`true` — whole records — is what an index wants**: a brief index
|
|
62
|
+
* cannot match body text the record's own detail page displays, and a reader who
|
|
63
63
|
* finds a word on the page and not in search meets the inconsistency two
|
|
64
64
|
* rankings would produce. The cost is the caller's and is bounded by `maxPages`.
|
|
65
|
+
* An external query is never collected — it has no records service to ask.
|
|
65
66
|
* @param {number} [options.maxPages] - the caller's own bound on the walk. The
|
|
66
67
|
* default is a bound, not a target; a caller that knows its per-request budget
|
|
67
68
|
* passes its own.
|
package/src/default-fetcher.js
CHANGED
|
@@ -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
|
|
@@ -271,27 +271,28 @@ function toQuestion(request) {
|
|
|
271
271
|
/**
|
|
272
272
|
* Send one batch to the records service and hand each question its own answer.
|
|
273
273
|
*
|
|
274
|
-
* The response is `{ data,
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
274
|
+
* The response is `{ data, whole?, cursors?, partial? }` (backend's records-query
|
|
275
|
+
* contract rev D, 2026-09-13), each map absent when empty: `data` answers exactly the
|
|
276
|
+
* keys sent — `[]` when nothing matched, and `[]` too for a question an author got
|
|
277
|
+
* wrong, since the service answers a mistake rather than refusing it; `whole[key]` says
|
|
278
|
+
* a key was served as whole records; `cursors[key]` that more records remain under the
|
|
279
|
+
* question's `limit`; `partial[key]` that a source of the key did not answer. A key
|
|
280
|
+
* missing from `data` is a protocol violation and is reported as an error, never as
|
|
281
|
+
* silence. ⛔ `errors` and `limits` are not read: the service stopped sending them in
|
|
282
|
+
* the same revision, and this client read both until 2026-09-14.
|
|
280
283
|
*
|
|
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.**
|
|
284
|
+
* ⭐ `cursors` ARE READ — 2026-09-06 [Diego], reversing the ruling that had them
|
|
285
|
+
* "received and IGNORED, because framework has no paging concept." ⛔ **That ruling
|
|
286
|
+
* described our client and was silently wrong about our USERS:** a `cursors` entry is
|
|
287
|
+
* how the service says there is more, and discarding it meant a hosted list could
|
|
288
|
+
* render part of its records with no way for an author to tell that from the end of
|
|
289
|
+
* the data. **The silent class, on a visitor's page.**
|
|
288
290
|
*
|
|
289
291
|
* Two behaviours, deliberately not one:
|
|
290
292
|
*
|
|
291
|
-
* - **a page render REPORTS** — `meta.partial` and
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* ten rows.
|
|
293
|
+
* - **a page render REPORTS** — `meta.partial` rides the answer, and nothing pages
|
|
294
|
+
* automatically. Auto-paging here would put unbounded round trips in front of
|
|
295
|
+
* paint for a section that may only show ten rows.
|
|
295
296
|
* - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
|
|
296
297
|
* until the service stops issuing them. `collectSiteRecords` is the caller
|
|
297
298
|
* that wants it (a corpus is not a page), and `maxPages` bounds the loop so
|
|
@@ -360,14 +361,14 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
360
361
|
return
|
|
361
362
|
}
|
|
362
363
|
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
363
|
-
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
364
364
|
// ⭐ `whole[key]` is present only for keys delivered as WHOLE entities — so a
|
|
365
365
|
// key's ABSENCE is the brief, and a key present that never asked is the
|
|
366
366
|
// brief-less Model saying so. Absent entirely when every key is briefs.
|
|
367
367
|
const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
|
|
368
|
-
// Both absent when empty, never `{}` (the records contract §5).
|
|
369
368
|
const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
|
|
370
|
-
|
|
369
|
+
// ⭐ `partial[key]` — `{ source, code, detail }`: a source of that key did not answer,
|
|
370
|
+
// and the key carries what the others returned. Unread, a degraded key looked complete.
|
|
371
|
+
const degraded = parsed && typeof parsed.partial === 'object' && parsed.partial ? parsed.partial : {}
|
|
371
372
|
queue.forEach((entry, i) => {
|
|
372
373
|
const key = keys[i]
|
|
373
374
|
// ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
|
|
@@ -375,21 +376,11 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
375
376
|
// corpus would rather have them marked partial than lose the key — losing it
|
|
376
377
|
// is indistinguishable from "this query has no records".
|
|
377
378
|
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
379
|
if (!(key in data)) {
|
|
392
|
-
|
|
380
|
+
const error = `the records service answered without the key "${key}"`
|
|
381
|
+
entry.resolve(held
|
|
382
|
+
? { data: held, error, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
383
|
+
: { data: null, error })
|
|
393
384
|
return
|
|
394
385
|
}
|
|
395
386
|
const whole = typeof wholes[key] === 'boolean'
|
|
@@ -397,7 +388,6 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
397
388
|
: (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
|
|
398
389
|
|
|
399
390
|
const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
|
|
400
|
-
const bound = typeof limits[key] === 'number' ? limits[key] : undefined
|
|
401
391
|
const rows = Array.isArray(data[key]) ? data[key] : data[key]
|
|
402
392
|
|
|
403
393
|
// An exhaustive caller collects the page and asks for the next one.
|
|
@@ -408,32 +398,30 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
408
398
|
? entry.request.maxPages
|
|
409
399
|
: DEFAULT_MAX_PAGES
|
|
410
400
|
if (page <= cap) {
|
|
411
|
-
pending.set(entry, { cursor, collected: acc, page, whole
|
|
401
|
+
pending.set(entry, { cursor, collected: acc, page, whole })
|
|
412
402
|
return
|
|
413
403
|
}
|
|
414
404
|
// The caller's own bound, not the service's: report rather than spin.
|
|
415
|
-
entry.resolve({ data: acc, meta: withMeta({ whole,
|
|
405
|
+
entry.resolve({ data: acc, meta: withMeta({ whole, partial: true, pages: cap }) })
|
|
416
406
|
return
|
|
417
407
|
}
|
|
418
408
|
|
|
419
409
|
const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
|
|
420
410
|
const meta = withMeta({
|
|
421
411
|
whole,
|
|
422
|
-
bound,
|
|
423
412
|
// ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
|
|
424
|
-
// a caller has one boolean to branch on rather than
|
|
413
|
+
// a caller has one boolean to branch on rather than several signals to
|
|
425
414
|
// combine. It is set in every case that means it:
|
|
426
415
|
// · a cursor came back and this caller does not page (a page render);
|
|
427
416
|
// · a cursor came back and the caller's `maxPages` stopped the walk;
|
|
428
417
|
// · the walk failed or aborted with pages already in hand;
|
|
429
|
-
// ·
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
partial: (cursor || bound !== undefined) ? true : undefined,
|
|
418
|
+
// · a source of the key did not answer (`partial[key]`), and `unavailable`
|
|
419
|
+
// carries the service's own account of which one.
|
|
420
|
+
// ⛔ Named `truncated` when it shipped in 0.17.0; renamed the same day, before
|
|
421
|
+
// any consumer adopted it, because "truncated" says something was cut and this
|
|
422
|
+
// also means "there is more you did not ask for".
|
|
423
|
+
partial: (cursor || key in degraded) ? true : undefined,
|
|
424
|
+
unavailable: key in degraded ? degraded[key] : undefined,
|
|
437
425
|
pages: entry.page && entry.page > 1 ? entry.page : undefined,
|
|
438
426
|
})
|
|
439
427
|
entry.resolve(meta ? { data: collected, meta } : { data: collected })
|
|
@@ -487,12 +475,24 @@ function withMeta(fields) {
|
|
|
487
475
|
return Object.keys(out).length ? out : undefined
|
|
488
476
|
}
|
|
489
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The locale a list's texts are collated in: the page's (ruled 2026-09-14 [Diego]).
|
|
480
|
+
* A request asked in a locale carries it; otherwise the caller's — the website's
|
|
481
|
+
* active locale in the browser, the locale a host's prerender renders.
|
|
482
|
+
*/
|
|
483
|
+
function sortLocale(request, ctx) {
|
|
484
|
+
if (typeof request?.locale === 'string' && request.locale) return request.locale
|
|
485
|
+
if (typeof ctx?.locale === 'string' && ctx.locale) return ctx.locale
|
|
486
|
+
const active = ctx?.website?.getActiveLocale?.()
|
|
487
|
+
return typeof active === 'string' && active ? active : null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
490
|
/**
|
|
491
491
|
* Evaluate the query over what the source returned — the ONE evaluator,
|
|
492
492
|
* `@uniweb/core`'s, so the browser orders and filters exactly as the build
|
|
493
493
|
* did when it materialized `/data/<name>.json`.
|
|
494
494
|
*/
|
|
495
|
-
function applyOperators(data, request, { dev = false } = {}) {
|
|
495
|
+
function applyOperators(data, request, { dev = false, locale = null } = {}) {
|
|
496
496
|
if (!Array.isArray(data)) return data
|
|
497
497
|
let result = data
|
|
498
498
|
// `scope` first: it names the branch the rest of the query reads. On this lane
|
|
@@ -500,7 +500,7 @@ function applyOperators(data, request, { dev = false } = {}) {
|
|
|
500
500
|
// wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
|
|
501
501
|
if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
|
|
502
502
|
if (request.where) result = matchWhere(request.where, result)
|
|
503
|
-
if (request.sort) result = applySort(result, request.sort, dev)
|
|
503
|
+
if (request.sort) result = applySort(result, request.sort, dev, locale)
|
|
504
504
|
if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
|
|
505
505
|
return result
|
|
506
506
|
}
|
|
@@ -513,9 +513,9 @@ function applyOperators(data, request, { dev = false } = {}) {
|
|
|
513
513
|
* logged once — a wrong order is not worth a broken page for a visitor.
|
|
514
514
|
*/
|
|
515
515
|
const warnedBadSorts = new Set()
|
|
516
|
-
function applySort(items, sortExpr, dev) {
|
|
516
|
+
function applySort(items, sortExpr, dev, locale) {
|
|
517
517
|
try {
|
|
518
|
-
return sortRecords(items, sortExpr)
|
|
518
|
+
return sortRecords(items, sortExpr, { locale })
|
|
519
519
|
} catch (err) {
|
|
520
520
|
if (dev) throw err
|
|
521
521
|
const key = String(sortExpr)
|
package/src/prefetch.js
CHANGED
|
@@ -216,7 +216,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
216
216
|
* @param {boolean} [opts.dev]
|
|
217
217
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
218
218
|
*/
|
|
219
|
-
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
|
|
219
|
+
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
|
|
220
220
|
if (prerender !== 'author' && prerender !== 'always') {
|
|
221
221
|
throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
222
222
|
}
|
|
@@ -225,7 +225,8 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
225
225
|
dev,
|
|
226
226
|
fetch,
|
|
227
227
|
})
|
|
228
|
-
|
|
228
|
+
// The page's locale, which a list's texts are collated in when the runtime sorts it.
|
|
229
|
+
const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
|
|
229
230
|
// Dispatched together, not one after another: the records service batches the
|
|
230
231
|
// requests issued in one tick into one POST, and a page's configs are
|
|
231
232
|
// independent of each other. Order is preserved in the result.
|
|
@@ -244,5 +245,5 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
244
245
|
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
|
245
246
|
export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
246
247
|
const configs = resolvePageFetchConfigs(content, route, { locale })
|
|
247
|
-
return executeFetchConfigs(configs, { content, fetch, dev, prerender })
|
|
248
|
+
return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
|
|
248
249
|
}
|