@uniweb/core 0.29.4 → 0.29.6

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.29.4",
3
+ "version": "0.29.6",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "./detail-url": "./src/detail-url.js",
14
14
  "./fetch-config": "./src/fetch-config.js",
15
15
  "./layout-name": "./src/layout-name.js",
16
+ "./page-data": "./src/page-data.js",
16
17
  "./icon-corpus": "./src/icon-corpus.js",
17
18
  "./locale-config": "./src/locale-config.js",
18
19
  "./route-match": "./src/route-match.js",
@@ -46,7 +47,7 @@
46
47
  "vitest": "^4.1.7"
47
48
  },
48
49
  "dependencies": {
49
- "@uniweb/semantic-parser": "^1.4.0",
50
+ "@uniweb/semantic-parser": "^1.4.1",
50
51
  "@uniweb/theming": "^0.1.15"
51
52
  },
52
53
  "scripts": {
@@ -14,10 +14,10 @@
14
14
  * and in-flight dedup.
15
15
  */
16
16
 
17
- import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, othersOf, siteReaches } from './fetch-config.js'
18
- import { declaredKeys, fillDeclaredKeys } from './data-keys.js'
19
- import { fillRoutePattern, matchesRouteParam } from './route-match.js'
20
- import { buildDetailConfig } from './detail-url.js'
17
+ import { resolveFetchConfigs, pageRouteQuery } from './fetch-config.js'
18
+ import { declaredKeys } from './data-keys.js'
19
+ import { fillRoutePattern } from './route-match.js'
20
+ import { fetchLevels, blockFills, pageRecordConfig, planFor, runPlan, runPlanSync } from './page-data.js'
21
21
 
