@uniweb/core 0.26.2 → 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@
11
11
  "./datastore": "./src/datastore.js",
12
12
  "./detail-url": "./src/detail-url.js",
13
13
  "./fetch-config": "./src/fetch-config.js",
14
+ "./layout-name": "./src/layout-name.js",
14
15
  "./icon-corpus": "./src/icon-corpus.js",
15
16
  "./locale-config": "./src/locale-config.js",
16
17
  "./route-match": "./src/route-match.js",
package/src/data-paths.js CHANGED
@@ -115,8 +115,8 @@ export function queryNameFromUrl(path) {
115
115
  * Whether a fetch config's `path` addresses compiled query data.
116
116
  *
117
117
  * Used to scope behavior that only makes sense for build-emitted files —
118
- * locale prefixing in particular, which must not touch a remote `url:`
119
- * source or an author-declared `detailUrl:`.
118
+ * locale prefixing in particular, which must not touch an external query's
119
+ * `url:`.
120
120
  *
121
121
  * @param {*} path - A fetch config's `path` field.
122
122
  * @returns {boolean}
package/src/detail-url.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Detail-record resolution — the ONE home for turning a query's `detail:`
3
- * declaration plus a dynamic route's params into a fetch config.
2
+ * Detail-record resolution — the ONE home for turning a resolved query config plus
3
+ * a parametric page's route value into the request for its record.
4
4
  *
5
5
  * Why this is a subpath rather than an EntityStore internal. A host that
6
6
  * renders a detail page server-side has to fetch *the same record* the browser
7
- * will fetch when it hydrates over that render. The four `detail:` forms below
8
- * each decide a different URL, so a host that resolves them even slightly
9
- * differently prerenders record A and hydrates record B — silently, and only on
10
- * the routes that have a pattern. That is the same failure `./route-match.js`
11
- * was extracted to end, on the fetch side instead of the routing side.
7
+ * will fetch when it hydrates over that render. If the two build the request even
8
+ * slightly differently, a host prerenders record A and the browser hydrates
9
+ * record B — silently, and only on parametric pages. That is the same failure
10
+ * `./route-match.js` was extracted to end, on the fetch side instead of the
11
+ * routing side.
12
12
  *
13
13
  * Zero-dependency leaf, like `./route-match.js`, `./data-paths.js` and
14
14
  * `./fetch-config.js`: it imports one sibling that itself imports nothing, so a
@@ -26,22 +26,15 @@ import { recordHandle, routeRecordKey } from './route-match.js'
26
26
  import { substitutePlaceholders } from './substitute-placeholders.js'
27
27
 
28
28
  /**
29
- * The substitution context for a detail pattern: the route's own param name,
29
+ * The substitution context for a record request: the route's own param name,
30
30
  * plus a generic `param` alias bound to the same value.
31
31
  *
32
- * ⭐ Why the alias exists. An AUTHOR writing a detail pattern knows their route
33
- * and writes `{slug}` or `{id}` — that convention is unchanged and must stay,
34
- * because it is what every existing site and the auto-injected per-record
35
- * pattern use. A HOST declaring a record address cannot know it: `param_name`
36
- * is the site's routing choice, and the host is publishing one pattern for
37
- * every site it serves. So the host writes `{param}`.
38
- *
39
- * Binding both names to one value is what lets the two conventions coexist
40
- * without a translation step between them — and a translation step is exactly
41
- * where the framework has twice grown a second copy of a rule that then drifted
42
- * (`route-match`, `data-paths`). `substitutePlaceholders` only resolves keys
43
- * present in the context, so an unrelated `{name}` still passes through
44
- * literally, as it always has.
32
+ * ⭐ Why the alias exists. An AUTHOR writing a record address knows their route
33
+ * and writes `{slug}` or `{id}` — that convention is unchanged and must stay. A
34
+ * HOST declaring a record address cannot know it: `param_name` is the site's
35
+ * routing choice. So the host writes `{param}`. `substitutePlaceholders` only
36
+ * resolves keys present in the context, so an unrelated `{name}` still passes
37
+ * through literally.
45
38
  */
46
39
  function paramContext(paramName, paramValue, record) {
47
40
  const context = { [paramName]: paramValue, param: paramValue }
@@ -62,33 +55,6 @@ function paramContext(paramName, paramValue, record) {
62
55
  return context
63
56
  }
64
57
 
65
- /**
66
- * Build a detail-URL fetch config from a query config + dynamic context.
67
- *
68
- * Four forms of `detail:`:
69
- * - `'rest'` — append paramValue as a path segment.
70
- * - `'query'` — append `?paramName=paramValue`.
71
- * - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders,
72
- * or the generic `{param}` alias (see below).
73
- * - `{ body, envelope }` — object form. Reuses the query's url /
74
- * method / headers / auth; adds per-detail
75
- * body (with placeholder substitution) and
76
- * per-detail envelope.
77
- *
78
- * Returns `null` — never throws — when the query declares no `detail:`,
79
- * when the dynamic context carries no param, or when the query has
80
- * neither `url:` nor `path:` to build from. A caller treats `null` as "this
81
- * query has no separate detail fetch", which is the common case.
82
- *
83
- * @param {Object} queryConfig - A resolved fetch config for the query
84
- * (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
85
- * `deferred:` query — see `./fetch-config.js`).
86
- * @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
87
- * the route's param and its value; `record`, when the caller already holds the
88
- * matched record, lets `{slug}` resolve to the record's own slug (see
89
- * `paramContext`).
90
- * @returns {Object|null} A fetch config carrying `url` or `path`, or null.
91
- */
92
58
  /**
93
59
  * The record key a `[slug]` (or `[...path]`) route matches — the entry's own
94
60
  * handle, which the records service guarantees unique among siblings (the
@@ -98,6 +64,33 @@ function paramContext(paramName, paramValue, record) {
98
64
  */
99
65
  export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
100
66
 
67
+ /**
68
+ * Build the request for a parametric page's record from its route query's resolved
69
+ * config (post-`resolveFetchConfigs`, which set `detail` — see `./fetch-config.js`).
70
+ * Three sources, and no others:
71
+ *
72
+ * - **the records service** — the route query's own question, plus `match`;
73
+ * - **an external query's `record:`** — its `url` (the query's when it names none),
74
+ * `method` (likewise), `body` and `transform` — ⛔ never the list's `body` or
75
+ * `transform`, since a record response is rarely wrapped the way the list is;
76
+ * - **a `deferred:` query's per-record file** — the `/data/<query>/{slug}.json`
77
+ * pattern the resolver injected.
78
+ *
79
+ * ⛔ The `detail:` forms an author wrote until 2026-09-13 — `rest`, `query`, a URL
80
+ * pattern and `{ body, envelope }` — are gone with inline `url:` fetches [Diego]: an
81
+ * external query's `record:` says the same thing, on the query. The record request
82
+ * reused the list's `transform` until then, so a list under `results` and a bare
83
+ * record endpoint delivered `[]` (measured).
84
+ *
85
+ * Returns `null` — never throws — when the config has no per-record source or the
86
+ * route carries no value. A caller treats `null` as "find the record in the list".
87
+ *
88
+ * @param {Object} queryConfig - a resolved config
89
+ * @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
90
+ * the route's param and its value; `record`, when the caller already holds the
91
+ * matched record, lets `{slug}` resolve to the record's own slug (`paramContext`).
92
+ * @returns {Object|null} a request, or null
93
+ */
101
94
  export function buildDetailConfig(queryConfig, dynamicContext) {
102
95
  const { detail } = queryConfig
103
96
  if (!detail) return null
@@ -125,88 +118,36 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
125
118
  }
126
119
  }
127
120
 
128
- // Two address kinds, and the detail request comes back as the SAME kind as
129
- // the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
130
- // `path`.
131
- const baseUrl = queryConfig.url || queryConfig.path
132
- if (!baseUrl) return null
133
- const addressKey = queryConfig.url ? 'url' : 'path'
134
-
135
- // What every detail config carries beside its address:
121
+ // What every other record request carries beside its address:
136
122
  // `as` — the binding key, so the record lands where the list did;
137
- // `query` — the query it is one record of (identity when asked);
138
- // `depth: 'full'` — what it asks for, and what the record index files it as;
139
- // `dynamicContext` — the route param, which the default fetcher already keys
140
- // a SINGLE-RECORD response on (`envelope.item`, body
141
- // placeholders). ⛔ The entity store never passed it, so
142
- // a live lane's bare record response was unwrapped with
143
- // the LIST key and read as `[]` (found 2026-09-04).
123
+ // `query` — the query it is one record of;
124
+ // `whole: true` — what it asks for, and what the record index files it as;
125
+ // `dynamicContext` — the route param, which the default fetcher substitutes
126
+ // into a POST body;
144
127
  // `locale` — carried from the list, so the two share a locale.
145
- const common = {
146
- as: queryConfig.as,
147
- transform: queryConfig.transform,
148
- whole: true,
149
- dynamicContext: { paramName, paramValue },
150
- }
128
+ const common = { as: queryConfig.as, whole: true, dynamicContext: { paramName, paramValue } }
151
129
  if (typeof queryConfig.query === 'string') common.query = queryConfig.query
152
130
  if (queryConfig.locale !== undefined) common.locale = queryConfig.locale
131
+ const context = paramContext(paramName, paramValue, record)
153
132
 
154
- // Object form: `detail: { body, envelope }`. Reuses the query's URL +
155
- // method + headers + auth. The body is placeholder-substituted against
156
- // the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
157
- if (detail && typeof detail === 'object') {
133
+ // ⭐ AN EXTERNAL QUERY'S `record:`.
134
+ const spec = queryConfig.record
135
+ if (spec && typeof spec === 'object' && typeof queryConfig.url === 'string') {
158
136
  const out = {
159
- [addressKey]: baseUrl,
137
+ url: typeof spec.url === 'string' && spec.url ? substitutePlaceholders(spec.url, context) : queryConfig.url,
160
138
  ...common,
161
139
  }
162
- if (queryConfig.method) out.method = queryConfig.method
163
- if (detail.body !== undefined) {
164
- out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue, record), { encode: false })
165
- } else if (queryConfig.body !== undefined) {
166
- out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue, record), { encode: false })
167
- }
168
- if (detail.envelope) out.envelope = detail.envelope
140
+ const method = typeof spec.method === 'string' && spec.method ? spec.method : queryConfig.method
141
+ if (method) out.method = method
142
+ if (spec.body !== undefined && spec.body !== null) out.body = substitutePlaceholders(spec.body, context, { encode: false })
143
+ if (typeof spec.transform === 'string' && spec.transform) out.transform = spec.transform
169
144
  return out
