@uniweb/core 0.20.0 → 0.22.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,13 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./base-path": "./src/base-path.js",
9
- "./collection-address": "./src/query-address.js",
10
- "./query-address": "./src/query-address.js",
9
+ "./records-service": "./src/records-service.js",
11
10
  "./data-paths": "./src/data-paths.js",
12
11
  "./datastore": "./src/datastore.js",
13
12
  "./detail-url": "./src/detail-url.js",
package/src/datastore.js CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * ⭐ TWO IDENTITIES, decided by whether the request carries an ADDRESS.
23
23
  *
24
- * An ADDRESSED request — `path`, `url` or `endpoint` — is identified by where it
24
+ * An ADDRESSED request — `path` or `url` — is identified by where it
25
25
  * goes: the address, the binding key, the unwrap, and for a POST its method and
26
26
  * body. Post-processing fields (`limit`, `sort`, `where`) are applied after the
27
27
  * fetch over one shared copy and must not split the cache; `query` and `depth`
@@ -30,15 +30,15 @@
30
30
  * asking for `{ path, as }` must hit the entry the page's declaration filled —
31
31
  * that shared cache is a documented property of `useFetched`.
32
32
  *
33
- * An ADDRESS-LESS request — a QUESTION sent to a records door — is identified by
34
- * the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
33
+ * An ADDRESS-LESS request — a QUESTION sent to the records service — is
34
+ * identified by the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
35
35
  * ⛔ The reason: with no per-query address, two pages
36
36
  * binding one `as` to two queries would otherwise share an entry, and a list
37
37
  * (brief) and a record (full) of one query would collide — the one defect on
38
38
  * this path that delivers WRONG data rather than none.
39
39
  *
40
- * `locale` is hashed on both when present: a live lane's address does not carry
41
- * the locale, and two locales' records must not share an entry (F1).
40
+ * `locale` is hashed on both when present: two locales' answers must not share
41
+ * an entry, and an asked config always carries the locale it was asked in.
42
42
  *
43
43
  * @param {Object} request - Normalized request (or fetch config)
44
44
  * @returns {string} A stable JSON string usable as a cache-Map key
