@uniweb/core 0.19.0 → 0.20.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.
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
+ import { fillRoutePattern } from './route-match.js'
19
+ import { sortRecords } from './sort.js'
18
20
 
19
21
  /**
20
22
  * A fetch config's binding key — the `content.data.<key>` a component reads.
@@ -85,18 +87,13 @@ export default class EntityStore {
85
87
  return null
86
88
  }
87
89
 
90
+ /**
91
+ * A refine block's `order: { orderBy, sortOrder }` — the same one-key sort the
92
+ * build and the fetcher fallback run (`./sort.js`), so the three cannot drift.
93
+ */
88
94
  _sortItems(items, order) {
89
- if (!order?.orderBy || !Array.isArray(items) || items.length === 0) return items
90
- const { orderBy, sortOrder = 'ASC' } = order
91
- const desc = sortOrder === 'DESC'
92
- return [...items].sort((a, b) => {
93
- const av = a[orderBy] ?? ''
94
- const bv = b[orderBy] ?? ''
95
- const cmp = typeof av === 'string' && typeof bv === 'string'
96
- ? av.localeCompare(bv)
97
- : (av > bv ? 1 : av < bv ? -1 : 0)
98
- return desc ? -cmp : cmp
99
- })
95
+ if (!order?.orderBy) return items
96
+ return sortRecords(items, { field: order.orderBy, desc: order.sortOrder === 'DESC' })
100
97
  }
101
98
 
102
99
  /**
@@ -132,6 +129,13 @@ export default class EntityStore {
132
129
 
133
130
  const page = block.page
134
131
  const website = block.website
132
+ const dynamicContext = block.dynamicContext || page?.dynamicContext
133
+ // The route's variables, for a query that binds `:path` / `:dir` / `:slug`.
134
+ // A baked page carries `params` when it has more than the one capture;
135
+ // otherwise the capture itself is the only variable.
136
+ const variables = dynamicContext
137
+ ? (dynamicContext.params ?? { [dynamicContext.paramName]: dynamicContext.paramValue })
138
+ : null
135
139
 
136
140
  return resolveFetchConfigs(
137
141
  [
@@ -152,6 +156,7 @@ export default class EntityStore {
152
156
  // local dev, which is why `resolveQuerySource` treats absence as
153
157
  // the ordinary case and reads the compiled artifact without comment.
154
158
  records: website?.config?.records ?? null,
159
+ variables,
155
160
  },
156
161
  )
157
162
  }
@@ -254,6 +259,16 @@ export default class EntityStore {
254
259
  } else {
255
260
  allCached = false
256
261
  }
262
+ } else if (isRouteQuery && cfg.door) {
263
+ // A question door: the record's own answer is cached under its own key.
264
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
265
+ const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
266
+ if (detailCached) {
267
+ const answer = Array.isArray(detailCached.data) ? detailCached.data : (detailCached.data ? [detailCached.data] : [])
268
+ data[schema] = answer.slice(0, 1)
269
+ } else {
270
+ allCached = false
271
+ }
257
272
  } else if (isRouteQuery) {
258
273
  // Detail page: deliver the focused record as a length-1 array under the
259
274
  // query key. A deferred/remote query fetches the full per-record;
@@ -268,9 +283,15 @@ export default class EntityStore {
268
283
  if (!match) {
269
284
  data[schema] = []
270
285
  } else if (cfg.detail) {
271
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
286
+ // Held in full already? Then it IS the record — no detail probe.
287
+ // The list is materialized from the record index, so `match` is the
288
+ // record at its latest depth; the index says which depth that is.
289
+ const held = heldInFull(dispatcher, match)
290
+ const detailCfg = held ? null : this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
272
291
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
273
- if (detailCfg && detailCached) {
292
+ if (held) {
293
+ data[schema] = [held]
294
+ } else if (detailCfg && detailCached) {
274
295
  data[schema] = [detailCached.data]
275
296
  } else if (detailCfg) {
276
297
  allCached = false
@@ -305,13 +326,23 @@ export default class EntityStore {
305
326
  * Async fetch — dispatches missing configs through the FetcherDispatcher
306
327
  * and assembles the result. List-first detail ordering preserved.
307
328
  *
329
+ * ⛔ A FAILED FETCH DELIVERS NOTHING UNDER ITS KEY, AND SAYS SO. Until 2026-09-04
330
+ * a failure wrote `[]` into `content.data` — the fetcher returns `{ data: [], error }`,
331
+ * `[]` is neither `undefined` nor `null`, and nothing read `error` — so a key
332
+ * whose request failed was indistinguishable from one that succeeded with no
333
+ * records, by the framework's own rule that `[]` is a value. Now the key is
334
+ * ABSENT from `data`, the message is on `errors[key]`, and in dev it is logged
335
+ * where the author is looking. A detail fetch that fails keeps the record the
336
+ * list already matched (the brief) rather than clobbering it.
337
+ *
308
338
  * @param {Object} [options]
309
339
  * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
310
- * @returns {Promise<{ data: Object|null }>}
340
+ * @returns {Promise<{ data: Object|null, errors: Object|null }>} `data` keyed by
341
+ * binding key; `errors` keyed the same way, `null` when every fetch succeeded.
311
342
  */