170
145
  }
171
146
 
172
- // String-form: URL-based conventions.
173
- //
174
- // ⭐ `rest` and `query` BUILD FROM THE LIST URL, so its query string survives onto
175
- // the detail request. That is deliberate and it is the safe default: the params
176
- // that matter most to a single-record read are exactly the ones a list carries —
177
- // `?lang=`, an API key, a tenancy id. Dropping them would 401 the detail request
178
- // or return the wrong language, on every detail page.
179
- //
180
- // ⚠️ The cost is real and lands on ONE category: a PROJECTION param (`?fields=`,
181
- // `?select=`) asks the API for a summary, and carrying it truncates the very
182
- // record the detail fetch exists to get in full. The request still succeeds and
183
- // only some fields are missing, so it reads as a component or API fault rather
184
- // than a URL one.
185
- //
186
- // ⛔ Framework cannot tell the categories apart — they are the host's vocabulary,
187
- // not ours. So the default keeps everything and the CUSTOM PATTERN form is the
188
- // way out: it is used verbatim, so nothing carries over unless the author writes
189
- // it. Documented for authors in `docs/reference/dynamic-routes.md` § *The list's
190
- // query string carries over*.
191
- let detailUrl
192
- if (detail === 'rest') {
193
- const [basePath, queryString] = baseUrl.split('?')
194
- const cleanBase = basePath.replace(/\/$/, '')
195
- detailUrl = queryString
196
- ? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
197
- : `${cleanBase}/${encodeURIComponent(paramValue)}`
198
- } else if (detail === 'query') {
199
- const sep = baseUrl.includes('?') ? '&' : '?'
200
- detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
201
- } else {
202
- // Custom pattern like '/articles/{slug}' — substitute placeholders
203
- // from the dynamic-route context. Only placeholders matching the
204
- // active paramName resolve; others pass through as literal `{name}`.
205
- detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue, record))
147
+ // ⭐ A `deferred:` QUERY'S PER-RECORD FILE — the pattern `applyDeferredDetail` injected.
148
+ if (typeof detail === 'string' && typeof queryConfig.path === 'string') {
149
+ return { path: substitutePlaceholders(detail, context), ...common }
206
150
  }
