@uniweb/core 0.21.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.21.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",
@@ -44,8 +43,8 @@
44
43
  "vitest": "^4.1.7"
45
44
  },
46
45
  "dependencies": {
47
- "@uniweb/theming": "^0.1.15",
48
- "@uniweb/semantic-parser": "^1.4.0"
46
+ "@uniweb/semantic-parser": "^1.4.0",
47
+ "@uniweb/theming": "^0.1.15"
49
48
  },
50
49
  "scripts": {
51
50
  "test": "vitest run"
package/src/datastore.js CHANGED
@@ -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
40
  * `locale` is hashed on both when present: two locales' answers must not share
41
- * an entry, and a door config always carries the locale it was asked in.
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
package/src/detail-url.js CHANGED
@@ -91,12 +91,12 @@ function paramContext(paramName, paramValue, record) {
91
91
  */
92
92
  /**
93
93
  * The key a route param narrows a QUESTION by — the entry's own handle, which
94
- * a records door guarantees unique among siblings (the records door's contract
94
+ * the records service guarantees unique among siblings (the records contract
95
95
  * §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
96
96
  * moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
97
- * → `$name`); the door is dark until a host stamps it, so this is the one place
98
- * to change if it moves again. Not read by the file lane or the address door,
99
- * 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]`).
100
100
  */
101
101
  export const ROUTE_HANDLE_KEY = '$name'
102
102
 
@@ -106,13 +106,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
106
106
  const { paramName, paramValue, record = null } = dynamicContext
107
107
  if (!paramName || paramValue === undefined) return null
108
108
 
109
- // ⭐ A QUESTION DOOR: the record is the list's own question, narrowed to one
110
- // entry by its handle and asked in full — the list page and the detail page
111
- // are the same query, differing only by whether the parameter is bound
112
- // (the records door's contract, §1a). `sort` and `limit` are the
113
- // list's and drop; `scope` and the authored `where` stay, so a scoped query
114
- // cannot be escaped through the URL.
115
- 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) {
116
116
  const { sort, limit, detail: _detail, ...rest } = queryConfig
117
117
  return {
118
118
  ...rest,
@@ -124,14 +124,14 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
124
124
 
125
125
  // Two address kinds, and the detail request comes back as the SAME kind as
126
126
  // the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
127
- // `path`. (A third kind, the host's address door, was retired 2026-09-04.)
127
+ // `path`.
128
128
  const baseUrl = queryConfig.url || queryConfig.path
129
129
  if (!baseUrl) return null
130
130
  const addressKey = queryConfig.url ? 'url' : 'path'
131
131
 
132
132
  // What every detail config carries beside its address:
133
133
  // `as` — the binding key, so the record lands where the list did;
134
- // `query` — the query it is one record of (identity on a door);
134
+ // `query` — the query it is one record of (identity when asked);
135
135
  // `depth: 'full'` — what it asks for, and what the record index files it as;
136
136
  // `dynamicContext` — the route param, which the default fetcher already keys
137
137
  // a SINGLE-RECORD response on (`envelope.item`, body
@@ -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
  )
@@ -259,8 +260,8 @@ export default class EntityStore {
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) {
@@ -390,8 +391,8 @@ export default class EntityStore {
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
@@ -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 { 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,12 +115,12 @@ 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
125
 
108
126
  // ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
@@ -141,14 +159,15 @@ function applyDeferredDetail(cfg, queries, records) {
141
159
  * The author names a query; this decides where its records live, and there are
142
160
  * exactly two answers:
143
161
  *
144
- * - a host declared a question door (`config.records.query`) → a `door`, and
145
- * the whole query goes to it (`schema` from the payload's `config.queries`;
146
- * a door with no Model ref is a loud per-key error, never a fallthrough);
147
- * - 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.
148
167
  *
149
- * ⛔ There is no third answer. The ADDRESS door `config.records.list` /
150
- * `.record`, a GET lane the runtime evaluated the query over locally — was
151
- * retired 2026-09-04 by ruling (`query-address.js` says why).
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.
152
171
  *
153
172
  * ⭐ The second is not a fallback in the apologetic sense. It is the answer for
154
173
  * every site with no backend, which is the framework's default rather than a
@@ -161,32 +180,31 @@ function applyDeferredDetail(cfg, queries, records) {
161
180
  * `query`, ignoring any `path`), so the two agree rather than disagreeing on a
162
181
  * shape nobody hand-writes.
163
182
  */
164
- function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
183
+ function resolveQuerySource(cfg, services, { queries = null, locale = null, defaultLocale = null } = {}) {
165
184
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
166
185
 
167
- // ⭐ THE QUESTION DOOR FIRST. A host that answers questions gets the whole
168
- // query — `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes
169
- // no per-query address at all (the records door's contract, §2). It
170
- // needs the query's MODEL REF, which lives on the site's `config.queries`
171
- // declaration; a payload that carries the door but not the declaration
172
- // cannot ask, and falls through to the address door below. ⚠️ Dark until a
173
- // host stamps the door; see `resolveQueryDoor`.
174
- const door = resolveQueryDoor(records, locale ?? defaultLocale)
175
- 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) {
176
194
  const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
177
195
  const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
178
196
  // Drop the transitional `path`: two addresses on one request is an
179
197
  // ambiguity the fetcher would have to break by accident of field order.
180
198
  const { path, url, ...rest } = cfg
181
199
  if (!schema) {
182
- // ⛔ LOUD, not a fallthrough. A payload that stamps a door and carries no
200
+ // ⛔ LOUD, not a fallthrough. A payload that offers the service and carries no
183
201
  // Model ref for the query cannot ask, and reading the compiled file
184
202
  // instead would turn a producer defect into a 404 that names the wrong
185
- // thing. The fetcher refuses a door request with no `schema` before any
203
+ // thing. The fetcher refuses an asked request with no `schema` before any
186
204
  // request is made, and the block's `dataError` says exactly this.
187
- return { ...rest, door, schema: null }
205
+ return { ...rest, ask, schema: null }
188
206
  }
189
- const asked = { ...rest, door, schema }
207
+ const asked = { ...rest, ask, schema }
190
208
  // A saved query's own narrowing applies unless the fetch overrides it.
191
209
  if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
192
210
  if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
@@ -248,9 +266,9 @@ function bindingKey(cfg) {
248
266
  * @param {string|null} [options.locale] - the locale being rendered
249
267
  * @param {string|null} [options.defaultLocale] - the site's default locale
250
268
  * @param {Object|null} [options.queries] - the site's `config.queries`
251
- * @param {Object|null} [options.records] - the site's `config.records`, a host's
252
- * live-records lane. Absent means the compiled artifact answers, which is
253
- * 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.
254
272
  * @param {Object|null} [options.variables] - the route's variables on a template
255
273
  * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
256
274
  * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
@@ -263,7 +281,7 @@ export function resolveFetchConfigs(sources, options = {}) {
263
281
  locale = null,
264
282
  defaultLocale = null,
265
283
  queries = null,
266
- records = null,
284
+ services = null,
267
285
  variables = null,
268
286
  } = options
269
287
 
@@ -280,10 +298,10 @@ export function resolveFetchConfigs(sources, options = {}) {
280
298
  if (!collectAll && !schemas.includes(key)) continue
281
299
  // Address first: localization and deferred-detail both key on `path`,
282
300
  // which a query ref does not have until this runs.
283
- const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
301
+ const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
284
302
  const localized = localizeConfig(sourced, locale, defaultLocale)
285
303
  const bound = foldScope(bindRouteVariables(localized, variables))
286
- configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
304
+ configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries), locale, defaultLocale))
287
305
  }
288
306
  }
289
307
 
@@ -305,8 +323,8 @@ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
305
323
  * the list page and the detail page: `where: { tag: :dir }` narrows on
306
324
  * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
307
325
  * variable bound to an empty string (`:dir` on a single-segment capture) is
308
- * bound, and binds the empty value. Backend's records door states the same
309
- * 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).
310
328
  *
311
329
  * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
312
330
  * to an intentional list page. Only an authoring surface can catch that; this
@@ -372,17 +390,17 @@ function bindWhere(where, variables) {
372
390
  }
373
391
 
374
392
  /**
375
- * `scope:` on a lane that cannot be ASKED — the compiled file, an address door
376
- * 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
377
395
  * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
378
396
  * is segment-aware containment. Folding it keeps the language the INTERSECTION
379
397
  * of both lanes: an author
380
398
  * writes `scope: :dir` once and it means the same branch on a static site and
381
- * on a door that takes `scope` natively. A config addressed to a question door
382
- * (`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.
383
401
  */
384
402
  function foldScope(cfg) {
385
- if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.door) return cfg
403
+ if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.ask) return cfg
386
404
  const { scope, ...rest } = cfg
387
405
  const under = { path: { under: scope } }
388
406
  const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
@@ -398,20 +416,20 @@ function foldScope(cfg) {
398
416
  * a list with a separate record address is a list of partial records: a live
399
417
  * lane answers a list at brief depth and a record in full, and a `deferred:`
400
418
  * query's compiled file is the stripped list. `full` otherwise. An explicit
401
- * `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).
402
420
  *
403
- * `locale` — stamped on a DOOR config only. A compiled path already carries its
404
- * locale (`/fr/data/…`); a door is asked in one locale (it is in the route), and
405
- * two locales' answers must not share a cache entry.
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.
406
424
  */
407
425
  function stampDepthAndLocale(cfg, locale, defaultLocale) {
408
426
  let out = cfg
409
427
  if (out.depth !== 'brief' && out.depth !== 'full') {
410
428
  out = { ...out, depth: out.detail ? 'brief' : 'full' }
411
429
  }
412
- // 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
413
431
  // carries it whatever the locale is; two locales' answers never share an entry.
414
- if (out.door && out.locale === undefined) {
432
+ if (out.ask && out.locale === undefined) {
415
433
  const asked = locale ?? defaultLocale
416
434
  if (asked) out = { ...out, locale: asked }
417
435
  }
package/src/index.js CHANGED
@@ -20,10 +20,8 @@ export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
- // `resolveQueryDoor` / `QUERY_DOOR_KEY` are NOT re-exported here: `./fetch-config.js`
24
- // reads them and a consumer that needs them imports `@uniweb/core/query-address`.
25
- // (The address door's `resolveQueryAddress` / `resolveRecordAddressPattern` were
26
- // deleted 2026-09-04 with the lane they addressed.)
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`.
27
25
  export { resolveFetchConfigs } from './fetch-config.js'
28
26
  export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
29
27
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
@@ -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
+ }
@@ -242,7 +242,7 @@ export function joinPathCapture({ dir, slug } = {}) {
242
242
  * A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
243
243
  * what a `[slug]` route (or the last segment of a `[...path]` one) matches.
244
244
  *
245
- * ⭐ Two lanes spell it differently and mean one thing. A host's records door
245
+ * ⭐ Two lanes spell it differently and mean one thing. A host's records service
246
246
  * serves the entry's handle as `$name` — `$`-namespaced because a Model may
247
247
  * declare its own `name` or `slug` field (five of eight seeded briefs do), and
248
248
  * the placement must not be shadowed by one. The file lane derives it from the
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
@@ -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 door
619
- // lane that carries `door` 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,7 +649,7 @@ 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
- // A door answers the record question as a list of one (a question's
652
+ // The service answers the record question as a list of one (a question's
653
653
  // answer is always a list); a per-record file answers the bare record.
654
654
  const raw = detailCached?.data
655
655
  const record = Array.isArray(raw) ? raw[0] : raw
@@ -1,114 +0,0 @@
1
- /**
2
- * Resolve a query request to the one address a host can declare for it.
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** declares a QUESTION DOOR at
10
- * `config.records.query` — a POST address with a `{locale}` slot. It owns
11
- * every segment of it; the runtime substitutes the one slot and sends the
12
- * whole query. ⛔ The question is composed elsewhere (`fetch-config.js`);
13
- * 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, and it is why this does not go through `resolveService` — a
21
- * service's absence means the site has no such feature and the caller draws
22
- * nothing, which is right for `submit` and wrong here, where the fallback is a
23
- * file the build knows it wrote.
24
- *
25
- * ## ⛔ The ADDRESS door is retired (2026-09-04) — do not bring it back
26
- *
27
- * Until that day this file also read two URL PATTERNS off the same stamp —
28
- * `list` (`{path}`) and `record` (`{param}`) — for a GET lane the runtime
29
- * evaluated the query over locally. It went by ruling, with no hosted site to
30
- * protect: two lanes on one host answered one query two ways (an operator the
31
- * door refuses was honoured locally on the GET lane), the precedence between
32
- * them was where the failure lived, and the address was composed from the
33
- * query's NAME, which is not a folder path. A query is a question; a host that
34
- * cannot answer one is a host with no records lane, and its site reads the
35
- * compiled file. A host's `list` / `record` / `envelope` stamps are not read.
36
- *
37
- * ## ⛔ A pattern, not a base — and the reason is a deleted function
38
- *
39
- * A base assumes the layout is "root plus one segment". A pattern assumes
40
- * nothing, so a host can carry a site id, a locale segment, a different root
41
- * for the door than for the site, or none of those, and move any of it
42
- * without a framework release. This is the `config.assets.url` rule applied to
43
- * records: the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
44
- * path layout, inside a published CLI — and it was deleted rather than
45
- * parameterized. Substituting `{locale}` is the WHOLE of what this does.
46
- *
47
- * Zero-dependency beyond one sibling leaf, so the SSR pipeline and a Worker
48
- * isolate can both import it.
49
- */
50
-
51
- import { substitutePlaceholders } from './substitute-placeholders.js'
52
-
53
-
54
- const warnedPatterns = new Set()
55
-
56
- function warnOnce(key, message) {
57
- if (warnedPatterns.has(key)) return
58
- warnedPatterns.add(key)
59
- console.warn(`[query-address] ${message}`)
60
- }
61
-
62
- /** Test seam — reset the once-per-pattern memo so suites do not leak. */
63
- export function _resetQueryAddressWarnings() {
64
- warnedPatterns.clear()
65
- }
66
-
67
- /**
68
- * Is this a usable lane declaration?
69
- *
70
- * A declaration present with no pattern is a host saying "not for this site" —
71
- * indistinguishable, for a caller, from no declaration at all. Both fall
72
- * through to the artifact.
73
- */
74
- function readPattern(lane, key) {
75
- if (!lane || typeof lane !== 'object' || Array.isArray(lane)) return null
76
- const pattern = lane[key]
77
- return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
78
- }
79
-
80
-
81
- /**
82
- * The QUESTION door a host declares — a POST address with a `{locale}` slot —
83
- * substituted for one locale, or `null` when the lane declares none.
84
- *
85
- * The stamp key is `config.records.query` — read here as a provisional spelling
86
- * on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
87
- * contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
88
- * One constant, changed in one place should it ever move. Everything downstream
89
- * wakes only when a host stamps it AND the payload carries the query's Model ref
90
- * (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
91
- * there, never a query param: a request that cannot name one does not address
92
- * this door at all.
93
- *
94
- * @param {Object|null} lane - `config.records`
95
- * @param {string|null} locale - the locale being rendered; required
96
- * @returns {string|null}
97
- */
98
- export const QUERY_DOOR_KEY = 'query'
99
-
100
- export function resolveQueryDoor(lane, locale) {
101
- const pattern = readPattern(lane, QUERY_DOOR_KEY)
102
- if (!pattern) return null
103
- if (typeof locale !== 'string' || locale.length === 0) return null
104
- if (!pattern.includes('{locale}')) {
105
- warnOnce(
106
- `query:${pattern}`,
107
- `config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
108
- `locale as a route segment. Ignoring it.`
109
- )
110
- return null
111
- }
112
- return substitutePlaceholders(pattern, { locale })
113
- }
114
-