@@ -47,18 +47,18 @@ export function deriveCacheKey(request) {
47
47
  // ⭐ `as` is the binding key — the name it has had since 2026-09-02, when the
48
48
  // compatibility alias for the older `schema` spelling was removed alongside
49
49
  // frontend's and hosting's.
50
- const { path, url, endpoint, transform, locale } = request || {}
50
+ const { path, url, transform, locale } = request || {}
51
51
  const as = request?.as
52
52
  const method = request?.method && request.method.toUpperCase() !== 'GET'
53
53
  ? request.method.toUpperCase()
54
54
  : undefined
55
55
  const body = method === 'POST' ? request?.body : undefined
56
- if (path || url || endpoint) {
56
+ if (path || url) {
57
57
  // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
58
58
  // In-memory stores repopulate; a consumer with a persistent cache takes one
59
59
  // cold pass. Chosen over hashing under the old name, which would have hidden
60
60
  // the rename inside the one function whose job is to be canonical.
61
- return JSON.stringify({ path, url, endpoint, as, transform, method, body, locale })
61
+ return JSON.stringify({ path, url, as, transform, method, body, locale })
62
62
  }
63
63
  const { query, schema, scope, where, sort, limit, depth } = request || {}
64
64
  return JSON.stringify({ query, schema, scope, where, sort, limit, depth, as, transform, locale })
package/src/detail-url.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * a matched route pattern says nothing about the record behind it.
23
23
  */
24
24
 
25
+ import { recordHandle } from './route-match.js'
25
26
  import { substitutePlaceholders } from './substitute-placeholders.js'
26
27
 
27
28
  /**
@@ -54,8 +55,9 @@ function paramContext(paramName, paramValue, record) {
54
55
  // `useEntityDetail` — its slug fills the name the FILE was written under. A
55
56
  // caller with no record in hand leaves `{slug}` literal rather than guessing
56
57
  // the capture is one: a visibly unresolved address beats a plausible wrong one.
57
- if (paramName !== 'slug' && record && typeof record === 'object' && record.slug != null && record.slug !== '') {
58
- context.slug = record.slug
58
+ const handle = recordHandle(record)
59
+ if (paramName !== 'slug' && handle != null && handle !== '') {
60
+ context.slug = handle
59
61
  }
60
62
  return context
61
63
  }
@@ -89,12 +91,12 @@ function paramContext(paramName, paramValue, record) {
89
91
  */
90
92
  /**
91
93
  * The key a route param narrows a QUESTION by — the entry's own handle, which
92
- * a records door guarantees unique among siblings (the records door's contract
94
+ * the records service guarantees unique among siblings (the records contract
93
95
  * §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
94
96
  * moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
95
- * → `$name`); the door is dark until a host stamps it, so this is the one place
96
- * to change if it moves again. Not read by the file lane or the address door,
97
- * which narrow by the route's own param (`item[paramName]`).
97
+ * → `$name`); the service is dark until a host stamps it, so this is the one
98
+ * place to change if it moves again. Not read by the file lane, which narrows
99
+ * by the route's own param (`item[paramName]`).
98
100
  */
99
101
  export const ROUTE_HANDLE_KEY = '$name'
100
102
 
@@ -104,13 +106,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
104
106
  const { paramName, paramValue, record = null } = dynamicContext
105
107
  if (!paramName || paramValue === undefined) return null
106
108
 
107
- // ⭐ A QUESTION DOOR: the record is the list's own question, narrowed to one
108
- // entry by its handle and asked in full — the list page and the detail page
109
- // are the same query, differing only by whether the parameter is bound
110
- // (the records door's contract, §1a). `sort` and `limit` are the
111
- // list's and drop; `scope` and the authored `where` stay, so a scoped query
112
- // cannot be escaped through the URL.
113
- if (queryConfig.door) {
109
+ // ⭐ THE RECORDS SERVICE: the record is the list's own question, narrowed to
110
+ // one entry by its handle and asked in full — the list page and the detail
111
+ // page are the same query, differing only by whether the parameter is bound
112
+ // (the records contract, §1a). `sort` and `limit` are the list's and drop;
113
+ // `scope` and the authored `where` stay, so a scoped query cannot be escaped
114
+ // through the URL.
115
+ if (queryConfig.ask) {
114
116
  const { sort, limit, detail: _detail, ...rest } = queryConfig
115
117
  return {
116
118
  ...rest,
@@ -120,21 +122,16 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
120
122
  }
121
123
  }
122
124
 
123
- // Three address kinds now, and the detail request must come back as the SAME
124
- // kind: an `endpoint` carries remote semantics the fetcher decides on, so
125
- // returning a detail as `path` would silently drop operator pushdown and the
126
- // site's static headers for exactly the request that is one record.
127
- const baseUrl = queryConfig.endpoint || queryConfig.url || queryConfig.path
125
+ // Two address kinds, and the detail request comes back as the SAME kind as
126
+ // the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
127
+ // `path`.
128
+ const baseUrl = queryConfig.url || queryConfig.path
128
129
  if (!baseUrl) return null
129
- const addressKey = queryConfig.endpoint
130
- ? 'endpoint'
131
- : queryConfig.url
132
- ? 'url'
133
- : 'path'
130
+ const addressKey = queryConfig.url ? 'url' : 'path'
134
131
 
135
132
  // What every detail config carries beside its address:
136
133
  // `as` — the binding key, so the record lands where the list did;
137
- // `query` — the query it is one record of (identity on a door);
134
+ // `query` — the query it is one record of (identity when asked);
138
135
  // `depth: 'full'` — what it asks for, and what the record index files it as;
139
136
  // `dynamicContext` — the route param, which the default fetcher already keys
140
137
  // a SINGLE-RECORD response on (`envelope.item`, body
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
- import { fillRoutePattern } from './route-match.js'
18
+ import { fillRoutePattern, routeParamValue } from './route-match.js'
19
19
  import { sortRecords } from './sort.js'
20
20
 
21
21
  /**
@@ -152,10 +152,11 @@ export default class EntityStore {
152
152
  // after the payload key was renamed — a dead option name, silently: the
153
153
  // resolver simply saw no queries and stopped injecting `detail:`.
154
154
  queries: website?.config?.queries ?? null,
155
- // A host's live-records lane. Absent on every static site and on
156
- // local dev, which is why `resolveQuerySource` treats absence as
157
- // the ordinary case and reads the compiled artifact without comment.
158
- records: website?.config?.records ?? null,
155
+ // The host's services; its `records` row is the live-records lane.
156
+ // Absent on every static site and on local dev, which is why
157
+ // `resolveQuerySource` treats absence as the ordinary case and reads
158
+ // the compiled artifact without comment.
159
+ services: website?.config?.services ?? null,
159
160
  variables,
160
161
  },
161
162
  )
@@ -252,15 +253,15 @@ export default class EntityStore {
252
253
  const { paramName, paramValue } = dynamicContext
253
254
  const items = cached.data
254
255
  let filtered = Array.isArray(items)
255
- ? items.filter((item) => String(item[paramName]) !== String(paramValue))
256
+ ? items.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
256
257
  : items
257
258
  if (order) filtered = this._sortItems(filtered, order)
258
259
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
259
260
  } else {
260
261
  allCached = false
261
262
  }
262
- } else if (isRouteQuery && cfg.door) {
263
- // A question door: the record's own answer is cached under its own key.
263
+ } else if (isRouteQuery && cfg.ask) {
264
+ // The records service: the record's own answer is cached under its own key.
264
265
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
265
266
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
266
267
  if (detailCached) {
@@ -278,7 +279,7 @@ export default class EntityStore {
278
279
  const { paramName, paramValue } = dynamicContext
279
280
  const items = cached.data
280
281
  const match = Array.isArray(items)
281
- ? items.find((item) => String(item[paramName]) === String(paramValue))
282
+ ? items.find((item) => String(routeParamValue(item, paramName)) === String(paramValue))
282
283
  : null
283
284
  if (!match) {
284
285
  data[schema] = []
@@ -386,12 +387,12 @@ export default class EntityStore {
386
387
  }
387
388
  const { paramName, paramValue } = dynamicContext
388
389
  let filtered = Array.isArray(records)
389
- ? records.filter((item) => String(item[paramName]) !== String(paramValue))
390
+ ? records.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
390
391
  : (records ?? [])
391
392
  if (order) filtered = this._sortItems(filtered, order)
392
393
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
393
- } else if (isRouteQuery && cfg.door) {
394
- // ⭐ A QUESTION DOOR needs no list to find the record: the record is the
394
+ } else if (isRouteQuery && cfg.ask) {
395
+ // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
395
396
  // same question narrowed by the route's handle, so list and record are
396
397
  // asked together — one round trip, and no client-side scan gating the
397
398
  // fetch (F13, the live half). The list is asked too, because sections
@@ -424,7 +425,7 @@ export default class EntityStore {
424
425
  }
425
426
 
426
427
  const match = records?.find(
427
- (item) => String(item[paramName]) === String(paramValue)
428
+ (item) => String(routeParamValue(item, paramName)) === String(paramValue)
428
429
  ) ?? null
429
430
 
430
431
  if (!match) {
@@ -14,12 +14,30 @@
14
14
  *
15
15
  * INTENTIONALLY A LEAF: safe to load anywhere — including environments with no
16
16
  * DOM, no filesystem, and a hard bundle-size ceiling. The rule that protects
17
- * that is **no transitive graph**: import nothing from the package root (which
18
- * pulls semantic-parser and theming) and nothing that itself imports. A
19
- * zero-dependency sibling leaf is admissible and `./data-paths.js` is the only
20
- * one taken the path convention it holds has to be identical here and in the
21
- * build that emits the files, and a second copy of that string is precisely
22
- * the drift this module exists to prevent.
17
+ * that is **import nothing from the package root** (which pulls semantic-parser
18
+ * and theming) and nothing that reaches it transitively.
19
+ *
20
+ * ⚠️ **This said "and nothing that itself imports `./data-paths.js` is the
21
+ * only one taken", and both halves had stopped being true.** `query-address.js`
22
+ * was added as an import and itself imports; on 2026-09-06 it became
23
+ * `records-service.js` and the chain grew again. **The admissible set, named
24
+ * rather than counted:**
25
+ *
26
+ * fetch-config → data-paths (leaf)
27
+ * → records-service → substitute-placeholders (leaf)
28
+ * → services → base-path (leaf)
29
+ *
30
+ * Every module in it is pure JS with no `node:*`, no DOM and no package-root
31
+ * import — the property that actually matters. "Depth 1" was a proxy for it
32
+ * that stopped holding without anything failing, which is why the rule is now
33
+ * stated as the property.
34
+ *
35
+ * ⛔ **Two edges are deliberate and must not be inlined**, for one reason:
36
+ * `data-paths.js` holds the `/data/<name>.json` convention, which has to be
37
+ * identical here and in the build that emits the files; `services.js` holds
38
+ * `readEndpoint`, the ONE rule for reading a service declaration. A second copy
39
+ * of either is precisely the drift this module exists to prevent. ⇒ **If you
40
+ * add an edge, say here why it holds.**
23
41
  *
24
42
  * WHAT THIS DOES NOT OWN: where the sources come from. A caller holding a live
25
43
  * object graph reads them off the graph; a caller holding a content document
@@ -28,7 +46,7 @@
28
46
  */
29
47
 
30
48
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
31
- import { resolveQueryAddress, resolveRecordAddressPattern, resolveQueryDoor } from './query-address.js'
49
+ import { resolveRecordsService } from './records-service.js'
32
50
 
33
51
  /**
34
52
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
@@ -97,31 +115,13 @@ function localizeConfig(cfg, locale, defaultLocale) {
97
115
  * @param {Object|null} queries - the site's `config.queries` map
98
116
  * @returns {Object} the original config, or a copy carrying `detail`
99
117
  */
100
- function applyDeferredDetail(cfg, queries, records) {
118
+ function applyDeferredDetail(cfg, queries) {
101
119
  if (cfg.detail !== undefined) return cfg
102
120
 
103
- // A question door answers a RECORD by the same question narrowed to it, so
104
- // every door config has a detail source; `buildDetailConfig` composes it.
105
- if (cfg.door) return { ...cfg, detail: true }
121
+ // The records service answers a RECORD by the same question narrowed to it,
122
+ // so every asked config has a detail source; `buildDetailConfig` composes it.
123
+ if (cfg.ask) return { ...cfg, detail: true }
106
124
 
107
- // ⭐ A lane's record address is injected whenever the lane declares one —
108
- // NOT only for a `deferred:` query, and the difference is load-bearing.
109
- //
110
- // A live lane answers a list request at brief depth and a record request in
111
- // full, so a detail page that filtered the list would render the brief and
112
- // silently miss the body. And it cannot fall back to the rule below: the
113
- // `deferred:` declaration lives in `config.queries`, which a host's
114
- // projection is not obliged to carry — so on such a host that rule can never
115
- // fire, and this is the only way a detail page reaches a whole record.
116
- if (cfg.endpoint) {
117
- // ⛔ `cfg.query`, not `cfg.query ?? cfg.schema`. The `??` was unreachable:
118
- // `endpoint` is set in exactly one place (`resolveQuerySource`), which returns
119
- // early unless `cfg.query` is a non-empty string — so reaching here proves it.
120
- // It read as a tolerance for two producer shapes and was really a vestige of
121
- // the build lane not emitting `query`, which it now does.
122
- const recordPattern = resolveRecordAddressPattern(cfg.query, records)
123
- if (recordPattern) return { ...cfg, detail: recordPattern }
124
- }
125
125
 
126
126
  // ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
127
127
  // This read `cfg.schema` — the BINDING KEY, which merely defaults to the query
@@ -159,9 +159,15 @@ function applyDeferredDetail(cfg, queries, records) {
159
159
  * The author names a query; this decides where its records live, and there are
160
160
  * exactly two answers:
161
161
  *
162
- * - a host declared a live lane (`config.records`) → an `endpoint`, final on
163
- * arrival, which the fetcher calls without composing anything further;
164
- * - nobody did → the `path` of the artifact the build emitted.
162
+ * - a host offers the `records` service (`config.services.records`) → an
163
+ * `ask` address, and the whole query goes to it (`schema` from the
164
+ * payload's `config.queries`; an `ask` with no Model ref is a loud per-key
165
+ * error, never a fallthrough);
166
+ * - nobody does → the `path` of the artifact the build emitted.
167
+ *
168
+ * ⛔ There is no third answer, and there has not been one since the GET lane
169
+ * the runtime evaluated locally was retired (2026-09-04). `records-service.js`
170
+ * says why the wrapper it lived in went with it.
165
171
  *
166
172
  * ⭐ The second is not a fallback in the apologetic sense. It is the answer for
167
173
  * every site with no backend, which is the framework's default rather than a
@@ -174,38 +180,37 @@ function applyDeferredDetail(cfg, queries, records) {
174
180
  * `query`, ignoring any `path`), so the two agree rather than disagreeing on a
175
181
  * shape nobody hand-writes.
176
182
  */
177
- function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
183
+ function resolveQuerySource(cfg, services, { queries = null, locale = null, defaultLocale = null } = {}) {
178
184
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
179
185
 
180
- // ⭐ THE QUESTION DOOR FIRST. A host that answers questions gets the whole
181
- // query — `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes
182
- // no per-query address at all (the records door's contract, §2). It
183
- // needs the query's MODEL REF, which lives on the site's `config.queries`
184
- // declaration; a payload that carries the door but not the declaration
185
- // cannot ask, and falls through to the address door below. ⚠️ Dark until a
186
- // host stamps the door; see `resolveQueryDoor`.
187
- const door = resolveQueryDoor(records, locale ?? defaultLocale)
188
- if (door) {
186
+ // ⭐ THE SERVICE FIRST. A host that answers questions gets the whole query —
187
+ // `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes no
188
+ // per-query address at all (the records contract, §2). It needs the query's
189
+ // MODEL REF, which lives on the site's `config.queries` declaration; a
190
+ // payload that offers the service but carries no declaration cannot ask, and
191
+ // says so. ⚠️ Dark until a host stamps the row; see `resolveRecordsService`.
192
+ const ask = resolveRecordsService(services, locale ?? defaultLocale)
193
+ if (ask) {
189
194
  const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
190
195
  const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
191
- if (schema) {
192
- const { path, url, ...rest } = cfg
193
- const asked = { ...rest, door, schema }
194
- // A saved query's own narrowing applies unless the fetch overrides it.
195
- if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
196
- if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
197
- if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
198
- if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
199
- return asked
200
- }
201
- }
202
-
203
- const endpoint = resolveQueryAddress(cfg.query, records)
204
- if (endpoint) {
205
196
  // Drop the transitional `path`: two addresses on one request is an
206
197
  // ambiguity the fetcher would have to break by accident of field order.
207
198
  const { path, url, ...rest } = cfg
208
- return { ...rest, endpoint }
199
+ if (!schema) {
200
+ // ⛔ LOUD, not a fallthrough. A payload that offers the service and carries no
201
+ // Model ref for the query cannot ask, and reading the compiled file
202
+ // instead would turn a producer defect into a 404 that names the wrong
203
+ // thing. The fetcher refuses an asked request with no `schema` before any
204
+ // request is made, and the block's `dataError` says exactly this.
205
+ return { ...rest, ask, schema: null }
206
+ }
207
+ const asked = { ...rest, ask, schema }
208
+ // A saved query's own narrowing applies unless the fetch overrides it.
209
+ if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
210
+ if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
211
+ if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
212
+ if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
213
+ return asked
209
214
  }
210
215
  return { ...cfg, path: queryDataUrl(cfg.query) }
211
216
  }
@@ -261,9 +266,9 @@ function bindingKey(cfg) {
261
266
  * @param {string|null} [options.locale] - the locale being rendered
262
267
  * @param {string|null} [options.defaultLocale] - the site's default locale
263
268
  * @param {Object|null} [options.queries] - the site's `config.queries`
264
- * @param {Object|null} [options.records] - the site's `config.records`, a host's
265
- * live-records lane. Absent means the compiled artifact answers, which is
266
- * the whole of what a site with no backend needs.
269
+ * @param {Object|null} [options.services] - the site's `config.services`. The
270
+ * `records` row is a host's live-records lane; absent means the compiled
271
+ * artifact answers, which is the whole of what a site with no backend needs.
267
272
  * @param {Object|null} [options.variables] - the route's variables on a template
268
273
  * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
269
274
  * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
@@ -276,7 +281,7 @@ export function resolveFetchConfigs(sources, options = {}) {
276
281
  locale = null,
277
282
  defaultLocale = null,
278
283
  queries = null,
279
- records = null,
284
+ services = null,
280
285
  variables = null,
281
286
  } = options
282
287
 
@@ -293,10 +298,10 @@ export function resolveFetchConfigs(sources, options = {}) {
293
298
  if (!collectAll && !schemas.includes(key)) continue
294
299
  // Address first: localization and deferred-detail both key on `path`,
295
300
  // which a query ref does not have until this runs.
296
- const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
301
+ const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
297
302
  const localized = localizeConfig(sourced, locale, defaultLocale)
298
303
  const bound = foldScope(bindRouteVariables(localized, variables))
299
- configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
304
+ configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries), locale, defaultLocale))
300
305
  }
301
306
  }
302
307
 
@@ -318,8 +323,8 @@ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
318
323
  * the list page and the detail page: `where: { tag: :dir }` narrows on
319
324
  * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
320
325
  * variable bound to an empty string (`:dir` on a single-segment capture) is
321
- * bound, and binds the empty value. Backend's records door states the same
322
- * rule from its side (the records door's contract, §6b).
326
+ * bound, and binds the empty value. The backend answering the records service
327
+ * states the same rule from its side (the records contract, §6b).
323
328
  *
324
329
  * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
325
330
  * to an intentional list page. Only an authoring surface can catch that; this
@@ -385,17 +390,17 @@ function bindWhere(where, variables) {
385
390
  }
386
391
 
387
392
  /**
388
- * `scope:` on a lane that cannot be ASKED — the compiled file, an address door
389
- * is the same question as `where: { path: { under: scope } }`: a record's
393
+ * `scope:` on a lane that cannot be ASKED — the compiled file is the same
394
+ * question as `where: { path: { under: scope } }`: a record's
390
395
  * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
391
396
  * is segment-aware containment. Folding it keeps the language the INTERSECTION
392
397
  * of both lanes: an author
393
398
  * writes `scope: :dir` once and it means the same branch on a static site and
394
- * on a door that takes `scope` natively. A config addressed to a question door
395
- * (`door`) keeps `scope` as the door's own field.
399
+ * on a service that takes `scope` natively. A config carrying `ask` keeps
400
+ * `scope` as the service's own field.
396
401
  */
397
402
  function foldScope(cfg) {
398
- if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.door) return cfg
403
+ if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.ask) return cfg
399
404
  const { scope, ...rest } = cfg
400
405
  const under = { path: { under: scope } }
401
406
  const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
@@ -411,24 +416,20 @@ function foldScope(cfg) {
411
416
  * a list with a separate record address is a list of partial records: a live
412
417
  * lane answers a list at brief depth and a record in full, and a `deferred:`
413
418
  * query's compiled file is the stripped list. `full` otherwise. An explicit
414
- * `depth` on the config wins (a question door's client sets it).
419
+ * `depth` on the config wins (the records service's client sets it).
415
420
  *
416
- * `locale` — stamped on a LIVE-lane config only. A compiled path already carries
417
- * its locale (`/fr/data/…`), but a live address does not, and two locales'
418
- * records must not share a cache entry (F1). Absent on the default locale, so a
419
- * site with one language sees no new field.
421
+ * `locale` — stamped on an ASKED config only. A compiled path already carries
422
+ * its locale (`/fr/data/…`); the service is asked in one locale (it is in the
423
+ * route), and two locales' answers must not share a cache entry.
420
424
  */
421
425
  function stampDepthAndLocale(cfg, locale, defaultLocale) {
422
426
  let out = cfg
423
427
  if (out.depth !== 'brief' && out.depth !== 'full') {
424
428
  out = { ...out, depth: out.detail ? 'brief' : 'full' }
425
429
  }
426
- if (out.endpoint && locale && locale !== defaultLocale && out.locale === undefined) {
427
- out = { ...out, locale }
428
- }
429
- // A door is asked in exactly one locale — it is in the route — so the config
430
+ // The service is asked in exactly one locale it is in the route — so the config
430
431
  // carries it whatever the locale is; two locales' answers never share an entry.
431
- if (out.door && out.locale === undefined) {
432
+ if (out.ask && out.locale === undefined) {
432
433
  const asked = locale ?? defaultLocale
433
434
  if (asked) out = { ...out, locale: asked }
434
435
  }
package/src/index.js CHANGED
@@ -20,15 +20,8 @@ export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
- // `resolveQueryAddress` / `resolveRecordAddressPattern` are NOT re-exported
24
- // here. They are read by `./fetch-config.js` and by nothing else in any repo
25
- // (measured 2026-09-01 over every .js/.jsx/.mjs/.ts/.tsx file in the
26
- // workspace). A consumer that needs them imports `@uniweb/core/query-address`,
27
- // which is a declared subpath and a zero-dependency leaf — the same reach
28
- // `route-match` and `section-id` already have. Re-exporting an internal from
29
- // the package entry is not free: the import-map bridge enumerates this file's
30
- // surface and emits a live named re-export for every one, so nothing here can
31
- // ever be tree-shaken on the hosted lane.
23
+ // `resolveRecordsService` / `RECORDS_SERVICE` are NOT re-exported here: `./fetch-config.js`
24
+ // reads them and a consumer that needs them imports `@uniweb/core/records-service`.
32
25
  export { resolveFetchConfigs } from './fetch-config.js'
33
26
  export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
34
27
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
@@ -52,7 +45,7 @@ export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
52
45
  // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
53
46
  // which is what keeps the static and live lanes answering a query identically.
54
47
  export { parseSort, sortRecords, sortToWire } from './sort.js'
55
- export { fillRoutePattern, splitPathCapture, joinPathCapture } from './route-match.js'
48
+ export { fillRoutePattern, splitPathCapture, joinPathCapture, recordHandle, routeParamValue } from './route-match.js'
56
49
  export { isRichSchema } from './schemas.js'
57
50
  // ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
58
51
  // object graph this package exists to define, and putting it here made every
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Where a site's queries are answered — the `records` service.
3
+ *
4
+ * ## The one idea
5
+ *
6
+ * A site names a query; it never names where its records live. Where they live
7
+ * is a **deployment** fact, and the two possible answers have different owners:
8
+ *
9
+ * 1. **A host that answers questions** offers the `records` service — an
10
+ * address with a `{locale}` slot, which takes a POST carrying the whole
11
+ * query. It owns every segment of it; the runtime substitutes the one slot
12
+ * and sends the question. ⛔ The question is composed elsewhere
13
+ * (`fetch-config.js`); this file only says where it goes.
14
+ * 2. **Nobody** — and the answer is the artifact the build itself emitted,
15
+ * `/data/<name>.json`, which is not an address at all but a path in the
16
+ * site's own URL space.
17
+ *
18
+ * Absence of (1) is therefore not an error and not a decline: it falls THROUGH
19
+ * to (2). That is what makes a site with no backend the default rather than a
20
+ * special case.
21
+ *
22
+ * ## ⭐ It is a SERVICE, and that is the whole of its shape
23
+ *
24
+ * *[Diego, 2026-09-06: "`records` does sound like a service. It is the service
25
+ * of having a dynamic record provider."]* So it sits in `config.services`
26
+ * beside `search`, `submit` and `assistant`, is read with the same
27
+ * `readEndpoint` rule (string shorthand or `{ endpoint }`), and follows the
28
+ * same law: **presence is the switch.** A host that offers a records lane
29
+ * stamps the row; one that does not, does not.
30
+ *
31
+ * ⛔ **It did NOT used to be.** Until 2026-09-06 it was `config.records`, an
32
+ * object of its own whose only surviving key was `query` — a wrapper left over
33
+ * from the day there were two lanes to choose between. One host-stamped address
34
+ * read by two mechanisms was the accident; a service row is the shape it always
35
+ * had.
36
+ *
37
+ * ## ⛔ The HOST tier only — and the reason is mechanical, not a policy
38
+ *
39
+ * `resolveService` reads the site's authored tier first and the host's second.
40
+ * This does not, because it cannot: its caller `resolveFetchConfigs` is a leaf
41
+ * that runs in an SSR isolate over a plain content object with no `Website` to
42
+ * hand. It reads `config.services` directly, so the authored tier is not
43
+ * available here. ⇒ **A site does not declare its own record provider**, which
44
+ * is also the standing ruling — a third-party source is a foundation transport,
45
+ * not site-level config *(2026-09-04, the retired `fetcher:` vocabulary)*.
46
+ *
47
+ * ## ⛔ A pattern, not a base — and the reason is a deleted function
48
+ *
49
+ * A base assumes the layout is "root plus one segment". A pattern assumes
50
+ * nothing, so a host can carry a site id, a locale segment, a different root
51
+ * for records than for the site, or none of those, and move any of it without a
52
+ * framework release. This is the `config.assets.url` rule applied to records:
53
+ * the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's path
54
+ * layout, inside a published CLI — and it was deleted rather than
55
+ * parameterized. Substituting `{locale}` is the WHOLE of what this does.
56
+ *
57
+ * Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
58
+ * isolate can both import it.
59
+ */
60
+
61
+ import { substitutePlaceholders } from './substitute-placeholders.js'
62
+ import { readEndpoint } from './services.js'
63
+
64
+ const warnedPatterns = new Set()
65
+
66
+ function warnOnce(key, message) {
67
+ if (warnedPatterns.has(key)) return
68
+ warnedPatterns.add(key)
69
+ console.warn(`[records-service] ${message}`)
70
+ }
71
+
72
+ /** Test seam — reset the once-per-pattern memo so suites do not leak. */
73
+ export function _resetRecordsServiceWarnings() {
74
+ warnedPatterns.clear()
75
+ }
76
+
77
+ /**
78
+ * The service name a host stamps to say it answers queries.
79
+ *
80
+ * One constant, changed in one place should it ever move.
81
+ */
82
+ export const RECORDS_SERVICE = 'records'
83
+
84
+ /**
85
+ * The address a query is asked at, substituted for one locale, or `null` when
86
+ * no host offers the service.
87
+ *
88
+ * A row present with no address is a host saying "not for this site" —
89
+ * indistinguishable, for a caller, from no row at all. Both fall through to the
90
+ * compiled artifact.
91
+ *
92
+ * The locale is a ROUTE SEGMENT there, never a query param: a request that
93
+ * cannot name one does not address the service at all.
94
+ *
95
+ * @param {Object|null} services - `config.services`
96
+ * @param {string|null} locale - the locale being rendered; required
97
+ * @returns {string|null}
98
+ */
99
+ export function resolveRecordsService(services, locale) {
100
+ if (!services || typeof services !== 'object' || Array.isArray(services)) return null
101
+ const endpoint = readEndpoint(services[RECORDS_SERVICE])
102
+ if (!endpoint) return null
103
+ if (typeof locale !== 'string' || locale.length === 0) return null
104
+ if (!endpoint.includes('{locale}')) {
105
+ warnOnce(
106
+ `records:${endpoint}`,
107
+ `config.services.${RECORDS_SERVICE} carries no {locale} placeholder; the service takes the ` +
108
+ `locale as a route segment. Ignoring it.`
109
+ )
110
+ return null
111
+ }
112
+ return substitutePlaceholders(endpoint, { locale })
113
+ }
@@ -238,6 +238,48 @@ export function joinPathCapture({ dir, slug } = {}) {
238
238
  return d ? `${d}/${slug}` : String(slug)
239
239
  }
240
240
 
241
+ /**
242
+ * A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
243
+ * what a `[slug]` route (or the last segment of a `[...path]` one) matches.
244
+ *
245
+ * ⭐ Two lanes spell it differently and mean one thing. A host's records service
246
+ * serves the entry's handle as `$name` — `$`-namespaced because a Model may
247
+ * declare its own `name` or `slug` field (five of eight seeded briefs do), and
248
+ * the placement must not be shadowed by one. The file lane derives it from the
249
+ * source filename and calls it `slug`. `$name` wins when present: on a live
250
+ * record a Model field named `slug` is the author's data, not the placement.
251
+ *
252
+ * @param {Object} record
253
+ * @returns {string|undefined}
254
+ */
255
+ export function recordHandle(record) {
256
+ if (!record || typeof record !== 'object') return undefined
257
+ const name = record.$name
258
+ if (typeof name === 'string' && name.length) return name
259
+ return record.slug
260
+ }
261
+
262
+ /**
263
+ * The value a record carries for a route param. `slug` — the default param, and
264
+ * the last segment of a `[...path]` route — is the placement handle
265
+ * (`recordHandle`); any other param is a field the site chose to route by.
266
+ *
267
+ * ⛔ Every reader that matches a delivered record to a route param goes through
268
+ * this — the entity store, the website's dynamic page, the kit's detail hook,
269
+ * the href encoder below. Until 2026-09-04 each read `item[paramName]` directly,
270
+ * so a record served with `$name` and no `slug` matched nothing: a template page
271
+ * on a live lane rendered `[]` and a list linked to no record.
272
+ *
273
+ * @param {Object} record
274
+ * @param {string} paramName
275
+ * @returns {*}
276
+ */
277
+ export function routeParamValue(record, paramName) {
278
+ if (!record || typeof record !== 'object') return undefined
279
+ if (paramName === 'slug') return recordHandle(record)
280
+ return record[paramName]
281
+ }
282
+
241
283
  /**
242
284
  * Fill a route pattern's params from a record — the ONE encoder for a record's href.
243
285
  *
@@ -274,16 +316,17 @@ export function fillRoutePattern(pattern, values) {
274
316
  // slashes between them kept as structure. `dir` is the placement; a record
275
317
  // carries it as `path` (the folder `records.yml` put it in), which is why
276
318
  // `path` here is read as the DIRECTORY and never as a composed capture.
277
- if (joinPathCapture({ dir: values.dir ?? values.path, slug: values.slug }) === null) return null
319
+ const handle = recordHandle(values)
320
+ if (joinPathCapture({ dir: values.dir ?? values.path, slug: handle }) === null) return null
278
321
  const dir = String(values.dir ?? values.path ?? '')
279
322
  const segments = dir.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
280
323
  // The handle is ONE segment whatever it contains: a `/` inside it is a value.
281
- segments.push(encodeURIComponent(String(values.slug)))
324
+ segments.push(encodeURIComponent(String(handle)))
282
325
  tailHref = '/' + segments.join('/')
283
326
  head = pattern.slice(0, tail.index)
284
327
  }
285
328
  const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
286
- const value = values[name]
329
+ const value = routeParamValue(values, name)
287
330
  if (value === undefined || value === null || value === '') {
288
331
  missing = true
289
332
  return ''
package/src/services.js CHANGED
@@ -96,7 +96,7 @@ const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
96
96
  * @param {*} declaration
97
97
  * @returns {string} the endpoint, or '' when there is none
98
98
  */
99
- function readEndpoint(declaration) {
99
+ export function readEndpoint(declaration) {
100
100
  if (typeof declaration === 'string') return declaration.trim()
101
101
  if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
102
102
  return ''
package/src/sort.js CHANGED
@@ -7,11 +7,11 @@
7
7
  * entity store's refine-order sort — and two of them split on commas and honoured
8
8
  * several keys while the one shipped wire dialect documented the same, so a site
9
9
  * authoring `sort: order asc, title asc` worked on the static lane and would have
10
- * been refused by the records door, which takes one key. The language is the
10
+ * been refused by the records service, which takes one key. The language is the
11
11
  * INTERSECTION of what both lanes honour, so a comma is refused here rather than
12
12
  * half-honoured somewhere.
13
13
  *
14
- * Author spelling, unchanged: `date`, `date asc`, `date desc`. The records door's
14
+ * Author spelling, unchanged: `date`, `date asc`, `date desc`. The records service's
15
15
  * spelling is `date` / `-date`; `-date` is accepted on the way in so a value that
16
16
  * came off the wire round-trips, and `sortToWire` produces it on the way out.
17
17
  *
@@ -67,7 +67,7 @@ export function parseSort(sort) {
67
67
  }
68
68
 
69
69
  /**
70
- * The door's spelling of a sort: `date` ascending, `-date` descending.
70
+ * The service's spelling of a sort: `date` ascending, `-date` descending.
71
71
  *
72
72
  * @param {string|{field:string, desc?:boolean}|null|undefined} sort
73
73
  * @returns {string|null}
package/src/website.js CHANGED
@@ -11,7 +11,7 @@ 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, routePatternToRegex, splitPathCapture } from './route-match.js'
14
+ import { matchDynamicRoute, decodeRouteValue, routePatternToRegex, splitPathCapture, routeParamValue } from './route-match.js'
15
15
  import { resolveFetchConfigs } from './fetch-config.js'
16
16
  import { buildDetailConfig } from './detail-url.js'
17
17
  import { resolveService } from './services.js'
@@ -615,8 +615,8 @@ export default class Website {
615
615
  //
616
616
  // ⛔ RESOLVED THE WAY THE ENTITY STORE RESOLVES IT, not the raw declaration.
617
617
  // Until 2026-09-04 this peeked `parentPage.fetch` as authored — `{ query,
618
- // path, as }` — while the store writes under the RESOLVED config: on a live
619
- // lane that carries `endpoint` and no `path`, and on a non-default locale a
618
+ // path, as }` — while the store writes under the RESOLVED config: on the
619
+ // asked lane that carries `ask` and no `path`, and on a non-default locale a
620
620
  // `/fr/data/…` path. Two different keys for one dataset, so the probe
621
621
  // missed on exactly those lanes: no title, no not-found, and the page was
622
622
  // never cached (`recordsLoaded` false on every visit). Silent, on a
@@ -635,7 +635,7 @@ export default class Website {
635
635
  locale: this.getActiveLocale(),
636
636
  defaultLocale: this.getDefaultLocale(),
637
637
  queries: this.config?.queries ?? null,
638
- records: this.config?.records ?? null,
638
+ services: this.config?.services ?? null,
639
639
  variables,
640
640
  }).get(pluralSchema)
641
641
  if (fetchConfig) {
@@ -649,8 +649,11 @@ export default class Website {
649
649
  ? buildDetailConfig(fetchConfig, { paramName, paramValue })
650
650
  : null
651
651
  const detailCached = detailCfg ? this.fetcher.peek(detailCfg, ctx) : null
652
- const record = detailCached?.data
653
- if (record && typeof record === 'object' && !Array.isArray(record)) currentItem = record
652
+ // The service answers the record question as a list of one (a question's
653
+ // answer is always a list); a per-record file answers the bare record.
654
+ const raw = detailCached?.data
655
+ const record = Array.isArray(raw) ? raw[0] : raw
656
+ if (record && typeof record === 'object') currentItem = record
654
657
 
655
658
  const cached = this.fetcher.peek(fetchConfig, ctx)
656
659
  items = Array.isArray(cached?.data) ? cached.data : []
@@ -658,7 +661,7 @@ export default class Website {
658
661
  }
659
662
 
660
663
  if (!currentItem) {
661
- currentItem = items.find(item => String(item[paramName]) === String(paramValue)) ?? null
664
+ currentItem = items.find(item => String(routeParamValue(item, paramName)) === String(paramValue)) ?? null
662
665
  }
663
666
 
664
667
  if (currentItem) {
@@ -1,176 +0,0 @@
1
- /**
2
- * Resolve a query request to an address the fetcher can call.
3
- *
4
- * ## The one idea
5
- *
6
- * A site names a query; it never names where its records live. Where
7
- * it lives is a **deployment** fact, and the two possible answers have different
8
- * owners:
9
- *
10
- * 1. **A host that serves records live** declares a pair of URL *patterns*
11
- * at `config.records`. It owns every segment of them.
12
- * 2. **Nobody** — and the answer is the artifact the build itself emitted,
13
- * `/data/<name>.json`, which is not an address at all but a path in the
14
- * site's own URL space.
15
- *
16
- * Absence of (1) is therefore not an error and not a decline: it falls THROUGH
17
- * to (2). That is what makes a site with no backend the default rather than a
18
- * special case, and it is why this does not go through `resolveService` — a
19
- * service's absence means the site has no such feature and the caller draws
20
- * nothing, which is right for `submit` and wrong here, where the fallback is a
21
- * file the build knows it wrote.
22
- *
23
- * ## ⛔ Patterns, not a base — and the reason is a deleted function
24
- *
25
- * A base assumes the layout is "root plus one segment". A pattern assumes
26
- * nothing, so a host can carry a site id, a locale segment, a different root
27
- * for records than for the list, or none of those, and move any of it
28
- * without a framework release.
29
- *
30
- * This is the `config.assets.url` rule applied to records. That pattern exists
31
- * because the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
32
- * path layout, inside a published CLI, on a release cadence the backend could
33
- * not move. It was deleted rather than parameterized. Composing a segment of
34
- * our own here would rebuild exactly that coupling, on a lane where the wrong
35
- * answer is *stale or missing content* rather than a visible 404.
36
- *
37
- * ⇒ Substituting `{path}` and `{param}` is the WHOLE of what this does.
38
- *
39
- * Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
40
- * isolate can both import it.
41
- */
42
-
43
- import { substitutePlaceholders } from './substitute-placeholders.js'
44
-
45
- /**
46
- * The placeholder a list pattern must carry.
47
- *
48
- * ⛔ IT IS `{path}`, NOT `{query}`, AND THAT IS NOT COSMETIC. A *query* is
49
- * framework's own build concept — a named set our build compiles to one file. A host
50
- * serving records has no such thing: it has content organised somewhere, and what we
51
- * substitute is a **path** to it. Naming the slot for our file vocabulary put that
52
- * vocabulary into a string a HOST writes, which makes them reason in our shape.
53
- */
54
- const PATH_SLOT = '{path}'
55
- /** The placeholder a record pattern must carry to address a specific record. */
56
- const PARAM_SLOT = '{param}'
57
-
58
- const warnedPatterns = new Set()
59
-
60
- function warnOnce(key, message) {
61
- if (warnedPatterns.has(key)) return
62
- warnedPatterns.add(key)
63
- console.warn(`[query-address] ${message}`)
64
- }
65
-
66
- /** Test seam — reset the once-per-pattern memo so suites do not leak. */
67
- export function _resetQueryAddressWarnings() {
68
- warnedPatterns.clear()
69
- }
70
-
71
- /**
72
- * Is this a usable lane declaration?
73
- *
74
- * A declaration present with no pattern is a host saying "not for this site" —
75
- * indistinguishable, for a caller, from no declaration at all. Both fall
76
- * through to the artifact.
77
- */
78
- function readPattern(lane, key) {
79
- if (!lane || typeof lane !== 'object' || Array.isArray(lane)) return null
80
- const pattern = lane[key]
81
- return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
82
- }
83
-
84
- /**
85
- * The address for a whole query's records, or `null` to fall through to the artifact.
86
- *
87
- * ⚠️ A pattern that does not carry `{path}` is REFUSED rather than used.
88
- * Substituting nothing would yield one identical URL for every query on the
89
- * site — every schema reading the same records, with a 200 on each request. That
90
- * is the failure this check exists for; an unusable pattern must degrade to the
91
- * artifact, which is at least correct.
92
- *
93
- * @param {string} query - the query's authored name (the wiring key).
94
- * @param {Object|null} lane - `config.records`.
95
- * @returns {string|null} the address, or null when nothing usable is declared.
96
- */
97
- export function resolveQueryAddress(query, lane) {
98
- if (typeof query !== 'string' || query.length === 0) return null
99
- const pattern = readPattern(lane, 'list')
100
- if (!pattern) return null
101
- if (!pattern.includes(PATH_SLOT)) {
102
- warnOnce(
103
- `list:${pattern}`,
104
- `config.records.list carries no ${PATH_SLOT} placeholder, so every ` +
105
- `query would resolve to the same address. Ignoring it and reading the ` +
106
- `compiled file instead.`
107
- )
108
- return null
109
- }
110
- return substitutePlaceholders(pattern, { path: query })
111
- }
112
-
113
- /**
114
- * The QUESTION door a host declares — a POST address with a `{locale}` slot —
115
- * substituted for one locale, or `null` when the lane declares none.
116
- *
117
- * The stamp key is `config.records.query` — read here as a provisional spelling
118
- * on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
119
- * contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
120
- * One constant, changed in one place should it ever move. Everything downstream
121
- * wakes only when a host stamps it AND the payload carries the query's Model ref
122
- * (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
123
- * there, never a query param: a request that cannot name one does not address
124
- * this door at all.
125
- *
126
- * @param {Object|null} lane - `config.records`
127
- * @param {string|null} locale - the locale being rendered; required
128
- * @returns {string|null}
129
- */
130
- export const QUERY_DOOR_KEY = 'query'
131
-
132
- export function resolveQueryDoor(lane, locale) {
133
- const pattern = readPattern(lane, QUERY_DOOR_KEY)
134
- if (!pattern) return null
135
- if (typeof locale !== 'string' || locale.length === 0) return null
136
- if (!pattern.includes('{locale}')) {
137
- warnOnce(
138
- `query:${pattern}`,
139
- `config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
140
- `locale as a route segment. Ignoring it.`
141
- )
142
- return null
143
- }
144
- return substitutePlaceholders(pattern, { locale })
145
- }
146
-
147
- /**
148
- * The address pattern for ONE record of a query, with `{param}` left in
149
- * place for the dynamic-route substitution that happens later.
150
- *
151
- * Returning a pattern rather than a finished URL is deliberate: the route param
152
- * is not known here, and the framework already has one place that resolves it
153
- * (`buildDetailConfig` / `substitutePlaceholders` at fetch time). Resolving it
154
- * twice, in two places, is how the two copies drift.
155
- *
156
- * @param {string} query
157
- * @param {Object|null} lane - `config.records`.
158
- * @returns {string|null} a pattern still containing `{param}`, or null.
159
- */
160
- export function resolveRecordAddressPattern(query, lane) {
161
- if (typeof query !== 'string' || query.length === 0) return null
162
- const pattern = readPattern(lane, 'record')
163
- if (!pattern) return null
164
- if (!pattern.includes(PARAM_SLOT)) {
165
- warnOnce(
166
- `record:${pattern}`,
167
- `config.records.record carries no ${PARAM_SLOT} placeholder, so every record ` +
168
- `would resolve to the same address. Ignoring it and reading the per-record ` +
169
- `file instead.`
170
- )
171
- return null
172
- }
173
- // Only `{path}` is substituted here — `{param}` survives for the
174
- // dynamic-route resolution that owns it.
175
- return substitutePlaceholders(pattern, { path: query })
176
- }