@uniweb/runtime 0.15.0 → 0.16.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/runtime",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.20.0",
39
+ "@uniweb/core": "^0.21.0",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.37.1"
47
+ "@uniweb/build": "0.38.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -6,22 +6,25 @@
6
6
  * starter/docs/marketing templates hitting /data/*.json — ride on this path
7
7
  * with zero config, and so does a site a host serves live.
8
8
  *
9
- * ⭐ It speaks exactly THREE lanes, and takes NO site-level vocabulary for a
9
+ * ⭐ It speaks exactly TWO lanes, and takes NO site-level vocabulary for a
10
10
  * backend of the author's own:
11
11
  *
12
12
  * - a compiled file — `path:` under the site's base (`/data/<query>.json`,
13
13
  * a per-record file), or a plain JSON `url:` the author wrote;
14
- * - the host's ADDRESS DOOR — `endpoint:`, resolved upstream from the
15
- * `config.records` stamp (`@uniweb/core/query-address`), unwrapped with
16
- * the stamp's own `envelope.records`;
17
14
  * - the host's QUESTION DOOR — `door:`, one POST per tick carrying every
18
15
  * question the page asked, answered per key (the records door's contract,
19
16
  * as this client reads it).
20
17
  *
21
18
  * `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
22
- * first two lanes return — with `@uniweb/core`'s one evaluator, the same the
23
- * build uses to materialize a file — and by the source on the third. Nothing
24
- * decides that per site: the LANE decides.
19
+ * first lane returns — with `@uniweb/core`'s one evaluator, the same the build
20
+ * uses to materialize a file — and by the source on the door. Nothing decides
21
+ * that per site: the LANE decides.
22
+ *
23
+ * ⛔ A third lane — the host's ADDRESS door, a GET per query with the query
24
+ * evaluated locally over the whole set — was retired 2026-09-04 by ruling,
25
+ * with no hosted site to protect: one host answering one query two ways, and a
26
+ * precedence between the two, was where the failure lived. The stamp's `list`,
27
+ * `record` and `envelope` keys are not read.
25
28
  *
26
29
  * ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
27
30
  * `supports`, `request.style` / `request.rename` and the `json-body`
@@ -67,9 +70,6 @@ import {
67
70
  * for subpath deployments. Remote URLs pass through unchanged.
68
71
  * @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
69
72
  * throws instead of delivering the records unsorted.
70
- * @param {Object|null} [options.records=null] - The host's `config.records`
71
- * stamp; its `envelope.records` names the key a list sits under on the
72
- * address door.
73
73
  * @param {Function|null} [options.fetch=null] - The transport. A host executing
74
74
  * fetches outside a browser (an SSR isolate) decides how a site-relative
75
75
  * address is dispatched — through its own origin or a service binding — and
@@ -77,23 +77,10 @@ import {
77
77
  * test stub installed later is honoured.
78
78
  * @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
79
79
  */
80
- export function createDefaultFetcher({ basePath = '', dev = false, records = null, fetch: fetchImpl = null } = {}) {
80
+ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {
81
81
  const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init)
82
82
  const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
83
83
 
84
- // ⭐ The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
85
- // that answers a records request, so where the array sits in ITS response is its to
86
- // declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
87
- // key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
88
- // agreed one (2026-08-30: `collection` retired; ⛔ not `list`, which is a URL pattern on the
89
- // same stamp). It applies only to a request that resolved to that lane (`endpoint` set).
90
- // Ruled 2026-09-03 [Diego]: the backend sets `config.records`; the fetch comes from the
91
- // runtime.
92
- const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
93
- && typeof records.envelope.records === 'string' && records.envelope.records.length)
94
- ? records.envelope.records
95
- : null
96
- const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
97
84
 
98
85
  // ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
99
86
  //
@@ -103,8 +90,18 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
103
90
  // The batch response is never cached as
104
91
  // one: each request gets its own answer, keyed by its own question.
105
92
  const doorQueues = new Map()