207
151
 
208
- return {
209
- [addressKey]: detailUrl,
210
- ...common,
211
- }
152
+ return null
212
153
  }
@@ -14,9 +14,8 @@
14
14
  * and in-flight dedup.
15
15
  */
16
16
 
17
- import { isFetchRefinement, resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
17
+ import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, othersOf, siteReaches } from './fetch-config.js'
18
18
  import { fillRoutePattern, matchesRouteParam } from './route-match.js'
19
- import { sortRecords } from './sort.js'
20
19
 
21
20
  /**
22
21
  * A fetch config's binding key — the `content.data.<key>` a component reads.
@@ -26,41 +25,12 @@ import { sortRecords } from './sort.js'
26
25
  const bindingKeyOf = (cfg) => cfg?.as
27
26
  import { buildDetailConfig } from './detail-url.js'
28
27
 
29
- /**
30
- * Is `block.fetch` a per-instance refinement of the ancestor's fetch config
31
- * rather than a new source? The spelling is `refine: true`.
32
- *
33
- * The predicate itself lives in `./fetch-config.js` with the rest of the
34
- * cascade rule; this alias keeps the local call sites reading as they did.
35
- */
36
- const isRefinement = isFetchRefinement
37
-
38
- /**
39
- * `fetch: { inherit: true }` was the earlier spelling of `refine: true`, kept
40
- * "for one release" from April 2026 and removed on 2026-09-02. It is refused
41
- * rather than ignored: ignored, the declaration would read as a source with no
42
- * location and the block would render empty with nothing to say why. Dev
43
- * throws, so a page does not render on the old spelling; production logs once
44
- * and the caller drops the declaration, so the block receives the cascaded
45
- * data unrefined.
46
- */
47
- let inheritRefusalLogged = false
48
- function refuseInheritAlias(block, dev) {
49
- const message =
50
- "[uniweb] 'fetch: { inherit: true }' is no longer accepted; write 'fetch: { refine: true }'. " +
51
- `Seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
52
- if (dev) throw new Error(message)
53
- if (inheritRefusalLogged) return
54
- inheritRefusalLogged = true
55
- console.error(message)
56
- }
57
-
58
28
  export default class EntityStore {
59
29
  /**
60
30
  * @param {Object} options
61
31
  * @param {import('./website.js').default} options.website
62
- * @param {boolean} [options.dev=false] - dev mode: a retired spelling throws
63
- * instead of logging once.
32
+ * @param {boolean} [options.dev=false] - dev mode: a failed fetch is logged
33
+ * where the author is looking.
64
34
  */
65
35
  constructor({ website, dev = false }) {
66
36
  this.website = website
@@ -68,34 +38,6 @@ export default class EntityStore {
68
38
  Object.seal(this)
69
39
  }
70
40
 
71
- _shouldInheritDetail(meta, block) {
72
- const bf = block?.fetch
73
- if (isRefinement(bf) && bf?.detail !== undefined) return bf.detail !== false
74
- if (!meta) return true
75
- return meta.inheritDetail !== false
76
- }
77
-
78
- _inheritLimit(meta, block) {
79
- const bf = block?.fetch
80
- if (isRefinement(bf) && bf?.limit > 0) return bf.limit
81
- return (meta?.inheritLimit > 0) ? meta.inheritLimit : null
82
- }
83
-
84
- _inheritOrder(block) {
85
- const bf = block?.fetch
86
- if (isRefinement(bf) && bf?.order?.orderBy) return bf.order
87
- return null
88
- }
89
-
90
- /**
91
- * A refine block's `order: { orderBy, sortOrder }` — the same one-key sort the
92
- * build and the fetcher fallback run (`./sort.js`), so the three cannot drift.
93
- */
94
- _sortItems(items, order) {
95
- if (!order?.orderBy) return items
96
- return sortRecords(items, { field: order.orderBy, desc: order.sortOrder === 'DESC' })
97
- }
98
-
99
41
  /**
100
42
  * Which schemas does this component want delivered?
101
43
  *
@@ -120,13 +62,8 @@ export default class EntityStore {
120
62
  * re-inline it here — divergence between copies is what the extraction
121
63
  * exists to prevent.
122
64
  */
123
- _findFetchConfigs(block, requested) {
124
- let blockFetch = block.fetch
125
- if (blockFetch?.inherit !== undefined) {
126
- refuseInheritAlias(block, this.dev)
127
- blockFetch = null
128
- }
129
-
65
+ _findFetchConfigs(block, requested, route = this._route(block)) {
66
+ const blockFetch = block.fetch
130
67
  const page = block.page
131
68
  const website = block.website
132
69
  const dynamicContext = block.dynamicContext || page?.dynamicContext
@@ -147,10 +84,16 @@ export default class EntityStore {
147
84
 
148
85
  return resolveFetchConfigs(
149
86
  [
150
- blockFetch && !isRefinement(blockFetch) ? blockFetch : null,
87
+ blockFetch,
151
88
  page?.fetch,
152
89
  page?.parent?.fetch,
153
- website?.config?.fetch,
90
+ // ⭐ A page nested inside a parametric page receives its capturing page's
91
+ // route binding — the cascade reaches one parent up, and the binding may sit
92
+ // above that (`pageRouteQuery`). Its key only; the rest does not cascade.
93
+ route?.nested ? route.config : null,
94
+ // The site's binding reaches a layout section and a top-level page's
95
+ // sections, and nothing deeper (`siteReaches`).
96
+ siteReaches(page?.parent) ? website?.config?.fetch : null,
154
97
  ],
155
98
  {
156
99
  schemas: requested,
@@ -171,30 +114,33 @@ export default class EntityStore {
171
114
  }
172
115
 
173
116
  /**
174
- * The binding key whose records a parametric page's URL narrows to one — its
175
- * ROUTE QUERY (`routeQuery`, `./fetch-config.js`), worked out from the same
176
- * sources `_findFetchConfigs` walks: the page's fetch, its parent's, the site's,
177
- * and, when none of those declares one, the key the page's sections share. So
178
- * what a section receives and what the URL narrows are read off one walk and
179
- * cannot disagree.
117
+ * The ROUTE QUERY of the block's page — the query its URL names one record of —
118
+ * worked out from the same sources `_findFetchConfigs` walks, at the page that
119
+ * captured the URL's variable (`pageRouteQuery`, `./fetch-config.js`): its fetch,
120
+ * its parent's, the site's, and, when none of those declares one, the key its
121
+ * sections share. So what a section receives and what the URL narrows are read
122
+ * off one walk and cannot disagree.
180
123
  *
181
124
  * ⛔ This was `dynamicContext.schema`, stored on the page by whoever built it —
182
125
  * the SPA from `parentSchema`, the static build from the parent's first
183
126
  * prerendered fetch — a stored copy of a derived answer, computed by a different
184
127
  * rule from the one sections are fed by. Deleted 2026-09-11 [Diego].
185
128
  *
186
- * @returns {string|null} null off a parametric page, or when it has no route query
129
+ * @returns {{ key: string, config: Object, nested: boolean }|null} null off a
130
+ * parametric page, or when it has no route query
187
131
  */
188
- _routeKey(block) {
132
+ _route(block) {
189
133
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
190
- if (!dynamicContext) return null
191
- const page = block.page
192
- return routeQuery({
193
- page: page?.fetch,
194
- parent: page?.parent?.fetch,
134
+ if (!dynamicContext || !block.page) return null
135
+ return pageRouteQuery(block.page, {
136
+ // a concrete page carries its template's route; its ancestors are templates, or
137
+ // pages the static build expanded, which carry theirs the same way
138
+ routeOf: (p) => p?.dynamicContext?.templateRoute ?? p?.route,
139
+ parentOf: (p) => p?.parent ?? null,
140
+ fetchOf: (p) => p?.fetch,
141
+ sectionsOf: (p) => p?._bodySections,
195
142
  site: block.website?.config?.fetch,
196
- sections: sectionFetches(page?._bodySections),
197
- })?.key ?? null
143
+ })
198
144
  }
199
145
 
200
146
  /**
@@ -265,37 +211,28 @@ export default class EntityStore {
265
211
 
266
212
  if (requested === null) return { status: 'none', data: null }
267
213
 
268
- const configs = this._findFetchConfigs(block, requested)
214
+ const route = this._route(block)
215
+ const configs = this._findFetchConfigs(block, requested, route)
269
216
  if (configs.size === 0) return { status: 'none', data: null }
270
217
 
271
218
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
272
- const inheritDetail = this._shouldInheritDetail(meta, block)
273
- const limit = this._inheritLimit(meta, block)
274
- const order = this._inheritOrder(block)
275
219
  const ctx = this._ctx(block)
276
220
 
277
221
  const data = {}
278
222
  let allCached = true
279
223
 
280
- const routeSchema = this._routeKey(block)
281
-
282
224
  for (const [schema, cfg] of configs) {
283
- const isRouteQuery = dynamicContext && schema === routeSchema
284
- if (isRouteQuery && !inheritDetail) {
285
- // refine detail:false — the records minus the active one (related items).
286
- const cached = dispatcher?.peek(cfg, ctx)
225
+ // `current:` applies under the key the page's URL narrows, and nowhere else.
226
+ const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
227
+ if (current === 'exclude') {
228
+ // The route query's records without this page's, `limit` counting the others.
229
+ const cached = dispatcher?.peek(othersView(cfg), ctx)
287
230
  if (cached) {
288
- const { paramName, paramValue } = dynamicContext
289
- const items = cached.data
290
- let filtered = Array.isArray(items)
291
- ? items.filter((item) => !matchesRouteParam(item, paramName, paramValue))
292
- : items
293
- if (order) filtered = this._sortItems(filtered, order)
294
- data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
231
+ data[schema] = othersOf(cached.data, cfg, isPageRecord(dynamicContext))
295
232
  } else {
296
233
  allCached = false
297
234
  }
298
- } else if (isRouteQuery && cfg.ask) {
235
+ } else if (current === 'only' && cfg.ask) {
299
236
  // The records service: the record's own answer is cached under its own key.
300
237
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
301
238
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
@@ -305,11 +242,12 @@ export default class EntityStore {
305
242
  } else {
306
243
  allCached = false
307
244
  }
308
- } else if (isRouteQuery) {
245
+ } else if (current === 'only') {
309
246
  // Detail page: deliver the focused record as a length-1 array under the
310
247
  // query key. A deferred/remote query fetches the full per-record;
311
- // others use the matched record. Not found → [].
312
- const cached = dispatcher?.peek(cfg, ctx)
248
+ // others use the matched record. Not found → []. Found in the route
249
+ // query's whole selection, never in a list its `limit` cut (`routeSelection`).
250
+ const cached = dispatcher?.peek(routeSelection(cfg), ctx)
313
251
  if (cached) {
314
252
  const { paramName, paramValue } = dynamicContext
315
253
  const items = cached.data
@@ -341,10 +279,11 @@ export default class EntityStore {
341
279
  allCached = false
342
280
  }
343
281
  } else {
282
+ // Any other key — and `current: include`, the route query's list as the
283
+ // binding describes it, this page's record among the rest.
344
284
  const cached = dispatcher?.peek(cfg, ctx)
345
285
  if (cached) {
346
- const items = cached.data
347
- data[schema] = order ? this._sortItems(items, order) : items
286
+ data[schema] = cached.data
348
287
  } else {
349
288
  allCached = false
350
289
  }
@@ -388,13 +327,11 @@ export default class EntityStore {
388
327
  }
389
328
  if (requested === null) return { data: null, errors: null }
390
329
 
391
- const configs = this._findFetchConfigs(block, requested)
330
+ const route = this._route(block)
331
+ const configs = this._findFetchConfigs(block, requested, route)
392
332
  if (configs.size === 0) return { data: null, errors: null }
393
333
 
394
334
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
395
- const inheritDetail = this._shouldInheritDetail(meta, block)
396
- const limit = this._inheritLimit(meta, block)
397
- const order = this._inheritOrder(block)
398
335
  const ctx = this._ctx(block, { signal })
399
336
 
400
337
  const data = {}
@@ -405,33 +342,27 @@ export default class EntityStore {
405
342
  reportFetchFailure(this.dev, block, key, cfg, message)
406
343
  }
407
344
 
408
- const routeSchema = this._routeKey(block)
409
-
410
345
  for (const [schema, cfg] of configs) {
411
- const isRouteQuery = dynamicContext && schema === routeSchema
412
- if (isRouteQuery && !inheritDetail) {
413
- // refine detail:false — the records minus the active one.
414
- let records = peekArray(dispatcher, cfg, ctx)
415
- if (records === null) {
416
- const result = await dispatcher.dispatch(cfg, ctx)
346
+ // `current:` applies under the key the page's URL narrows, and nowhere else.
347
+ const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
348
+ if (current === 'exclude') {
349
+ // The route query's records without this page's, `limit` counting the others:
350
+ // asked one longer (`othersView`), so removing the record still leaves enough.
351
+ const view = othersView(cfg)
352
+ parallelFetches.push(dispatcher.dispatch(view, ctx).then((result) => {
417
353
  if (result?.error) {
418
- fail(schema, cfg, result.error)
419
- continue
354
+ fail(schema, view, result.error)
355
+ return
420
356
  }
421
- records = Array.isArray(result?.data) ? result.data : null
422
- }
423
- const { paramName, paramValue } = dynamicContext
424
- let filtered = Array.isArray(records)
425
- ? records.filter((item) => !matchesRouteParam(item, paramName, paramValue))
426
- : (records ?? [])
427
- if (order) filtered = this._sortItems(filtered, order)
428
- data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
429
- } else if (isRouteQuery && cfg.ask) {
357
+ if (result?.data !== undefined && result?.data !== null) {
358
+ data[schema] = othersOf(result.data, cfg, isPageRecord(dynamicContext))
359
+ }
360
+ }))
361
+ } else if (current === 'only' && cfg.ask) {
430
362
  // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
431
363
  // same question narrowed by the route's handle, so list and record are
432
364
  // asked together — one round trip, and no client-side scan gating the
433
- // fetch (F13, the live half). The list is asked too, because sections
434
- // beside the record read it (`refine: true, detail: false`) and the
365
+ // fetch (F13, the live half). The list is asked too, because the record
435
366
  // index files its briefs; the record's own answer is the answer.
436
367
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
437
368
  parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
@@ -445,15 +376,18 @@ export default class EntityStore {
445
376
  const answer = Array.isArray(result?.data) ? result.data : (result?.data ? [result.data] : [])
446
377
  data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
447
378
  }))
448
- } else if (isRouteQuery) {
449
- // Detail page: focused record as a length-1 array under the query key.
379
+ } else if (current === 'only') {
380
+ // Detail page: focused record as a length-1 array under the query key,
381
+ // found in the route query's whole selection (`routeSelection`) — a record
382
+ // past a list's `limit` still has its page.
450
383
  const { paramName, paramValue } = dynamicContext
384
+ const selection = routeSelection(cfg)
451
385
 
452
- let records = peekArray(dispatcher, cfg, ctx)
386
+ let records = peekArray(dispatcher, selection, ctx)
453
387
  if (records === null) {
454
- const result = await dispatcher.dispatch(cfg, ctx)
388
+ const result = await dispatcher.dispatch(selection, ctx)
455
389
  if (result?.error) {
456
- fail(schema, cfg, result.error)
390
+ fail(schema, selection, result.error)
457
391
  continue
458
392
  }
459
393
  records = Array.isArray(result?.data) ? result.data : null
@@ -500,6 +434,7 @@ export default class EntityStore {
500
434
  data[schema] = [match]
501
435
  }
502
436
  } else {
437
+ // Any other key — and `current: include`, the list as the binding describes it.
503
438
  parallelFetches.push(
504
439
  dispatcher.dispatch(cfg, ctx).then((result) => {
505
440
  if (result?.error) {
@@ -507,10 +442,7 @@ export default class EntityStore {
507
442
  return
508
443
  }
509
444
  if (result?.data !== undefined && result?.data !== null) {
510
- // The same refine `order` the sync path applies (`resolve`), so a
511
- // block sorts identically on a cache hit and on the fetch that
512
- // filled it — it did not until 2026-09-04.
513
- data[schema] = order ? this._sortItems(result.data, order) : result.data
445
+ data[schema] = result.data
514
446
  }
515
447
  })
516
448
  )
@@ -558,6 +490,11 @@ function heldWhole(dispatcher, match) {
558
490
  return held?.whole === true ? held.record : null
559
491
  }
560
492
 
493
+ /** Matches the record a parametric page is about — the one `current: only` delivers. */
494
+ function isPageRecord({ paramName, paramValue }) {
495
+ return (item) => matchesRouteParam(item, paramName, paramValue)
496
+ }
497
+
561
498
  /**
562
499
  * Sync-peek helper: return the cached array for a config, or null on miss.
563
500
  */