22
22
  export default class EntityStore {
23
23
  /**
@@ -33,24 +33,20 @@ export default class EntityStore {
33
33
  }
34
34
 
35
35
  /**
36
- * The fetches that reach a block, one level each, most specific first: its own, its
37
- * page's, its parent page's, a nested page's route binding, and the site's.
38
- *
39
- * ⭐ A page nested inside a parametric page receives its capturing page's route binding
40
- * — the cascade reaches one parent up, and the binding may sit above that
41
- * (`pageRouteQuery`). Its key only; the rest does not cascade. The site's binding
42
- * reaches a layout section and a top-level page's sections, and nothing deeper
43
- * (`siteReaches`).
36
+ * The fetches that reach a block — `fetchLevels` (`./page-data.js`), the one rule, read
37
+ * off this block's graph. ⛔ Do not spell the levels out here: every lane that has
38
+ * needed this answer has written its own list, and each list has drifted
39
+ * (`kb/framework/plans/what-a-page-needs.md`).
44
40
  */
45
41
  _levels(block, route = this._route(block)) {
46
42
  const page = block.page
47
- return [
48
- block.fetch,
49
- page?.fetch,
50
- page?.parent?.fetch,
51
- route?.nested ? route.config : null,
52
- siteReaches(page?.parent) ? block.website?.config?.fetch : null,
53
- ]
43
+ return fetchLevels({
44
+ own: block.fetch,
45
+ page: page?.fetch,
46
+ parent: page?.parent ?? null,
47
+ route,
48
+ site: block.website?.config?.fetch,
49
+ })
54
50
  }
55
51
 
56
52
  /**
@@ -117,29 +113,73 @@ export default class EntityStore {
117
113
  */
118
114
  _fills(block, meta, route) {
119
115
  const website = block.website
120
- const declared = website?.declaredKeys ? website.declaredKeys(meta) : declaredKeys(meta?.data)
121
- const out = new Map()
122
- if (declared.length === 0) return out
123
- const holds = block.heldData || {}
124
- const levels = this._levels(block, route)
125
- const fills = fillDeclaredKeys(declared, levels, {
116
+ return blockFills({
117
+ declared: website?.declaredKeys ? website.declaredKeys(meta) : declaredKeys(meta?.data),
118
+ levels: this._levels(block, route),
119
+ holds: block.heldData || {},
126
120
  queries: website?.config?.queries ?? null,
127
- held: declared.map(([key]) => key).filter((key) => holds[key] !== undefined),
121
+ options: this._resolveOptions(block),
128
122
  })
129
- // where the first fetch of each key reaching the block sits — the one a held answer
130
- // belongs to
131
- const firstOfKey = new Map()
132
- levels.forEach((level, at) => fetchEntries(level).forEach((fetch, index) => {
133
- if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
134
- }))
135
- const options = this._resolveOptions(block)
136
- for (const [key, { fetch, level, index }] of fills) {
137
- const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
138
- if (!cfg) continue
139
- const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
140
- out.set(key, { cfg, held })
141
- }
142
- return out
123
+ }
124
+
125
+ /**
126
+ * The plan for a block — one program per declared key (`planFor`, `./page-data.js`),
127
+ * which both `resolve` and `fetch` run. They differ in where an answer comes from and in
128
+ * nothing else.
129
+ */
130
+ _plan(block, fills, route, dispatcher = this.website?.fetcher) {
131
+ return planFor(fills, {
132
+ dynamicContext: block.dynamicContext || block.page?.dynamicContext,
133
+ route,
134
+ peekRecord: (id) => dispatcher?.peekRecord?.(id),
135
+ })
136
+ }
137
+
138
+ /** The filling config of each key — what `_applyRecordRoutes` links records by. */
139
+ _configs(fills) {
140
+ return new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
141
+ }
142
+
143
+ /**
144
+ * ⭐ THE PAGE'S OWN RECORD, ASKED — the question `Website#_createDynamicPage` peeks to name the
145
+ * page and to know whether it exists at all.
146
+ *
147
+ * A host's data step asks it whether or not a section declares the route key, because a page's
148
+ * existence is not a section's business: a parametric page with no section reading its record is
149
+ * a pseudo-error, but its 404 is not. The question is `pageRecordConfig`'s, so the asker and the
150
+ * page cannot ask two different things.
151
+ *
152
+ * @param {Object} page - a concrete parametric page (it carries `dynamicContext`)
153
+ * @param {Object} [options]
154
+ * @param {Object} [options.dispatcher] - fetch through this one (a host's data step)
155
+ * @returns {Promise<{ data: Object, errors: Object|null }|null>} null off a parametric page, or
156
+ * when the page's route query resolves to nothing
157
+ */
158
+ async fetchPageRecord(page, { dispatcher = this.website?.fetcher } = {}) {
159
+ const dynamicContext = page?.dynamicContext
160
+ if (!dynamicContext || !dispatcher) return null
161
+
162
+ // A block-shaped probe standing for the page itself: its levels, its route query.
163
+ const probe = { fetch: null, page, website: this.website, dynamicContext: null }
164
+ const route = this._route(probe)
165
+ const cfg = route && pageRecordConfig({
166
+ pageFetch: page.fetch,
167
+ parent: page.parent ?? null,
168
+ route,
169
+ site: this.website?.config?.fetch,
170
+ options: this._resolveOptions(probe),
171
+ })
172
+ if (!cfg) return null
173
+
174
+ const plan = planFor(new Map([[route.key, { cfg }]]), {
175
+ dynamicContext,
176
+ route,
177
+ // The page is about ONE record, whatever a section's `current:` says.
178
+ current: 'only',
179
+ peekRecord: (id) => dispatcher.peekRecord?.(id),
180
+ })
181
+ const ctx = this._ctx(probe)
182
+ return runPlan(plan, { dispatch: (request) => dispatcher.dispatch(request, ctx) })
143
183
  }
144
184
 
145
185
  /**
@@ -233,17 +273,6 @@ export default class EntityStore {
233
273
  if (Object.keys(linked).some((key) => linked[key] !== held[key])) block.parsedContent.data = linked
234
274
  }
235
275
 
236
- /**
237
- * Build a detail-URL fetch config from a query config + dynamic context.
238
- *
239
- * Delegates to the exported resolver so a host fetching this record
240
- * server-side reaches the identical rule — see `./detail-url.js` for why the
241
- * four `detail:` forms are a contract rather than an implementation detail.
242
- */
243
- _buildDetailConfig(queryConfig, dynamicContext) {
244
- return buildDetailConfig(queryConfig, dynamicContext)
245
- }
246
-
247
276
  /**
248
277
  * Build the `ctx` handed to the dispatcher for a given block.
249
278
  * @private
@@ -269,99 +298,21 @@ export default class EntityStore {
269
298
  const route = this._route(block)
270
299
  const fills = this._fills(block, meta, route)
271
300
  if (fills.size === 0) return { status: 'none', data: null }
272
- const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
273
301
 
274
- const dynamicContext = block.dynamicContext || block.page?.dynamicContext
275
302
  const ctx = this._ctx(block)
303
+ const result = runPlanSync(this._plan(block, fills, route), {
304
+ peek: (request) => dispatcher?.peek(request, ctx),
305
+ })
306
+ if (result.status !== 'ready') return { status: 'pending', data: null }
276
307
 
277
- const data = {}
278
- let allCached = true
279
-
280
- for (const [schema, { cfg, held }] of fills) {
281
- if (held !== undefined) {
282
- // The block holds this fetch's answer already — a static build prerendered it.
283
- data[schema] = held
284
- continue
285
- }
286
- // How this fetch uses the page's record: by the query it names (`currentFor`).
287
- const current = dynamicContext ? currentFor(cfg, route) : null
288
- if (current === 'exclude') {
289
- // The query's records without this page's, `limit` counting the others.
290
- const cached = dispatcher?.peek(othersView(cfg), ctx)
291
- if (cached) {
292
- data[schema] = othersOf(cached.data, cfg, isPageRecord(dynamicContext))
293
- } else {
294
- allCached = false
295
- }
296
- } else if (current === 'only' && cfg.ask) {
297
- // The records service: the record's own answer is cached under its own key.
298
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
299
- const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
300
- if (detailCached) {
301
- const answer = Array.isArray(detailCached.data) ? detailCached.data : (detailCached.data ? [detailCached.data] : [])
302
- data[schema] = answer.slice(0, 1)
303
- } else {
304
- allCached = false
305
- }
306
- } else if (current === 'only') {
307
- // Detail page: deliver the focused record as a length-1 array under the
308
- // query key. A deferred/remote query fetches the full per-record;
309
- // others use the matched record. Not found → []. Found in the set of the
310
- // query the fetch names — the route query's, or another's under `current: only`
311
- // — never in a list a fetch narrowed (`routeSelection`).
312
- const cached = dispatcher?.peek(routeSelection(cfg), ctx)
313
- if (cached) {
314
- const { paramName, paramValue } = dynamicContext
315
- const items = cached.data
316
- const match = Array.isArray(items)
317
- ? items.find((item) => matchesRouteParam(item, paramName, paramValue))
318
- : null
319
- if (!match) {
320
- data[schema] = []
321
- } else if (cfg.detail) {
322
- // ⭐ Held in full already? Then it IS the record — no detail probe.
323
- // The list is materialized from the record index, so `match` is the
324
- // record at its latest depth; the index says which depth that is.
325
- const held = heldWhole(dispatcher, match)
326
- const detailCfg = held ? null : this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
327
- const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
328
- if (held) {
329
- data[schema] = [held]
330
- } else if (detailCfg && detailCached) {
331
- data[schema] = [detailCached.data]
332
- } else if (detailCfg) {
333
- allCached = false
334
- } else {
335
- data[schema] = [match]
336
- }
337
- } else {
338
- data[schema] = [match]
339
- }
340
- } else {
341
- allCached = false
342
- }
343
- } else {
344
- // A fetch the page's record plays no part in (`currentFor`), and `current:
345
- // include`: the records as the fetch describes them, the page's among the rest.
346
- const cached = dispatcher?.peek(cfg, ctx)
347
- if (cached) {
348
- data[schema] = cached.data
349
- } else {
350
- allCached = false
351
- }
352
- }
353
- }
354
-
355
- if (allCached) {
356
- this._applyRecordRoutes(data, configs, block.website)
357
- return { status: 'ready', data }
358
- }
359
- return { status: 'pending', data: null }
308
+ this._applyRecordRoutes(result.data, this._configs(fills), block.website)
309
+ return result
360
310
  }
361
311
 
362
312
  /**
363
- * Async fetch — dispatches missing configs through the FetcherDispatcher
364
- * and assembles the result. List-first detail ordering preserved.
313
+ * Async fetch — the same plan, run by dispatching. List-first detail ordering preserved:
314
+ * a record found in its query's set is asked for in full only after the set answers, and
315
+ * only that key waits.
365
316
  *
366
317
  * ⛔ A FAILED FETCH DELIVERS NOTHING UNDER ITS KEY, AND SAYS SO. Until 2026-09-04
367
318
  * a failure wrote `[]` into `content.data` — the fetcher returns `{ data: [], error }`,
@@ -374,146 +325,27 @@ export default class EntityStore {
374
325
  *
375
326
  * @param {Object} [options]
376
327
  * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
328
+ * @param {Object} [options.dispatcher] - ⭐ Fetch through this one instead of the graph's: a
329
+ * host's data step holds the request's transport and a cache of its own (`Website#dispatcherFor`).
330
+ * The plan is the same; only where the answers come from and land differs.
377
331
  * @returns {Promise<{ data: Object|null, errors: Object|null }>} `data` keyed by
378
332
  * binding key; `errors` keyed the same way, `null` when every fetch succeeded.
379
333
  */
380
- async fetch(block, meta, { signal } = {}) {
381
- const dispatcher = this.website?.fetcher
334
+ async fetch(block, meta, { signal, dispatcher = this.website?.fetcher } = {}) {
382
335
  if (!dispatcher) return { data: null, errors: null }
383
336
 
384
337
  const route = this._route(block)
385
338
  const fills = this._fills(block, meta, route)
386
339
  if (fills.size === 0) return { data: null, errors: null }
387
- const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
388
340
 
389
- const dynamicContext = block.dynamicContext || block.page?.dynamicContext
390
341
  const ctx = this._ctx(block, { signal })
342
+ const { data, errors } = await runPlan(this._plan(block, fills, route, dispatcher), {
343
+ dispatch: (request) => dispatcher.dispatch(request, ctx),
344
+ onFailure: (key, cfg, message) => reportFetchFailure(this.dev, block, key, cfg, message),
345
+ })
391
346
 
392
- const data = {}
393
- const errors = {}
394
- const parallelFetches = []
395
- const fail = (key, cfg, message) => {
396
- errors[key] = message
397
- reportFetchFailure(this.dev, block, key, cfg, message)
398
- }
399
-
400
- for (const [schema, { cfg, held }] of fills) {
401
- if (held !== undefined) {
402
- // The block holds this fetch's answer already — a static build prerendered it.
403
- data[schema] = held
404
- continue
405
- }
406
- // How this fetch uses the page's record: by the query it names (`currentFor`).
407
- const current = dynamicContext ? currentFor(cfg, route) : null
408
- if (current === 'exclude') {
409
- // The query's records without this page's, the fetch's `limit` counting
410
- // the others: its `narrow.limit` asked one higher (`othersView`), so removing
411
- // the record still leaves enough.
412
- const view = othersView(cfg)
413
- parallelFetches.push(dispatcher.dispatch(view, ctx).then((result) => {
414
- if (result?.error) {
415
- fail(schema, view, result.error)
416
- return
417
- }
418
- if (result?.data !== undefined && result?.data !== null) {
419
- data[schema] = othersOf(result.data, cfg, isPageRecord(dynamicContext))
420
- }
421
- }))
422
- } else if (current === 'only' && cfg.ask) {
423
- // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
424
- // query's set narrowed by the route's handle, so its one question
425
- // answers the record if the set holds it and `[]` if not — no client-side
426
- // scan gating the fetch (F13, the live half).
427
- //
428
- // ⛔ The list was asked beside it until 2026-09-14, for the record index to
429
- // file its briefs, while the record question dropped the query's `sort` and
430
- // `limit` and so could not say whether the set held the record. It checks the
431
- // set now, and the list sent the whole set to render one record of it.
432
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
433
- parallelFetches.push(dispatcher.dispatch(detailCfg, ctx).then((result) => {
434
- if (result?.error) {
435
- fail(schema, detailCfg, result.error)
436
- return
437
- }
438
- const answer = Array.isArray(result?.data) ? result.data : (result?.data ? [result.data] : [])
439
- data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
440
- }))
441
- } else if (current === 'only') {
442
- // Detail page: focused record as a length-1 array under the fetch's key,
443
- // found in its query's set (`routeSelection`) — a record past a
444
- // fetch's `limit` still has its page, and one past the query's has none.
445
- const { paramName, paramValue } = dynamicContext
446
- const selection = routeSelection(cfg)
447
-
448
- let records = peekArray(dispatcher, selection, ctx)
449
- if (records === null) {
450
- const result = await dispatcher.dispatch(selection, ctx)
451
- if (result?.error) {
452
- fail(schema, selection, result.error)
453
- continue
454
- }
455
- records = Array.isArray(result?.data) ? result.data : null
456
- }
457
-
458
- const match = records?.find(
459
- (item) => matchesRouteParam(item, paramName, paramValue)
460
- ) ?? null
461
-
462
- if (!match) {
463
- data[schema] = []
464
- continue
465
- }
466
-
467
- const held = cfg.detail ? heldWhole(dispatcher, match) : null
468
- if (held) {
469
- // R1: the record index holds it in full — a detail fetch would only
470
- // re-fetch what the page already has.
471
- data[schema] = [held]
472
- } else if (cfg.detail) {
473
- const detailCfg = this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
474
- if (detailCfg) {
475
- parallelFetches.push(
476
- dispatcher.dispatch(detailCfg, ctx).then((result) => {
477
- // The list already matched the record, so the brief is a HELD
478
- // value: a failed detail fetch keeps it and reports, rather than
479
- // delivering `[[]]` — which is what `result.data ?? match` did,
480
- // because a failure's `data` is `[]`, not null.
481
- if (result?.error) {
482
- fail(schema, detailCfg, result.error)
483
- data[schema] = [match]
484
- return
485
- }
486
- const record = (result?.data !== undefined && result?.data !== null)
487
- ? result.data
488
- : match
489
- data[schema] = [record]
490
- })
491
- )
492
- } else {
493
- data[schema] = [match]
494
- }
495
- } else {
496
- data[schema] = [match]
497
- }
498
- } else {
499
- // Any other key — and `current: include`, the list as the binding describes it.
500
- parallelFetches.push(
501
- dispatcher.dispatch(cfg, ctx).then((result) => {
502
- if (result?.error) {
503
- fail(schema, cfg, result.error)
504
- return
505
- }
506
- if (result?.data !== undefined && result?.data !== null) {
507
- data[schema] = result.data
508
- }
509
- })
510
- )
511
- }
512
- }
513
-
514
- if (parallelFetches.length > 0) await Promise.all(parallelFetches)
515
- this._applyRecordRoutes(data, configs, block.website)
516
- return { data, errors: Object.keys(errors).length ? errors : null }
347
+ this._applyRecordRoutes(data, this._configs(fills), block.website)
348
+ return { data, errors }
517
349
  }
518
350
  }
519
351
 
@@ -540,32 +372,6 @@ function reportFetchFailure(dev, block, key, cfg, message) {
540
372
  )
541
373
  }
542
374
 
543
- /**
544
- * The record the index holds in FULL for a list match, or null — the R1 gate.
545
- * A record with no identity (`$uuid`) is never indexed, so the answer for it is
546
- * null and the detail fetch proceeds as before.
547
- */
548
- function heldWhole(dispatcher, match) {
549
- const id = match?.$uuid
550
- if (typeof id !== 'string' || !id || typeof dispatcher?.peekRecord !== 'function') return null
551
- const held = dispatcher.peekRecord(id)
552
- return held?.whole === true ? held.record : null
553
- }
554
-
555
- /** Matches the record a parametric page is about — the one `current: only` delivers. */
556
- function isPageRecord({ paramName, paramValue }) {
557
- return (item) => matchesRouteParam(item, paramName, paramValue)
558
- }
559
-
560
- /**
561
- * Sync-peek helper: return the cached array for a config, or null on miss.
562
- */
563
- function peekArray(dispatcher, cfg, ctx) {
564
- const cached = dispatcher.peek(cfg, ctx)
565
- if (!cached) return null
566
- return Array.isArray(cached.data) ? cached.data : null
567
- }
568
-
569
375
  /**
570
376
  * The route template a fetch's records link to — its `detailPage`, else its query's page.
571
377
  *
@@ -204,6 +204,27 @@ export default class FetcherDispatcher {
204
204
  return this._dataStore.get(key)
205
205
  }
206
206
 
207
+ /**
208
+ * ⭐ FILE AN ANSWER SOMEONE ELSE FETCHED, under the key THIS dispatcher will look for it.
209
+ *
210
+ * A build and a host both arrive with answers in hand — baked into a payload, or fetched by a
211
+ * data step — and the only thing that makes them findable is being filed where `peek` looks.
212
+ *
213
+ * ⛔ That is not `deriveCacheKey` for every request, and a caller cannot know which ones.
214
+ * A fetcher may declare its own `cacheKey` — the documented case is a request whose identity
215
+ * depends on state its address does not carry — and the site chooses which key goes to which
216
+ * transport. So the derivation has exactly one home, this class (see the header), and hydrating
217
+ * by hand files a keyed transport's answer where nothing looks: measured 2026-09-20, every page
218
+ * of such a site prerendered empty and the browser refetched it, with no error anywhere.
219
+ *
220
+ * @param {Object} request - the resolved config the answer belongs to
221
+ * @param {{ data: *, meta?: Object }} entry - the answer, in the shape `peek` returns
222
+ * @param {Object} [ctx] - `{ website }`, for the site's transport selection
223
+ */
224
+ hydrate(request, entry, ctx = {}) {
225
+ this._dataStore.set(this._cacheKey(this._selectFetcher(request, ctx), request), entry)
226
+ }
227
+
207
228
  /**
208
229
  * The record held under an identity, if any, with its depth — the entity
209
230
  * store asks this before fetching a detail record, so a record already held
package/src/index.js CHANGED
@@ -25,6 +25,9 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
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
27
  export { declaredKeys, sameSchema, fillDeclaredKeys } from './data-keys.js'
28
+ // ⭐ What a page needs — the one rule. `fetchLevels` is the part every lane has an answer for
29
+ // (which fetches reach a block); the drivers and the per-key program are `@uniweb/core/page-data`.
30
+ export { fetchLevels } from './page-data.js'
28
31
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
29
32
  // and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
30
33
  export {
@@ -0,0 +1,361 @@
1
+ /**
2
+ * WHAT A PAGE NEEDS — the one rule, and the two ways to run it.
3
+ *
4
+ * ⭐ *Which requests does rendering this block need, and what does its component receive?* The
5
+ * answer has four parts, and this module is all four in one place:
6
+ *
7
+ * - **(a) levels** — `fetchLevels`: which fetches reach a block at all.
8
+ * - **(b) fills** — `blockFills`: which of them fills each key the component declares
9
+ * (`fillDeclaredKeys`, `./data-keys.js`), and the resolved config of each.
10
+ * - **(c) requests** — `keyProgram`: what a filling fetch asks on THIS page, by `current:`.
11
+ * - **(d) assembly** — the same program: how each answer becomes the key's value.
12
+ *
13
+ * ⭐ **(c) is written in STEPS, because one request depends on an answer.** Off the records
14
+ * service, a parametric page's record is FOUND in its query's set and only then asked for in full,
15
+ * so the second request cannot be worked out before the first is answered. A flat list of configs
16
+ * computed up front cannot express that — it has to guess, and the guess is wrong wherever the
17
+ * record's own fields name the address (measured 2026-09-19: an `[id]` route with a `deferred:`
18
+ * query asked for `{slug}` unfilled). A generator says it once, and both drivers get it.
19
+ *
20
+ * ⛔ **Two drivers, never two rules.** `runPlanSync` answers from the cache alone — SSR, and the
21
+ * browser's first render — and stops at the first miss, which is what `pending` means. `runPlan`
22
+ * dispatches. They differ in where an answer comes from and in nothing else, so a render cannot
23
+ * look for something its fetch never asked for.
24
+ *
25
+ * *The copies this replaces, and what each of them got wrong:*
26
+ * `kb/framework/plans/what-a-page-needs.md`. The import boundary that stops the next one:
27
+ * `framework/_contracts/page-data-rule-boundary.test.js`.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import { resolveFetchConfigs, fetchEntries, routeSelection, currentFor, othersView, othersOf, siteReaches } from './fetch-config.js'
33
+ import { fillDeclaredKeys } from './data-keys.js'
34
+ import { matchesRouteParam } from './route-match.js'
35
+ import { buildDetailConfig } from './detail-url.js'
36
+
37
+ /**
38
+ * (a) The fetches that reach a block, most specific first: its own, its page's, its parent page's,
39
+ * a nested page's route binding, and the site's where it reaches.
40
+ *
41
+ * ⭐ A page nested inside a parametric page receives its capturing page's route binding — the
42
+ * cascade reaches one parent up, and the binding may sit above that. Its key only; the rest does
43
+ * not cascade. The site's binding reaches a layout area and a top-level page's sections, and
44
+ * nothing deeper (`siteReaches`, which is why this takes the parent PAGE and not its fetch).
45
+ *
46
+ * Plain data in and out, so a caller holding payload objects gets the same answer as one holding
47
+ * the object graph.
48
+ *
49
+ * ⭐ `includeRoute` is for a caller looking for the PAGE'S OWN record rather than feeding a section:
50
+ * the route query's declaration may sit on a section, and so be in no other level, and the page
51
+ * still has to find the record it is about. A block sees that declaration only when it is nested,
52
+ * because otherwise it is already among the levels above.
53
+ *
54
+ * @param {Object} where
55
+ * @param {*} [where.own] - the block's own `fetch`
56
+ * @param {*} [where.page] - its page's `fetch`
57
+ * @param {Object|null} [where.parent] - its parent PAGE, or null
58
+ * @param {{ config: *, nested: boolean }|null} [where.route] - the page's route query
59
+ * @param {*} [where.site] - the site's `fetch`
60
+ * @param {boolean} [where.includeRoute] - include the route query's declaration wherever it came from
61
+ * @returns {Array<*>} one level each, falsy where a level declares nothing
62
+ */
63
+ export function fetchLevels({ own = null, page = null, parent = null, route = null, site = null, includeRoute = false } = {}) {
64
+ return [
65
+ own,
66
+ page,
67
+ parent?.fetch ?? null,
68
+ includeRoute || route?.nested ? route?.config ?? null : null,
69
+ siteReaches(parent) ? site : null,
70
+ ]
71
+ }
72
+
73
+ /**
74
+ * (b) Each declared key paired with the fetch that fills it, resolved.
75
+ *
76
+ * ⭐ A declared key the block already HOLDS — a tagged data block, or its own fetch's prerendered
77
+ * answer under that name — is not filled from a request. `held` carries it, and only for the first
78
+ * fetch of that key reaching the block: the one whose answer a build put there.
79
+ *
80
+ * Each filling fetch is resolved on its own, so two fetches of one `as` can fill two keys.
81
+ *
82
+ * @param {Object} input
83
+ * @param {Array<[string, string|null]>} input.declared - `declaredKeys(meta)`
84
+ * @param {Array<*>} input.levels - `fetchLevels`
85
+ * @param {Object} [input.holds] - what the block already holds, by key
86
+ * @param {Object|null} [input.queries] - the site's `config.queries`
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
90
+ */
91
+ export function blockFills({ declared, levels, holds = {}, queries = null, options = {} }) {
92
+ const out = new Map()
93
+ if (!declared || declared.length === 0) return out
94
+
95
+ const fills = fillDeclaredKeys(declared, levels, {
96
+ queries,
97
+ held: declared.map(([key]) => key).filter((key) => holds[key] !== undefined),
98
+ })
99
+ // where the first fetch of each key reaching the block sits — the one a held answer belongs to
100
+ const firstOfKey = new Map()
101
+ levels.forEach((level, at) => fetchEntries(level).forEach((fetch, index) => {
102
+ if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
103
+ }))
104
+
105
+ for (const [key, { fetch, level, index }] of fills) {
106
+ const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
107
+ if (!cfg) continue
108
+ const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
109
+ out.set(key, { cfg, held })
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * The fetch a parametric page's OWN record is asked for with — its route query, resolved.
116
+ *
117
+ * ⭐ A page's existence is not a section's business: it is asked whether or not a section declares
118
+ * the route key, which is why this is its own entry point and why the levels include the route
119
+ * query's declaration wherever it came from (`includeRoute`). Its two callers — the page naming
120
+ * itself from the cache, and a host's data step asking for it — cannot drift apart.
121
+ *
122
+ * @param {Object} where
123
+ * @param {*} [where.pageFetch] - the parametric page's own `fetch`
124
+ * @param {Object|null} [where.parent] - its parent page
125
+ * @param {{ key: string, config: * }} where.route - its route query (`pageRouteQuery`)
126
+ * @param {*} [where.site] - the site's `fetch`
127
+ * @param {Object} [where.options] - what the fetch is resolved with (`resolveFetchConfigs`)
128
+ * @returns {Object|undefined} the resolved config, or undefined when the page declares none
129
+ */
130
+ export function pageRecordConfig({ pageFetch = null, parent = null, route, site = null, options = {} }) {
131
+ if (!route?.key) return undefined
132
+ return resolveFetchConfigs(
133
+ fetchLevels({ page: pageFetch, parent, route, site, includeRoute: true }),
134
+ { ...options, schemas: [route.key] },
135
+ ).get(route.key)
136
+ }
137
+
138
+ /** The record a parametric page is about — the one `current: only` delivers. */
139
+ function isPageRecord({ paramName, paramValue }) {
140
+ return (item) => matchesRouteParam(item, paramName, paramValue)
141
+ }
142
+
143
+ /**
144
+ * The record held under an identity IN FULL, or null — the R1 gate. A record with no identity
145
+ * (`$uuid`) is never indexed, so the answer for it is null and the detail request proceeds.
146
+ */
147
+ function heldWhole(peekRecord, match) {
148
+ const id = match?.$uuid
149
+ if (typeof id !== 'string' || !id || typeof peekRecord !== 'function') return null
150
+ const held = peekRecord(id)
151
+ return held?.whole === true ? held.record : null
152
+ }
153
+
154
+ /**
155
+ * (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.
159
+ *
160
+ * ⛔ **A failed request delivers NOTHING under its key, and says so.** The key is absent from the
161
+ * 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.
164
+ *
165
+ * @param {Object} cfg - the filling fetch, resolved
166
+ * @param {Object} where
167
+ * @param {Object|null} where.dynamicContext - the route's param and value, off a parametric page
168
+ * @param {{ key: string, config: Object, nested: boolean }|null} where.route - the page's route query
169
+ * @param {*} [where.held] - what the block already holds for this key
170
+ * @param {Function} [where.peekRecord] - the record index, for the R1 gate
171
+ * @param {'only'|'exclude'|'include'|null} [where.current] - override how this page's record is
172
+ * used. ⭐ For a caller asking for the PAGE'S OWN record: a page is about one record whatever a
173
+ * section's declaration says about how that section uses it.
174
+ * @param {boolean} [where.opportunistic] - answer from whatever is already cached and never
175
+ * report a miss. ⭐ For a caller that runs BEFORE any fetch and must not cause one: the page
176
+ * naming itself from its record. On a question door the record has its own answer, but a list
177
+ * page may already hold the set, and the record is in it — so a miss falls through to the set,
178
+ * and a record found there answers with the brief it holds. No value at all means *unknown*,
179
+ * which is not the same as `[]`, *not in the set*.
180
+ * @returns {Generator<Object, { value?: *, error?: string, errorConfig?: Object }>}
181
+ */
182
+ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false } = {}) {
183
+ /** A step this caller can do without: a miss answers `{ missing: true }` instead of stopping. */
184
+ const maybe = (request) => (opportunistic ? { request, optional: true } : request)
185
+ // The block holds this fetch's answer already — a static build prerendered it.
186
+ if (held !== undefined) return { value: held }
187
+
188
+ // How this fetch uses the page's record: by the query it names (`currentFor`).
189
+ const current = forced ?? (dynamicContext ? currentFor(cfg, route) : null)
190
+
191
+ if (current === 'exclude') {
192
+ // The query's records without this page's, its `limit` counting the others.
193
+ const view = othersView(cfg)
194
+ const answer = yield view
195
+ if (answer.error) return { error: answer.error, errorConfig: view }
196
+ if (answer.data === undefined || answer.data === null) return {}
197
+ return { value: othersOf(answer.data, cfg, isPageRecord(dynamicContext)) }
198
+ }
199
+
200
+ if (current === 'only' && cfg.ask) {
201
+ // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the query's set
202
+ // 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)
205
+ // No per-record source, or a route carrying no value: an unanswerable question, and asking it
206
+ // is worse than leaving the key absent. ⚠️ `resolve` reported this as `pending` and `fetch`
207
+ // dispatched a null config; neither was intended (unified 2026-09-19).
208
+ if (!detailCfg && !opportunistic) return {}
209
+ const answer = detailCfg ? yield maybe(detailCfg) : { missing: true }
210
+ if (!answer.missing) {
211
+ if (answer.error) return { error: answer.error, errorConfig: detailCfg }
212
+ const list = Array.isArray(answer.data) ? answer.data : (answer.data ? [answer.data] : [])
213
+ return { value: list.slice(0, 1) } // a route resolves to ONE; `[]` is not found
214
+ }
215
+ // Opportunistic, and the record's own answer is not held: the set may be, and the record is
216
+ // in it. Falls through to the set below, which is where every other lane finds it.
217
+ }
218
+
219
+ if (current === 'only') {
220
+ // Off the service the record is FOUND in the set — the query as saved, without the fetch's
221
+ // `narrow` (`routeSelection`): a record past a fetch's `limit` still has its page, and one
222
+ // past the query's has none.
223
+ const selection = routeSelection(cfg)
224
+ const answer = yield maybe(selection)
225
+ // Opportunistic and nothing held: UNKNOWN, which is not `[]`. A caller naming a page from its
226
+ // record leaves the name alone rather than calling the record missing.
227
+ if (answer.missing) return {}
228
+ if (answer.error) return { error: answer.error, errorConfig: selection }
229
+ const items = Array.isArray(answer.data) ? answer.data : null
230
+ const { paramName, paramValue } = dynamicContext
231
+ const match = items?.find((item) => matchesRouteParam(item, paramName, paramValue)) ?? null
232
+ if (!match) return { value: [] } // not found
233
+ if (!cfg.detail) return { value: [match] }
234
+
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] }
239
+
240
+ // ⭐ THE STEP THAT NEEDS THE ANSWER: the record's own address can name its own fields, so it
241
+ // is built with the record in hand and never from the route's value alone.
242
+ const detailCfg = buildDetailConfig(cfg, { ...dynamicContext, record: match })
243
+ if (!detailCfg) return { value: [match] }
244
+ 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] }
251
+ }
252
+
253
+ // A fetch the page's record plays no part in, and `current: include`: the records as the fetch
254
+ // describes them, the page's among the rest.
255
+ const answer = yield cfg
256
+ if (answer.error) return { error: answer.error, errorConfig: cfg }
257
+ if (answer.data === undefined || answer.data === null) return {}
258
+ return { value: answer.data }
259
+ }
260
+
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 }
264
+ }
265
+
266
+ /**
267
+ * Every filling key's program, ready to run.
268
+ *
269
+ * @param {Map<string, { cfg: Object, held: * }>} fills - `blockFills`
270
+ * @param {Object} where - passed to each `keyProgram`
271
+ * @returns {Map<string, Generator>}
272
+ */
273
+ export function planFor(fills, where = {}) {
274
+ const plan = new Map()
275
+ for (const [key, { cfg, held }] of fills) {
276
+ plan.set(key, keyProgram(cfg, { ...where, held }))
277
+ }
278
+ return plan
279
+ }
280
+
281
+ /**
282
+ * Run a plan against the CACHE alone — SSR, and the browser's first render.
283
+ *
284
+ * Stops a key at its first miss and reports `pending` for the block, which is what the caller
285
+ * falls through to `runPlan` for. A key whose program needs no request is answered here.
286
+ *
287
+ * @param {Map<string, Generator>} plan
288
+ * @param {Object} io
289
+ * @param {(request: Object) => ({ data: * }|null|undefined)} io.peek
290
+ * @returns {{ status: 'ready'|'pending', data: Object|null }}
291
+ */
292
+ export function runPlanSync(plan, { peek }) {
293
+ const data = {}
294
+ let complete = true
295
+
296
+ for (const [key, program] of plan) {
297
+ let sent
298
+ for (;;) {
299
+ const step = program.next(sent)
300
+ if (step.done) {
301
+ const out = step.value ?? {}
302
+ if (out.value !== undefined) data[key] = out.value
303
+ break
304
+ }
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
312
+ }
313
+ // Not cached: this key is not answerable without a request, so the block is not ready.
314
+ program.return()
315
+ complete = false
316
+ break
317
+ }
318
+ sent = { data: cached.data }
319
+ }
320
+ }
321
+
322
+ return complete ? { status: 'ready', data } : { status: 'pending', data: null }
323
+ }
324
+
325
+ /**
326
+ * Run a plan by DISPATCHING — the browser filling a block, and a host's data step.
327
+ *
328
+ * ⭐ Every key's first request is issued in the same tick, which is what lets a question door batch
329
+ * 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.
331
+ *
332
+ * @param {Map<string, Generator>} plan
333
+ * @param {Object} io
334
+ * @param {(request: Object) => Promise<{ data: *, error?: string }>} io.dispatch
335
+ * @param {(key: string, cfg: Object|null, message: string) => void} [io.onFailure]
336
+ * @returns {Promise<{ data: Object, errors: Object|null }>}
337
+ */
338
+ export async function runPlan(plan, { dispatch, onFailure = null }) {
339
+ const data = {}
340
+ const errors = {}
341
+
342
+ await Promise.all([...plan].map(async ([key, program]) => {
343
+ let sent
344
+ for (;;) {
345
+ const step = program.next(sent)
346
+ if (step.done) {
347
+ const out = step.value ?? {}
348
+ if (out.value !== undefined) data[key] = out.value
349
+ if (out.error) {
350
+ errors[key] = out.error
351
+ onFailure?.(key, out.errorConfig ?? null, out.error)
352
+ }
353
+ return
354
+ }
355
+ const result = await dispatch(asStep(step.value).request)
356
+ sent = { data: result?.data, error: result?.error }
357
+ }
358
+ }))
359
+
360
+ return { data, errors: Object.keys(errors).length ? errors : null }
361
+ }
package/src/website.js CHANGED
@@ -11,10 +11,10 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
- import { matchDynamicRoute, decodeRouteValue, matchesRouteParam, recordTitle, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
15
- import { resolveFetchConfigs, pageRouteQuery, recordPages, routeSelection, siteReaches } from './fetch-config.js'
14
+ import { matchDynamicRoute, decodeRouteValue, recordTitle, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
15
+ import { resolveFetchConfigs, pageRouteQuery, recordPages } from './fetch-config.js'
16
16
  import { declaredKeys } from './data-keys.js'
17
- import { buildDetailConfig } from './detail-url.js'
17
+ import { pageRecordConfig, planFor, runPlanSync } from './page-data.js'
18
18
  import { findLayoutEntry } from './layout-name.js'
19
19
  import { resolveService } from './services.js'
20
20
 
@@ -197,6 +197,34 @@ export default class Website {
197
197
  this.assets = assets
198
198
  }
199
199
 
200
+ /**
201
+ * ⭐ A DISPATCHER OF THIS SITE'S TRANSPORTS OVER A CACHE OF THE CALLER'S — for a caller that
202
+ * fetches this site's data without touching what the page is rendering from, and with a
203
+ * transport of its own.
204
+ *
205
+ * The one caller is a host's data step: it is handed the transport per request (a function does
206
+ * not survive every isolate boundary, so it cannot be wired in at construction), and it must
207
+ * fetch fresh rather than serve whatever an earlier request left in the graph's cache. What it
208
+ * must NOT do is answer with the framework default where the site chose a foundation transport,
209
+ * which is what building a plain fetcher of its own would do — hence this.
210
+ *
211
+ * @param {Object} options
212
+ * @param {import('./datastore.js').default} options.dataStore - the caller's cache
213
+ * @param {{ resolve: Function }|null} [options.defaultFetcher] - its transport, for every
214
+ * request the site routes to no named transport
215
+ * @returns {FetcherDispatcher}
216
+ */
217
+ dispatcherFor({ dataStore, defaultFetcher = null }) {
218
+ return new FetcherDispatcher({
219
+ foundation: this._foundation,
220
+ extensions: this._extensions,
221
+ dataStore,
222
+ defaultFetcher,
223
+ transport: this._transport,
224
+ dev: this._dev,
225
+ })
226
+ }
227
+
200
228
  /**
201
229
  * Rebuild in place. Content-only rebuilds preserve the dispatcher and all
202
230
  * state (site and per-page). Passing `foundation` or `extensions` reassembles
@@ -624,60 +652,58 @@ export default class Website {
624
652
  // missed on exactly those lanes: no title, no not-found, and the page was
625
653
  // never cached (`recordsLoaded` false on every visit). Silent, on a
626
654
  // visitor's page — the "write key ≠ read key" failure.
627
- let items = []
655
+ // ⭐ THE PAGE'S RECORD, BY THE RULE ITS SECTIONS ARE FED BY — the plan for the route
656
+ // key, run against the cache (`./page-data.js`). It is the `current: only` program on
657
+ // either lane: the records service asks the record alone and its answer is definitive,
658
+ // and off the service the record is found in the query's SET — the query as saved, its
659
+ // `limit` included, never a list a fetch narrowed — and only then asked for in full.
660
+ //
661
+ // ⛔ This walked and probed on its own until 2026-09-19, which is how it came to peek a
662
+ // key the store never wrote (no title, no not-found, never cached — F3, 2026-09-04) and
663
+ // to read a list a fetch had narrowed, calling a record past that `limit` "Not found"
664
+ // (2026-09-13). Both were fixed here and nowhere else, twice over.
628
665
  let currentItem = null
629
- // ⭐ On the records service the record's own question checks the route query's
630
- // set (`buildDetailConfig`), so once it has answered, its answer is definitive —
631
- // the record, or `[]` for not found — with no list to consult.
632
- let recordAnswered = false
666
+ // The cache could answer: a record, or its absence from a set that IS loaded.
667
+ let answered = false
633
668
 
634
669
  if (this.fetcher) {
635
- // The same sources the store walks for a section that declares nothing,
636
- // plus the route binding itself — which sits above the parent on a nested
637
- // page, or on a section when the key came from the sections.
638
- const fetchConfig = resolveFetchConfigs(
639
- [originalData.fetch, parentPage?.fetch, route.config, siteReaches(parentPage) ? this.config?.fetch : null],
640
- {
641
- schemas: [route.key],
670
+ const fetchConfig = pageRecordConfig({
671
+ pageFetch: originalData.fetch,
672
+ parent: parentPage ?? null,
673
+ route,
674
+ site: this.config?.fetch,
675
+ options: {
642
676
  locale: this.getActiveLocale(),
643
677
  defaultLocale: this.getDefaultLocale(),
644
678
  queries: this.config?.queries ?? null,
645
679
  services: this.config?.services ?? null,
646
680
  variables,
647
681
  },
648
- ).get(route.key)
682
+ })
649
683
  if (fetchConfig) {
650
684
  const ctx = { website: this }
651
- // ⭐ The page is about ONE record, so ask for that record first: a
652
- // cached detail fetch (a live lane's record address, a deferred
653
- // query's per-record file) carries the title even when the list was
654
- // never fetched — a cold load on a detail URL — where a scan of the
655
- // list finds nothing and silently sets no title (F3, 2026-09-04).
656
- const detailCfg = fetchConfig.detail
657
- ? buildDetailConfig(fetchConfig, { paramName, paramValue })
658
- : null
659
- const detailCached = detailCfg ? this.fetcher.peek(detailCfg, ctx) : null
660
- // The service answers the record question as a list of one (a question's
661
- // answer is always a list); a per-record file answers the bare record.
662
- const raw = detailCached?.data
663
- const record = Array.isArray(raw) ? raw[0] : raw
664
- if (record && typeof record === 'object') currentItem = record
665
- if (fetchConfig.ask && detailCached && Array.isArray(raw)) recordAnswered = true
666
-
667
- // ⭐ The route query's set (`routeSelection`) — the query as saved, its `limit`
668
- // included, never a list a fetch narrowed: a record past a fetch's `limit` is a
669
- // record, and one past the query's is not (ruled 2026-09-14 [Diego]). ⛔ Until
670
- // 2026-09-13 this read the cut list and declared a record past a list's
671
- // `limit` "Not found".
672
- const cached = this.fetcher.peek(routeSelection(fetchConfig), ctx)
673
- items = Array.isArray(cached?.data) ? cached.data : []
685
+ const plan = planFor(new Map([[route.key, { cfg: fetchConfig }]]), {
686
+ dynamicContext: { paramName, paramValue },
687
+ route,
688
+ peekRecord: (id) => this.fetcher.peekRecord?.(id),
689
+ // A page is about ONE record, whatever a section's `current:` says about how that
690
+ // section uses it.
691
+ current: 'only',
692
+ // This runs before anything has been fetched and must not cause a fetch: it names the
693
+ // page from whatever is held — the record's own answer, or the record inside a set a
694
+ // list page already holds — and says nothing when neither is.
695
+ opportunistic: true,
696
+ })
697
+ const out = runPlanSync(plan, { peek: (request) => this.fetcher.peek(request, ctx) })
698
+ const found = out.data?.[route.key]
699
+ if (found !== undefined) {
700
+ // The cache answered: the record, or `[]` for a set that does not hold it.
701
+ answered = true
702
+ currentItem = found.length > 0 ? found[0] : null
703
+ }
674
704
  }
675
705
  }
676
706
 
677
- if (!currentItem) {
678
- currentItem = items.find(item => matchesRouteParam(item, paramName, paramValue)) ?? null
679
- }
680
-
681
707
  if (currentItem) {
682
708
  // ⭐ The record names the page by the one rule its search result is named by
683
709
  // too (`recordTitle`: `title`, `name`, then its handle). ⛔ Until 2026-09-14 this
@@ -687,21 +713,19 @@ export default class Website {
687
713
  if (currentItem.description || currentItem.excerpt) {
688
714
  pageData.description = currentItem.description || currentItem.excerpt
689
715
  }
690
- } else if (items.length > 0 || recordAnswered) {
691
- // The set is loaded and this ID isn't in it, or the record question answered
692
- // `[]` — definitive not found
716
+ } else if (answered) {
717
+ // The cache answered and the record is not there: the set is loaded and this ID is
718
+ // not in it, or the record question answered `[]` — definitive not found.
693
719
  pageData.title = 'Not found'
694
720
  pageData.notFound = true
695
721
  }
696
722
 
697
- // Track whether the records were available at creation time.
698
- // Note: the matched record and the sibling list are intentionally NOT
699
- // stored on dynamicContext — nothing reads them (its shape is
700
- // { templateRoute, params, paramName, paramValue }; the record reaches
701
- // components via content.data, the others via
702
- // `fetch: { query, current: exclude }`). The local
703
- // `currentItem`/`items` above drive title/description/notFound.
704
- pageData._recordsLoaded = items.length > 0 || currentItem !== null || recordAnswered
723
+ // Track whether the records were available at creation time — which is exactly
724
+ // whether the plan could be answered from the cache. Note: the matched record and the
725
+ // sibling list are intentionally NOT stored on dynamicContext — nothing reads them
726
+ // (its shape is { templateRoute, params, paramName, paramValue }; the record reaches
727
+ // components via content.data, the others via `fetch: { query, current: exclude }`).
728
+ pageData._recordsLoaded = answered
705
729
  }
706
730
 
707
731
  // Create the page instance