@uniweb/core 0.6.0 → 0.6.2

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,125 @@ 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
140
  configs.set(cfg.schema, this._localizeConfig(cfg, website))
158
141
  }
159
142
  }
160
143
  }
161
-
162
144
  return configs
163
145
  }
164
146
 
165
147
  /**
166
- * Build a fetch config for a single entity using the detail convention.
148
+ * Build a detail-URL fetch config from a collection config + dynamic context.
167
149
  *
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
150
+ * Three forms of `detail:`:
151
+ * - `'rest'` — append paramValue as a path segment.
152
+ * - `'query'` — append `?paramName=paramValue`.
153
+ * - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
154
+ * - `{ body, envelope }` — object form. Reuses the collection's url /
155
+ * method / headers / auth; adds per-detail
156
+ * body (with placeholder substitution) and
157
+ * per-detail envelope.
171
158
  */
172
159
  _buildDetailConfig(collectionConfig, dynamicContext) {
173
160
  const { detail } = collectionConfig
174
161
  if (!detail) return null
175
-
176
162
  const { paramName, paramValue } = dynamicContext
177
163
  if (!paramName || paramValue === undefined) return null
178
164
 
179
165
  const baseUrl = collectionConfig.url || collectionConfig.path
180
166
  if (!baseUrl) return null
167
+ const isLocalPath = !!collectionConfig.path && !collectionConfig.url
181
168
 
182
- let detailUrl
169
+ // Object form: `detail: { body, envelope }`. Reuses collection's URL +
170
+ // method + headers + auth. The body is placeholder-substituted against
171
+ // the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
172
+ if (detail && typeof detail === 'object') {
173
+ const out = {
174
+ ...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
175
+ schema: singularize(collectionConfig.schema) || collectionConfig.schema,
176
+ transform: collectionConfig.transform,
177
+ }
178
+ if (collectionConfig.method) out.method = collectionConfig.method
179
+ if (detail.body !== undefined) {
180
+ out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
181
+ } else if (collectionConfig.body !== undefined) {
182
+ out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
183
+ }
184
+ if (detail.envelope) out.envelope = detail.envelope
185
+ return out
186
+ }
183
187
 
188
+ // String-form: URL-based conventions.
189
+ let detailUrl
184
190
  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
191
  const [basePath, queryString] = baseUrl.split('?')
189
192
  const cleanBase = basePath.replace(/\/$/, '')
190
193
  detailUrl = queryString
191
194
  ? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
192
195
  : `${cleanBase}/${encodeURIComponent(paramValue)}`
193
196
  } else if (detail === 'query') {
194
- // Query param convention: {baseUrl}?{paramName}={paramValue}
195
197
  const sep = baseUrl.includes('?') ? '&' : '?'
196
198
  detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
197
199
  } 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
- })
200
+ // Custom pattern like '/articles/{slug}' — substitute placeholders
201
+ // from the dynamic-route context. Only placeholders matching the
202
+ // active paramName resolve; others pass through as literal `{name}`.
203
+ detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
203
204
  }
204
205
 