106
- const askDoor = (request, ctx) =>
107
- new Promise((resolve) => {
93
+ const askDoor = (request, ctx) => {
94
+ // A door question needs the query's Model ref. A payload that stamps the
95
+ // door and carries no `config.queries` entry for the query cannot ask; that
96
+ // is a producer defect and it is said here, per key, with no request made.
97
+ if (typeof request.schema !== 'string' || !request.schema) {
98
+ return Promise.resolve({
99
+ data: null,
100
+ error: `the payload stamps a records door but carries no Model ref for query ` +
101
+ `"${request.query ?? request.as}" (config.queries) — the door cannot be asked`,
102
+ })
103
+ }
104
+ return new Promise((resolve) => {
108
105
  const url = resolveServiceUrl(request.door, pathPrefix)
109
106
  let queue = doorQueues.get(url)
110
107
  if (!queue) {
@@ -117,6 +114,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
117
114
  }
118
115
  queue.push({ request, ctx, resolve })
119
116
  })
117
+ }
120
118
 
121
119
  return {
122
120
  /**
@@ -134,7 +132,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
134
132
  async resolve(request, ctx = {}) {
135
133
  if (!request) return { data: null }
136
134
  if (request.door) return askDoor(request, ctx)
137
- const { path, url, endpoint, transform, body: rawBody } = request
135
+ const { path, url, transform, body: rawBody } = request
138
136
 
139
137
  // Normalize method. Only GET and POST are supported by the default
140
138
  // fetcher — mutations (PUT/PATCH/DELETE) are a different feature
@@ -146,24 +144,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
146
144
  }
147
145
 
148
146
  let target
149
- if (endpoint) {
150
- // The host's address door, resolved upstream from the pattern it
151
- // published. FINAL ON ARRIVAL: the site `base` is applied to a rooted
152
- // address — the same rule every other site-relative address follows,
153
- // shared with `resolveServiceUrl` rather than spelled a second time —
154
- // and nothing else is joined onto it. A pattern may carry a site id,
155
- // its own root, any layout at all; none of it is ours.
156
- target = resolveServiceUrl(endpoint, pathPrefix)
157
- // ⭐ The locale rides as a query param on the address door — the config
158
- // carries one only on a non-default-locale live request (F1). Until
159
- // 2026-09-04 nothing put it on the wire, so localized fields arrived as
160
- // `{lang: …}` maps; hosting confirmed that day that an appended `?locale=`
161
- // passes through their reshape verbatim, on both the browser and the
162
- // isolate path, and backend answers it with the locale's strings.
163
- if (typeof request.locale === 'string' && request.locale) {
164
- target += (target.includes('?') ? '&' : '?') + 'locale=' + encodeURIComponent(request.locale)
165
- }
166
- } else if (path) {
147
+ if (path) {
167
148
  // Local file under public/ — basePath applies for subpath deploys.
168
149
  target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
169
150
  ? pathPrefix + path
@@ -172,7 +153,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
172
153
  // A URL the author wrote, sent exactly as written.
173
154
  target = url
174
155
  } else {
175
- return { data: [], error: 'No path, url or endpoint specified' }
156
+ return { data: [], error: 'No path, url or door specified' }
176
157
  }
177
158
 
178
159
  const init = { signal: ctx.signal, method }
@@ -196,11 +177,10 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
196
177
  const response = await doFetch(target, init)
197
178
 
198
179
  // A per-request envelope (set by the object form of `detail:`) describes
199
- // this one response; on the address door the stamp describes the lane.
200
- const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
180
+ // this one response.
181
+ const envelope = (request.envelope && typeof request.envelope === 'object')
201
182
  ? request.envelope
202
- : null
203
- const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {})
183
+ : {}
204
184
 
205
185
  if (!response.ok) {
206
186
  // If `envelope.error` names a path, try to extract a human message
@@ -256,11 +236,10 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
256
236
  // it returned.
257
237
  data = applyOperators(data, request, { dev })
258
238
 
259
- // ⭐ Say what depth was delivered, so the record index can file it. On
260
- // the address door that is what the config asked for a list at brief
261
- // depth when the query has a per-record source, a record in full so
262
- // the config's `depth` is echoed. A door that reports `depths` per key
263
- // will override this with what it actually served.
239
+ // ⭐ Say what depth was delivered, so the record index can file it — what
240
+ // the config asked for, echoed: a list at brief depth when the query has
241
+ // a per-record source, a record in full. (A door reports `depths` per
242
+ // key and overrides this with what it actually served.)
264
243
  const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
265
244
  return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
266
245
  } catch (error) {
@@ -315,12 +294,15 @@ function renameOperators(where) {
315
294
  /**
316
295
  * Send one batch to a door and hand each question its own answer.
317
296
  *
318
- * The response is `{ data, depths?, errors? }` (contract §5): `data` answers
319
- * exactly the keys sent, `[]` when nothing matched; a key that ERRORED is absent
320
- * from `data` and present in `errors`; `depths` says what was actually served,
321
- * which the record index files rather than what was asked for. A key missing
322
- * from both is a protocol violation and is reported as an error, never as
323
- * silence.
297
+ * The response is `{ data, depths?, errors?, cursors?, limits? }` (contract §5):
298
+ * `data` answers exactly the keys sent, `[]` when nothing matched; a key that
299
+ * ERRORED is absent from `data` and present in `errors`; `depths` says what was
300
+ * actually served, which the record index files rather than what was asked for.
301
+ * A key missing from both is a protocol violation and is reported as an error,
302
+ * never as silence. `cursors` (a next page per key) and `limits` (a `limit` the
303
+ * door bounded) are received and IGNORED, by ruling: framework has no paging
304
+ * concept and is not this door's only client, so whether either is consumed is
305
+ * a product decision, not a client default.
324
306
  */
325
307
  async function flushDoor(url, queue, doFetch) {
326
308
  const body = {}
@@ -340,7 +322,19 @@ async function flushDoor(url, queue, doFetch) {
340
322
  body: JSON.stringify(body),
341
323
  })
342
324
  if (!response.ok) {
343
- const error = `HTTP ${response.status}: ${response.statusText}`
325
+ // A protocol violation is refused for the WHOLE request with a problem body
326
+ // whose `detail` names the key and the fault (an unknown operator, an empty
327
+ // binding key, a non-BCP-47 locale segment…). Surface that sentence on every
328
+ // key of the batch rather than the bare status: the author reads
329
+ // `block.dataError` and the status alone says nothing they can act on.
330
+ let detail = null
331
+ try {
332
+ const problem = safeParseJSON(await response.text())
333
+ if (problem && typeof problem.detail === 'string' && problem.detail) detail = problem.detail
334
+ } catch { /* an unreadable body falls back to the status line */ }
335
+ const error = detail
336
+ ? `HTTP ${response.status}: ${detail}`
337
+ : `HTTP ${response.status}: ${response.statusText}`
344
338
  for (const entry of queue) entry.resolve({ data: null, error })
345
339
  return
346
340
  }
@@ -356,8 +350,14 @@ async function flushDoor(url, queue, doFetch) {
356
350
  queue.forEach((entry, i) => {
357
351
  const key = keys[i]
358
352
  if (key in errors) {
353
+ // A per-key error is `{ code, detail }` — `schema_not_found`,
354
+ // `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
355
+ // rides beside it for a reader that wants to branch on it.
359
356
  const e = errors[key]
360
- entry.resolve({ data: null, error: typeof e === 'string' ? e : (e?.message || JSON.stringify(e)) })
357
+ const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
358
+ const out = { data: null, error: detail }
359
+ if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
360
+ entry.resolve(out)
361
361
  return
362
362
  }
363
363
  if (!(key in data)) {
@@ -17,7 +17,8 @@
17
17
  * ⛔ A floor is a promise about a VERSION, not a rename guard. A site on a newer
18
18
  * runtime with a renamed export is still a missing symbol; the test above is what
19
19
  * makes that fail before it ships, and the announcement to the consumer is still
20
- * ours to send (`framework/CLAUDE.md` § Decoupling is the architecture).
20
+ * ours to send, because a consumer that bundles this package by workspace link
21
+ * gets the change at commit time, with no version to pin against.
21
22
  *
22
23
  * ⛔ Not `runtime-pin.json`. That file is emitted per FOUNDATION build and records an
23
24
  * observed fact ("built against"), never a guarantee; an isolate-API floor is a
@@ -18,8 +18,8 @@
18
18
  *
19
19
  * - **Shell assembly.** The shell arrives built. The import map, the CDN base and
20
20
  * cache headers are host layout, and a runtime that assembled them would be
21
- * modelling a deployment it cannot see (hosting drew this line themselves,
22
- * 2026-09-03; `framework/CLAUDE.md` § *Serve locations are read, never constructed*).
21
+ * modelling a deployment it cannot see (the host drew this line itself,
22
+ * 2026-09-03: a serve location is read from the payload, never constructed here).
23
23
  * - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
24
24
  * build initializes once and hydrates every collection up front, an isolate
25
25
  * initializes per locale and prefetches per route. Folding either in would fit
package/src/prefetch.js CHANGED
@@ -46,7 +46,8 @@
46
46
  * is a different cache decision (hosting, 2026-09-03).
47
47
  *
48
48
  * It resolves nothing the host owns and models no host route layout: every address is
49
- * `{base}/…` from the payload, or an endpoint the host itself published in `config.records`.
49
+ * `{base}/…` from the payload, or the question door the host itself published at
50
+ * `config.records.query`.
50
51
  */
51
52
  import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
52
53
  import { deriveCacheKey } from '@uniweb/core/datastore'
@@ -173,7 +174,6 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
173
174
  }
174
175
  const fetcher = createDefaultFetcher({
175
176
  basePath: content?.config?.base || '',
176
- records: content?.config?.records ?? null,
177
177
  dev,
178
178
  fetch,
179
179
  })
package/src/setup.js CHANGED
@@ -246,11 +246,8 @@ function buildDefaultFetcher(content) {
246
246
  // transports (`fetcher.transports`) plus a transport's own binding config,
247
247
  // which a transport reads through ctx.website.config.fetcher.
248
248
  //
249
- // The host's live-records stamp (`config.records`), when a backend set one: the fetcher
250
- // reads its `envelope` for requests that resolved to that lane.
251
- const records = content?.config?.records ?? null
252
249
  const dev = !!(import.meta.env && import.meta.env.DEV)
253
- return createDefaultFetcher({ basePath, dev, records })
250
+ return createDefaultFetcher({ basePath, dev })
254
251
  }
255
252
 
256
253
  /**