@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.23.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.27.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.2"
47
+ "@uniweb/build": "0.48.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -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 {'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
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.
@@ -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, depths?, errors?, cursors?, limits? }` (contract §5):
275
- * `data` answers exactly the keys sent, `[]` when nothing matched; a key that
276
- * ERRORED is absent from `data` and present in `errors`; `depths` says what was
277
- * actually served, which the record index files rather than what was asked for.
278
- * A key missing from both is a protocol violation and is reported as an error,
279
- * never as silence.
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` AND `limits` ARE READ — 2026-09-06 [Diego], reversing the ruling
282
- * that had them "received and IGNORED, because framework has no paging concept."
283
- * ⛔ **That ruling described our client and was silently wrong about our USERS:**
284
- * the service bounds every answer at 100 (the records contract §4.2/§5), and a
285
- * `cursors` entry is how it says there is more. Discarding both meant a hosted
286
- * list of 500 rendered 100 — no error, no warning, no way for an author to tell
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 `meta.bound` ride the
292
- * answer, and nothing pages automatically. Auto-paging here would put
293
- * unbounded round trips in front of paint for a section that may only show
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
- const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
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
- entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
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, bound })
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, bound, partial: true, pages: cap }) })
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 three signals to
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
- // · the service reported it BOUNDED the answer and offered no cursor.
430
- // ⚠️ The last is why `limits` is read at all: a cursor is the signal for a
431
- // query that declared no `limit`, and `limits` is the signal for one whose
432
- // author limit was clamped (the records contract §5). Neither alone covers
433
- // both. ⛔ Named `truncated` when it shipped in 0.17.0 this morning; renamed
434
- // the same day, before any consumer adopted it, because "truncated" says
435
- // something was cut and this also means "there is more you did not ask for".
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
- const ctx = { website: null }
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
  }