@uniweb/core 0.28.0 → 0.29.1

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.
@@ -92,22 +92,67 @@ export function currentOf(cfg) {
92
92
  }
93
93
 
94
94
  /**
95
- * The list a `current: exclude` section asks for: its own view, one record longer,
96
- * so that removing the page's record still leaves `limit` others. The order is
97
- * narrow, sort, remove the record, limit — a `limit` counts the others.
95
+ * ⭐ HOW A FETCH ON A PARAMETRIC PAGE USES THE PAGE'S RECORD — `current:` FOLLOWS THE QUERY,
96
+ * NOT THE KEY (ruled 2026-09-14 [Diego]). The one test the entity store (`resolve`, `fetch`),
97
+ * the prefetch and the build's warning all make:
98
+ *
99
+ * - **a fetch naming the route query** gets the page's record — `only` — unless its
100
+ * `current:` says `exclude` or `include`, whatever its `as`: `{ query: articles, as:
101
+ * related, current: exclude }` is the others under `related`;
102
+ * - **a fetch naming another query** gets that query's records as it describes them,
103
+ * and its `current:` is read when written: `exclude` takes the page's record out, `only`
104
+ * keeps just that record;
105
+ * - **a fetch naming no query** (a `path:` or `url:` config) is decided by its key, as
106
+ * before: the route key's fetch gets the record. So is every fetch on a page whose
107
+ * route query names none, since there is no query to compare.
108
+ *
109
+ * The page's record is found in any of them the way it is found in the route query's set —
110
+ * by the route's parameter (`matchesRouteParam`).
111
+ *
112
+ * ⛔ Until 2026-09-14 both were decided by the key: `current:` was read only under the route
113
+ * key, and there it defaulted to the record whatever query the fetch named. So `{ query:
114
+ * articles, as: related, current: exclude }` got every article, and `{ query: news, as:
115
+ * articles }` got the page's article out of `news`.
116
+ *
117
+ * @param {Object} cfg - a fetch config — resolved, or as authored with its `as`
118
+ * @param {{ key: string, config: Object }|null} route - the page's route query
119
+ * (`pageRouteQuery`), or null off a parametric page
120
+ * @returns {'only'|'exclude'|'include'|null} null when the page's record plays no part: the
121
+ * fetch gets its records as it describes them
122
+ */
123
+ export function currentFor(cfg, route) {
124
+ if (!cfg || !route) return null
125
+ const named = (c) => (typeof c?.query === 'string' && c.query ? c.query : null)
126
+ const query = named(cfg)
127
+ const routeQueryName = named(route.config)
128
+ if (query === null || routeQueryName === null) return bindingKey(cfg) === route.key ? currentOf(cfg) : null
129
+ if (query === routeQueryName) return currentOf(cfg)
130
+ return CURRENT_MODES.includes(cfg.current) ? cfg.current : null
131
+ }
132
+
133
+ /**
134
+ * The list a `current: exclude` section asks for: its own view, with `narrow.limit`
135
+ * one higher, so that removing the page's record still leaves `limit` others. The
136
+ * order is the set, then the fetch's `where` and `sort`, remove the record, `limit` —
137
+ * so a fetch's `limit` counts the others.
138
+ *
139
+ * ⚠️ Only the fetch's own `limit` is raised. A query's `limit` defines its set (ruled
140
+ * 2026-09-14 [Diego]), so the others never reach past it: with no `limit` of the
141
+ * fetch's own, the section gets the set without the page's record.
98
142
  *
99
143
  * @param {Object} cfg - a resolved config under the route key
100
144
  * @returns {Object}
101
145
  */
102
146
  export function othersView(cfg) {
103
- if (!cfg || typeof cfg.limit !== 'number' || cfg.limit <= 0) return cfg
104
- return { ...cfg, limit: cfg.limit + 1 }
147
+ const limit = cfg?.narrow?.limit
148
+ if (typeof limit !== 'number' || limit <= 0) return cfg
149
+ return { ...cfg, narrow: { ...cfg.narrow, limit: limit + 1 } }
105
150
  }
106
151
 
