@uniweb/core 0.30.1 → 0.31.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/core",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,8 +47,8 @@
47
47
  "vitest": "^4.1.7"
48
48
  },
49
49
  "dependencies": {
50
- "@uniweb/semantic-parser": "^1.4.1",
51
- "@uniweb/theming": "^0.1.15"
50
+ "@uniweb/theming": "^0.1.15",
51
+ "@uniweb/semantic-parser": "^1.4.1"
52
52
  },
53
53
  "scripts": {
54
54
  "test": "vitest run"
package/src/data-keys.js CHANGED
@@ -24,31 +24,42 @@
24
24
 
25
25
  import { fetchEntries } from './fetch-config.js'
26
26
 
27
+ /** The suffix that asks for whole records: `'@std/article/*'`. */
28
+ export const WHOLE_SUFFIX = '/*'
29
+
27
30
  /**
28
- * A declared key's schema ref, as `data:` spells it: a ref string (`'@std/article'`), an
29
- * entry naming one (`{ schema: '@/member' }`), or null for an inline shape — a field map
30
- * or a form — which names no schema.
31
+ * A declared key's schema ref, and whether its component expects WHOLE records — as `data:`
32
+ * spells it: a ref string (`'@std/article'`), an entry naming one (`{ schema: '@/member' }`),
33
+ * or neither for an inline shape — a field map or a form — which names no schema.
34
+ *
35
+ * ⭐ `/*` after the ref asks for whole records (`'@std/article/*'`) — the record as stored, one
36
+ * key per section — where a bare ref asks for briefs, the brief's fields at the top (ruled
37
+ * 2026-09-27 [Diego]). The runtime asks each key's question accordingly; a key declared whole
38
+ * that its fetch cannot fill whole is `null`.
31
39
  *
32
40
  * @param {*} value - one `data:` entry's value, authored or leaned
33
- * @returns {string|null}
41
+ * @returns {{ ref: string|null, whole: boolean }}
34
42
  */
35
- function refOf(value) {
36
- if (typeof value === 'string') return value || null
37
- if (value && typeof value === 'object' && typeof value.schema === 'string') return value.schema || null
38
- return null
43
+ export function dataRefOf(value) {
44
+ const raw = typeof value === 'string'
45
+ ? value
46
+ : (value && typeof value === 'object' && typeof value.schema === 'string' ? value.schema : '')
47
+ if (!raw) return { ref: null, whole: false }
48
+ if (raw.endsWith(WHOLE_SUFFIX)) return { ref: raw.slice(0, -WHOLE_SUFFIX.length) || null, whole: true }
49
+ return { ref: raw, whole: false }
39
50
  }
40
51
 
41
52
  /**
42
- * The keys a section's component receives, in order, each with its schema ref — its
43
- * component's `data:` first, then its foundation's. A key both declare is the
44
- * component's.
53
+ * The keys a section's component receives, in order, each with its schema ref and whether
54
+ * it expects whole records (`dataRefOf`) — its component's `data:` first, then its
55
+ * foundation's. A key both declare is the component's.
45
56
  *
46
57
  * `data: false`, a missing `data:` or anything that is not a map declares nothing. Each
47
58
  * value may be as authored or as the build leans it (`{ key: ref|null }`).
48
59
  *
49
60
  * @param {Object|false|null|undefined} componentData - the component's `data:`
50
61
  * @param {Object|false|null|undefined} [foundationData] - its foundation's `main.js` `data:`
51
- * @returns {Array<[string, string|null]>}
62
+ * @returns {Array<[string, string|null, boolean]>} `[key, ref, whole]`
52
63
  */
53
64
  export function declaredKeys(componentData, foundationData = null) {
54
65
  const out = []
@@ -58,7 +69,8 @@ export function declaredKeys(componentData, foundationData = null) {
58
69
  for (const [key, value] of Object.entries(data)) {
59
70
  if (!key || seen.has(key)) continue
60
71
  seen.add(key)
61
- out.push([key, refOf(value)])
72
+ const { ref, whole } = dataRefOf(value)
73
+ out.push([key, ref, whole])
62
74
  }
63
75
  }
64
76
  return out
@@ -116,7 +128,7 @@ function parseRef(ref) {
116
128
  * The mapping says where an answer lands; it renames no fetch. A fetch keeps its `as`
117
129
  * for its question, its cache key and its prefetch.
118
130
  *
119
- * @param {Array<[string, string|null]>} declared - `declaredKeys`
131
+ * @param {Array<[string, string|null, boolean]>} declared - `declaredKeys`
120
132
  * @param {Array<*>} levels - each level's `fetch`, most specific first; falsy levels skipped
121
133
  * @param {Object} [options]
122
134
  * @param {Object|null} [options.queries] - the site's `config.queries`, for each fetch's
package/src/data-paths.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * literal in six places across three packages: the build wrote it
12
12
  * (the query processor), the build resolved the query shorthand to it
13
13
  * (`data-fetcher.js`), core injected the per-record default
14
- * (`fetch-config.js applyDeferredDetail`), core gated locale-prefixing on it
14
+ * (`fetch-config.js applyRecordFiles`), core gated locale-prefixing on it
15
15
  * (`fetch-config.js localizeConfig`), the dev server matched it with a regex
16
16
  * (`build/src/site/plugin.js`), and kit's `useEntityDetail` — `useWholeRecord`
17
17
  * since 2026-09-14 — requested it.
@@ -60,8 +60,7 @@ export const DATA_DIR = 'data'
60
60
  export const DATA_URL_PREFIX = `/${DATA_DIR}/`
61
61
 
62
62
  /**
63
- * URL of a query's cascade payload — every record it returns, with
64
- * `deferred:` fields stripped when the query declares them.
63
+ * URL of a query's list — every record it returns, each as its brief.
65
64
  *
66
65
  * @param {string} name - The query name.
67
66
  * @returns {string} e.g. `/data/articles.json`
@@ -71,11 +70,11 @@ export function queryDataUrl(name) {
71
70
  }
72
71
 
73
72
  /**
74
- * URL of one record's full payload — every field, including deferred ones.
75
- * Emitted per record only when the query declares `deferred:`.
73
+ * URL of one record's own file — the record whole, as stored. Emitted for every
74
+ * record of a compiled query (only for a `deferred:` query until 2026-09-27).
76
75
  *
77
76
  * Takes either a concrete slug — a caller holding the record — or the literal
78
- * placeholder `{slug}` (core's `applyDeferredDetail`, which builds a pattern
77
+ * placeholder `{slug}` (core's `applyRecordFiles`, which builds a pattern
79
78
  * that `substitutePlaceholders` resolves later against the dynamic-route
80
79
  * param). Both are plain interpolation; this function does not encode,
81
80
  * matching the behavior of the call sites it replaced.
package/src/datastore.js CHANGED
@@ -119,8 +119,8 @@ function indexableWhole(entry) {
119
119
  }
120
120
 
121
121
  /**
122
- * ⭐ THE RECORD INDEX — records are held ONCE, by identity, with the depth they
123
- * were fetched at; a query's result holds their ids.
122
+ * ⭐ THE RECORD INDEX — records are held ONCE, by identity, with whether they
123
+ * were fetched whole or as briefs; a query's result holds their ids.
124
124
  *
125
125
  * [Diego, 2026-09-04]: "they need to be able to hydrate records on their own…
126
126
  * track if they already have the briefs, and don't confuse that with knowing the
@@ -129,15 +129,20 @@ function indexableWhole(entry) {
129
129
  * record was already held in full still fetched it again, or — with the record's
130
130
  * list already cached — delivered the brief as if it were the record.
131
131
  *
132
- * Three rules:
133
- * R1 an entry whose `meta.depth` is `brief` or `full` and whose records all
134
- * carry `$uuid` is filed by id; `get()` materializes it from the index, so
135
- * every list holding a record sees the record's latest depth;
136
- * R2 depth is MONOTONIC — a brief never overwrites a record held in full;
137
- * R3 an upgrade MERGES the full record over the brief rather than replacing
138
- * it, so nothing depends on the full being a superset of the brief.
139
- * An entry with no depth, or with a record lacking identity, is held inline
140
- * exactly as before — the file lane with no synced records changes nothing.
132
+ * Two rules:
133
+ * R1 an entry whose `meta.whole` is a boolean and whose records all carry
134
+ * `$uuid` is filed by id; `get()` materializes it from the index, so every
135
+ * entry holding a record sees the latest copy of it;
136
+ * R2 a record's BRIEF and its WHOLE record are held apart, each replaced only
137
+ * by a fresher copy of its own kind — they are two shapes (a brief's fields at
138
+ * the top, a whole record's sections at the top; ruled 2026-09-27 [Diego]), so
139
+ * a list of briefs stays briefs when its record is fetched whole.
140
+ * ⛔ Until 2026-09-27 a whole record MERGED over the brief it found (`{ ...brief,
141
+ * ...whole }`) and every list holding the record showed the merge — one object
142
+ * holding a brief's fields beside section names.
143
+ * An entry whose `meta.whole` is not a boolean, or with a record lacking
144
+ * identity, is held inline exactly as before — the file lane with no synced
145
+ * records changes nothing.
141
146
  */
142
147
  export default class DataStore {
143
148
  constructor() {
@@ -149,7 +154,7 @@ export default class DataStore {
149
154
  this._listeners = new Set()
150
155
  // Key-scoped listeners: key → Set<Function>
151
156
  this._keyedListeners = new Map()
152
- // $uuid → { whole, record } — the record index
157
+ // $uuid → { brief?, whole? } — the record index
153
158
  this._records = new Map()
154
159
  // bumps on every index write, so a materialized list knows it is stale
155
160
  this._recordsVersion = 0
@@ -216,7 +221,8 @@ export default class DataStore {
216
221
  if (!entry || !entry.ids) return entry
217
222
  // Indexed: materialize from the record index, once per index version.
218
223
  if (entry._at === this._recordsVersion && entry._data) return entry._data
219
- const records = entry.ids.map((id) => this._records.get(id)?.record).filter(Boolean)
224
+ const slot = entry.meta?.whole === true ? 'whole' : 'brief'
225
+ const records = entry.ids.map((id) => this._records.get(id)?.[slot]).filter(Boolean)
220
226
  const data = entry.single ? (records[0] ?? null) : records
221
227
  const out = entry.meta !== undefined ? { data, meta: entry.meta } : { data }
222
228
  entry._at = this._recordsVersion
@@ -228,9 +234,9 @@ export default class DataStore {
228
234
  * Cache store. Fires listeners: first the global ones (all-writes), then
229
235
  * any subscribers registered for this specific key.
230
236
  *
231
- * An entry carrying `meta.depth` whose records all carry `$uuid` is filed in
232
- * the record index and stored as ids — see the class note. Anything else is
233
- * stored as given.
237
+ * An entry whose `meta.whole` is a boolean and whose records all carry `$uuid`
238
+ * is filed in the record index and stored as ids — see the class note.
239
+ * Anything else is stored as given.
234
240
  *
235
241
  * @param {string} key
236
242
  * @param {{ data: any, meta?: Object }} entry
@@ -245,39 +251,34 @@ export default class DataStore {
245
251
  }
246
252
 
247
253
  /**
248
- * The record held under an identity, with the depth it was fetched at — the
249
- * question a detail page asks before fetching: "do I hold this in full?"
254
+ * The record held under an identity, with whether it was fetched whole — the
255
+ * question a detail page asks before fetching: "do I hold this whole?"
250
256
  *
251
257
  * @param {string} id - a `$uuid`
252
- * @returns {{ depth: 'brief'|'full', record: Object } | null}
258
+ * @returns {{ whole: boolean, record: Object } | null}
253
259
  */
254
260
  getRecord(id) {
255
- return (id && this._records.get(id)) || null
261
+ const held = id ? this._records.get(id) : null
262
+ if (held?.whole) return { whole: true, record: held.whole }
263
+ if (held?.brief) return { whole: false, record: held.brief }
264
+ return null
256
265
  }
257
266
 
258
267
  /**
259
- * File one record at a depth, honouring R2 and R3. Returns the record now held.
268
+ * File one record, whole or a brief, honouring R2 — the fresher copy of its own
269
+ * kind replaces the one held. Returns the record now held.
260
270
  *
261
271
  * @param {Object} record
262
- * @param {'brief'|'full'} depth
272
+ * @param {boolean} whole - true for a record fetched whole, false for a brief
263
273
  * @returns {Object}
264
274
  */
265
275
  upsertRecord(record, whole) {
266
276
  const id = recordIdentity(record)
267
277
  if (!id || typeof whole !== 'boolean') return record
268
- const held = this._records.get(id)
269
- if (!held) {
270
- this._records.set(id, { whole, record })
271
- this._recordsVersion += 1
272
- return record
273
- }
274
- // R2 — a brief never displaces a whole record.
275
- if (!whole && held.whole) return held.record
276
- // R3 — a whole record merges over a brief; same kind takes the fresher copy.
277
- const next = whole && !held.whole ? { ...held.record, ...record } : record
278
- this._records.set(id, { whole, record: next })
278
+ const held = this._records.get(id) || {}
279
+ this._records.set(id, { ...held, [whole ? 'whole' : 'brief']: record })
279
280
  this._recordsVersion += 1
280
- return next
281
+ return record
281
282
  }
282
283
 
283
284
  _index(entry) {
package/src/detail-url.js CHANGED
@@ -38,18 +38,20 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
38
38
  */
39
39
  function paramContext(paramName, paramValue, record) {
40
40
  const context = { [paramName]: paramValue, param: paramValue }
41
- // ⭐ `{slug}` is the RECORD'S slug, whatever the route calls its param. The
42
- // file lane keys a query's per-record files by `item.slug` (`writeQueryFiles`)
41
+ // ⭐ `{slug}` is the RECORD'S handle, `$name`, whatever the route calls its param.
42
+ // The file lane keys a query's per-record files by `$name` (`writeQueryFiles`)
43
43
  // and injects `/data/<name>/{slug}.json` — so on a site routing `[id]`, the
44
44
  // route's context carried `id` and `param` and `{slug}` stayed literal: the
45
45
  // detail URL was `/data/articles/{slug}.json`, a guaranteed 404, on every
46
- // template page with `deferred:` fields (measured 2026-09-04). When the caller
46
+ // `[id]` page that read a record's own file (measured 2026-09-04). When the caller
47
47
  // holds the record — the entity store does (it matched it), and so does
48
48
  // kit's `useWholeRecord` — its slug fills the name the FILE was written under. A
49
49
  // caller with no record in hand leaves `{slug}` literal rather than guessing
50
50
  // the capture is one: a visibly unresolved address beats a plausible wrong one.
51
+ // ⭐ Whatever the route calls its param — and whatever field its query binds `:slug` to — a
52
+ // record's own file is named by its `$name`, so a record in hand with one fills `{slug}`.
51
53
  const handle = recordHandle(record)
52
- if (paramName !== 'slug' && handle != null && handle !== '') {
54
+ if (handle != null && handle !== '') {
53
55
  context.slug = handle
54
56
  }
55
57
  return context
@@ -73,7 +75,7 @@ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
73
75
  * - **an external query's `record:`** — its `url` (the query's when it names none),
74
76
  * `method` (likewise), `body` and `transform` — ⛔ never the list's `body` or
75
77
  * `transform`, since a record response is rarely wrapped the way the list is;
76
- * - **a `deferred:` query's per-record file** — the `/data/<query>/{slug}.json`
78
+ * - **a compiled query's per-record file** — the `/data/<query>/{slug}.json`
77
79
  * pattern the resolver injected.
78
80
  *
79
81
  * ⛔ The `detail:` forms an author wrote until 2026-09-13 — `rest`, `query`, a URL
@@ -85,13 +87,19 @@ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
85
87
  * Returns `null` — never throws — when the config has no per-record source or the
86
88
  * route carries no value. A caller treats `null` as "find the record in the list".
87
89
  *
90
+ * ⭐ `whole` — whether the record is asked whole (the default) or as its brief (ruled
91
+ * 2026-09-27 [Diego]: a component declares which it expects). Only the records service answers
92
+ * one record as its brief; everywhere else a record's brief is what its list holds, so asking
93
+ * for a brief there is `null`.
94
+ *
88
95
  * @param {Object} queryConfig - a resolved config
89
96
  * @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
90
97
  * the route's param and its value; `record`, when the caller already holds the
91
98
  * matched record, lets `{slug}` resolve to the record's own slug (`paramContext`).
99
+ * @param {{ whole?: boolean }} [options]
92
100
  * @returns {Object|null} a request, or null
93
101
  */
94
- export function buildDetailConfig(queryConfig, dynamicContext) {
102
+ export function buildDetailConfig(queryConfig, dynamicContext, { whole = true } = {}) {
95
103
  const { detail } = queryConfig
96
104
  if (!detail) return null
97
105
  const { paramName, paramValue, record = null } = dynamicContext
@@ -117,14 +125,15 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
117
125
  // string — it is a URL segment — and the service compares it as one, as the local
118
126
  // match does.
119
127
  if (queryConfig.ask) {
120
- const { narrow: _narrow, detail: _detail, match: _match, ...set } = queryConfig
128
+ const { narrow: _narrow, detail: _detail, match: _match, whole: _whole, ...set } = queryConfig
121
129
  return {
122
130
  ...set,
123
- narrow: { match: { [routeRecordKey(paramName)]: String(paramValue) } },
124
- whole: true,
131
+ narrow: { match: { [routeRecordKey(paramName, queryConfig.routeField)]: String(paramValue) } },
132
+ ...(whole ? { whole: true } : {}),
125
133
  dynamicContext: { paramName, paramValue },
126
134
  }
127
135
  }
136
+ if (!whole) return null
128
137
 
129
138
  // What every other record request carries beside its address:
130
139
  // `as` — the binding key, so the record lands where the list did;
@@ -152,7 +161,7 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
152
161
  return out
153
162
  }
154
163
 
155
- // ⭐ A `deferred:` QUERY'S PER-RECORD FILE — the pattern `applyDeferredDetail` injected.
164
+ // ⭐ A COMPILED QUERY'S PER-RECORD FILE — the pattern `applyRecordFiles` injected.
156
165
  if (typeof detail === 'string' && typeof queryConfig.path === 'string') {
157
166
  return { path: substitutePlaceholders(detail, context), ...common }
158
167
  }
@@ -51,7 +51,7 @@ export default class EntityStore {
51
51
 
52
52
  /**
53
53
  * The options each fetch of a block is resolved with. The rule applied to a fetch
54
- * (locale normalization, route variables, deferred-detail injection) lives in
54
+ * (locale normalization, route variables, the record files' pattern) lives in
55
55
  * `./fetch-config.js`, shared with every other host that has to answer the same
56
56
  * question. Do not re-inline it here — divergence between copies is what the
57
57
  * extraction exists to prevent.
@@ -237,7 +237,7 @@ export default class EntityStore {
237
237
  if (!Array.isArray(items) || items.length === 0) continue
238
238
  const template = recordRouteTemplate(cfg, website)
239
239
  if (!template) continue
240
- const linked = items.map((item) => withRecordRoute(item, template))
240
+ const linked = items.map((item) => withRecordRoute(item, template, cfg.routeField))
241
241
  // an unchanged list keeps its identity, so a render that re-links changes nothing
242
242
  if (linked.some((item, i) => item !== items[i])) data[key] = linked
243
243
  }
@@ -402,8 +402,8 @@ function recordRouteTemplate(cfg, website) {
402
402
  * ⭐ The encoding is `fillRoutePattern`'s: the one encoder for a record's href, which the
403
403
  * route matcher decodes (F14, 2026-09-04).
404
404
  */
405
- function withRecordRoute(item, template) {
405
+ function withRecordRoute(item, template, field = null) {
406
406
  if (!item || typeof item !== 'object') return item
407
- const route = fillRoutePattern(template, item)
407
+ const route = fillRoutePattern(template, item, { field })
408
408
  return route === null || item.$route === route ? item : { ...item, $route: route }
409
409
  }
@@ -4,7 +4,7 @@
4
4
  * "Which fetch configs apply here?" is a framework concept. Authors declare
5
5
  * `fetch:` at the section, page, folder and site levels; the framework decides
6
6
  * which declaration wins per schema, how a local data path is localized, and
7
- * when a query with deferred fields gets a detail pattern injected.
7
+ * where a compiled query's records have their own files.
8
8
  *
9
9
  * Every host that renders a page needs that answer — the browser runtime, the
10
10
  * build-time prerenderer, and any server-side renderer. The rule had grown
@@ -54,7 +54,7 @@ import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
54
54
  import { resolveRecordsService } from './records-service.js'
55
55
  // A leaf that imports nothing — `recordRouteBase` is the one rule for which page
56
56
  // addresses a record, and a route query is chosen at exactly that page.
57
- import { recordRouteBase } from './route-match.js'
57
+ import { recordRouteBase, routeFieldOf } from './route-match.js'
58
58
 
59
59
  /**
60
60
  * ⭐ HOW A SECTION ON A PARAMETRIC PAGE USES THE PAGE'S RECORD — `current:` on its
@@ -194,13 +194,15 @@ function localizeConfig(cfg, locale, defaultLocale) {
194
194
  * - the records service → `true`: the record is the route query's set narrowed
195
195
  * by the URL's value (`buildDetailConfig`);
196
196
  * - an external query declaring `record:` → `true`: its own request;
197
- * - a query declaring `deferred:` → `/data/<query>/{slug}.json`, the per-record
198
- * file the build emits beside the lean list, keyed by the record's slug. ⭐ On a
199
- * locale other than the default it is `/<locale>/data/<query>/{slug}.json`, the
200
- * way the list's own path is (`localizeConfig`): a localized build writes a
201
- * translated copy of every per-record file beside the translated list. ⛔ Until
202
- * 2026-09-14 the pattern was never localized, so a translated record page asked
203
- * for the default locale's file and showed an untranslated body.
197
+ * - a compiled query → `/data/<query>/{slug}.json`, the per-record file the build
198
+ * emits beside the list for every record — the record whole, as stored, where the
199
+ * list holds its brief. ⭐ On a locale other than the default it is
200
+ * `/<locale>/data/<query>/{slug}.json`, the way the list's own path is
201
+ * (`localizeConfig`): a localized build writes a translated copy of every
202
+ * per-record file beside the translated list. ⛔ Until 2026-09-14 the pattern was
203
+ * never localized, so a translated record page asked for the default locale's file
204
+ * and showed an untranslated body. ⛔ And until 2026-09-27 only a query with
205
+ * `deferred:` had one: the list held every other query's records whole.
204
206
  *
205
207
  * ⛔ `detail:` is no longer AUTHORED (retired 2026-09-13 [Diego]; the build refuses
206
208
  * it): its `rest`, `query`, pattern and `{ body, envelope }` forms are an external
@@ -216,7 +218,7 @@ function localizeConfig(cfg, locale, defaultLocale) {
216
218
  * @param {string|null} [defaultLocale] - the site's default locale
217
219
  * @returns {Object} the original config, or a copy carrying `detail`
218
220
  */
219
- function applyDeferredDetail(cfg, queries, locale = null, defaultLocale = null) {
221
+ function applyRecordFiles(cfg, queries, locale = null, defaultLocale = null) {
220
222
  if (cfg.detail !== undefined) return cfg
221
223
 
222
224
  // The records service answers a RECORD by the query's set narrowed to it, so
@@ -240,11 +242,9 @@ function applyDeferredDetail(cfg, queries, locale = null, defaultLocale = null)
240
242
  // is the only key there is. Two shapes, two answers; the deleted one had one
241
243
  // shape and pretended otherwise.
242
244
  const queryName = cfg.query || bindingKey(cfg)
243
- if (!queryName || !queries) return cfg
245
+ if (!queryName || !queries || typeof cfg.path !== 'string') return cfg
244
246
  const collConfig = queries[queryName]
245
247
  if (!collConfig || typeof collConfig !== 'object') return cfg
246
- const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
247
- if (!deferred || deferred.length === 0) return cfg
248
248
  const pattern = recordDataUrl(queryName, '{slug}')
249
249
  const localized = locale && locale !== defaultLocale && isDataUrl(pattern) ? `/${locale}${pattern}` : pattern
250
250
  return { ...cfg, detail: localized }
@@ -266,7 +266,7 @@ export function isExternalQuery(decl) {
266
266
  }
267
267
 
268
268
  /** Keys a binding may not carry, whatever a stale payload holds — the query supplies them. */
269
- const QUERY_SUPPLIED = ['scope', 'url', 'method', 'body', 'transform', 'record', 'detail', 'envelope']
269
+ const QUERY_SUPPLIED = ['scope', 'url', 'method', 'body', 'transform', 'record', 'detail', 'envelope', 'routeField']
270
270
 
271
271
  /**
272
272
  * Resolve a query reference to something the fetcher can call.
@@ -412,6 +412,11 @@ function setAndNarrow(out, decl, queryWhere) {
412
412
  if (someWhere(queryWhere)) next.where = queryWhere
413
413
  if (decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
414
414
  if (isCount(decl.limit)) next.limit = decl.limit
415
+ // The field its `where` binds the URL's last segment to — what a parametric page of it
416
+ // matches in place of `$name` (`routeFieldOf`, `./route-match.js`). On every page, so the
417
+ // query's own page links its records by the same field.
418
+ const routeField = routeFieldOf(decl)
419
+ if (routeField) next.routeField = routeField
415
420
  }
416
421
  const narrow = {}
417
422
  if (someWhere(where)) narrow.where = where
@@ -552,12 +557,12 @@ export function resolveFetchConfigs(sources, options = {}) {
552
557
  if (!key) continue
553
558
  if (configs.has(key)) continue
554
559
  if (!collectAll && !schemas.includes(key)) continue
555
- // Address first: localization and deferred-detail both key on `path`,
560
+ // Address first: localization and the record files both key on `path`,
556
561
  // which a query ref does not have until this runs.
557
562
  const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
558
563
  const localized = localizeConfig(sourced, locale, defaultLocale)
559
564
  const bound = dropRootScope(bindRouteVariables(localized, variables))
560
- configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, locale, defaultLocale), locale, defaultLocale))
565
+ configs.set(key, stampDepthAndLocale(applyRecordFiles(bound, queries, locale, defaultLocale), locale, defaultLocale))
561
566
  }
562
567
  }
563
568
 
@@ -976,8 +981,8 @@ export function recordPages(pages, { routeOf, parentOf, fetchOf, sectionsOf, sit
976
981
  *
977
982
  * `depth` — `brief` when the config has a per-record source (`detail`), because
978
983
  * a list with a separate record address is a list of partial records: a live
979
- * lane answers a list at brief depth and a record in full, and a `deferred:`
980
- * query's compiled file is the stripped list. `full` otherwise. An explicit
984
+ * lane answers a list at brief depth and a record in full, and a compiled
985
+ * query's list holds briefs beside each record's own file. `full` otherwise. An explicit
981
986
  * `depth` on the config wins (the records service's client sets it).
982
987
  *
983
988
  * `locale` — stamped on an ASKED config only. A compiled path already carries
package/src/index.js CHANGED
@@ -24,7 +24,7 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
24
24
  // reads them and a consumer that needs them imports `@uniweb/core/records-service`.
25
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
+ export { declaredKeys, sameSchema, fillDeclaredKeys, dataRefOf, WHOLE_SUFFIX } from './data-keys.js'
28
28
  // ⭐ What a page needs — the one rule. `fetchLevels` is the part every lane has an answer for
29
29
  // (which fetches reach a block); the drivers and the per-key program are `@uniweb/core/page-data`.
30
30
  export { fetchLevels } from './page-data.js'
@@ -64,12 +64,14 @@ export { applyScope, withinScope } from './scope.js'
64
64
  export { parseSort, sortRecords, sortToWire } from './sort.js'
65
65
  // A config's set, then its `narrow` — the one order of work the runtime's default
66
66
  // fetcher and `@uniweb/build` both evaluate a fetch with.
67
- export { evaluateQuery } from './query-evaluation.js'
67
+ export { evaluateQuery, mapQueryPaths } from './query-evaluation.js'
68
68
  export {
69
69
  fillRoutePattern,
70
70
  splitPathCapture,
71
71
  joinPathCapture,
72
72
  recordHandle,
73
+ routeFieldOf,
74
+ recordKeyValues,
73
75
  recordTitle,
74
76
  routeParamValue,
75
77
  routeParamValues,
package/src/page-data.js CHANGED
@@ -80,13 +80,13 @@ export function fetchLevels({ own = null, page = null, parent = null, route = nu
80
80
  * Each filling fetch is resolved on its own, so two fetches of one `as` can fill two keys.
81
81
  *
82
82
  * @param {Object} input
83
- * @param {Array<[string, string|null]>} input.declared - `declaredKeys(meta)`
83
+ * @param {Array<[string, string|null, boolean]>} input.declared - `declaredKeys(meta)`
84
84
  * @param {Array<*>} input.levels - `fetchLevels`
85
85
  * @param {Object} [input.holds] - what the block already holds, by key
86
86
  * @param {Object|null} [input.queries] - the site's `config.queries`
87
87
  * @param {Object} [input.options] - what each fetch is resolved with (`resolveFetchConfigs`)
88
- * @returns {Map<string, { cfg: Object, held: * }>} declared key → its filling config, in the order
89
- * `declared` lists the keys
88
+ * @returns {Map<string, { cfg: Object, held: *, whole: boolean }>} declared key → its filling config,
89
+ * and whether its component expects whole records, in the order `declared` lists the keys
90
90
  */
91
91
  export function blockFills({ declared, levels, holds = {}, queries = null, options = {} }) {
92
92
  const out = new Map()
@@ -102,11 +102,12 @@ export function blockFills({ declared, levels, holds = {}, queries = null, optio
102
102
  if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
103
103
  }))
104
104
 
105
+ const wholeKeys = new Set(declared.filter(([, , whole]) => whole).map(([key]) => key))
105
106
  for (const [key, { fetch, level, index }] of fills) {
106
107
  const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
107
108
  if (!cfg) continue
108
109
  const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
109
- out.set(key, { cfg, held })
110
+ out.set(key, { cfg, held, whole: wholeKeys.has(key) })
110
111
  }
111
112
  return out
112
113
  }
@@ -136,8 +137,8 @@ export function pageRecordConfig({ pageFetch = null, parent = null, route, site
136
137
  }
137
138
 
138
139
  /** The record a parametric page is about — the one `current: only` delivers. */
139
- function isPageRecord({ paramName, paramValue }) {
140
- return (item) => matchesRouteParam(item, paramName, paramValue)
140
+ function isPageRecord({ paramName, paramValue }, field = null) {
141
+ return (item) => matchesRouteParam(item, paramName, paramValue, field)
141
142
  }
142
143
 
143
144
  /**
@@ -153,14 +154,23 @@ function heldWhole(peekRecord, match) {
153
154
 
154
155
  /**
155
156
  * (c) + (d) One declared key's program: the requests it needs, in order, and what its answers
156
- * become. Each `yield` is a request; what comes back is `{ data, error }` — a driver dispatching
157
- * may report an error, a driver reading the cache never does, because the cache holds answers
158
- * only.
157
+ * become. Each `yield` is a request — or a list of requests asked together — and what comes back is
158
+ * `{ data, error }` (a list of them for a list) — a driver dispatching may report an error, a driver
159
+ * reading the cache never does, because the cache holds answers only.
160
+ *
161
+ * ⭐ **What is asked follows what the component expects** (`whole`, from its `data:` —
162
+ * `'@std/article/*'`; ruled 2026-09-27 [Diego]). Briefs by default: the records service's question
163
+ * as it is, a compiled file's list, and on a parametric page the record FOUND in its query's set.
164
+ * Whole records when declared: the question carries `whole: true`, and a list off the service is
165
+ * followed by each record's own request. A key declared whole that cannot be filled whole — its
166
+ * source has no request for one record — is `null`: the contract cannot be met. An external query's
167
+ * records have no data schema and so no brief: what its source answers is each record whole.
159
168
  *
160
169
  * ⛔ **A failed request delivers NOTHING under its key, and says so.** The key is absent from the
161
170
  * returned value and the message rides on `error`, because `[]` is a value: a request that failed
162
- * must not be indistinguishable from a query with no records. The one exception is the record's
163
- * own request on a page that already matched it in the set — there the brief is kept.
171
+ * must not be indistinguishable from a query with no records. ⛔ Until 2026-09-27 a parametric
172
+ * page's record was always asked whole, and a failed record request kept the brief the set held —
173
+ * one shape standing in for the other.
164
174
  *
165
175
  * @param {Object} cfg - the filling fetch, resolved
166
176
  * @param {Object} where
@@ -177,31 +187,43 @@ function heldWhole(peekRecord, match) {
177
187
  * page may already hold the set, and the record is in it — so a miss falls through to the set,
178
188
  * and a record found there answers with the brief it holds. No value at all means *unknown*,
179
189
  * which is not the same as `[]`, *not in the set*.
180
- * @returns {Generator<Object, { value?: *, error?: string, errorConfig?: Object }>}
190
+ * @param {boolean} [where.whole] - the component expects whole records
191
+ * @returns {Generator<Object|Object[], { value?: *, error?: string, errorConfig?: Object }>}
181
192
  */
182
- export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false } = {}) {
193
+ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false, whole = false } = {}) {
183
194
  /** A step this caller can do without: a miss answers `{ missing: true }` instead of stopping. */
184
195
  const maybe = (request) => (opportunistic ? { request, optional: true } : request)
185
196
  // The block holds this fetch's answer already — a static build prerendered it.
186
197
  if (held !== undefined) return { value: held }
187
198
 
199
+ // ⭐ WHOLE OR BRIEF IS A QUESTION ABOUT RECORDS OF A DATA SCHEMA — the records service's, or a
200
+ // compiled query's. An external query's records have none, so they have no brief: a list is what
201
+ // its source answers, and a parametric page's record is its own request's answer (`record:`),
202
+ // whatever the component expects — as the records service answers a Model with no brief whole,
203
+ // even as a brief.
204
+ const external = !cfg.ask && typeof cfg.path !== 'string'
205
+ if (external) whole = false
206
+
188
207
  // How this fetch uses the page's record: by the query it names (`currentFor`).
189
208
  const current = forced ?? (dynamicContext ? currentFor(cfg, route) : null)
190
209
 
191
210
  if (current === 'exclude') {
192
211
  // The query's records without this page's, its `limit` counting the others.
193
212
  const view = othersView(cfg)
194
- const answer = yield view
195
- if (answer.error) return { error: answer.error, errorConfig: view }
213
+ const asked = whole && cfg.ask ? { ...view, whole: true } : view
214
+ const answer = yield asked
215
+ if (answer.error) return { error: answer.error, errorConfig: asked }
196
216
  if (answer.data === undefined || answer.data === null) return {}
197
- return { value: othersOf(answer.data, cfg, isPageRecord(dynamicContext)) }
217
+ const others = othersOf(answer.data, cfg, isPageRecord(dynamicContext, cfg.routeField))
218
+ if (!whole || cfg.ask) return { value: others }
219
+ return yield* wholeRecordsOf(cfg, others)
198
220
  }
199
221
 
200
222
  if (current === 'only' && cfg.ask) {
201
223
  // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the query's set
202
224
  // narrowed by the route's handle, so its one question answers the record if the set holds it
203
- // and `[]` if not.
204
- const detailCfg = buildDetailConfig(cfg, dynamicContext)
225
+ // and `[]` if not — whole or its brief, as the component expects.
226
+ const detailCfg = buildDetailConfig(cfg, dynamicContext, { whole })
205
227
  // No per-record source, or a route carrying no value: an unanswerable question, and asking it
206
228
  // is worse than leaving the key absent. ⚠️ `resolve` reported this as `pending` and `fetch`
207
229
  // dispatched a null config; neither was intended (unified 2026-09-19).
@@ -228,52 +250,104 @@ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = u
228
250
  if (answer.error) return { error: answer.error, errorConfig: selection }
229
251
  const items = Array.isArray(answer.data) ? answer.data : null
230
252
  const { paramName, paramValue } = dynamicContext
231
- const match = items?.find((item) => matchesRouteParam(item, paramName, paramValue)) ?? null
253
+ const match = items?.find((item) => matchesRouteParam(item, paramName, paramValue, cfg.routeField)) ?? null
232
254
  if (!match) return { value: [] } // not found
233
- if (!cfg.detail) return { value: [match] }
255
+ if (external) return yield* ownRecord(cfg, dynamicContext, match, peekRecord, maybe)
256
+ // A brief is what the set holds: the record is found, and that is the answer.
257
+ if (!whole) return { value: [match] }
234
258
 
235
- // ⭐ Held in full already? Then it IS the record — no second request. The list is materialized
236
- // from the record index, so `match` is the record at its latest depth.
237
- const whole = heldWhole(peekRecord, match)
238
- if (whole) return { value: [whole] }
259
+ // ⭐ Held whole already? Then it IS the record — no second request.
260
+ const wholeHeld = heldWhole(peekRecord, match)
261
+ if (wholeHeld) return { value: [wholeHeld] }
239
262
 
240
263
  // ⭐ THE STEP THAT NEEDS THE ANSWER: the record's own address can name its own fields, so it
241
264
  // is built with the record in hand and never from the route's value alone.
242
265
  const detailCfg = buildDetailConfig(cfg, { ...dynamicContext, record: match })
243
- if (!detailCfg) return { value: [match] }
266
+ // No request for one record: it cannot be had whole.
267
+ if (!detailCfg) return { value: null }
244
268
  const detail = yield maybe(detailCfg)
245
- // The list already matched the record, so the brief is a HELD value: a failed detail — or,
246
- // for an opportunistic caller, one not yet held — keeps it rather than delivering `[[]]`.
247
- if (detail.missing) return { value: [match] }
248
- if (detail.error) return { value: [match], error: detail.error, errorConfig: detailCfg }
249
- const record = detail.data !== undefined && detail.data !== null ? detail.data : match
250
- return { value: [record] }
269
+ if (detail.missing) return {}
270
+ if (detail.error) return { error: detail.error, errorConfig: detailCfg }
271
+ return { value: detail.data !== undefined && detail.data !== null ? [detail.data] : [] }
251
272
  }
252
273
 
253
274
  // A fetch the page's record plays no part in, and `current: include`: the records as the fetch
254
275
  // describes them, the page's among the rest.
255
- const answer = yield cfg
256
- if (answer.error) return { error: answer.error, errorConfig: cfg }
276
+ const asked = whole && cfg.ask ? { ...cfg, whole: true } : cfg
277
+ const answer = yield asked
278
+ if (answer.error) return { error: answer.error, errorConfig: asked }
257
279
  if (answer.data === undefined || answer.data === null) return {}
258
- return { value: answer.data }
280
+ if (!whole || cfg.ask) return { value: answer.data }
281
+ return yield* wholeRecordsOf(cfg, answer.data)
259
282
  }
260
283
 
261
- /** A yielded step: a request, or a request a caller can do without (`opportunistic`). */
262
- function asStep(step) {
263
- return step && step.request ? { request: step.request, optional: !!step.optional } : { request: step, optional: false }
284
+ /**
285
+ * An external query's record: its own request's answer (`record:`), found first in the list. The list
286
+ * already matched the record, so what it holds is a HELD value: a record with no request of its own,
287
+ * a failed request — or, for an opportunistic caller, one not yet held — keeps it rather than
288
+ * delivering `[[]]`.
289
+ */
290
+ function* ownRecord(cfg, dynamicContext, match, peekRecord, maybe) {
291
+ // ⭐ Held in full already? Then it IS the record — no second request.
292
+ const held = heldWhole(peekRecord, match)
293
+ if (held) return { value: [held] }
294
+ // ⭐ THE STEP THAT NEEDS THE ANSWER: the record's own address can name its own fields, so it is
295
+ // built with the record in hand and never from the route's value alone.
296
+ const detailCfg = buildDetailConfig(cfg, { ...dynamicContext, record: match })
297
+ if (!detailCfg) return { value: [match] }
298
+ const detail = yield maybe(detailCfg)
299
+ if (detail.missing) return { value: [match] }
300
+ if (detail.error) return { value: [match], error: detail.error, errorConfig: detailCfg }
301
+ return { value: [detail.data !== undefined && detail.data !== null ? detail.data : match] }
302
+ }
303
+
304
+ /**
305
+ * A list's records WHOLE, each by its own request — a compiled file's per-record file, an external
306
+ * query's `record:` — asked together, in one step. `null` when a record has no such request: the list
307
+ * cannot be had whole, and a component expecting whole records is given none.
308
+ *
309
+ * @param {Object} cfg - the list's resolved config
310
+ * @param {*} records - the list's answer
311
+ * @returns {Generator<Object[], { value?: *, error?: string, errorConfig?: Object }>}
312
+ */
313
+ function* wholeRecordsOf(cfg, records) {
314
+ if (!Array.isArray(records)) return { value: null }
315
+ if (records.length === 0) return { value: [] }
316
+ const requests = records.map((record) => recordRequest(cfg, record))
317
+ if (requests.some((request) => !request)) return { value: null }
318
+ const answers = yield requests
319
+ for (let i = 0; i < answers.length; i++) {
320
+ if (answers[i]?.error) return { error: answers[i].error, errorConfig: requests[i] }
321
+ }
322
+ if (answers.some((a) => a?.missing)) return {}
323
+ return { value: answers.map((a) => a?.data).filter((record) => record !== undefined && record !== null) }
324
+ }
325
+
326
+ /** The request for one record of a list, by its handle — `buildDetailConfig` with the record in hand. */
327
+ function recordRequest(cfg, record) {
328
+ const handle = record && typeof record === 'object' ? record.$name : undefined
329
+ if (handle === undefined || handle === null || handle === '') return null
330
+ return buildDetailConfig(cfg, { paramName: 'slug', paramValue: String(handle), record })
331
+ }
332
+
333
+ /** A yielded step as `{ request, optional }` entries: one request, or several asked together. */
334
+ function asSteps(value) {
335
+ return (Array.isArray(value) ? value : [value]).map((step) =>
336
+ step && step.request ? { request: step.request, optional: !!step.optional } : { request: step, optional: false }
337
+ )
264
338
  }
265
339
 
266
340
  /**
267
341
  * Every filling key's program, ready to run.
268
342
  *
269
- * @param {Map<string, { cfg: Object, held: * }>} fills - `blockFills`
343
+ * @param {Map<string, { cfg: Object, held: *, whole?: boolean }>} fills - `blockFills`
270
344
  * @param {Object} where - passed to each `keyProgram`
271
345
  * @returns {Map<string, Generator>}
272
346
  */
273
347
  export function planFor(fills, where = {}) {
274
348
  const plan = new Map()
275
- for (const [key, { cfg, held }] of fills) {
276
- plan.set(key, keyProgram(cfg, { ...where, held }))
349
+ for (const [key, { cfg, held, whole = false }] of fills) {
350
+ plan.set(key, keyProgram(cfg, { ...where, held, whole }))
277
351
  }
278
352
  return plan
279
353
  }
@@ -302,20 +376,25 @@ export function runPlanSync(plan, { peek }) {
302
376
  if (out.value !== undefined) data[key] = out.value
303
377
  break
304
378
  }
305
- const { request, optional } = asStep(step.value)
306
- const cached = peek(request)
307
- if (!cached) {
308
- if (optional) {
309
- // The program can do without this one and says what it makes of that.
310
- sent = { missing: true }
311
- continue
379
+ const answers = []
380
+ let missed = false
381
+ for (const { request, optional } of asSteps(step.value)) {
382
+ const cached = peek(request)
383
+ if (cached) answers.push({ data: cached.data })
384
+ // The program can do without this one and says what it makes of that.
385
+ else if (optional) answers.push({ missing: true })
386
+ else {
387
+ missed = true
388
+ break
312
389
  }
390
+ }
391
+ if (missed) {
313
392
  // Not cached: this key is not answerable without a request, so the block is not ready.
314
393
  program.return()
315
394
  complete = false
316
395
  break
317
396
  }
318
- sent = { data: cached.data }
397
+ sent = Array.isArray(step.value) ? answers : answers[0]
319
398
  }
320
399
  }
321
400
 
@@ -327,7 +406,7 @@ export function runPlanSync(plan, { peek }) {
327
406
  *
328
407
  * ⭐ Every key's first request is issued in the same tick, which is what lets a question door batch
329
408
  * a page's questions into one POST. A key whose second request depends on its first answer takes a
330
- * later tick, and only that key waits.
409
+ * later tick, and only that key waits; the requests of one step are issued together.
331
410
  *
332
411
  * @param {Map<string, Generator>} plan
333
412
  * @param {Object} io
@@ -352,8 +431,9 @@ export async function runPlan(plan, { dispatch, onFailure = null }) {
352
431
  }
353
432
  return
354
433
  }
355
- const result = await dispatch(asStep(step.value).request)
356
- sent = { data: result?.data, error: result?.error }
434
+ const results = await Promise.all(asSteps(step.value).map(({ request }) => dispatch(request)))
435
+ const answers = results.map((result) => ({ data: result?.data, error: result?.error }))
436
+ sent = Array.isArray(step.value) ? answers : answers[0]
357
437
  }
358
438
  }))
359
439
 
@@ -25,8 +25,8 @@
25
25
 
26
26
  import { match as matchWhere } from './where.js'
27
27
  import { applyScope } from './scope.js'
28
- import { sortRecords } from './sort.js'
29
- import { routeParamValues } from './route-match.js'
28
+ import { sortRecords, parseSort } from './sort.js'
29
+ import { recordKeyValues } from './route-match.js'
30
30
 
31
31
  /**
32
32
  * Evaluate a resolved config's set and its narrowing over records.
@@ -60,6 +60,43 @@ export function evaluateQuery(records, config, { locale = null, sort = sortRecor
60
60
  return out
61
61
  }
62
62
 
63
+ /**
64
+ * A resolved config with every FIELD PATH it names mapped — the `where` keys and the `sort` field of
65
+ * its set and of its narrowing — so it can be evaluated over records held in another shape. A static
66
+ * build maps each path to where the record AS STORED holds it (`@uniweb/schemas/conform`'s
67
+ * `storedPath`), which is how the records service reads a path (ruled 2026-09-27
68
+ * [Diego]). A `$` key, and `narrow.match` — which names the record, not a
69
+ * field of it — are kept as they are.
70
+ *
71
+ * @param {Object} config - a resolved config, or a question
72
+ * @param {(path: string) => string} mapPath
73
+ * @returns {Object} a new config; the input is not mutated
74
+ */
75
+ export function mapQueryPaths(config, mapPath) {
76
+ if (!config || typeof config !== 'object' || typeof mapPath !== 'function') return config
77
+ const level = (at) => {
78
+ const out = { ...at }
79
+ if (at.where && typeof at.where === 'object') out.where = mapWherePaths(at.where, mapPath)
80
+ const spec = at.sort ? parseSort(at.sort) : null
81
+ if (spec) out.sort = { field: mapPath(spec.field), desc: spec.desc }
82
+ return out
83
+ }
84
+ const out = level(config)
85
+ if (config.narrow && typeof config.narrow === 'object') out.narrow = level(config.narrow)
86
+ return out
87
+ }
88
+
89
+ function mapWherePaths(where, mapPath) {
90
+ if (!where || typeof where !== 'object' || Array.isArray(where)) return where
91
+ const out = {}
92
+ for (const [key, value] of Object.entries(where)) {
93
+ if (key === 'and' || key === 'or') out[key] = Array.isArray(value) ? value.map((sub) => mapWherePaths(sub, mapPath)) : value
94
+ else if (key === 'not') out[key] = mapWherePaths(value, mapPath)
95
+ else out[key.startsWith('$') ? key : mapPath(key)] = value
96
+ }
97
+ return out
98
+ }
99
+
63
100
  /** A count that cuts: a whole number above 0. Absent, `0` or anything else is every record. */
64
101
  function isLimit(limit) {
65
102
  return typeof limit === 'number' && limit > 0
@@ -68,20 +105,14 @@ function isLimit(limit) {
68
105
  /**
69
106
  * A `match` — the record a parametric page's URL names — as a filter: the key is the
70
107
  * 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`).
108
+ * itself), and its value is compared as a STRING, member-wise on a list
109
+ * (`recordKeyValues`). ⛔ Until 2026-09-27 a key was mapped back to a param name, so a field
110
+ * called `slug` read the record's `$name`.
73
111
  */
74
112
  function matching(match) {
75
113
  const entries = Object.entries(match)
76
114
  return (record) => entries.every(([key, value]) => {
77
115
  const target = String(value)
78
- return routeParamValues(record, paramNameOf(key)).some((held) => held === target)
116
+ return recordKeyValues(record, key).some((held) => held === target)
79
117
  })
80
118
  }
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
- }
@@ -242,14 +242,19 @@ export function joinPathCapture({ dir, slug } = {}) {
242
242
 
243
243
  /**
244
244
  * A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
245
- * what a `[slug]` route (or the last segment of a `[...path]` one) matches.
246
- *
247
- * ⭐ Two lanes spell it differently and mean one thing. A host's records service
248
- * serves the entry's handle as `$name` — `$`-namespaced because a Model may
249
- * declare its own `name` or `slug` field (five of eight seeded briefs do), and
250
- * the placement must not be shadowed by one. The file lane derives it from the
251
- * source filename and calls it `slug`. `$name` wins when present: on a live
252
- * record a Model field named `slug` is the author's data, not the placement.
245
+ * what a `[slug]` route (or the last segment of a `[...path]` one) matches: `$name`,
246
+ * and nothing else.
247
+ *
248
+ * ⭐ `$name` is the name of the record as an entry in its folder [Diego, 2026-09-27]: a
249
+ * host's records service serves it, and the static build gives it as the record file's
250
+ * name. `$`-namespaced because a record's own fields are the author's data, and no field —
251
+ * `slug`, `name` — is the placement. A record that is an entry in no folder — an external
252
+ * API's — has none; its page is found by the field its route query binds the URL to
253
+ * (`routeFieldOf`, below).
254
+ * ⛔ Until 2026-09-27 a record with no `$name` fell back to its `slug` field, which
255
+ * gave one field name a meaning the framework must not assign [Diego, 2026-09-27:
256
+ * "remove any notion that we, runtime/core, read `slug` from a record's field as if it
257
+ * had any meaning to us"].
253
258
  *
254
259
  * @param {Object} record
255
260
  * @returns {string|undefined}
@@ -257,8 +262,7 @@ export function joinPathCapture({ dir, slug } = {}) {
257
262
  export function recordHandle(record) {
258
263
  if (!record || typeof record !== 'object') return undefined
259
264
  const name = record.$name
260
- if (typeof name === 'string' && name.length) return name
261
- return record.slug
265
+ return typeof name === 'string' && name.length ? name : undefined
262
266
  }
263
267
 
264
268
  /**
@@ -268,7 +272,7 @@ export function recordHandle(record) {
268
272
  * parametric page (`Website._createDynamicPage`, so the SPA and a host's render), the
269
273
  * static build's page expansion, and the record search index (`@uniweb/projections`).
270
274
  *
271
- * title → name → the record's handle (`recordHandle`: `$name`, else `slug`)
275
+ * title → name → the record's handle (`recordHandle`: `$name`)
272
276
  *
273
277
  * ⛔ Until then a page read `title` alone while the search index read `title || name`,
274
278
  * so a record with a `name` and no `title` — a person — titled its search result and
@@ -304,17 +308,17 @@ export function recordTitle(record) {
304
308
  * @param {string} paramName
305
309
  * @returns {string}
306
310
  */
307
- export function routeRecordKey(paramName) {
308
- if (paramName === 'slug') return '$name'
311
+ export function routeRecordKey(paramName, field = null) {
312
+ if (paramName === 'slug') return typeof field === 'string' && field ? field : '$name'
309
313
  if (paramName === 'uuid') return '$uuid'
310
314
  return paramName
311
315
  }
312
316
 
313
317
  /**
314
- * The value a record carries for a route param, by `routeRecordKey`'s map. The two
315
- * built-in keys fall back to the plain field a source without them carries: the
316
- * handle to `slug` (`recordHandle`), the identity to `uuid` — so a remote API that
317
- * routes `[uuid]` on its own `uuid` field keeps matching (ruled 2026-09-11).
318
+ * The value a record carries for a route param, by `routeRecordKey`'s map. The handle
319
+ * is `$name` alone (`recordHandle`); the identity falls back to the plain `uuid` field a
320
+ * source without `$uuid` carries — so a remote API that routes `[uuid]` on its own
321
+ * `uuid` field keeps matching (ruled 2026-09-11).
318
322
  *
319
323
  * ⛔ Every reader that matches a delivered record to a route param goes through
320
324
  * this — the entity store, the website's parametric page, the static build's
@@ -327,9 +331,9 @@ export function routeRecordKey(paramName) {
327
331
  * @param {string} paramName
328
332
  * @returns {*}
329
333
  */
330
- export function routeParamValue(record, paramName) {
334
+ export function routeParamValue(record, paramName, field = null) {
331
335
  if (!record || typeof record !== 'object') return undefined
332
- if (paramName === 'slug') return recordHandle(record)
336
+ if (paramName === 'slug') return typeof field === 'string' && field ? record[field] : recordHandle(record)
333
337
  if (paramName === 'uuid') {
334
338
  const id = record.$uuid
335
339
  return typeof id === 'string' && id.length ? id : record.uuid
@@ -361,8 +365,28 @@ export function routeParamValue(record, paramName) {
361
365
  * @param {string} paramName
362
366
  * @returns {string[]} the values, in the record's own order
363
367
  */
364
- export function routeParamValues(record, paramName) {
365
- const raw = routeParamValue(record, paramName)
368
+ export function routeParamValues(record, paramName, field = null) {
369
+ return stringValues(routeParamValue(record, paramName, field))
370
+ }
371
+
372
+ /**
373
+ * Every value a record holds under a record KEY — `$name`, `$uuid`, or a field of its own —
374
+ * as strings, member-wise: what a `narrow.match` key (`routeRecordKey`'s output) is compared
375
+ * with. ⛔ Not `routeParamValues` over a param name: a field called `slug` is the author's data,
376
+ * while the param `slug` names the record's `$name`.
377
+ *
378
+ * @param {Object} record
379
+ * @param {string} key
380
+ * @returns {string[]}
381
+ */
382
+ export function recordKeyValues(record, key) {
383
+ if (!record || typeof record !== 'object') return []
384
+ if (key === '$name') return stringValues(recordHandle(record))
385
+ if (key === '$uuid') return stringValues(routeParamValue(record, 'uuid'))
386
+ return stringValues(record[key])
387
+ }
388
+
389
+ function stringValues(raw) {
366
390
  const out = []
367
391
  for (const value of Array.isArray(raw) ? raw : [raw]) {
368
392
  if (value === undefined || value === null || value === '') continue
@@ -382,11 +406,36 @@ export function routeParamValues(record, paramName) {
382
406
  * @param {Object} record
383
407
  * @param {string} paramName
384
408
  * @param {string|number} value - the URL segment
409
+ * @param {string|null} [field] - the field the route query binds `:slug` to (`routeFieldOf`)
385
410
  * @returns {boolean}
386
411
  */
387
- export function matchesRouteParam(record, paramName, value) {
412
+ export function matchesRouteParam(record, paramName, value, field = null) {
388
413
  const target = String(value)
389
- return routeParamValues(record, paramName).some((held) => held === target)
414
+ return routeParamValues(record, paramName, field).some((held) => held === target)
415
+ }
416
+
417
+ /**
418
+ * The record field a route query binds the URL's last segment to — `where: { code: ':slug' }`
419
+ * → `code` — or null when it binds none.
420
+ *
421
+ * ⭐ A PARAMETRIC PAGE'S RECORD IS ITS ROUTE QUERY'S TO FIND [Diego, 2026-09-27: "it's put to a
422
+ * parametric query to choose"]. By default a `[slug]` page (and a `[...path]` page's last segment)
423
+ * names a record by its `$name`, the name it has as an entry in its folder. A query whose records
424
+ * are named otherwise — an external API's, which are entries in no folder — says which of their
425
+ * fields the URL names by binding the segment in its own `where`; the page then matches that
426
+ * field, its records link by it, and a static build expands over it. On the query's own page the
427
+ * clause drops, since no URL binds it. Only a top-level clause whose whole value is `:slug` counts.
428
+ *
429
+ * @param {Object|null} decl - a query declaration (`config.queries[<name>]`)
430
+ * @returns {string|null}
431
+ */
432
+ export function routeFieldOf(decl) {
433
+ const where = decl && typeof decl === 'object' ? decl.where : null
434
+ if (!where || typeof where !== 'object' || Array.isArray(where)) return null
435
+ for (const [key, value] of Object.entries(where)) {
436
+ if (value === ':slug' && key !== 'and' && key !== 'or' && key !== 'not' && !key.startsWith('$')) return key
437
+ }
438
+ return null
390
439
  }
391
440
 
392
441
  /**
@@ -527,9 +576,11 @@ export function recordRouteBase(route) {
527
576
  *
528
577
  * @param {string} pattern - a route pattern with `:param` placeholders
529
578
  * @param {Object} values - a record, read by param name
579
+ * @param {{ field?: string|null }} [options] - `field`: the record field `:slug` is filled from
580
+ * when the route query binds it (`routeFieldOf`); by default the record's `$name`
530
581
  * @returns {string|null}
531
582
  */
532
- export function fillRoutePattern(pattern, values) {
583
+ export function fillRoutePattern(pattern, values, { field = null } = {}) {
533
584
  if (typeof pattern !== 'string' || !values || typeof values !== 'object') return null
534
585
  let missing = false
535
586
  let head = pattern
@@ -541,7 +592,7 @@ export function fillRoutePattern(pattern, values) {
541
592
  // slashes between them kept as structure. `dir` is the placement; a record
542
593
  // carries it as `path` (the folder `records/folder.yml` put it in), which is why
543
594
  // `path` here is read as the DIRECTORY and never as a composed capture.
544
- const handle = recordHandle(values)
595
+ const [handle] = routeParamValues(values, 'slug', field)
545
596
  if (joinPathCapture({ dir: values.dir ?? values.path, slug: handle }) === null) return null
546
597
  const dir = String(values.dir ?? values.path ?? '')
547
598
  const segments = dir.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
@@ -554,7 +605,7 @@ export function fillRoutePattern(pattern, values) {
554
605
  // ⭐ THE FIRST MEMBER of a `multi` field — a record has ONE canonical href, and
555
606
  // every member routes to it (`routeParamValues`). Baked whole, a `['a','b']`
556
607
  // field produced `/tags/a%2Cb`, a URL no lane matches.
557
- const [value] = routeParamValues(values, name)
608
+ const [value] = routeParamValues(values, name, name === 'slug' ? field : null)
558
609
  if (value === undefined) {
559
610
  missing = true
560
611
  return ''