@uniweb/runtime 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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.21.0",
39
+ "@uniweb/core": "^0.22.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.38.0"
47
+ "@uniweb/build": "0.39.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Every record a site's declared queries return — for a host building something
3
+ * over the whole corpus rather than rendering one page.
4
+ *
5
+ * ## Why this is framework's and not the caller's
6
+ *
7
+ * A host that indexes a site has to ask the same questions the site's pages ask.
8
+ * ⛔ **If it composes its own, the index and the page can disagree**, and the way
9
+ * they disagree is the worst one available: the index offers a result whose page
10
+ * then renders empty. That is the two-answers-to-one-question failure the GET
11
+ * lane was retired for, reintroduced one lane over.
12
+ *
13
+ * ⇒ So the composition is not duplicated here either. This walks the site's
14
+ * `config.queries`, hands each one to `resolveFetchConfigs` — **the same rule a
15
+ * page render uses**, applying each saved query's own `scope` / `where` / `sort`
16
+ * / `limit` — and asks through the same client. What differs from a page is two
17
+ * fields and nothing else: `depth: 'brief'` (an index wants what a list shows)
18
+ * and `exhaustive: true` (a corpus is not a page, so it follows `cursors` to the
19
+ * end; the records contract bounds a single answer at 100).
20
+ *
21
+ * ## What the caller supplies
22
+ *
23
+ * ⭐ **The transport, and nothing else.** A host outside a browser decides how a
24
+ * site-relative address is dispatched — through its own origin, a service
25
+ * binding, a forwarding table that turns `/_…` into an upstream — and hands that
26
+ * in as `fetch`. **Framework decides what to ask and how to read the answer;
27
+ * the host decides how the bytes travel.** That split is the whole of the
28
+ * boundary here.
29
+ *
30
+ * ⛔ **A site with no records service is not an error.** It has no live records,
31
+ * so this returns empty rather than throwing: a static site's corpus comes from
32
+ * its own compiled artifacts, which the caller already has.
33
+ */
34
+
35
+ import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
36
+ import { resolveRecordsService } from '@uniweb/core/records-service'
37
+ import { createDefaultFetcher } from './default-fetcher.js'
38
+
39
+ /**
40
+ * Ask a site's records service for every record its declared queries return.
41
+ *
42
+ * @param {Object} content - the render payload (`config.services`, `config.queries`)
43
+ * @param {Object} options
44
+ * @param {string} options.locale - the locale to ask in; it is a route segment
45
+ * of the service's address, so there is no asking without one
46
+ * @param {Function} options.fetch - the transport, `(url, init) => Response`
47
+ * @param {AbortSignal} [options.signal]
48
+ * @param {string[]} [options.only] - restrict to these query names
49
+ * @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
50
+ * `records` is keyed by query NAME, each a flat array; `errors` is keyed the
51
+ * same and is null when nothing failed; `meta[name]` carries `{ depth, pages,
52
+ * truncated? }` — `truncated` meaning the loop hit its own bound, never that
53
+ * the site has more.
54
+ */
55
+ export async function collectSiteRecords(content, { locale, fetch, signal, only = null } = {}) {
56
+ const config = content?.config
57
+ const services = config?.services ?? null
58
+ const queries = config?.queries ?? null
59
+
60
+ // No service, no queries, or no locale to ask in ⇒ nothing to collect. Each is
61
+ // an ordinary state of a site, not a fault: the caller reads its own artifacts.
62
+ if (!queries || typeof queries !== 'object') return empty()
63
+ if (!resolveRecordsService(services, locale)) return empty()
64
+
65
+ const names = Object.keys(queries).filter((n) => (only ? only.includes(n) : true))
66
+ if (names.length === 0) return empty()
67
+
68
+ // One synthetic source per query — `as` is the query's own name, so the answer
69
+ // comes back under the name the caller asked by.
70
+ const configs = resolveFetchConfigs(
71
+ names.map((name) => ({ query: name, as: name })),
72
+ { services, queries, locale, defaultLocale: config?.defaultLanguage ?? locale },
73
+ )
74
+
75
+ const fetcher = createDefaultFetcher({ fetch, basePath: config?.base ?? '' })
76
+ const records = {}
77
+ const errors = {}
78
+ const meta = {}
79
+
80
+ await Promise.all([...configs].map(async ([name, cfg]) => {
81
+ // ⛔ Only a config the service answers. A query that resolved to a compiled
82
+ // `path` has no live lane, and reading that file is the caller's business,
83
+ // not ours — it is in the site's own URL space and they already serve it.
84
+ if (!cfg.ask) return
85
+ const result = await fetcher.resolve({ ...cfg, depth: 'brief', exhaustive: true }, { signal })
86
+ if (result?.error) {
87
+ errors[name] = result.error
88
+ return
89
+ }
90
+ records[name] = Array.isArray(result?.data) ? result.data : []
91
+ if (result?.meta) meta[name] = result.meta
92
+ }))
93
+
94
+ return { records, errors: Object.keys(errors).length ? errors : null, meta }
95
+ }
96
+
97
+ function empty() {
98
+ return { records: {}, errors: null, meta: {} }
99
+ }
@@ -11,21 +11,15 @@
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 QUESTION DOOR — `door:`, one POST per tick carrying every
15
- * question the page asked, answered per key (the records door's contract,
16
- * as this client reads it).
14
+ * - the host's RECORDS SERVICE — `ask:`, one POST per tick carrying every
15
+ * question the page asked, answered per key (the records contract, as this
16
+ * client reads it).
17
17
  *