107
152
  /**
108
153
  * The records a `current: exclude` section receives, from what `othersView` asked:
109
154
  * the list without the page's record — the first that matches the route's value, the
110
- * one `only` delivers — cut to the binding's `limit`.
155
+ * one `only` delivers — cut to the fetch's `narrow.limit`.
111
156
  *
112
157
  * @param {Array|*} records - what `othersView(cfg)` answered
113
158
  * @param {Object} cfg - the section's resolved config
@@ -118,7 +163,8 @@ export function othersOf(records, cfg, isCurrent) {
118
163
  if (!Array.isArray(records)) return records
119
164
  const at = records.findIndex(isCurrent)
120
165
  const others = at < 0 ? records : [...records.slice(0, at), ...records.slice(at + 1)]
121
- return typeof cfg?.limit === 'number' && cfg.limit > 0 ? others.slice(0, cfg.limit) : others
166
+ const limit = cfg?.narrow?.limit
167
+ return typeof limit === 'number' && limit > 0 ? others.slice(0, limit) : others
122
168
  }
123
169
 
124
170
  /**
@@ -145,7 +191,7 @@ function localizeConfig(cfg, locale, defaultLocale) {
145
191
  * Say whether a resolved config has a PER-RECORD source — `detail` — so a
146
192
  * parametric page asks for its record on its own rather than finding it in the list.
147
193
  *
148
- * - the records service → `true`: the record is the list's own question narrowed
194
+ * - the records service → `true`: the record is the route query's set narrowed
149
195
  * by the URL's value (`buildDetailConfig`);
150
196
  * - an external query declaring `record:` → `true`: its own request;
151
197
  * - a query declaring `deferred:` → `/data/<query>/{slug}.json`, the per-record
@@ -167,8 +213,8 @@ function localizeConfig(cfg, locale, defaultLocale) {
167
213
  function applyDeferredDetail(cfg, queries) {
168
214
  if (cfg.detail !== undefined) return cfg
169
215
 
170
- // The records service answers a RECORD by the same question narrowed to it,
171
- // so every asked config has a detail source; `buildDetailConfig` composes it.
216
+ // The records service answers a RECORD by the query's set narrowed to it, so
217
+ // every asked config has a detail source; `buildDetailConfig` composes it.
172
218
  if (cfg.ask) return { ...cfg, detail: true }
173
219
  // An external query's `record:` request.
174
220
  if (cfg.record && typeof cfg.record === 'object') return { ...cfg, detail: true }
@@ -271,13 +317,13 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
271
317
  if (decl.record && typeof decl.record === 'object') out.record = decl.record
272
318
  // a live endpoint is the browser's to fetch, unless the binding says otherwise
273
319
  if (out.prerender === undefined) out.prerender = false
274
- return narrowQuery(out, decl, decl.where)
320
+ return setAndNarrow(out, decl, decl.where)
275
321
  }
276
322
 
277
- // ⭐ THE SERVICE FIRST. A host that answers questions gets the whole query —
278
- // `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes no
279
- // per-query address at all (the records contract, §2). It needs the query's
280
- // MODEL REF, which lives on the site's `config.queries` declaration; a
323
+ // ⭐ THE SERVICE FIRST. A host that answers questions gets the query as saved —
324
+ // `schema`, `scope`, `where`, `sort`, `limit` — and the fetch's `narrow` beside it,
325
+ // and composes no per-query address at all (the records contract, §2). It needs
326
+ // the query's MODEL REF, which lives on the site's `config.queries` declaration; a
281
327
  // payload that offers the service but carries no declaration cannot ask, and
282
328
  // says so. ⚠️ Dark until a host stamps the row; see `resolveRecordsService`.
283
329
  const ask = resolveRecordsService(services, locale ?? defaultLocale)
@@ -293,65 +339,88 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
293
339
  // instead would turn a producer defect into a 404 that names the wrong
294
340
  // thing. The fetcher refuses an asked request with no `schema` before any
295
341
  // request is made, and the block's `dataError` says exactly this.
296
- return { ...rest, ask, schema: null }
342
+ return setAndNarrow({ ...rest, ask, schema: null }, decl, decl?.where ?? null)
297
343
  }
298
- return narrowQuery({ ...rest, ask, schema }, decl, decl.where)
344
+ return setAndNarrow({ ...rest, ask, schema }, decl, decl.where)
299
345
  }
300
346
 
301
347
  // ⭐ THE COMPILED FILE. The build applied the named query's fixed `where` when it
302
- // wrote `/data/<query>.json`; what it could NOT apply is a clause bound to the
303
- // route (`where: { tag: :dir }`) — a file is written once and the route differs
304
- // per page — and it applies neither `scope` nor `limit`. So those travel here and
305
- // are applied per page, exactly as the service applies them (`bindRouteVariables`,
306
- // below). ⛔ Until 2026-09-11 nothing travelled: a named query's `scope: :dir` was
307
- // ignored on this lane and `where: { tag: :dir }` was applied at build to the
308
- // literal `':dir'`, compiling to no records (measured).
348
+ // wrote `/data/<query>.json`, in its `sort`; what it could NOT apply is a clause
349
+ // bound to the route (`where: { tag: :dir }`) — a file is written once and the
350
+ // route differs per page — and it applies neither `scope` nor `limit`. So the
351
+ // runtime makes the set per page from what travels here — `scope`, the routed
352
+ // clauses, the query's `sort` in the page's locale, its `limit` — exactly as the
353
+ // service makes it (`bindRouteVariables`, below; `evaluateQuery`). ⛔ Until
354
+ // 2026-09-11 nothing travelled: a named query's `scope: :dir` was ignored on this
355
+ // lane and `where: { tag: :dir }` was applied at build to the literal `':dir'`,
356
+ // compiling to no records (measured).
309
357
  const out = { ...cfg, path: queryDataUrl(cfg.query) }
310
358
  for (const key of QUERY_SUPPLIED) delete out[key]
311
- return decl ? narrowQuery(out, decl, routeVariableClauses(decl.where)) : out
359
+ return setAndNarrow(out, decl, decl ? routeVariableClauses(decl.where) : null)
312
360
  }
313
361
 
314
362
  /**
315
- * ⭐ A BINDING NARROWS ITS QUERY, AND NEVER WIDENS IT — ruled 2026-09-13 [Diego]:
316
- * *"binding can narrow its query and pick its own order and count, but it can never
317
- * add records the query leaves out."* One composition, so the records service is
318
- * asked exactly what the compiled file is evaluated with:
319
- *
320
- * - `scope` — the query's; a binding has none;
321
- * - `where` — the query's and the binding's must BOTH hold, as `{ and: [ … ] }`;
322
- * - `sort`, `limit` — the binding's, else the query's. A binding's `limit` is not
323
- * bounded by the query's: a count is how many to show, never which records exist.
324
- *
325
- * ⛔ Until 2026-09-13 each field of a binding REPLACED the query's here, while the
326
- * static build had already baked the query's `where`, `sort` and `limit` into the
327
- * file — so a binding narrowed inside the query on a static site and replaced it on
328
- * a hosted one (measured: `where: { published: true }` plus `{ tag: x }` delivered
329
- * published records tagged x from the file and asked the service for `{ tag: x }`).
330
- *
331
- * @param {Object} out - the binding, addressed
332
- * @param {Object} decl - the query's declaration
363
+ * ⭐ A FETCH IS ITS QUERY'S SET, AND WHAT IT TAKES OF THE SET — two levels, ruled
364
+ * 2026-09-14 [Diego]:
365
+ *
366
+ * - **the top level is the query as saved** — its `scope`, `where`, `sort` and
367
+ * `limit`. They define THE SET: the records its `scope` and `where` select, in its
368
+ * `sort`, up to its `limit`. A query's `limit` is part of what it selects, so a
369
+ * parametric page has a URL for each record of its route query's set, and no other;
370
+ * - **`narrow` is the fetch's own `where`, `sort` and `limit`**, applied to the set:
371
+ * its `where` filters what the set holds, its `sort` re-orders what is left, and its
372
+ * `limit` cuts the rest — never past the set. Absent when the fetch takes the whole
373
+ * set.
374
+ *
375
+ * Every lane evaluates the set first and then `narrow` — the records service at the
376
+ * source, the default fetcher and the build over a file or a response
377
+ * (`@uniweb/core`'s `evaluateQuery`) — and every question about one query carries the
378
+ * same top level, so a record question checks the set with the query's own `sort` and
379
+ * `limit` (`buildDetailConfig` adds `narrow.match`).
380
+ *
381
+ * ⛔ Until 2026-09-14 the two levels were MERGED into one flat question — the fetch's
382
+ * `where` joined the query's with `and`, and its `sort` and `limit` replaced the
383
+ * query's. So *"the latest 3 about pandas, among the query's 100 most recent"* was
384
+ * asked as *"the latest 3 about pandas"*, whether or not they were among the 100; a
385
+ * fetch's `limit` could exceed its query's; and a record question dropped the query's
386
+ * `sort` and `limit`, so a record outside the 100 still had a page.
387
+ *
388
+ * A config that names a query ALWAYS carries the fetch's adaptations in `narrow`, with
389
+ * a declaration for the query or without one, so the top level never holds a
390
+ * fetch's field. A `narrow`, `match` or `cursor` a stale binding carries is not read:
391
+ * the resolver and the client compose those.
392
+ *
393
+ * @param {Object} out - the fetch, addressed
394
+ * @param {Object|null} decl - the query's declaration, when the payload carries one
333
395
  * @param {Object|null} queryWhere - the part of the query's `where` this lane still
334
- * applies: all of it on the service, the routed clauses on the compiled file
396
+ * applies: all of it on the service and for an external query, the routed clauses on
397
+ * the compiled file
335
398
  * @returns {Object}
336
399
  */