205
- // Build a fetch config for the single item
206
- const isLocalPath = !!collectionConfig.path && !collectionConfig.url
207
206
  return {
208
207
  ...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
209
208
  schema: singularize(collectionConfig.schema) || collectionConfig.schema,
@@ -212,19 +211,13 @@ export default class EntityStore {
212
211
  }
213
212
 
214
213
  /**
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
214
+ * For dynamic routes: extract the matching item from a collection and
215
+ * expose it under the singular schema key (articles article).
221
216
  */
222
217
  _resolveSingularItem(data, dynamicContext) {
223
218
  if (!dynamicContext) return data
224
-
225
219
  const { paramName, paramValue, schema: pluralSchema } = dynamicContext
226
220
  if (!pluralSchema || !paramName || paramValue === undefined) return data
227
-
228
221
  const items = data[pluralSchema]
229
222
  if (!Array.isArray(items)) return data
230
223
 
@@ -232,63 +225,66 @@ export default class EntityStore {
232
225
  const currentItem = items.find(
233
226
  (item) => String(item[paramName]) === String(paramValue)
234
227
  )
235
-
236
228
  if (currentItem && singularSchema) {
237
229
  return { ...data, [singularSchema]: currentItem }
238
230
  }
239
-
240
231
  return data
241
232
  }
242
233
 
243
234
  /**
244
- * Sync resolution. Checks DataStore cache for fetch configs found in hierarchy.
235
+ * Build the `ctx` handed to the dispatcher for a given block.
236
+ * @private
237
+ */
238
+ _ctx(block, extra = {}) {
239
+ return {
240
+ website: this.website,
241
+ page: block?.page || null,
242
+ block: block || null,
243
+ signal: extra.signal,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Sync resolution — probes the cache via `fetcher.peek`. Returns
249
+ * `ready` only when every relevant entry is cached, otherwise `pending`
250
+ * (caller falls through to `fetch()` to populate and await).
245
251
  *
246
- * @param {import('./block.js').default} block
247
- * @param {Object} meta - Component runtime metadata
248
252
  * @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
249
253
  */
250
254
  resolve(block, meta) {
255
+ const dispatcher = this.website?.fetcher
251
256
  let requested = this._getRequestedSchemas(meta)
252
257
 
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.
258
+ // If the component hasn't declared data inheritance but the block itself
259
+ // has a fetch config, target the block's schema explicitly rather than
260
+ // collecting all cascade matches.
257
261
  if (requested === null && block.fetch) {
258
262
  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
- }
263
+ const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
264
+ if (schemas.length > 0) requested = schemas
265
265
  }
266
266
 
267
- if (requested === null) {
268
- return { status: 'none', data: null }
269
- }
267
+ if (requested === null) return { status: 'none', data: null }
270
268
 
271
- // Walk hierarchy for fetch configs
272
269
  const configs = this._findFetchConfigs(block, requested)
270
+ if (configs.size === 0) return { status: 'none', data: null }
273
271
 
274
- if (configs.size === 0) {
275
- return { status: 'none', data: null }
276
- }
277
-
278
- // Check DataStore cache for each config
279
272
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
280
273
  const inheritDetail = this._shouldInheritDetail(meta, block)
281
274
  const limit = this._inheritLimit(meta, block)
282
275
  const order = this._inheritOrder(block)
276
+ const ctx = this._ctx(block)
277
+
283
278
  const data = {}
284
279
  let allCached = true
285
280
 
286
281
  for (const [schema, cfg] of configs) {
287
282
  if (dynamicContext && cfg.detail && !inheritDetail) {
288
- // detail: false — return collection minus the active item (sidebar/related use case)
289
- if (this.dataStore.has(cfg)) {
283
+ // detail: false — return collection minus the active item.
284
+ const cached = dispatcher?.peek(cfg, ctx)
285
+ if (cached) {
290
286
  const { paramName, paramValue } = dynamicContext
291
- const items = this.dataStore.get(cfg)
287
+ const items = cached.data
292
288
  let filtered = Array.isArray(items)
293
289
  ? items.filter((item) => String(item[paramName]) !== String(paramValue))
294
290
  : items
@@ -298,11 +294,10 @@ export default class EntityStore {
298
294
  allCached = false
299
295
  }
300
296
  } 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)
297
+ // Collection-first detail: the collection is the content gate.
298
+ const cachedCollection = dispatcher?.peek(cfg, ctx)
299
+ if (cachedCollection) {
300
+ const collectionItems = cachedCollection.data
306
301
  const { paramName, paramValue } = dynamicContext
307
302
  const singularKey = singularize(schema) || schema
308
303
  const match = Array.isArray(collectionItems)
@@ -310,27 +305,29 @@ export default class EntityStore {
310
305
  : null
311
306
 
312
307
  if (!match) {
313
- // Item not in collection — definitive "not found" (content gate).
314
308
  data[singularKey] = null
315
309
  } else {
316
- // Item is valid. Check if the detail result is already cached.
317
310
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
318
- if (detailCfg && this.dataStore.has(detailCfg)) {
319
- data[singularKey] = this.dataStore.get(detailCfg)
311
+ const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
312
+ if (detailCfg && detailCached) {
313
+ data[singularKey] = detailCached.data
320
314
  } else if (detailCfg) {
321
- allCached = false // Collection cached, item valid, detail still needed
315
+ allCached = false
322
316
  } else {
323
- data[singularKey] = match // No detail URL — use collection item directly
317
+ data[singularKey] = match
324
318
  }
325
319
  }
326
320
  } else {
327
- allCached = false // Collection not yet cached — must fetch it first
321
+ allCached = false
328
322
  }
329
- } else if (this.dataStore.has(cfg)) {
330
- const items = this.dataStore.get(cfg)
331
- data[schema] = order ? this._sortItems(items, order) : items
332
323
  } else {
333
- allCached = false
324
+ const cached = dispatcher?.peek(cfg, ctx)
325
+ if (cached) {
326
+ const items = cached.data
327
+ data[schema] = order ? this._sortItems(items, order) : items
328
+ } else {
329
+ allCached = false
330
+ }
334
331
  }
335
332
  }
336
333
 
@@ -338,57 +335,48 @@ export default class EntityStore {
338
335
  const resolved = this._resolveSingularItem(data, dynamicContext)
339
336
  return { status: 'ready', data: resolved }
340
337
  }
341
-
342
338
  return { status: 'pending', data: null }
343
339
  }
344
340
 
345
341
  /**
346
- * Async fetch. Walks hierarchy, fetches missing data via DataStore.
342
+ * Async fetch dispatches missing configs through the FetcherDispatcher
343
+ * and assembles the result. Collection-first detail ordering preserved.
347
344
  *
348
- * @param {import('./block.js').default} block
349
- * @param {Object} meta - Component runtime metadata
345
+ * @param {Object} [options]
346
+ * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
350
347
  * @returns {Promise<{ data: Object|null }>}
351
348
  */
352
- async fetch(block, meta) {
353
- let requested = this._getRequestedSchemas(meta)
349
+ async fetch(block, meta, { signal } = {}) {
350
+ const dispatcher = this.website?.fetcher
351
+ if (!dispatcher) return { data: null }
354
352
 
355
- // Same block-level fetch fallback as resolve()
353
+ let requested = this._getRequestedSchemas(meta)
356
354
  if (requested === null && block.fetch) {
357
355
  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 }
356
+ const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
357
+ if (schemas.length > 0) requested = schemas
368
358
  }
359
+ if (requested === null) return { data: null }
369
360
 
370
361
  const configs = this._findFetchConfigs(block, requested)
371
- if (configs.size === 0) {
372
- return { data: null }
373
- }
362
+ if (configs.size === 0) return { data: null }
374
363
 
375
- // Fetch all missing configs
376
364
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
377
365
  const inheritDetail = this._shouldInheritDetail(meta, block)
378
366
  const limit = this._inheritLimit(meta, block)
379
367
  const order = this._inheritOrder(block)
368
+ const ctx = this._ctx(block, { signal })
380
369
 
381
370
  const data = {}
382
371
  const parallelFetches = []
383
372
 
384
373
  for (const [schema, cfg] of configs) {
385
374
  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
375
+ // detail: false — collection-only, minus the active item.
376
+ let collectionItems = peekArray(dispatcher, cfg, ctx)
389
377
  if (collectionItems === null) {
390
- const result = await this.dataStore.fetch(cfg)
391
- collectionItems = Array.isArray(result.data) ? result.data : null
378
+ const result = await dispatcher.dispatch(cfg, ctx)
379
+ collectionItems = Array.isArray(result?.data) ? result.data : null
392
380
  }
393
381
  const { paramName, paramValue } = dynamicContext
394
382
  let filtered = Array.isArray(collectionItems)
@@ -397,48 +385,41 @@ export default class EntityStore {
397
385
  if (order) filtered = this._sortItems(filtered, order)
398
386
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
399
387
  } 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.
388
+ // Collection-first detail resolution.
404
389
  const { paramName, paramValue } = dynamicContext
405
390
  const singularKey = singularize(schema) || schema
406
391
 
407
- // Step 1: ensure collection is cached (sequential needed for validation)
408
- let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
392
+ let collectionItems = peekArray(dispatcher, cfg, ctx)
409
393
  if (collectionItems === null) {
410
- const result = await this.dataStore.fetch(cfg)
411
- collectionItems = Array.isArray(result.data) ? result.data : null
394
+ const result = await dispatcher.dispatch(cfg, ctx)
395
+ collectionItems = Array.isArray(result?.data) ? result.data : null
412
396
  }
413
397
 
414
- // Step 2: validate paramValue is in the collection (content gate)
415
398
  const match = collectionItems?.find(
416
399
  (item) => String(item[paramName]) === String(paramValue)
417
400
  ) ?? null
418
401
 
419
402
  if (!match) {
420
- data[singularKey] = null // Not in collection — content gate
403
+ data[singularKey] = null
421
404
  continue
422
405
  }
423
406
 
424
- // Step 3: fetch detail URL for richer data; fall back to collection item
425
407
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
426
408
  if (detailCfg) {
427
409
  parallelFetches.push(
428
- this.dataStore.fetch(detailCfg).then((result) => {
429
- data[singularKey] = (result.data !== undefined && result.data !== null)
410
+ dispatcher.dispatch(detailCfg, ctx).then((result) => {
411
+ data[singularKey] = (result?.data !== undefined && result?.data !== null)
430
412
  ? result.data
431
- : match // fallback: collection item is still valid
413
+ : match
432
414
  })
433
415
  )
434
416
  } else {
435
- data[singularKey] = match // No detail URL — collection item is enough
417
+ data[singularKey] = match
436
418
  }
437
419
  } else {
438
- // Default: fetch the full collection
439
420
  parallelFetches.push(
440
- this.dataStore.fetch(cfg).then((result) => {
441
- if (result.data !== undefined && result.data !== null) {
421
+ dispatcher.dispatch(cfg, ctx).then((result) => {
422
+ if (result?.data !== undefined && result?.data !== null) {
442
423
  data[schema] = result.data
443
424
  }
444
425
  })
@@ -446,10 +427,17 @@ export default class EntityStore {
446
427
  }
447
428
  }
448
429
 
449
- if (parallelFetches.length > 0) {
450
- await Promise.all(parallelFetches)
451
- }
430
+ if (parallelFetches.length > 0) await Promise.all(parallelFetches)
452
431
  const resolved = this._resolveSingularItem(data, dynamicContext)
453
432
  return { data: resolved }
454
433
  }
455
434
  }
435
+
436
+ /**
437
+ * Sync-peek helper: return the cached array for a config, or null on miss.
438
+ */
439
+ function peekArray(dispatcher, cfg, ctx) {
440
+ const cached = dispatcher.peek(cfg, ctx)
441
+ if (!cached) return null
442
+ return Array.isArray(cached.data) ? cached.data : null
443
+ }