18
18
  * `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
19
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
20
+ * uses to materialize a file — and at the source when asked. Nothing decides
21
21
  * that per site: the LANE decides.
22
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.
28
- *
29
23
  * ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
30
24
  * `supports`, `request.style` / `request.rename` and the `json-body`
31
25
  * request-style registry. *"3rd party endpoints must be supported at the
@@ -82,34 +76,34 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
82
76
  const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
83
77
 
84
78
 
85
- // ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
79
+ // ⭐ THE RECORDS SERVICE — a batch of the misses, one POST, merged per key.
86
80
  //
87
81
  // The entity store dispatches every config a page needs in one synchronous
88
- // loop before awaiting any of them, so a door request enqueued here and
89
- // flushed on the next microtask carries every miss of that page in one body
90
- // The batch response is never cached as
91
- // one: each request gets its own answer, keyed by its own question.
92
- const doorQueues = new Map()
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.
82
+ // loop before awaiting any of them, so a question enqueued here and flushed
83
+ // on the next microtask carries every miss of that page in one body. The
84
+ // batch response is never cached as one: each request gets its own answer,
85
+ // keyed by its own question.
86
+ const askQueues = new Map()
87
+ const askRecords = (request, ctx) => {
88
+ // ⛔ A question needs the query's Model ref. A payload that offers the
89
+ // service and carries no `config.queries` entry for the query cannot ask;
90
+ // that is a producer defect and it is said here, per key, with no request.
97
91
  if (typeof request.schema !== 'string' || !request.schema) {
98
92
  return Promise.resolve({
99
93
  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`,
94
+ error: `the payload offers the records service but carries no Model ref for query ` +
95
+ `"${request.query ?? request.as}" (config.queries) — it cannot be asked`,
102
96
  })
103
97
  }
