@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.23.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.27.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.2"
47
+ "@uniweb/build": "0.49.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -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
- * ## ⛔ `limit` IS DROPPED, and it is the one place a corpus must diverge
18
+ * ## ⭐ Each query is asked AS SAVED — its `limit` included
19
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.
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
- * ⚠️ **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.
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 {'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
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 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.
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]` carries
71
- * `{ depth, pages, partial?, bound? }`.
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
- // `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 }
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 })
@@ -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:` are evaluated HERE, locally, over what the
19
- * first lane returns — with `@uniweb/core`'s one evaluator, the same the build
20
- * uses to materialize a file — and at the source when asked. Nothing decides
21
- * that per site: the LANE decides.
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
- matchWhere,
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): `schema` required, `scope` a bare path, `sort`
234
- * one key spelled `date` / `-date`, `whole` when the whole record is wanted,
235
- * `match` on a parametric page's record. ⭐ The where-object crosses exactly as
236
- * authored: the evaluator and the service speak one language (`@uniweb/core`'s where evaluator).
237
- * ⛔ `nin` was respelled `not_in` here until 2026-09-13, when the evaluator took
238
- * `not_in` as its only spelling. Anything the service does not answer (a dotted
239
- * path, a retired operator) is sent as written and answered there: never
240
- * approximated.
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
- // ⭐ The record of a parametric page: the question unchanged plus `match` — one
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
- // ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
262
- // it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
263
- // `exhaustive` deliberately does NOT cross — it is a client instruction about
264
- // how many times to ask, not part of the question being asked.
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, 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.
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` 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.**
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 `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.
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
- const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
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
- entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
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, bound })
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, bound, partial: true, pages: cap }) })
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 three signals to
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
- // · 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,
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, so the browser orders and filters exactly as the build
493
- * did when it materialized `/data/<name>.json`.
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
- if (!Array.isArray(data)) return data
497
- let result = data
498
- // `scope` first: it names the branch the rest of the query reads. On this lane
499
- // it is evaluated over each record's placement (`path`), as the build did when it
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, currentOf, othersView, siteReaches } from '@uniweb/core/fetch-config'
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
- for (const cfg of resolveFetchConfigs(sources, options).values()) {
164
- const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null
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
- // any other key, or `current: include` — the list as the binding describes it
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 route key
178
- // resolves to — a section re-declaring the route query asks its own record
179
- // question — by the one rule the entity store uses (`buildDetailConfig`), so
180
- // the question prefetched is the question the render asks.
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 list is asked beside the record; off it, the
186
- // record is FOUND in the route query's whole selection — the list without its
187
- // `limit` (`routeSelection`) — which is all the render reads for it.
188
- put(cfg.ask ? cfg : routeSelection(cfg))
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
- const ctx = { website: null }
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
  }