@uniweb/core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,53 +1,71 @@
1
1
  /**
2
2
  * EntityStore
3
3
  *
4
- * Resolves entity data for components by walking the page hierarchy.
5
- * Leverages DataStore for caching and deduplication.
4
+ * Walks the block→page→parent→site cascade to find applicable fetch configs,
5
+ * asks the Website's FetcherDispatcher to execute them, and assembles the
6
+ * data payload passed to `prepare-props`.
6
7
  *
7
- * Two-method API:
8
- * - resolve(block, meta)sync, reads cache only
9
- * - fetch(block, meta) — async, fetches missing data via DataStore
8
+ * The cascade, localization, detail-query handling, and collection-first
9
+ * content-gate logic all live here unchanged from the pre-refactor model.
10
+ * What changed: EntityStore no longer talks to DataStore directly. It calls
11
+ * `website.fetcher.peek(request, ctx)` for the sync path (resolve) and
12
+ * `website.fetcher.dispatch(request, ctx)` for the async path (fetch).
13
+ * The dispatcher owns fetcher selection, cache-key derivation, cache lookup,
14
+ * and in-flight dedup.
10
15
  */
11
16
 
12
17
  import singularize from './singularize.js'
18
+ import { substitutePlaceholders } from './substitute-placeholders.js'
19
+
20
+ /**
21
+ * Is `block.fetch` a per-instance refinement of the ancestor's fetch config
22
+ * rather than a new source? The canonical spelling is `refine: true`; the
23
+ * legacy spelling `inherit: true` is still honored for one release with a
24
+ * dev-mode warning.
25
+ */
26
+ function isRefinement(bf) {
27
+ return bf?.refine === true || bf?.inherit === true
28
+ }
29
+
30
+ let inheritDeprecationWarned = false
31
+ function warnInheritDeprecation(block) {
32
+ if (inheritDeprecationWarned) return
33
+ inheritDeprecationWarned = true
34
+ // Dev-only; production builds typically strip console.warn. We gate on
35
+ // the presence of the deprecated key and fire once per process.
36
+ console.warn(
37
+ "[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
38
+ 'Accepted for one release; will be removed in the next minor. ' +
39
+ `First seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
40
+ )
41
+ }
13
42
 
14
43
  export default class EntityStore {
15
44
  /**
16
45
  * @param {Object} options
17
- * @param {import('./datastore.js').default} options.dataStore
46
+ * @param {import('./website.js').default} options.website
18
47
  */
19
- constructor({ dataStore }) {
20
- this.dataStore = dataStore
21
-
48
+ constructor({ website }) {
49
+ this.website = website
22
50
  Object.seal(this)
23
51
  }
24
52
 
25
- /**
26
- * Whether a component wants detail-URL resolution on dynamic pages.
27
- * Default true. Set to false via `data: { inherit: true, detail: false }`
28
- * to receive the full collection (minus the active item) instead.
29
- *
30
- * @param {Object} meta
31
- * @returns {boolean}
32
- */
33
53
  _shouldInheritDetail(meta, block) {
34
- // Block-level fetch inherit override takes priority over meta
35
54
  const bf = block?.fetch
36
- if (bf?.inherit === true && bf?.detail !== undefined) return bf.detail !== false
55
+ if (isRefinement(bf) && bf?.detail !== undefined) return bf.detail !== false
37
56
  if (!meta) return true
38
57
  return meta.inheritDetail !== false
39
58
  }
40
59
 
41
60
  _inheritLimit(meta, block) {
42
- // Block-level fetch inherit override takes priority over meta
43
61
  const bf = block?.fetch
44
- if (bf?.inherit === true && bf?.limit > 0) return bf.limit
62
+ if (isRefinement(bf) && bf?.limit > 0) return bf.limit
45
63
  return (meta?.inheritLimit > 0) ? meta.inheritLimit : null
46
64
  }
47
65
 
48
66
  _inheritOrder(block) {
49
67
  const bf = block?.fetch
50
- if (bf?.inherit === true && bf?.order?.orderBy) return bf.order
68
+ if (isRefinement(bf) && bf?.order?.orderBy) return bf.order
51
69
  return null
52
70
  }
53
71
 
@@ -66,144 +84,157 @@ export default class EntityStore {
66
84
  }
67
85
 
68
86
  /**
69
- * Determine which schemas a component requests.
87
+ * Which schemas does this component want delivered?
70
88
  *
71
- * @param {Object} meta - Component runtime metadata
72
- * @returns {string[]|null} Array of schema names, or null if none requested
89
+ * - meta missing → default-on: collect all available schemas.
90
+ * - meta.inheritData === false opt out entirely.
91
+ * - Anything else → collect all (legacy inheritData arrays collapse here).
73
92
  */
74
93
  _getRequestedSchemas(meta) {
75
- if (!meta) return null
76
-
77
- const inheritData = meta.inheritData
78
- if (!inheritData) return null
79
-
80
- // inheritData: true → inherit all (resolved from fetch configs)
81
- // inheritData: ['articles'] → specific schemas
82
- if (Array.isArray(inheritData)) return inheritData.length > 0 ? inheritData : null
83
- if (inheritData === true) return [] // empty = "all available"
84
-
85
- return null
94
+ if (!meta) return []
95
+ if (meta.inheritData === false) return null
96
+ return []
86
97
  }
87
98
 
88
99
  /**
89
100
  * Return a localized copy of a fetch config for collection data.
90
- * For non-default locales, prepends /{locale} to /data/ paths so the
91
- * client fetches the translated JSON (e.g. /fr/data/articles.json).
92
- *
93
- * @param {Object} cfg - Fetch config
94
- * @param {import('./website.js').default|null} website
95
- * @returns {Object} Localized config (or original if no change needed)
101
+ * Non-default locales get /{locale} prefixed onto /data/ paths so the
102
+ * client fetches the translated JSON (/fr/data/articles.json).
96
103
  */
97
104
  _localizeConfig(cfg, website) {
98
105
  if (!cfg.path || !website) return cfg
99
-
100
- const locale = website.getActiveLocale()
101
- const defaultLocale = website.getDefaultLocale()
106
+ const locale = website.getActiveLocale?.()
107
+ const defaultLocale = website.getDefaultLocale?.()
102
108
  if (!locale || locale === defaultLocale) return cfg
103
-
104
109
  if (!cfg.path.startsWith('/data/')) return cfg
105
-
106
110
  return { ...cfg, path: `/${locale}${cfg.path}` }
107
111
  }
108
112
 
109
113
  /**
110
- * Walk the hierarchy to find fetch configs for requested schemas.
111
- * Order: block.fetch page.fetch parent.fetch → site config.fetch
112
- * First match per schema wins. Only walks one parent level (auto-wiring).
113
- *
114
- * @param {import('./block.js').default} block
115
- * @param {string[]} requested - Schema names (empty = collect all)
116
- * @returns {Map<string, Object>} schema → fetch config
114
+ * Walk the four-level hierarchy and collect fetch configs for the
115
+ * requested schemas. First match per schema wins.
117
116
  */
118
117
  _findFetchConfigs(block, requested) {
119
118
  const configs = new Map()
120
119
  const collectAll = requested.length === 0
121
-
122
120
  const sources = []
123
121
 
124
- // 1. Block-level fetch (skip inherit-merge configs they have no URL, only override props)
125
- if (block.fetch && !block.fetch.inherit) {
126
- sources.push(block.fetch)
122
+ if (block.fetch?.inherit === true && block.fetch?.refine !== true) {
123
+ warnInheritDeprecation(block)
127
124
  }
128
-
129
- // 2. Page-level fetch
125
+ if (block.fetch && !isRefinement(block.fetch)) sources.push(block.fetch)
130
126
  const page = block.page
131
- if (page?.fetch) {
132
- sources.push(page.fetch)
133
- }
134
-
135
- // 3. Parent page fetch (one level — auto-wiring for dynamic routes)
136
- if (page?.parent?.fetch) {
137
- sources.push(page.parent.fetch)
138
- }
139
-
140
- // 4. Site-level fetch
127
+ if (page?.fetch) sources.push(page.fetch)
128
+ if (page?.parent?.fetch) sources.push(page.parent.fetch)
141
129
  const siteFetch = block.website?.config?.fetch
142
- if (siteFetch) {
143
- sources.push(siteFetch)
144
- }
130
+ if (siteFetch) sources.push(siteFetch)
145
131
 
146
132
  const website = block.website
147
133
 
148
134
  for (const source of sources) {
149
- // Normalize: single config or array of configs
150
135
  const configList = Array.isArray(source) ? source : [source]
151
-
152
136
  for (const cfg of configList) {
153
137
  if (!cfg.schema) continue
154
- if (configs.has(cfg.schema)) continue // first match wins
155
-
138
+ if (configs.has(cfg.schema)) continue
156
139
  if (collectAll || requested.includes(cfg.schema)) {
157
- configs.set(cfg.schema, this._localizeConfig(cfg, website))
140
+ const localized = this._localizeConfig(cfg, website)
141
+ const withDetail = this._applyDeferredDetail(localized, website)
142
+ configs.set(cfg.schema, withDetail)
158
143
  }
159
144
  }
160
145
  }
161
-
162
146
  return configs
163
147
  }
164
148
 
165
149
  /**
166
- * Build a fetch config for a single entity using the detail convention.
150
+ * Auto-inject `detail:` on collection refs whose collection has
151
+ * `deferred:` declared. The build emits per-record files at
152
+ * `/data/<name>/<slug>.json` for those collections; this populates
153
+ * the detail-fetch URL so the existing dynamic-route singular flow
154
+ * uses the per-record file (with deferred fields) instead of the
155
+ * matched-item-from-cascade-collection (without).
167
156
  *
168
- * @param {Object} collectionConfig - The collection's fetch config (must have `detail`)
169
- * @param {Object} dynamicContext - { paramName, paramValue, schema }
170
- * @returns {Object|null} A fetch config for the single entity, or null
157
+ * Conventions:
158
+ * - Per-record files are keyed by `item.slug`. The injected pattern
159
+ * uses the `{slug}` placeholder; substitution works when the
160
+ * dynamic route's paramName is 'slug' (the documented convention).
161
+ * Routes using other param names need an explicit author-written
162
+ * `detail:` value.
163
+ * - Author-supplied `cfg.detail` always wins. This helper only fills
164
+ * in the default for collections that have declared deferred fields.
165
+ * - Per-record files are not currently localized; sites needing
166
+ * localized deferred collections write their own `detail:` URL.
167
+ */
168
+ _applyDeferredDetail(cfg, website) {
169
+ if (cfg.detail !== undefined) return cfg
170
+ const schema = cfg.schema
171
+ if (!schema) return cfg
172
+ const collConfig = website?.config?.collections?.[schema]
173
+ if (!collConfig || typeof collConfig !== 'object') return cfg
174
+ const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
175
+ if (!deferred || deferred.length === 0) return cfg
176
+ return { ...cfg, detail: `/data/${schema}/{slug}.json` }
177
+ }
178
+
179
+ /**
180
+ * Build a detail-URL fetch config from a collection config + dynamic context.
181
+ *
182
+ * Three forms of `detail:`:
183
+ * - `'rest'` — append paramValue as a path segment.
184
+ * - `'query'` — append `?paramName=paramValue`.
185
+ * - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
186
+ * - `{ body, envelope }` — object form. Reuses the collection's url /
187
+ * method / headers / auth; adds per-detail
188
+ * body (with placeholder substitution) and
189
+ * per-detail envelope.
171
190
  */
172
191
  _buildDetailConfig(collectionConfig, dynamicContext) {
173
192
  const { detail } = collectionConfig
174
193
  if (!detail) return null
175
-
176
194
  const { paramName, paramValue } = dynamicContext
177
195
  if (!paramName || paramValue === undefined) return null
178
196
 
179
197
  const baseUrl = collectionConfig.url || collectionConfig.path
180
198
  if (!baseUrl) return null
199
+ const isLocalPath = !!collectionConfig.path && !collectionConfig.url
181
200
 
182
- let detailUrl
201
+ // Object form: `detail: { body, envelope }`. Reuses collection's URL +
202
+ // method + headers + auth. The body is placeholder-substituted against
203
+ // the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
204
+ if (detail && typeof detail === 'object') {
205
+ const out = {
206
+ ...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
207
+ schema: singularize(collectionConfig.schema) || collectionConfig.schema,
208
+ transform: collectionConfig.transform,
209
+ }
210
+ if (collectionConfig.method) out.method = collectionConfig.method
211
+ if (detail.body !== undefined) {
212
+ out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
213
+ } else if (collectionConfig.body !== undefined) {
214
+ out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
215
+ }
216
+ if (detail.envelope) out.envelope = detail.envelope
217
+ return out
218
+ }
183
219
 
220
+ // String-form: URL-based conventions.
221
+ let detailUrl
184
222
  if (detail === 'rest') {
185
- // REST convention: {baseUrl}/{paramValue}
186
- // Preserve query string (auth params like token, profileLang) — only insert
187
- // the param value before the '?', not after.
188
223
  const [basePath, queryString] = baseUrl.split('?')
189
224
  const cleanBase = basePath.replace(/\/$/, '')
190
225
  detailUrl = queryString
191
226
  ? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
192
227
  : `${cleanBase}/${encodeURIComponent(paramValue)}`
193
228
  } else if (detail === 'query') {
194
- // Query param convention: {baseUrl}?{paramName}={paramValue}
195
229
  const sep = baseUrl.includes('?') ? '&' : '?'
196
230
  detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
197
231
  } else {
198
- // Custom pattern: replace {paramName} placeholders
199
- detailUrl = detail.replace(/\{(\w+)\}/g, (_, key) => {
200
- if (key === paramName) return encodeURIComponent(paramValue)
201
- return `{${key}}` // leave unknown placeholders
202
- })
232
+ // Custom pattern like '/articles/{slug}' — substitute placeholders
233
+ // from the dynamic-route context. Only placeholders matching the
234
+ // active paramName resolve; others pass through as literal `{name}`.
235
+ detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
203
236
  }
204
237
 
205
- // Build a fetch config for the single item
206
- const isLocalPath = !!collectionConfig.path && !collectionConfig.url
207
238
  return {
208
239
  ...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
209
240
  schema: singularize(collectionConfig.schema) || collectionConfig.schema,
@@ -212,19 +243,13 @@ export default class EntityStore {
212
243
  }
213
244
 
214
245
  /**
215
- * Resolve singular item for dynamic routes.
216
- * If block/page has dynamicContext, find the matching item in the collection.
217
- *
218
- * @param {Object} data - Resolved entity data { schema: items[] }
219
- * @param {Object|null} dynamicContext
220
- * @returns {Object} data with singular key added if applicable
246
+ * For dynamic routes: extract the matching item from a collection and
247
+ * expose it under the singular schema key (articles article).
221
248
  */
222
249
  _resolveSingularItem(data, dynamicContext) {
223
250
  if (!dynamicContext) return data
224
-
225
251
  const { paramName, paramValue, schema: pluralSchema } = dynamicContext
226
252
  if (!pluralSchema || !paramName || paramValue === undefined) return data
227
-
228
253
  const items = data[pluralSchema]
229
254
  if (!Array.isArray(items)) return data
230
255
 
@@ -232,63 +257,66 @@ export default class EntityStore {
232
257
  const currentItem = items.find(
233
258
  (item) => String(item[paramName]) === String(paramValue)
234
259
  )
235
-
236
260
  if (currentItem && singularSchema) {
237
261
  return { ...data, [singularSchema]: currentItem }
238
262
  }
239
-
240
263
  return data
241
264
  }
242
265
 
243
266
  /**
244
- * Sync resolution. Checks DataStore cache for fetch configs found in hierarchy.
267
+ * Build the `ctx` handed to the dispatcher for a given block.
268
+ * @private
269
+ */
270
+ _ctx(block, extra = {}) {
271
+ return {
272
+ website: this.website,
273
+ page: block?.page || null,
274
+ block: block || null,
275
+ signal: extra.signal,
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Sync resolution — probes the cache via `fetcher.peek`. Returns
281
+ * `ready` only when every relevant entry is cached, otherwise `pending`
282
+ * (caller falls through to `fetch()` to populate and await).
245
283
  *
246
- * @param {import('./block.js').default} block
247
- * @param {Object} meta - Component runtime metadata
248
284
  * @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
249
285
  */
250
286
  resolve(block, meta) {
287
+ const dispatcher = this.website?.fetcher
251
288
  let requested = this._getRequestedSchemas(meta)
252
289
 
253
- // If the component doesn't declare inheritData but the block itself
254
- // has a fetch config (e.g. data_source_info converted to fetch),
255
- // use the block's schema directly. Block-level fetch is an explicit
256
- // data assignment, not inheritance.
290
+ // If the component hasn't declared data inheritance but the block itself
291
+ // has a fetch config, target the block's schema explicitly rather than
292
+ // collecting all cascade matches.
257
293
  if (requested === null && block.fetch) {
258
294
  const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
259
- const schemas = blockFetchList
260
- .filter((cfg) => cfg.schema)
261
- .map((cfg) => cfg.schema)
262
- if (schemas.length > 0) {
263
- requested = schemas
264
- }
295
+ const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
296
+ if (schemas.length > 0) requested = schemas
265
297
  }
266
298
 
267
- if (requested === null) {
268
- return { status: 'none', data: null }
269
- }
299
+ if (requested === null) return { status: 'none', data: null }
270
300
 
271
- // Walk hierarchy for fetch configs
272
301
  const configs = this._findFetchConfigs(block, requested)
302
+ if (configs.size === 0) return { status: 'none', data: null }
273
303
 
274
- if (configs.size === 0) {
275
- return { status: 'none', data: null }
276
- }
277
-
278
- // Check DataStore cache for each config
279
304
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
280
305
  const inheritDetail = this._shouldInheritDetail(meta, block)
281
306
  const limit = this._inheritLimit(meta, block)
282
307
  const order = this._inheritOrder(block)
308
+ const ctx = this._ctx(block)
309
+
283
310
  const data = {}
284
311
  let allCached = true
285
312
 
286
313
  for (const [schema, cfg] of configs) {
287
314
  if (dynamicContext && cfg.detail && !inheritDetail) {
288
- // detail: false — return collection minus the active item (sidebar/related use case)
289
- if (this.dataStore.has(cfg)) {
315
+ // detail: false — return collection minus the active item.
316
+ const cached = dispatcher?.peek(cfg, ctx)
317
+ if (cached) {
290
318
  const { paramName, paramValue } = dynamicContext
291
- const items = this.dataStore.get(cfg)
319
+ const items = cached.data
292
320
  let filtered = Array.isArray(items)
293
321
  ? items.filter((item) => String(item[paramName]) !== String(paramValue))
294
322
  : items
@@ -298,11 +326,10 @@ export default class EntityStore {
298
326
  allCached = false
299
327
  }
300
328
  } else if (dynamicContext && cfg.detail) {
301
- // Collection-first detail resolution:
302
- // The collection acts as the access gate — the item must exist in the
303
- // cached collection before we'll serve (or fetch) its detail data.
304
- if (this.dataStore.has(cfg)) {
305
- const collectionItems = this.dataStore.get(cfg)
329
+ // Collection-first detail: the collection is the content gate.
330
+ const cachedCollection = dispatcher?.peek(cfg, ctx)
331
+ if (cachedCollection) {
332
+ const collectionItems = cachedCollection.data
306
333
  const { paramName, paramValue } = dynamicContext
307
334
  const singularKey = singularize(schema) || schema
308
335
  const match = Array.isArray(collectionItems)
@@ -310,27 +337,29 @@ export default class EntityStore {
310
337
  : null
311
338
 
312
339
  if (!match) {
313
- // Item not in collection — definitive "not found" (content gate).
314
340
  data[singularKey] = null
315
341
  } else {
316
- // Item is valid. Check if the detail result is already cached.
317
342
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
318
- if (detailCfg && this.dataStore.has(detailCfg)) {
319
- data[singularKey] = this.dataStore.get(detailCfg)
343
+ const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
344
+ if (detailCfg && detailCached) {
345
+ data[singularKey] = detailCached.data
320
346
  } else if (detailCfg) {
321
- allCached = false // Collection cached, item valid, detail still needed
347
+ allCached = false
322
348
  } else {
323
- data[singularKey] = match // No detail URL — use collection item directly
349
+ data[singularKey] = match
324
350
  }
325
351
  }
326
352
  } else {
327
- allCached = false // Collection not yet cached — must fetch it first
353
+ allCached = false
328
354
  }
329
- } else if (this.dataStore.has(cfg)) {
330
- const items = this.dataStore.get(cfg)
331
- data[schema] = order ? this._sortItems(items, order) : items
332
355
  } else {
333
- allCached = false
356
+ const cached = dispatcher?.peek(cfg, ctx)
357
+ if (cached) {
358
+ const items = cached.data
359
+ data[schema] = order ? this._sortItems(items, order) : items
360
+ } else {
361
+ allCached = false
362
+ }
334
363
  }
335
364
  }
336
365
 
@@ -338,57 +367,48 @@ export default class EntityStore {
338
367
  const resolved = this._resolveSingularItem(data, dynamicContext)
339
368
  return { status: 'ready', data: resolved }
340
369
  }
341
-
342
370
  return { status: 'pending', data: null }
343
371
  }
344
372
 
345
373
  /**
346
- * Async fetch. Walks hierarchy, fetches missing data via DataStore.
374
+ * Async fetch dispatches missing configs through the FetcherDispatcher
375
+ * and assembles the result. Collection-first detail ordering preserved.
347
376
  *
348
- * @param {import('./block.js').default} block
349
- * @param {Object} meta - Component runtime metadata
377
+ * @param {Object} [options]
378
+ * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
350
379
  * @returns {Promise<{ data: Object|null }>}
351
380
  */
352
- async fetch(block, meta) {
353
- let requested = this._getRequestedSchemas(meta)
381
+ async fetch(block, meta, { signal } = {}) {
382
+ const dispatcher = this.website?.fetcher
383
+ if (!dispatcher) return { data: null }
354
384
 
355
- // Same block-level fetch fallback as resolve()
385
+ let requested = this._getRequestedSchemas(meta)
356
386
  if (requested === null && block.fetch) {
357
387
  const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
358
- const schemas = blockFetchList
359
- .filter((cfg) => cfg.schema)
360
- .map((cfg) => cfg.schema)
361
- if (schemas.length > 0) {
362
- requested = schemas
363
- }
364
- }
365
-
366
- if (requested === null) {
367
- return { data: null }
388
+ const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
389
+ if (schemas.length > 0) requested = schemas
368
390
  }
391
+ if (requested === null) return { data: null }
369
392
 
370
393
  const configs = this._findFetchConfigs(block, requested)
371
- if (configs.size === 0) {
372
- return { data: null }
373
- }
394
+ if (configs.size === 0) return { data: null }
374
395
 
375
- // Fetch all missing configs
376
396
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
377
397
  const inheritDetail = this._shouldInheritDetail(meta, block)
378
398
  const limit = this._inheritLimit(meta, block)
379
399
  const order = this._inheritOrder(block)
400
+ const ctx = this._ctx(block, { signal })
380
401
 
381
402
  const data = {}
382
403
  const parallelFetches = []
383
404
 
384
405
  for (const [schema, cfg] of configs) {
385
406
  if (dynamicContext && cfg.detail && !inheritDetail) {
386
- // detail: false — fetch collection and return it minus the active item
387
- // (sidebar / related-posts use case on a dynamic page)
388
- let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
407
+ // detail: false — collection-only, minus the active item.
408
+ let collectionItems = peekArray(dispatcher, cfg, ctx)
389
409
  if (collectionItems === null) {
390
- const result = await this.dataStore.fetch(cfg)
391
- collectionItems = Array.isArray(result.data) ? result.data : null
410
+ const result = await dispatcher.dispatch(cfg, ctx)
411
+ collectionItems = Array.isArray(result?.data) ? result.data : null
392
412
  }
393
413
  const { paramName, paramValue } = dynamicContext
394
414
  let filtered = Array.isArray(collectionItems)
@@ -397,48 +417,41 @@ export default class EntityStore {
397
417
  if (order) filtered = this._sortItems(filtered, order)
398
418
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
399
419
  } else if (dynamicContext && cfg.detail) {
400
- // Collection-first detail resolution:
401
- // 1. Ensure the collection is in DataStore (fetching if needed).
402
- // 2. Validate that paramValue exists in the collection (content gate).
403
- // 3. Only then fetch the detail URL for richer item data.
420
+ // Collection-first detail resolution.
404
421
  const { paramName, paramValue } = dynamicContext
405
422
  const singularKey = singularize(schema) || schema
406
423
 
407
- // Step 1: ensure collection is cached (sequential needed for validation)
408
- let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
424
+ let collectionItems = peekArray(dispatcher, cfg, ctx)
409
425
  if (collectionItems === null) {
410
- const result = await this.dataStore.fetch(cfg)
411
- collectionItems = Array.isArray(result.data) ? result.data : null
426
+ const result = await dispatcher.dispatch(cfg, ctx)
427
+ collectionItems = Array.isArray(result?.data) ? result.data : null
412
428
  }
413
429
 
414
- // Step 2: validate paramValue is in the collection (content gate)
415
430
  const match = collectionItems?.find(
416
431
  (item) => String(item[paramName]) === String(paramValue)
417
432
  ) ?? null
418
433
 
419
434
  if (!match) {
420
- data[singularKey] = null // Not in collection — content gate
435
+ data[singularKey] = null
421
436
  continue
422
437
  }
423
438
 
424
- // Step 3: fetch detail URL for richer data; fall back to collection item
425
439
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
426
440
  if (detailCfg) {
427
441
  parallelFetches.push(
428
- this.dataStore.fetch(detailCfg).then((result) => {
429
- data[singularKey] = (result.data !== undefined && result.data !== null)
442
+ dispatcher.dispatch(detailCfg, ctx).then((result) => {
443
+ data[singularKey] = (result?.data !== undefined && result?.data !== null)
430
444
  ? result.data
431
- : match // fallback: collection item is still valid
445
+ : match
432
446
  })
433
447
  )
434
448
  } else {
435
- data[singularKey] = match // No detail URL — collection item is enough
449
+ data[singularKey] = match
436
450
  }
437
451
  } else {
438
- // Default: fetch the full collection
439
452
  parallelFetches.push(
440
- this.dataStore.fetch(cfg).then((result) => {
441
- if (result.data !== undefined && result.data !== null) {
453
+ dispatcher.dispatch(cfg, ctx).then((result) => {
454
+ if (result?.data !== undefined && result?.data !== null) {
442
455
  data[schema] = result.data
443
456
  }
444
457
  })
@@ -446,10 +459,17 @@ export default class EntityStore {
446
459
  }
447
460
  }
448
461
 
449
- if (parallelFetches.length > 0) {
450
- await Promise.all(parallelFetches)
451
- }
462
+ if (parallelFetches.length > 0) await Promise.all(parallelFetches)
452
463
  const resolved = this._resolveSingularItem(data, dynamicContext)
453
464
  return { data: resolved }
454
465
  }
455
466
  }
467
+
468
+ /**
469
+ * Sync-peek helper: return the cached array for a config, or null on miss.
470
+ */
471
+ function peekArray(dispatcher, cfg, ctx) {
472
+ const cached = dispatcher.peek(cfg, ctx)
473
+ if (!cached) return null
474
+ return Array.isArray(cached.data) ? cached.data : null
475
+ }