337
- function narrowQuery(out, decl, queryWhere) {
338
- const next = { ...out }
339
- if (typeof decl.scope === 'string') next.scope = decl.scope
340
- const where = bothHold(queryWhere, out.where)
341
- if (where) next.where = where
342
- else delete next.where
343
- if (next.sort === undefined && decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
344
- if (next.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) next.limit = decl.limit
400
+ function setAndNarrow(out, decl, queryWhere) {
401
+ const { where, sort, limit, narrow: _narrow, match: _match, cursor: _cursor, ...next } = out
402
+ if (decl) {
403
+ if (typeof decl.scope === 'string') next.scope = decl.scope
404
+ if (someWhere(queryWhere)) next.where = queryWhere
405
+ if (decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
406
+ if (isCount(decl.limit)) next.limit = decl.limit
407
+ }
408
+ const narrow = {}
409
+ if (someWhere(where)) narrow.where = where
410
+ if (sort !== undefined && sort !== null && sort !== '') narrow.sort = sort
411
+ if (isCount(limit)) narrow.limit = limit
412
+ if (Object.keys(narrow).length > 0) next.narrow = narrow
345
413
  return next
346
414
  }
347
415
 
348
- /** Two where-objects that must both hold — `{ and: [a, b] }`, or whichever one there is. */
349
- function bothHold(a, b) {
350
- const some = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w) && Object.keys(w).length > 0
351
- if (some(a) && some(b)) return { and: [a, b] }
352
- if (some(b)) return b
353
- if (some(a)) return a
354
- return null
416
+ /** A where-object that says something: a non-empty plain object. */
417
+ function someWhere(where) {
418
+ return Boolean(where) && typeof where === 'object' && !Array.isArray(where) && Object.keys(where).length > 0
419
+ }
420
+
421
+ /** A `limit` that cuts: a number above 0. `0` and anything else mean every record. */
422
+ function isCount(limit) {
423
+ return typeof limit === 'number' && limit > 0
355
424
  }
356
425
 
357
426
  /**
@@ -384,6 +453,47 @@ function bindingKey(cfg) {
384
453
  return cfg?.as
385
454
  }
386
455
 
456
+ /**
457
+ * One fetch entry as the runtime reads it. ⭐ **A STRING IS A QUERY NAME, and `as`
458
+ * DEFAULTS TO THE QUERY'S NAME — on every lane** (ruled 2026-09-13 [Diego]: *"a string
459
+ * is a query name, so, `fetch: "members"` … means `fetch: {query: "members"}`"*, and
460
+ * *"`as` defaults to `query`"*). The build writes both out when it parses a content
461
+ * file (`parseFetchConfig`), so a static payload never needs this. A payload a backend
462
+ * publishes carries a page's and a section's `fetch` as stored — where a bare `"people"`
463
+ * and a `{ query }` with no `as` are ordinary — and ⛔ **both were skipped here, for
464
+ * having no `as`, until 2026-09-14** (measured): a hosted section bound that way
465
+ * received nothing, and a parametric page whose parent named its query that way had no
466
+ * route query.
467
+ *
468
+ * A string that is not a query name — a path, a data file — is not one: `/data/…` is
469
+ * never authored (the build refuses it), and guessing at it here would fetch a file no
470
+ * query declares.
471
+ *
472
+ * @param {*} entry - a string, an object, or anything else a stored `fetch` holds
473
+ * @returns {Object|null} the entry with its `as`, or null when it is not a fetch
474
+ */
475
+ function fetchEntry(entry) {
476
+ if (typeof entry === 'string') {
477
+ const name = entry.trim()
478
+ return name && !/[\\/]|\.(json|ya?ml)$/i.test(name) ? { query: name, as: name } : null
479
+ }
480
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
481
+ if (entry.as === undefined && typeof entry.query === 'string' && entry.query) return { ...entry, as: entry.query }
482
+ return entry
483
+ }
484
+
485
+ /**
486
+ * A `fetch` as the runtime reads it — one entry, several, or none — each through
487
+ * `fetchEntry`, so a string and a `{ query }` with no `as` are read as the query they name.
488
+ *
489
+ * @param {*} fetch - a level's `fetch`, as a payload carries it
490
+ * @returns {Array<Object>}
491
+ */
492
+ export function fetchEntries(fetch) {
493
+ if (!fetch) return []
494
+ return (Array.isArray(fetch) ? fetch : [fetch]).map(fetchEntry).filter(Boolean)
495
+ }
496
+
387
497
  /**
388
498
  * Resolve the applicable fetch configs from an ordered list of sources.
389
499
  *
@@ -429,9 +539,7 @@ export function resolveFetchConfigs(sources, options = {}) {
429
539
  const collectAll = schemas.length === 0
430
540
 
431
541
  for (const source of sources) {
432
- if (!source) continue
433
- const configList = Array.isArray(source) ? source : [source]
434
- for (const cfg of configList) {
542
+ for (const cfg of fetchEntries(source)) {
435
543
  const key = bindingKey(cfg)
436
544
  if (!key) continue
437
545
  if (configs.has(key)) continue
@@ -477,6 +585,11 @@ function unset(value) {
477
585
  * to an intentional list page. Only an authoring surface can catch that; this
478
586
  * function cannot.
479
587
  *
588
+ * ⭐ Both levels bind: the query's `scope` and `where`, and the fetch's own `where`
589
+ * inside `narrow` — a variable may be a value in either (`setAndNarrow`). A `narrow`
590
+ * left with nothing in it is dropped, so it cannot split a cache entry from the fetch
591
+ * that takes the whole set.
592
+ *
480
593
  * @param {Object} cfg - a resolved config
481
594
  * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a parametric page
482
595
  * @returns {Object} the config, with placeholders bound or their clauses dropped
@@ -496,14 +609,24 @@ function bindRouteVariables(cfg, variables) {
496
609
  out = bound === null ? rest : { ...rest, where: soleMember(bound) }
497
610
  }
498
611
  }
612
+ const narrowWhere = cfg.narrow?.where
613
+ if (narrowWhere && typeof narrowWhere === 'object') {
614
+ const bound = bindWhere(narrowWhere, variables)
615
+ if (bound !== narrowWhere) {
616
+ const { where, ...narrowRest } = out.narrow
617
+ const narrow = bound === null ? narrowRest : { ...narrowRest, where: soleMember(bound) }
618
+ const { narrow: _unbound, ...rest } = out
619
+ out = Object.keys(narrow).length > 0 ? { ...rest, narrow } : rest
620
+ }
621
+ }
499
622
  return out
500
623
  }
501
624
 
502
625
  /**
503
626
  * An `and` or `or` left holding ONE member once its unbound clauses dropped is that
504
- * member. It is how a query whose `where` is all route clauses, joined with its
505
- * binding's (`narrowQuery`), asks the list page the binding's `where` alone — the same
506
- * question, and the same cache entry, as a binding of a query with no `where`.
627
+ * member — so an authored `{ and: [ { tag: :dir }, { featured: true } ] }` asks the
628
+ * list page, where `:dir` is unbound, `{ featured: true }`: the same question, and the
629
+ * same cache entry, as the where written without the routed clause.
507
630
  */
508
631
  function soleMember(where) {
509
632
  if (!where || typeof where !== 'object' || Array.isArray(where)) return where
@@ -622,23 +745,27 @@ export function withoutRouteVariables({ where = null, scope = null } = {}) {
622
745
  }
623
746
 
624
747
  /**
625
- * ⭐ THE RECORDS A ROUTE QUERY SELECTS, UNCUT — the list a parametric page finds its
626
- * record in, and the list its detail pages are made from. A `limit` is how many a
627
- * list shows, never which records have a page: a binding `limit: 3` on `/blog` leaves
628
- * `/blog/d` a record (ruled 2026-09-13 [Diego]). `sort` stays, as the selection's order.
629
- *
630
- * ⛔ Until 2026-09-13 the compiled-file lane looked the record up in the LIMITED list,
631
- * so `/blog/d` rendered "not found" wherever its route query had a `limit`, and the
632
- * static build made no page for it. The records service never had this: its record
633
- * question drops `limit` (`buildDetailConfig`).
748
+ * ⭐ THE SET — the records a route query selects, and the list a parametric page finds
749
+ * its record in and its pages are made from: the config as the query saved it, without
750
+ * the fetch's `narrow`. Its `sort` and `limit` are the query's and stay, because a
751
+ * query's `limit` is part of what it selects (ruled 2026-09-14 [Diego]): a parametric
752
+ * page has a URL for each record of its route query's set, and no other — a field
753
+ * note older than the 100 most recent has no page under a query of the 100 most
754
+ * recent. A fetch's narrowing never decides which pages exist: `limit: 3` on `/blog`
755
+ * leaves `/blog/d` a record, when the query selects it.
756
+ *
757
+ * ⛔ Until 2026-09-14 this dropped the `limit` of a merged config instead — the fetch's
758
+ * or the query's, indistinguishable once merged — so every record the query's `where`
759
+ * selected had a page, whatever its `limit`, and a fetch's `where` did decide. The
760
+ * records service's record question dropped `sort` and `limit` to the same effect.
634
761
  *
635
762
  * @param {Object} cfg - a resolved config
636
- * @returns {Object} the config itself when it has no `limit`, else a copy without one
763
+ * @returns {Object} the config itself when it has no `narrow`, else a copy without it
637
764
  */
638
765
  export function routeSelection(cfg) {
639
- if (!cfg || typeof cfg !== 'object' || cfg.limit === undefined) return cfg
640
- const { limit, ...rest } = cfg
641
- return rest
766
+ if (!cfg || typeof cfg !== 'object' || cfg.narrow === undefined) return cfg
767
+ const { narrow, ...set } = cfg
768
+ return set
642
769
  }
643
770
 
644
771
  /**
@@ -660,10 +787,9 @@ export function siteReaches(parent) {
660
787
  return !parent
661
788
  }
662
789
 
663
- /** A `fetch` as a list: one declaration, several, or none. */
790
+ /** A `fetch` as a list: one declaration, several, or none — each read as the runtime reads it. */
664
791
  function fetchList(fetch) {
665
- if (!fetch) return []
666
- return Array.isArray(fetch) ? fetch.filter(Boolean) : [fetch]
792
+ return fetchEntries(fetch)
667
793
  }
668
794
 
669
795
  /**
@@ -789,6 +915,54 @@ export function pageRouteQuery(page, { routeOf, parentOf, fetchOf, sectionsOf, s
789
915
  return found ? { ...found, capturing, nested: capturing !== page } : null
790
916
  }
791
917
 
918
+ /**
919
+ * ⭐ THE PAGE A QUERY'S RECORDS LINK TO — ruled 2026-09-14 [Diego]: *a rendered record
920
+ * links to its query's page.* For each query, the parametric page whose ROUTE QUERY is
921
+ * that query (`routeQuery`), the first in page order:
922
+ *
923
+ * - **by the query's name, never by its schema** — a page over the same schema may not
924
+ * select the record, so its URL could name a record it answers "not found" for;
925
+ * - **a page nested inside a parametric page is not a record's page** — its route does
926
+ * not end in the parameter (`recordRouteBase`), so a record cannot fill it;
927
+ * - **no page, no link** — a query absent from the map has none.
928
+ *
929
+ * A fetch picks another page with `detailPage:`, which the caller resolves; this is
930
+ * the page with none. ⛔ It replaces `route:` on a query, which the build baked into
931
+ * compiled records as `route` — a field in the author's namespace, overwritten.
932
+ *
933
+ * Shape-agnostic, like `pageRouteQuery`: the object graph and a content document hand in
934
+ * their own accessors, so the runtime, a host and the editor get one answer.
935
+ *
936
+ * @param {Array<Object>} pages - every page, in page order, in the caller's shape
937
+ * @param {Object} access
938
+ * @param {(page: Object) => string} access.routeOf - its route pattern
939
+ * @param {(page: Object) => Object|null} access.parentOf - its parent page, or null
940
+ * @param {(page: Object) => *} access.fetchOf - its `fetch`
941
+ * @param {(page: Object) => Array<Object>|undefined} access.sectionsOf - its raw sections
942
+ * @param {*} [access.site] - the site's `fetch`
943
+ * @returns {Map<string, { page: Object, route: string, key: string }>} query name → its
944
+ * record page: the page, its route pattern, and the key its record is delivered under
945
+ */
946
+ export function recordPages(pages, { routeOf, parentOf, fetchOf, sectionsOf, site = null }) {
947
+ const out = new Map()
948
+ for (const page of pages || []) {
949
+ const route = routeOf(page)
950
+ if (recordRouteBase(route) === null) continue
951
+ const parent = parentOf(page)
952
+ const found = routeQuery({
953
+ page: fetchOf(page),
954
+ parent: parent ? fetchOf(parent) : null,
955
+ // the site's binding is a route query for a top-level page only (`siteReaches`)
956
+ site: siteReaches(parent) ? site : null,
957
+ sections: sectionFetches(sectionsOf(page)),
958
+ })
959
+ const name = found?.config?.query
960
+ if (typeof name !== 'string' || !name || out.has(name)) continue
961
+ out.set(name, { page, route, key: found.key })
962
+ }
963
+ return out
964
+ }
965
+
792
966
  /**
793
967
  * Say what a resolved config will GET, so the record index can file it.
794
968
  *
@@ -343,7 +343,15 @@ export default class FetcherDispatcher {
343
343
 
344
344
  const entry = meta !== undefined ? { data, meta } : { data }
345
345
  this._dataStore.set(key, entry)
346
- return { data, meta }
346
+ // ⭐ What the store now HOLDS, not what arrived: an entry the record index filed
347
+ // materializes each record at the depth the index holds it — so a list fetched
348
+ // after its record was fetched whole delivers that record whole, on this pass as
349
+ // on every later read (R3). ⛔ Until 2026-09-14 a miss returned what arrived, and
350
+ // the upgrade reached a list only once it was read back from the cache — which a
351
+ // parametric page on the records service happened to do, by asking its list
352
+ // beside its record, until its record question checked the set on its own.
353
+ const held = this._dataStore.get(key)
354
+ return held ? { data: held.data, meta: held.meta } : { data, meta }
347
355
  } catch (err) {
348
356
  if (this._dataStore.inflight.get(key) === inflight) {
349
357
  this._dataStore.inflight.delete(key)
package/src/index.js CHANGED
@@ -22,8 +22,9 @@ export { default as ObservableState } from './observable-state.js'
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
23
  // `resolveRecordsService` / `RECORDS_SERVICE` are NOT re-exported here: `./fetch-config.js`
24
24
  // reads them and a consumer that needs them imports `@uniweb/core/records-service`.
25
- export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES } from './fetch-config.js'
25
+ export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES, currentFor } from './fetch-config.js'
26
26
  export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
27
+ export { declaredKeys, sameSchema, fillDeclaredKeys } from './data-keys.js'
27
28
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
28
29
  // and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
29
30
  export {
@@ -58,6 +59,9 @@ export { applyScope, withinScope } from './scope.js'
58
59
  // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
59
60
  // which is what keeps the static and live lanes answering a query identically.
60
61
  export { parseSort, sortRecords, sortToWire } from './sort.js'
62
+ // A config's set, then its `narrow` — the one order of work the runtime's default
63
+ // fetcher and `@uniweb/build` both evaluate a fetch with.
64
+ export { evaluateQuery } from './query-evaluation.js'
61
65
  export {
62
66
  fillRoutePattern,
63
67
  splitPathCapture,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ⭐ A QUERY'S SET, THEN WHAT A FETCH TAKES OF IT — evaluated over the records a
3
+ * source returned. The ONE order of work for every lane that evaluates locally: the
4
+ * runtime's default fetcher over a compiled file or an external query's response,
5
+ * and the build when it runs a page's fetch.
6
+ *
7
+ * A resolved config has two levels (ruled 2026-09-14 [Diego],
8
+ * `@uniweb/core/fetch-config`):
9
+ *
10
+ * 1. **the set** — the query as saved: `scope`, then `where`, ordered by `sort`,
11
+ * cut by `limit`;
12
+ * 2. **`narrow`** — the fetch's narrowing of that set: `where` and `match` filter
13
+ * what the set holds, `sort` re-orders what is left (the set's order when there
14
+ * is none), and `limit` cuts the rest.
15
+ *
16
+ * So a narrowing never reaches past the set: its `limit` can cut the set and never
17
+ * extend it. A host's records service evaluates the same two levels at the source.
18
+ *
19
+ * ⚠️ `narrow.cursor` is not evaluated here: a cursor resumes a service's answer, and
20
+ * no local source issues one.
21
+ *
22
+ * INTENTIONALLY A LEAF — pure JS, no `node:*`, no DOM, no package-root import — so
23
+ * the build and a browser runtime evaluate with the same code.
24
+ */
25
+
26
+ import { match as matchWhere } from './where.js'
27
+ import { applyScope } from './scope.js'
28
+ import { sortRecords } from './sort.js'
29
+ import { routeParamValues } from './route-match.js'
30
+
31
+ /**
32
+ * Evaluate a resolved config's set and its narrowing over records.
33
+ *
34
+ * @param {Array<Object>|*} records - what the source returned; anything but an array
35
+ * is returned as it is (a single record is not filtered, sorted or cut)
36
+ * @param {Object} config - a resolved fetch config
37
+ * @param {Object} [options]
38
+ * @param {string|null} [options.locale] - the page's locale, which texts sort in
39
+ * @param {(items: Array, sort: *, options: { locale: string|null }) => Array} [options.sort] -
40
+ * the sort to apply, `sortRecords` by default. A lane that answers a bad `sort:`
41
+ * differently — the runtime delivers the records unsorted in production — passes its own.
42
+ * @returns {Array<Object>|*} the records the config selects, in its order
43
+ */
44
+ export function evaluateQuery(records, config, { locale = null, sort = sortRecords } = {}) {
45
+ if (!Array.isArray(records) || !config || typeof config !== 'object') return records
46
+ const order = (items, expr) => sort(items, expr, { locale })
47
+
48
+ let out = records
49
+ if (typeof config.scope === 'string' && config.scope) out = applyScope(out, config.scope)
50
+ if (config.where) out = matchWhere(config.where, out)
51
+ if (config.sort) out = order(out, config.sort)
52
+ if (isLimit(config.limit)) out = out.slice(0, config.limit)
53
+
54
+ const narrow = config.narrow
55
+ if (!narrow || typeof narrow !== 'object') return out
56
+ if (narrow.where) out = matchWhere(narrow.where, out)
57
+ if (narrow.match && typeof narrow.match === 'object') out = out.filter(matching(narrow.match))
58
+ if (narrow.sort) out = order(out, narrow.sort)
59
+ if (isLimit(narrow.limit)) out = out.slice(0, narrow.limit)
60
+ return out
61
+ }
62
+
63
+ /** A count that cuts: a whole number above 0. Absent, `0` or anything else is every record. */
64
+ function isLimit(limit) {
65
+ return typeof limit === 'number' && limit > 0
66
+ }
67
+
68
+ /**
69
+ * A `match` — the record a parametric page's URL names — as a filter: the key is the
70
+ * record field `routeRecordKey` maps a route param to (`$name`, `$uuid`, or the field
71
+ * itself), and its value is compared as a STRING, member-wise on a list, by the one
72
+ * rule every lane matches a route param with (`routeParamValues`).
73
+ */
74
+ function matching(match) {
75
+ const entries = Object.entries(match)
76
+ return (record) => entries.every(([key, value]) => {
77
+ const target = String(value)
78
+ return routeParamValues(record, paramNameOf(key)).some((held) => held === target)
79
+ })
80
+ }
81
+
82
+ /** The inverse of `routeRecordKey`: `$name` is a `[slug]` page's handle, `$uuid` a `[uuid]` page's identity. */
83
+ function paramNameOf(key) {
84
+ if (key === '$name') return 'slug'
85
+ if (key === '$uuid') return 'uuid'
86
+ return key
87
+ }
@@ -479,12 +479,13 @@ export function recordRouteBase(route) {
479
479
  *
480
480
  * `/blog/:slug` + `{ slug: 'a post' }` → `/blog/a%20post`. Every value is
481
481
  * `encodeURIComponent`-ed, because the output is a URL: it is compared against
482
- * `location.pathname` (`isActive(item.route)`) and matched back through
482
+ * `location.pathname` (`isActive(item.$route)`) and matched back through
483
483
  * `matchDynamicRoute`, which decodes what it captures. A raw interpolation and an
484
484
  * encoded one compare unequal on the first slug with a space — and they used to
485
485
  * both exist: the build baked `${base}/${item.slug}` raw into `/data/*.json` while
486
486
  * the runtime interpolated with encoding, and which one a site got was
487
- * lane-dependent (measured 2026-09-04). Two producers of one field now call this.
487
+ * lane-dependent (measured 2026-09-04). The build bakes no link since 2026-09-14;
488
+ * the runtime's `$route` and a search index's record URL both call this.
488
489
  *
489
490
  * ⛔ NOT for a file path. The SSG writes `dist/<route>/index.html` from the DECODED
490
491
  * value on purpose — a server decodes the request path before looking a file up,
package/src/sort.js CHANGED
@@ -104,9 +104,8 @@ export function sortToWire(sort) {
104
104
  * Numbers, ISO dates and booleans compare as values.
105
105
  *
106
106
  * ⭐ A RECORD WITH NO VALUE FOR THE KEY SORTS LAST, IN EITHER DIRECTION, and records
107
- * that compare equal keep their order — the language both lanes answer
108
- * (`kb/frontend/contracts/site-queries.md` §1.4, as the records service built it on
109
- * 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
107
+ * that compare equal keep their order — the language both lanes answer (as a host's
108
+ * records service answers it since 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
110
109
  * list, since nothing says which member orders the record (a dotted path that meets
111
110
  * a list reads no value). ⛔ Until 2026-09-14 a missing value sorted as the empty
112
111
  * string — first ascending, last descending — so a static site and a hosted one put