@uniweb/core 0.29.4 → 0.29.5
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 +3 -2
- package/src/entity-store.js +99 -293
- package/src/index.js +3 -0
- package/src/page-data.js +361 -0
- package/src/website.js +78 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.5",
|
|
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.
|
|
50
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
50
51
|
"@uniweb/theming": "^0.1.15"
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
package/src/entity-store.js
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* and in-flight dedup.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { resolveFetchConfigs,
|
|
18
|
-
import { declaredKeys
|
|
19
|
-
import { fillRoutePattern
|
|
20
|
-
import {
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
51
|
-
route
|
|
52
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
121
|
+
options: this._resolveOptions(block),
|
|
128
122
|
})
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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 —
|
|
364
|
-
*
|
|
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
|
-
|
|
393
|
-
|
|
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
|
*
|
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 {
|
package/src/page-data.js
ADDED
|
@@ -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,
|
|
15
|
-
import { resolveFetchConfigs, pageRouteQuery, recordPages
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
630
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
)
|
|
682
|
+
})
|
|
649
683
|
if (fetchConfig) {
|
|
650
684
|
const ctx = { website: this }
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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 (
|
|
691
|
-
// The
|
|
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
|
|
699
|
-
// stored on dynamicContext — nothing reads them
|
|
700
|
-
// { templateRoute, params, paramName, paramValue }; the record reaches
|
|
701
|
-
// components via content.data, the others via
|
|
702
|
-
|
|
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
|