312
343
  async fetch(block, meta, { signal } = {}) {
313
344
  const dispatcher = this.website?.fetcher
314
- if (!dispatcher) return { data: null }
345
+ if (!dispatcher) return { data: null, errors: null }
315
346
 
316
347
  let requested = this._getRequestedSchemas(meta)
317
348
  if (requested === null && block.fetch) {
@@ -319,10 +350,10 @@ export default class EntityStore {
319
350
  const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
320
351
  if (schemas.length > 0) requested = schemas
321
352
  }
322
- if (requested === null) return { data: null }
353
+ if (requested === null) return { data: null, errors: null }
323
354
 
324
355
  const configs = this._findFetchConfigs(block, requested)
325
- if (configs.size === 0) return { data: null }
356
+ if (configs.size === 0) return { data: null, errors: null }
326
357
 
327
358
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
328
359
  const inheritDetail = this._shouldInheritDetail(meta, block)
@@ -331,7 +362,12 @@ export default class EntityStore {
331
362
  const ctx = this._ctx(block, { signal })
332
363
 
333
364
  const data = {}
365
+ const errors = {}
334
366
  const parallelFetches = []
367
+ const fail = (key, cfg, message) => {
368
+ errors[key] = message
369
+ reportFetchFailure(this.dev, block, key, cfg, message)
370
+ }
335
371
 
336
372
  const routeSchema = dynamicContext?.schema
337
373
 
@@ -342,6 +378,10 @@ export default class EntityStore {
342
378
  let records = peekArray(dispatcher, cfg, ctx)
343
379
  if (records === null) {
344
380
  const result = await dispatcher.dispatch(cfg, ctx)
381
+ if (result?.error) {
382
+ fail(schema, cfg, result.error)
383
+ continue
384
+ }
345
385
  records = Array.isArray(result?.data) ? result.data : null
346
386
  }
347
387
  const { paramName, paramValue } = dynamicContext
@@ -350,6 +390,25 @@ export default class EntityStore {
350
390
  : (records ?? [])
351
391
  if (order) filtered = this._sortItems(filtered, order)
352
392
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
393
+ } else if (isRouteQuery && cfg.door) {
394
+ // ⭐ A QUESTION DOOR needs no list to find the record: the record is the
395
+ // same question narrowed by the route's handle, so list and record are
396
+ // asked together — one round trip, and no client-side scan gating the
397
+ // fetch (F13, the live half). The list is asked too, because sections
398
+ // beside the record read it (`refine: true, detail: false`) and the
399
+ // index files its briefs; the record's own answer is the answer.
400
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
401
+ parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
402
+ if (result?.error) fail(schema, cfg, result.error)
403
+ }))
404
+ parallelFetches.push(dispatcher.dispatch(detailCfg, ctx).then((result) => {
405
+ if (result?.error) {
406
+ fail(schema, detailCfg, result.error)
407
+ return
408
+ }
409
+ const answer = Array.isArray(result?.data) ? result.data : (result?.data ? [result.data] : [])
410
+ data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
411
+ }))
353
412
  } else if (isRouteQuery) {
354
413
  // Detail page: focused record as a length-1 array under the query key.
355
414
  const { paramName, paramValue } = dynamicContext
@@ -357,6 +416,10 @@ export default class EntityStore {
357
416
  let records = peekArray(dispatcher, cfg, ctx)
358
417
  if (records === null) {
359
418
  const result = await dispatcher.dispatch(cfg, ctx)
419
+ if (result?.error) {
420
+ fail(schema, cfg, result.error)
421
+ continue
422
+ }
360
423
  records = Array.isArray(result?.data) ? result.data : null
361
424
  }
362
425
 
@@ -369,11 +432,25 @@ export default class EntityStore {
369
432
  continue
370
433
  }
371
434
 
372
- if (cfg.detail) {
373
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
435
+ const held = cfg.detail ? heldInFull(dispatcher, match) : null
436
+ if (held) {
437
+ // R1: the record index holds it in full — a detail fetch would only
438
+ // re-fetch what the page already has.
439
+ data[schema] = [held]
440
+ } else if (cfg.detail) {
441
+ const detailCfg = this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
374
442
  if (detailCfg) {
375
443
  parallelFetches.push(
376
444
  dispatcher.dispatch(detailCfg, ctx).then((result) => {
445
+ // The list already matched the record, so the brief is a HELD
446
+ // value: a failed detail fetch keeps it and reports, rather than
447
+ // delivering `[[]]` — which is what `result.data ?? match` did,
448
+ // because a failure's `data` is `[]`, not null.
449
+ if (result?.error) {
450
+ fail(schema, detailCfg, result.error)
451
+ data[schema] = [match]
452
+ return
453
+ }
377
454
  const record = (result?.data !== undefined && result?.data !== null)
378
455
  ? result.data
379
456
  : match
@@ -389,8 +466,15 @@ export default class EntityStore {
389
466
  } else {
390
467
  parallelFetches.push(
391
468
  dispatcher.dispatch(cfg, ctx).then((result) => {
469
+ if (result?.error) {
470
+ fail(schema, cfg, result.error)
471
+ return
472
+ }
392
473
  if (result?.data !== undefined && result?.data !== null) {
393
- data[schema] = result.data
474
+ // The same refine `order` the sync path applies (`resolve`), so a
475
+ // block sorts identically on a cache hit and on the fetch that
476
+ // filled it — it did not until 2026-09-04.
477
+ data[schema] = order ? this._sortItems(result.data, order) : result.data
394
478
  }
395
479
  })
396
480
  )
@@ -399,10 +483,45 @@ export default class EntityStore {
399
483
 
400
484
  if (parallelFetches.length > 0) await Promise.all(parallelFetches)
401
485
  this._applyDetailRoutes(data, configs, block.website)
402
- return { data }
486
+ return { data, errors: Object.keys(errors).length ? errors : null }
403
487
  }
404
488
  }
405
489
 
490
+ /**
491
+ * Say where a fetch failed, once per key per page, where the author is looking.
492
+ *
493
+ * Dev only: production has no reader for a console line, and the page has the
494
+ * structured answer already — the key is absent from `data`, the message is on
495
+ * `errors[key]`, and the runtime sets `block.dataError`. What must never happen
496
+ * again is the third option this path used to take: an empty array under the
497
+ * key, and silence.
498
+ */
499
+ const reportedFailures = new Set()
500
+ function reportFetchFailure(dev, block, key, cfg, message) {
501
+ if (!dev) return
502
+ const where = cfg?.endpoint || cfg?.url || cfg?.path || '(no address)'
503
+ const page = block?.page?.route ?? '(unknown page)'
504
+ const memo = `${page}::${key}::${where}`
505
+ if (reportedFailures.has(memo)) return
506
+ reportedFailures.add(memo)
507
+ console.error(
508
+ `[uniweb] fetch for content.data.${key} failed on ${page} (${where}): ${message}. ` +
509
+ `The key is left absent — not [] — and block.dataError carries this message.`
510
+ )
511
+ }
512
+
513
+ /**
514
+ * The record the index holds in FULL for a list match, or null — the R1 gate.
515
+ * A record with no identity (`$uuid`) is never indexed, so the answer for it is
516
+ * null and the detail fetch proceeds as before.
517
+ */
518
+ function heldInFull(dispatcher, match) {
519
+ const id = match?.$uuid
520
+ if (typeof id !== 'string' || !id || typeof dispatcher?.peekRecord !== 'function') return null
521
+ const held = dispatcher.peekRecord(id)
522
+ return held?.depth === 'full' ? held.record : null
523
+ }
524
+
406
525
  /**
407
526
  * Sync-peek helper: return the cached array for a config, or null on miss.
408
527
  */
@@ -421,17 +540,12 @@ function peekArray(dispatcher, cfg, ctx) {
421
540
  * (the file lane bakes one via the query processor) is returned untouched. A
422
541
  * `:param` with no matching record field → no `route` (graceful; degrades to the
423
542
  * component's own fallback rather than emitting a broken href).
543
+ *
544
+ * ⭐ The encoding is `fillRoutePattern`'s, shared with the build's bake, so the
545
+ * two producers of `item.route` agree — they did not (F14, 2026-09-04).
424
546
  */
425
547
  function addDetailRoute(item, template) {
426
548
  if (!item || typeof item !== 'object' || item.route !== undefined) return item
427
- let missing = false
428
- const route = template.replace(/:(\w+)/g, (_, name) => {
429
- const value = item[name]
430
- if (value == null) {
431
- missing = true
432
- return ''
433
- }
434
- return encodeURIComponent(String(value))
435
- })
436
- return missing ? item : { ...item, route }
549
+ const route = fillRoutePattern(template, item)
550
+ return route === null ? item : { ...item, route }
437
551
  }
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
31
- import { resolveQueryAddress, resolveRecordAddressPattern } from './query-address.js'
31
+ import { resolveQueryAddress, resolveRecordAddressPattern, resolveQueryDoor } from './query-address.js'
32
32
 
33
33
  /**
34
34
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
@@ -100,6 +100,10 @@ function localizeConfig(cfg, locale, defaultLocale) {
100
100
  function applyDeferredDetail(cfg, queries, records) {
101
101
  if (cfg.detail !== undefined) return cfg
102
102
 
103
+ // A question door answers a RECORD by the same question narrowed to it, so
104
+ // every door config has a detail source; `buildDetailConfig` composes it.
105
+ if (cfg.door) return { ...cfg, detail: true }
106
+
103
107
  // ⭐ A lane's record address is injected whenever the lane declares one —
104
108
  // NOT only for a `deferred:` query, and the difference is load-bearing.
105
109
  //
@@ -170,9 +174,32 @@ function applyDeferredDetail(cfg, queries, records) {
170
174
  * `query`, ignoring any `path`), so the two agree rather than disagreeing on a
171
175
  * shape nobody hand-writes.
172
176
  */
173
- function resolveQuerySource(cfg, records) {
177
+ function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
174
178
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
175
179
 
180
+ // ⭐ THE QUESTION DOOR FIRST. A host that answers questions gets the whole
181
+ // query — `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes
182
+ // no per-query address at all (the records door's contract, §2). It
183
+ // needs the query's MODEL REF, which lives on the site's `config.queries`
184
+ // declaration; a payload that carries the door but not the declaration
185
+ // cannot ask, and falls through to the address door below. ⚠️ Dark until a
186
+ // host stamps the door; see `resolveQueryDoor`.
187
+ const door = resolveQueryDoor(records, locale ?? defaultLocale)
188
+ if (door) {
189
+ const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
190
+ const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
191
+ if (schema) {
192
+ const { path, url, ...rest } = cfg
193
+ const asked = { ...rest, door, schema }
194
+ // A saved query's own narrowing applies unless the fetch overrides it.
195
+ if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
196
+ if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
197
+ if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
198
+ if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
199
+ return asked
200
+ }
201
+ }
202
+
176
203
  const endpoint = resolveQueryAddress(cfg.query, records)
177
204
  if (endpoint) {
178
205
  // Drop the transitional `path`: two addresses on one request is an
@@ -237,6 +264,10 @@ function bindingKey(cfg) {
237
264
  * @param {Object|null} [options.records] - the site's `config.records`, a host's
238
265
  * live-records lane. Absent means the compiled artifact answers, which is
239
266
  * the whole of what a site with no backend needs.
267
+ * @param {Object|null} [options.variables] - the route's variables on a template
268
+ * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
269
+ * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
270
+ * them, and an unbound one drops its clause. Null off a template page.
240
271
  * @returns {Map<string, Object>} schema name → resolved config
241
272
  */
242
273
  export function resolveFetchConfigs(sources, options = {}) {
@@ -246,6 +277,7 @@ export function resolveFetchConfigs(sources, options = {}) {
246
277
  defaultLocale = null,
247
278
  queries = null,
248
279
  records = null,
280
+ variables = null,
249
281
  } = options
250
282
 
251
283
  const configs = new Map()
@@ -261,11 +293,144 @@ export function resolveFetchConfigs(sources, options = {}) {
261
293
  if (!collectAll && !schemas.includes(key)) continue
262
294
  // Address first: localization and deferred-detail both key on `path`,
263
295
  // which a query ref does not have until this runs.
264
- const sourced = resolveQuerySource(cfg, records)
296
+ const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
265
297
  const localized = localizeConfig(sourced, locale, defaultLocale)
266
- configs.set(key, applyDeferredDetail(localized, queries, records))
298
+ const bound = foldScope(bindRouteVariables(localized, variables))
299
+ configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
267
300
  }
268
301
  }
269
302
 
270
303
  return configs
271
304
  }
305
+
306
+ /**
307
+ * The three route variables a query may reference, and only these — `:path`,
308
+ * `:dir`, `:slug` — as a VALUE in `where:` or as the whole `scope:`. Ruled
309
+ * 2026-09-04 [Diego]: standard names, never author-chosen; a placeholder fills
310
+ * a value, never a key or an operator, never `schema`.
311
+ */
312
+ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
313
+
314
+ /**
315
+ * Bind a query's route placeholders from the page's variables.
316
+ *
317
+ * ⭐ UNBOUND ⇒ THE CLAUSE DROPS. That is what lets ONE saved query serve both
318
+ * the list page and the detail page: `where: { tag: :dir }` narrows on
319
+ * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
320
+ * variable bound to an empty string (`:dir` on a single-segment capture) is
321
+ * bound, and binds the empty value. Backend's records door states the same
322
+ * rule from its side (the records door's contract, §6b).
323
+ *
324
+ * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
325
+ * to an intentional list page. Only an authoring surface can catch that; this
326
+ * function cannot.
327
+ *
328
+ * @param {Object} cfg - a resolved config
329
+ * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a template page
330
+ * @returns {Object} the config, with placeholders bound or their clauses dropped
331
+ */
332
+ function bindRouteVariables(cfg, variables) {
333
+ let out = cfg
334
+ if (typeof cfg.scope === 'string' && ROUTE_VARIABLE.test(cfg.scope)) {
335
+ const name = cfg.scope.slice(1)
336
+ const value = variables?.[name]
337
+ const { scope, ...rest } = out
338
+ out = value === undefined || value === null ? rest : { ...rest, scope: String(value) }
339
+ }
340
+ if (cfg.where && typeof cfg.where === 'object') {
341
+ const bound = bindWhere(cfg.where, variables)
342
+ if (bound !== cfg.where) {
343
+ const { where, ...rest } = out
344
+ out = bound === null ? rest : { ...rest, where: bound }
345
+ }
346
+ }
347
+ return out
348
+ }
349
+
350
+ /** Walk a where-object: bind `:var` VALUES, drop clauses whose variable is unbound. */
351
+ function bindWhere(where, variables) {
352
+ if (Array.isArray(where)) {
353
+ let changed = false
354
+ const next = []
355
+ for (const item of where) {
356
+ const b = item && typeof item === 'object' ? bindWhere(item, variables) : item
357
+ if (b !== item) changed = true
358
+ if (b !== null) next.push(b)
359
+ }
360
+ if (!changed) return where
361
+ return next.length ? next : null
362
+ }
363
+ if (!where || typeof where !== 'object') return where
364
+ let changed = false
365
+ const next = {}
366
+ for (const [key, value] of Object.entries(where)) {
367
+ if (typeof value === 'string' && ROUTE_VARIABLE.test(value)) {
368
+ const bound = variables?.[value.slice(1)]
369
+ changed = true
370
+ if (bound === undefined || bound === null) continue // unbound ⇒ drop
371
+ next[key] = String(bound)
372
+ continue
373
+ }
374
+ if (value && typeof value === 'object') {
375
+ const b = bindWhere(value, variables)
376
+ if (b !== value) changed = true
377
+ if (b === null) continue // an operator object or sub-predicate emptied out
378
+ next[key] = b
379
+ continue
380
+ }
381
+ next[key] = value
382
+ }
383
+ if (!changed) return where
384
+ return Object.keys(next).length ? next : null
385
+ }
386
+
387
+ /**
388
+ * `scope:` on a lane that cannot be ASKED — the compiled file, an address door —
389
+ * is the same question as `where: { path: { under: scope } }`: a record's
390
+ * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
391
+ * is segment-aware containment. Folding it keeps the language the INTERSECTION
392
+ * of both lanes: an author
393
+ * writes `scope: :dir` once and it means the same branch on a static site and
394
+ * on a door that takes `scope` natively. A config addressed to a question door
395
+ * (`door`) keeps `scope` as the door's own field.
396
+ */
397
+ function foldScope(cfg) {
398
+ if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.door) return cfg
399
+ const { scope, ...rest } = cfg
400
+ const under = { path: { under: scope } }
401
+ const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
402
+ ? { and: [cfg.where, under] }
403
+ : under
404
+ return { ...rest, where }
405
+ }
406
+
407
+ /**
408
+ * Say what a resolved config will GET, so the record index can file it.
409
+ *
410
+ * `depth` — `brief` when the config has a per-record source (`detail`), because
411
+ * a list with a separate record address is a list of partial records: a live
412
+ * lane answers a list at brief depth and a record in full, and a `deferred:`
413
+ * query's compiled file is the stripped list. `full` otherwise. An explicit
414
+ * `depth` on the config wins (a question door's client sets it).
415
+ *
416
+ * `locale` — stamped on a LIVE-lane config only. A compiled path already carries
417
+ * its locale (`/fr/data/…`), but a live address does not, and two locales'
418
+ * records must not share a cache entry (F1). Absent on the default locale, so a
419
+ * site with one language sees no new field.
420
+ */
421
+ function stampDepthAndLocale(cfg, locale, defaultLocale) {
422
+ let out = cfg
423
+ if (out.depth !== 'brief' && out.depth !== 'full') {
424
+ out = { ...out, depth: out.detail ? 'brief' : 'full' }
425
+ }
426
+ if (out.endpoint && locale && locale !== defaultLocale && out.locale === undefined) {
427
+ out = { ...out, locale }
428
+ }
429
+ // A door is asked in exactly one locale — it is in the route — so the config
430
+ // carries it whatever the locale is; two locales' answers never share an entry.
431
+ if (out.door && out.locale === undefined) {
432
+ const asked = locale ?? defaultLocale
433
+ if (asked) out = { ...out, locale: asked }
434
+ }
435
+ return out
436
+ }
@@ -204,6 +204,18 @@ export default class FetcherDispatcher {
204
204
  return this._dataStore.get(key)
205
205
  }
206
206
 
207
+ /**
208
+ * The record held under an identity, if any, with its depth — the entity
209
+ * store asks this before fetching a detail record, so a record already held
210
+ * in full is delivered rather than fetched again (R1).
211
+ *
212
+ * @param {string} id - a `$uuid`
213
+ * @returns {{ depth: 'brief'|'full', record: Object } | null}
214
+ */
215
+ peekRecord(id) {
216
+ return this._dataStore.getRecord(id)
217
+ }
218
+
207
219
  /**
208
220
  * Full dispatch — selection, cache check, in-flight dedup, execution.
209
221
  *
package/src/index.js CHANGED
@@ -13,7 +13,7 @@ export { default as Website } from './website.js'
13
13
  export { default as Page } from './page.js'
14
14
  export { default as Block } from './block.js'
15
15
  export { default as Theme, hasDarkScheme } from './theme.js'
16
- export { default as DataStore, deriveCacheKey } from './datastore.js'
16
+ export { default as DataStore, deriveCacheKey, recordIdentity } from './datastore.js'
17
17
  export { default as EntityStore } from './entity-store.js'
18
18
  export { default as FetcherDispatcher } from './fetcher-dispatcher.js'
19
19
  export { default as ObservableState } from './observable-state.js'
@@ -30,7 +30,7 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
30
30
  // surface and emits a live named re-export for every one, so nothing here can
31
31
  // ever be tree-shaken on the hosted lane.
32
32
  export { resolveFetchConfigs } from './fetch-config.js'
33
- export { buildDetailConfig } from './detail-url.js'
33
+ export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
34
34
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
35
35
  // and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
36
36
  export {
@@ -48,6 +48,11 @@ export {
48
48
  isDataUrl
49
49
  } from './data-paths.js'
50
50
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
51
+ // The one sort evaluator and the one href encoder — both read by `@uniweb/build`
52
+ // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
53
+ // which is what keeps the static and live lanes answering a query identically.
54
+ export { parseSort, sortRecords, sortToWire } from './sort.js'
55
+ export { fillRoutePattern, splitPathCapture, joinPathCapture } from './route-match.js'
51
56
  export { isRichSchema } from './schemas.js'
52
57
  // ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
53
58
  // object graph this package exists to define, and putting it here made every
@@ -60,7 +65,6 @@ export { isRichSchema } from './schemas.js'
60
65
  // since it must not pull the package root into an SSR/Worker bundle.
61
66
  export { resolveService, resolveServiceUrl, readServiceOptions } from './services.js'
62
67
  export { applyBasePath } from './base-path.js'
63
- export { resolveStyle as resolveRequestStyle } from './request-styles/index.js'
64
68
 
65
69
  /**
66
70
  * The singleton Uniweb instance.
package/src/page.js CHANGED
@@ -111,6 +111,12 @@ export default class Page {
111
111
  // Dynamic route context (for pages created from dynamic routes like /blog/:slug)
112
112
  this.dynamicContext = pageData.dynamicContext || null
113
113
 
114
+ // A detail page whose URL names no record: the records were loaded and the
115
+ // param matched none. Set by `Website._createDynamicPage` beside the
116
+ // 'Not found' title. Documented for years, carried only since 2026-09-04 —
117
+ // the flag was set on the page DATA and this sealed class dropped it.
118
+ this.notFound = pageData.notFound === true
119
+
114
120
  // Version context (for pages within versioned sections like /docs/v1/*)
115
121
  this.version = pageData.version || null // { id, label, latest, deprecated }
116
122
  this.versionMeta = pageData.versionMeta || null // { versions, latestId }
@@ -50,7 +50,6 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
50
50
  * serving records has no such thing: it has content organised somewhere, and what we
51
51
  * substitute is a **path** to it. Naming the slot for our file vocabulary put that
52
52
  * vocabulary into a string a HOST writes, which makes them reason in our shape.
53
- * See `kb/framework/architecture/backend-boundary.md` §2.
54
53
  */
55
54
  const PATH_SLOT = '{path}'
56
55
  /** The placeholder a record pattern must carry to address a specific record. */
@@ -111,6 +110,40 @@ export function resolveQueryAddress(query, lane) {
111
110
  return substitutePlaceholders(pattern, { path: query })
112
111
  }
113
112
 
113
+ /**
114
+ * The QUESTION door a host declares — a POST address with a `{locale}` slot —
115
+ * substituted for one locale, or `null` when the lane declares none.
116
+ *
117
+ * The stamp key is `config.records.query` — read here as a provisional spelling
118
+ * on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
119
+ * contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
120
+ * One constant, changed in one place should it ever move. Everything downstream
121
+ * wakes only when a host stamps it AND the payload carries the query's Model ref
122
+ * (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
123
+ * there, never a query param: a request that cannot name one does not address
124
+ * this door at all.
125
+ *
126
+ * @param {Object|null} lane - `config.records`
127
+ * @param {string|null} locale - the locale being rendered; required
128
+ * @returns {string|null}
129
+ */
130
+ export const QUERY_DOOR_KEY = 'query'
131
+
132
+ export function resolveQueryDoor(lane, locale) {
133
+ const pattern = readPattern(lane, QUERY_DOOR_KEY)
134
+ if (!pattern) return null
135
+ if (typeof locale !== 'string' || locale.length === 0) return null
136
+ if (!pattern.includes('{locale}')) {
137
+ warnOnce(
138
+ `query:${pattern}`,
139
+ `config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
140
+ `locale as a route segment. Ignoring it.`
141
+ )
142
+ return null
143
+ }
144
+ return substitutePlaceholders(pattern, { locale })
145
+ }
146
+
114
147
  /**
115
148
  * The address pattern for ONE record of a query, with `{param}` left in
116
149
  * place for the dynamic-route substitution that happens later.