104
98
  return new Promise((resolve) => {
105
- const url = resolveServiceUrl(request.door, pathPrefix)
106
- let queue = doorQueues.get(url)
99
+ const url = resolveServiceUrl(request.ask, pathPrefix)
100
+ let queue = askQueues.get(url)
107
101
  if (!queue) {
108
102
  queue = []
109
- doorQueues.set(url, queue)
103
+ askQueues.set(url, queue)
110
104
  queueMicrotask(() => {
111
- doorQueues.delete(url)
112
- flushDoor(url, queue, doFetch)
105
+ askQueues.delete(url)
106
+ flushAsked(url, queue, doFetch)
113
107
  })
114
108
  }
115
109
  queue.push({ request, ctx, resolve })
@@ -118,8 +112,8 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
118
112
 
119
113
  return {
120
114
  /**
121
- * The cache identity is the request's ADDRESS — or, on a question door,
122
- * the QUESTION (`deriveCacheKey` hashes every operator of an address-less
115
+ * The cache identity is the request's ADDRESS — or, when asked of the
116
+ * records service, the QUESTION (`deriveCacheKey` hashes every operator of an address-less
123
117
  * request). Operators evaluated here run over a shared cached value and
124
118
  * must NOT split the cache: two pages declaring different `where:` clauses
125
119
  * against the same path share one entry — the file is fetched once and
@@ -131,7 +125,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
131
125
 
132
126
  async resolve(request, ctx = {}) {
133
127
  if (!request) return { data: null }
134
- if (request.door) return askDoor(request, ctx)
128
+ if (request.ask) return askRecords(request, ctx)
135
129
  const { path, url, transform, body: rawBody } = request
136
130
 
137
131
  // Normalize method. Only GET and POST are supported by the default
@@ -153,7 +147,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
153
147
  // A URL the author wrote, sent exactly as written.
154
148
  target = url
155
149
  } else {
156
- return { data: [], error: 'No path, url or door specified' }
150
+ return { data: [], error: 'No path, url or ask specified' }
157
151
  }
158
152
 
159
153
  const init = { signal: ctx.signal, method }
@@ -238,7 +232,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
238
232
 
239
233
  // ⭐ Say what depth was delivered, so the record index can file it — what
240
234
  // 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
235
+ // a per-record source, a record in full. (The service reports `depths` per
242
236
  // key and overrides this with what it actually served.)
243
237
  const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
244
238
  return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
@@ -253,16 +247,16 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
253
247
  }
254
248
 
255
249
  /**
256
- * One question of a door batch, in the door's own vocabulary
257
- * (the records door's contract, §2): `schema` required, `scope` a bare
258
- * path, `sort` one key spelled `date` / `-date`, `depth` brief or full. The
259
- * where-object crosses as authored except for the two spellings the language
260
- * settled differently from the evaluator's: `nin` is `not_in` there, and a
261
- * top-level `path: { under }` — the file lane's way of naming a folder branch —
262
- * is the door's `scope`. Anything the door does not accept (`like`, a dotted
263
- * path) is sent as written and refused there by name: loud, never approximated.
250
+ * One question of a batch, in the records service's own vocabulary
251
+ * (the records contract, §2): `schema` required, `scope` a bare path, `sort`
252
+ * one key spelled `date` / `-date`, `depth` brief or full. The where-object
253
+ * crosses as authored except for the two spellings the language settled
254
+ * differently from the evaluator's: `nin` is `not_in` there, and a top-level
255
+ * `path: { under }` — the file lane's way of naming a folder branch — is
256
+ * `scope`. Anything the service does not accept (`like`, a dotted path) is
257
+ * sent as written and refused there by name: loud, never approximated.
264
258
  */
265
- function doorQuestion(request) {
259
+ function toQuestion(request) {
266
260
  const q = { schema: request.schema }
267
261
  let where = request.where && typeof request.where === 'object' ? request.where : null
268
262
  let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
@@ -277,34 +271,61 @@ function doorQuestion(request) {
277
271
  if (sort) q.sort = sort
278
272
  if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
279
273
  if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
274
+ // ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
275
+ // it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
276
+ // `exhaustive` deliberately does NOT cross — it is a client instruction about
277
+ // how many times to ask, not part of the question being asked.
278
+ if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
280
279
  return q
281
280
  }
282
281
 
283
- const DOOR_OPERATOR = { nin: 'not_in' }
282
+ const OPERATOR_ALIAS = { nin: 'not_in' }
284
283
  function renameOperators(where) {
285
284
  if (Array.isArray(where)) return where.map(renameOperators)
286
285
  if (!where || typeof where !== 'object') return where
287
286
  const out = {}
288
287
  for (const [key, value] of Object.entries(where)) {
289
- out[DOOR_OPERATOR[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
288
+ out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
290
289
  }
291
290
  return out
292
291
  }
293
292
 
294
293
  /**
295
- * Send one batch to a door and hand each question its own answer.
294
+ * Send one batch to the records service and hand each question its own answer.
296
295
  *
297
296
  * The response is `{ data, depths?, errors?, cursors?, limits? }` (contract §5):
298
297
  * `data` answers exactly the keys sent, `[]` when nothing matched; a key that
299
298
  * ERRORED is absent from `data` and present in `errors`; `depths` says what was
300
299
  * actually served, which the record index files rather than what was asked for.
301
300
  * 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.
301
+ * never as silence.
302
+ *
303
+ * `cursors` AND `limits` ARE READ 2026-09-06 [Diego], reversing the ruling
304
+ * that had them "received and IGNORED, because framework has no paging concept."
305
+ * ⛔ **That ruling described our client and was silently wrong about our USERS:**
306
+ * the service bounds every answer at 100 (the records contract §4.2/§5), and a
307
+ * `cursors` entry is how it says there is more. Discarding both meant a hosted
308
+ * list of 500 rendered 100 — no error, no warning, no way for an author to tell
309
+ * a bound from the end of the data. **The silent class, on a visitor's page.**
310
+ *
311
+ * Two behaviours, deliberately not one:
312
+ *
313
+ * - **a page render REPORTS** — `meta.truncated` and `meta.bound` ride the
314
+ * answer, and nothing pages automatically. Auto-paging here would put
315
+ * unbounded round trips in front of paint for a section that may only show
316
+ * ten rows.
317
+ * - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
318
+ * until the service stops issuing them. `collectSiteRecords` is the caller
319
+ * that wants it (a corpus is not a page), and `maxPages` bounds the loop so
320
+ * a service that always answers with a cursor cannot spin.
306
321
  */
307
- async function flushDoor(url, queue, doFetch) {
322
+
323
+ /** Pages an exhaustive request will follow before giving up and reporting truncation. */
324
+ const MAX_PAGES = 50
325
+ async function flushAsked(url, queue, doFetch) {
326
+ // One shared page loop: the batch is sent, and any entry that asked to be
327
+ // exhaustive and came back with a cursor is re-sent alone until it is done.
328
+ const pending = new Map()
308
329
  const body = {}
309
330
  const keys = []
310
331
  for (const entry of queue) {
@@ -312,7 +333,7 @@ async function flushDoor(url, queue, doFetch) {
312
333
  let key = base
313
334
  for (let n = 2; key in body; n += 1) key = `${base}#${n}`
314
335
  keys.push(key)
315
- body[key] = doorQuestion(entry.request)
336
+ body[key] = toQuestion(entry.request)
316
337
  }
317
338
  let parsed
318
339
  try {
@@ -347,6 +368,9 @@ async function flushDoor(url, queue, doFetch) {
347
368
  const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
348
369
  const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
349
370
  const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
371
+ // Both absent when empty, never `{}` (the records contract §5).
372
+ const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
373
+ const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
350
374
  queue.forEach((entry, i) => {
351
375
  const key = keys[i]
352
376
  if (key in errors) {
@@ -361,14 +385,62 @@ async function flushDoor(url, queue, doFetch) {
361
385
  return
362
386
  }
363
387
  if (!(key in data)) {
364
- entry.resolve({ data: null, error: `the records door answered without the key "${key}"` })
388
+ entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
365
389
  return
366
390
  }
367
391
  const depth = depths[key] === 'brief' || depths[key] === 'full'
368
392
  ? depths[key]
369
393
  : (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
370
- entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] })
394
+
395
+ const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
396
+ const bound = typeof limits[key] === 'number' ? limits[key] : undefined
397
+ const rows = Array.isArray(data[key]) ? data[key] : data[key]
398
+
399
+ // An exhaustive caller collects the page and asks for the next one.
400
+ if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
401
+ const acc = entry.collected ? entry.collected.concat(rows) : rows.slice()
402
+ const page = (entry.page || 1) + 1
403
+ if (page <= MAX_PAGES) {
404
+ pending.set(entry, { cursor, collected: acc, page, depth, bound })
405
+ return
406
+ }
407
+ // The loop's own bound, not the service's: report rather than spin.
408
+ entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) })
409
+ return
410
+ }
411
+
412
+ const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
413
+ const meta = withMeta({
414
+ depth,
415
+ bound,
416
+ // ⭐ A cursor IS the truncation signal, and it is the only one for a query
417
+ // that declared no `limit`: `limits` is reported only when an author's own
418
+ // limit was clamped (the records contract §5).
419
+ truncated: cursor ? true : undefined,
420
+ pages: entry.page && entry.page > 1 ? entry.page : undefined,
421
+ })
422
+ entry.resolve(meta ? { data: collected, meta } : { data: collected })
371
423
  })
424
+
425
+ if (pending.size === 0) return
426
+ // Re-ask each unfinished key on its own — the cursor is per key, so a batch
427
+ // would have to correlate several independent positions through one body.
428
+ await Promise.all([...pending].map(([entry, state]) => {
429
+ const next = {
430
+ ...entry,
431
+ request: { ...entry.request, cursor: state.cursor },
432
+ collected: state.collected,
433
+ page: state.page,
434
+ }
435
+ return flushAsked(url, [next], doFetch)
436
+ }))
437
+ }
438
+
439
+ /** Build a `meta` from the fields that are actually present, or `undefined`. */
440
+ function withMeta(fields) {
441
+ const out = {}
442
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined) out[k] = v
443
+ return Object.keys(out).length ? out : undefined
372
444
  }
373
445
 
374
446
  /**
@@ -28,8 +28,46 @@
28
28
  * "since" is the first PUBLISHED version (git tag) whose `@uniweb/runtime/ssr`
29
29
  * exported the name — measured with `git log --reverse -S<name> -- src/ssr.js` and
30
30
  * `git tag --contains`, 2026-09-04.
31
+ *
32
+ * ## ⭐ `UNRELEASED` — and why the mechanism needed it
33
+ *
34
+ * ⛔ **This file had no way to stamp an export that has not shipped, and the gap
35
+ * was invisible because it was written retrospectively** (2026-09-04, at 0.14.2,
36
+ * stamping names that had all already published). The first genuinely new export
37
+ * hit it immediately: the test demands every export be stamped, a stamp must be
38
+ * `<=` package.json's version, and the version an export will ship in **does not
39
+ * exist until the publish that creates it** — versions are derived from commits
40
+ * at publish time and never hand-edited.
41
+ *
42
+ * ⚠️ **The tempting fix is the dangerous one.** Stamping the current version
43
+ * (`0.16.0`) would satisfy every check and be **false**: backend reads
44
+ * `isolateApiFloor` from the channel index and refuses to serve a site below it,
45
+ * so a floor of 0.16.0 would promise an export that 0.16.0 does not contain —
46
+ * and a host at the floor is entitled to skip feature detection. That is a
47
+ * guarantee broken in the one direction the floor exists to prevent.
48
+ *
49
+ * ⇒ **`UNRELEASED` says the true thing: this export exists in the tree and in no
50
+ * published version.** It is stamped like any other name, so nothing rides out
51
+ * unnoticed, and it is EXCLUDED from the floor — which therefore never promises
52
+ * more than a published artifact delivers. **The floor rises one step after the
53
+ * publish, not one step before it**, and the sequence is:
54
+ *
55
+ * 1. land the export stamped `UNRELEASED` — the floor does not move;
56
+ * 2. publish (Diego; agents never publish);
57
+ * 3. replace `UNRELEASED` with the version that publish produced — the floor
58
+ * moves here, and the runtime channel's `isolateApiFloor` follows at the
59
+ * next channel publish;
60
+ * 4. tell backend, which holds the number and must ratchet it.
61
+ *
62
+ * ⚖️ **Step 3 is a real obligation, not bookkeeping**: an export left
63
+ * `UNRELEASED` after it ships keeps the floor below its own API forever, so a
64
+ * host feature-detects something it could have relied on. The guard is the
65
+ * test's own message.
31
66
  */
32
67
 
68
+ /** An export present in the tree and in no published version. Excluded from the floor. */
69
+ export const UNRELEASED = 'UNRELEASED'
70
+
33
71
  /** Every export of `@uniweb/runtime/ssr`, with the version it first shipped in. */
34
72
  export const ISOLATE_API = Object.freeze({
35
73
  // props preparation
@@ -67,13 +105,22 @@ export const ISOLATE_API = Object.freeze({
67
105
  // the composed render entry
68
106
  createPageRenderer: '0.14.2',
69
107
  prefetchAndHydrate: '0.14.2',
108
+ // the whole corpus, for a host that indexes rather than renders
109
+ collectSiteRecords: UNRELEASED,
70
110
  })
71
111
 
72
112
  /**
73
113
  * The runtime version at or above which EVERY name in `ISOLATE_API` is exported —
74
114
  * the absolute floor a host may rely on with no feature detection.
75
115
  */
76
- export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API).reduce((max, v) => (compareVersions(v, max) > 0 ? v : max), '0.0.0')
116
+ export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API)
117
+ .filter((v) => v !== UNRELEASED)
118
+ .reduce((max, v) => (compareVersions(v, max) > 0 ? v : max), '0.0.0')
119
+
120
+ /** The exports that exist here and in no published version — empty is the steady state. */
121
+ export const UNRELEASED_EXPORTS = Object.freeze(
122
+ Object.entries(ISOLATE_API).filter(([, v]) => v === UNRELEASED).map(([name]) => name),
123
+ )
77
124
 
78
125
  /** Compare two `x.y.z` versions numerically. Returns <0, 0 or >0. */
79
126
  export function compareVersions(a, b) {
package/src/prefetch.js CHANGED
@@ -10,12 +10,12 @@
10
10
  * compute that itself: resolve the configs, issue the requests, unwrap the responses in the
11
11
  * shape the datastore expects — a copy of the runtime's logic, in another repo, drifting
12
12
  * (the records envelope went silently unread that way on 2026-09-02). [Diego, 2026-09-03]:
13
- * *the backend sets `config.records`; the fetch comes from the runtime.* The host now calls
13
+ * *the backend sets the records service; the fetch comes from the runtime.* The host now calls
14
14
  * this and carries no copy. Hosting agreed to exactly that shape the same day.
15
15
  *
16
16
  * ⛔ Contract with the host, deliberately small:
17
17
  * - `content` the render payload (`site-content.json` / `__DATA__`), config included —
18
- * `config.records` and `config.base` are read from it.
18
+ * `config.services` and `config.base` are read from it.
19
19
  * - `route` the page to prefetch for; a `[slug]` template resolves through the same
20
20
  * matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
21
21
  * - `fetch` how to dispatch a request. The runtime composes the address; the host
@@ -46,8 +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 the question door the host itself published at
50
- * `config.records.query`.
49
+ * `{base}/…` from the payload, or the records service the host itself
50
+ * published at `config.services.records`.
51
51
  */
52
52
  import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
53
53
  import { deriveCacheKey } from '@uniweb/core/datastore'
@@ -118,7 +118,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
118
118
  locale,
119
119
  defaultLocale: resolveDefaultLocale(content?.config) ?? null,
120
120
  queries: content?.config?.queries ?? null,
121
- records: content?.config?.records ?? null,
121
+ services: content?.config?.services ?? null,
122
122
  variables: binding?.variables ?? null,
123
123
  }
124
124
  const out = new Map()
@@ -163,7 +163,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
163
163
  * @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
164
164
  * call to `resolveFetchConfigs`)
165
165
  * @param {Object} opts
166
- * @param {Object} opts.content the payload — `config.base`, `config.records`
166
+ * @param {Object} opts.content the payload — `config.base`, `config.services`
167
167
  * @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
168
168
  * @param {boolean} [opts.dev]
169
169
  * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
@@ -178,7 +178,7 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
178
178
  fetch,
179
179
  })
180
180
  const ctx = { website: null }
181
- // Dispatched together, not one after another: a question door batches the
181
+ // Dispatched together, not one after another: the records service batches the
182
182
  // requests issued in one tick into one POST, and a page's configs are
183
183
  // independent of each other. Order is preserved in the result.
184
184
  return Promise.all((configs || []).filter(Boolean).map(async (config) => {
package/src/ssr.js CHANGED
@@ -58,7 +58,7 @@ export {
58
58
 
59
59
  // Server-side prefetch — the runtime executing a page's fetches for a host, so an isolate
60
60
  // receives `fetchedData` computed by our fetcher and the host carries no copy of it.
61
- // [Diego, 2026-09-03]: the backend sets config.records; the fetch comes from the runtime.
61
+ // [Diego, 2026-09-03]: the backend sets the records service; the fetch comes from the runtime.
62
62
  export {
63
63
  findPageForRoute,
64
64
  resolvePageFetchConfigs,
@@ -72,6 +72,13 @@ export {
72
72
  // what it deliberately leaves to the host.
73
73
  export { createPageRenderer, prefetchAndHydrate } from './page-renderer.js'
74
74
 
75
+ // The whole corpus, rather than one page — for a host that indexes a site or
76
+ // derives something over every record its queries return. It asks through the
77
+ // same client and the same composition a page render uses, which is the point:
78
+ // an index that composed its own questions could offer a result whose page then
79
+ // renders empty. The host supplies only the transport.
80
+ export { collectSiteRecords } from './collect-records.js'
81
+
75
82
  // Appearance. injectPageContent() already emits this for every prerendered
76
83
  // page; exported for lanes that assemble a shell without a per-page render.
77
84
  export { renderAppearanceBootScript } from './appearance.js'