@uniweb/runtime 0.14.2 → 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.
@@ -2,201 +2,137 @@
2
2
  * Runtime default fetcher.
3
3
  *
4
4
  * Used as the FetcherDispatcher's terminal fallback when no foundation
5
- * route and no foundation fallback match. Sites that declare no fetcher
6
- * at all — starter/docs/marketing templates hitting /data/*.json — ride
7
- * on this path with zero config.
5
+ * transport claims the request. Sites that declare no transport at all —
6
+ * starter/docs/marketing templates hitting /data/*.json — ride on this path
7
+ * with zero config, and so does a site a host serves live.
8
8
  *
9
- * The fetcher recognizes a general-purpose vocabulary under `site.yml fetcher:`
10
- * so sites with a real backend don't need a foundation just to add a base URL
11
- * or static headers:
9
+ * It speaks exactly TWO lanes, and takes NO site-level vocabulary for a
10
+ * backend of the author's own:
12
11
  *
13
- * fetcher:
14
- * baseUrl: https://api.example.com
15
- * headers:
16
- * X-Tenant: acme
17
- * Accept: application/vnd.example+json
18
- * envelope:
19
- * list: data.items
20
- * item: data.article
21
- * error: errors.0.message
12
+ * - a compiled file — `path:` under the site's base (`/data/<query>.json`,
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).
22
17
  *
23
- * Per-fetch, the request may carry `method: 'POST'` + `body:` for backends
24
- * that take queries in a body (GraphQL, search endpoints). `{paramName}`
25
- * placeholders in body strings are substituted from `request.dynamicContext`
26
- * so template-page detail queries can reference route params.
18
+ * `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
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.
27
22
  *
28
- * Every key is optional. When the config is empty, behavior is byte-for-byte
29
- * identical to a plain `fetch()` with JSON parsing.
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
+ * ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
30
+ * `supports`, `request.style` / `request.rename` and the `json-body`
31
+ * request-style registry. *"3rd party endpoints must be supported at the
32
+ * foundation level… making the runtime+core lean."* A backend with its own
33
+ * base, headers, wire or query language is a TRANSPORT — a named
34
+ * `{ resolve, cacheKey? }` the foundation (or an extension) registers and
35
+ * the site selects per schema in `fetcher.transports`. The build warns once
36
+ * and drops a retired key from the payload, so an author's backend does not
37
+ * silently stop being reached.
38
+ *
39
+ * Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
40
+ * backend that takes a query in a body (GraphQL, a search endpoint);
41
+ * `{paramName}` placeholders in body strings are substituted from
42
+ * `request.dynamicContext`, so a template page's detail query can reference
43
+ * its route param. `transform:` and the object form of `detail:` (its own
44
+ * `envelope`) stay per fetch too — they describe ONE response, not a backend.
30
45
  *
31
46
  * Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
32
47
  * runtime-level callers (the editor's preview iframe, custom runtime
33
48
  * harnesses). **Foundations should not import this.** A foundation that
34
- * wants plain URL + JSON behavior simply omits its own fetcher; the
35
- * runtime installs this one automatically. A foundation that needs
36
- * auth / retry / response normalization declares a named transport
37
- * and composes `@uniweb/fetchers` middleware around its own `resolve()`.
38
- *
39
- * There is intentionally no "reuse the default and wrap it" path for
40
- * foundations — doing so would duplicate this code into every foundation
41
- * bundle. The subpath export exists specifically for preview-mode shells
42
- * that need to delegate *non-authenticated* requests to a default-fetcher
43
- * instance while intercepting authenticated ones via their own transport.
49
+ * wants plain URL + JSON behavior simply omits its own transport; the
50
+ * runtime installs this one automatically.
44
51
  *
45
- * Intentional omissions: credentials / secrets are NOT part of the vocabulary.
46
- * Any value the framework puts into the served HTML is public to the browser.
47
- * Sites needing private credentials use a deployment-layer proxy — the site
48
- * fetches a same-origin URL, and a layer in front (e.g. the Uniweb platform's
49
- * edge worker, or any custom backend) resolves the credential and forwards
50
- * upstream. Framework sees a plain URL; platform owns the secret.
51
- *
52
- * `headers:` IS supported because static per-site headers (tenant routing,
53
- * content-type negotiation, custom Accept values) aren't credentials and
54
- * aren't anything sites try to hide. Sites that accidentally put a secret
55
- * in `headers:` have the same problem they'd have hardcoding it in the URL:
56
- * it's public. That's not a framework feature gap; it's how browsers work.
52
+ * Intentional omission: credentials / secrets. Any value the framework puts
53
+ * into the served HTML is public to the browser. Sites needing private
54
+ * credentials use a deployment-layer proxy — the site fetches a same-origin
55
+ * URL, and a layer in front resolves the credential and forwards upstream.
57
56
  */
58
57
 
59
58
  import {
60
59
  substitutePlaceholders,
61
60
  matchWhere,
61
+ sortRecords,
62
+ sortToWire,
62
63
  deriveCacheKey,
63
- resolveRequestStyle,
64
64
  resolveServiceUrl,
65
65
  } from '@uniweb/core'
66
66
 
67
- // The request style is the wire dialect operators are encoded in. One
68
- // ships — json-body, the framework's own — and `resolveRequestStyle` is
69
- // loud on any other name: it throws in dev and logs once in production.
70
- // Another dialect is a named transport, from the foundation or from an
71
- // extension the site selects; it is never a second built-in style.
72
-
73
- // Operators the default fetcher knows how to handle. When listed in
74
- // `config.supports`, they're shipped to the source as part of the
75
- // request; when not listed, they're applied as a JS fallback after
76
- // fetch. The cache key reflects which operators get pushed down — same
77
- // query against different `supports:` produces different cache entries.
78
- const KNOWN_OPERATORS = new Set(['where', 'limit', 'sort'])
79
-
80
67
  /**
81
68
  * @param {Object} [options]
82
69
  * @param {string} [options.basePath=''] - Prepended to local absolute paths
83
70
  * for subpath deployments. Remote URLs pass through unchanged.
84
- * @param {Object} [options.config={}] - Site-level fetcher config from
85
- * `site.yml fetcher:`. Vocabulary recognized by the default fetcher:
86
- * `baseUrl`, `headers`, `envelope`, `supports`, `request.style`,
87
- * `request.rename`. Unknown keys are ignored (foundations may use the
88
- * same block for their own keys). Default behavior (empty config)
89
- * matches today's plain GET + JSON.
90
- * @param {boolean} [options.dev=false] - Enable dev-mode diagnostics: an
91
- * unknown request style throws; a rename entry for an operator the wire
92
- * does not carry warns.
93
- * @returns {{ resolve: (req: Object, ctx: Object) => Promise<{ data, error? }> }}
71
+ * @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
72
+ * throws instead of delivering the records unsorted.
73
+ * @param {Function|null} [options.fetch=null] - The transport. A host executing
74
+ * fetches outside a browser (an SSR isolate) decides how a site-relative
75
+ * address is dispatched through its own origin or a service binding — and
76
+ * hands that in. Defaults to the global `fetch`, resolved at call time so a
77
+ * test stub installed later is honoured.
78
+ * @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
94
79
  */
95
- export function createDefaultFetcher({ basePath = '', config = {}, dev = false, records = null, fetch: fetchImpl = null } = {}) {
96
- // The transport is injectable: a host executing fetches outside a browser (an SSR isolate)
97
- // decides how a site-relative address such as `/_records/members` is dispatched — through its
98
- // own origin or a service binding — and hands that in. Defaults to the global `fetch`, resolved
99
- // at call time so a test stub installed later is honoured.
80
+ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {
100
81
  const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init)
101
82
  const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
102
83
 
103
- const baseUrl = typeof config?.baseUrl === 'string'
104
- ? config.baseUrl.replace(/\/$/, '')
105
- : ''
106
-
107
- // Static headers merged into every remote request. Local `/data/*.json`
108
- // requests are never decorated — they're just file reads under public/.
109
- const staticHeaders = buildStaticHeaders(config?.headers)
110
-
111
- // `supports:` declares which query operators (where, limit, sort) the
112
- // backend evaluates at the source. Operators in this list are shipped
113
- // in the request; operators not in this list are applied as a JS
114
- // fallback after the response arrives. Default: empty — the framework
115
- // default fetcher serving static files supports nothing natively.
116
- const supports = normalizeSupports(config?.supports)
117
-
118
- // Request style — the wire dialect operators are encoded in. Read from
119
- // `site.yml fetcher.request.style`; `null`/absent and `json-body` both
120
- // resolve to the one shipped style, and any other name is loud (see
121
- // `resolveRequestStyle`).
122
- const requestConfig = (config?.request && typeof config.request === 'object') ? config.request : {}
123
- const styleName = typeof requestConfig.style === 'string' ? requestConfig.style : null
124
- const style = resolveRequestStyle(styleName, { dev })
125
84
 
126
- // Operator-name renames applied on top of the style's wire names.
127
- // Shallow: only the operator keys (where / limit / sort) are rewritten.
128
- // Field names inside a where-object are untouched.
129
- const rename = normalizeRename(requestConfig.rename, style, { dev })
130
-
131
- // `envelope:` extends today's `transform:` to cover detail responses and
132
- // errors. Three dot-paths, all optional:
133
- // - envelope.list — applied on list responses. Per-fetch
134
- // `transform:` on the request wins (per-fetch overrides site-level).
135
- // - envelope.item — applied when request.dynamicContext is set
136
- // (the request is for a template-page item).
137
- // - envelope.error — extract error text from non-2xx response body.
85
+ // THE QUESTION DOOR a batch of the misses, one POST, merged per key.
138
86
  //
139
- // Priority (highest wins): per-fetch request.envelope > site-level
140
- // config.envelope > style.defaultEnvelope. json-body declares no
141
- // envelope; the slot is the encoder's to fill, and a site-level value
142
- // always wins over it.
143
- const siteEnvelope = (config?.envelope && typeof config.envelope === 'object')
144
- ? config.envelope
145
- : null
146
- const envelope = { ...(style.defaultEnvelope || {}), ...(siteEnvelope || {}) }
147
-
148
- // The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
149
- // that answers a records request, so where the array sits in ITS response is its to
150
- // declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
151
- // key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
152
- // agreed one (2026-08-30: `collection` retired; not `list`, which is a URL pattern on the
153
- // same stamp). It applies only to a request that resolved to that lane (`endpoint` set)
154
- // and wins over the site's own `fetcher.envelope`, which describes the author's backend.
155
- // Ruled 2026-09-03 [Diego]: the backend sets `config.records`; the fetch comes from the
156
- // runtime. Until this line the runtime resolved `list`/`record` off the stamp and ignored
157
- // its envelope.
158
- const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
159
- && typeof records.envelope.records === 'string' && records.envelope.records.length)
160
- ? records.envelope.records
161
- : null
162
- const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
87
+ // 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.
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) => {
105
+ const url = resolveServiceUrl(request.door, pathPrefix)
106
+ let queue = doorQueues.get(url)
107
+ if (!queue) {
108
+ queue = []
109
+ doorQueues.set(url, queue)
110
+ queueMicrotask(() => {
111
+ doorQueues.delete(url)
112
+ flushDoor(url, queue, doFetch)
113
+ })
114
+ }
115
+ queue.push({ request, ctx, resolve })
116
+ })
117
+ }
163
118
 
164
119
  return {
165
120
  /**
166
- * Cache-key function. The default-fetcher's cache key includes only
167
- * the operators it pushes down (because they affect what the source
168
- * sees). Operators applied as runtime fallback operate on a shared
169
- * cached value and therefore must NOT split the cache.
170
- *
171
- * Example: with `supports: []`, two pages declaring different
172
- * `where:` clauses against the same path share one cache entry —
173
- * the file is fetched once and each page filters its own copy. With
174
- * `supports: [where]`, the same two pages fire two requests because
175
- * the predicate travels in the request.
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
123
+ * request). Operators evaluated here run over a shared cached value and
124
+ * must NOT split the cache: two pages declaring different `where:` clauses
125
+ * against the same path share one entry — the file is fetched once and
126
+ * each page filters its own copy.
176
127
  */
177
128
  cacheKey(request) {
178
- // Build a request projection that includes only operators the active
179
- // style will actually push for this request. deriveCacheKey already
180
- // covers the always-keyed fields.
181
- //
182
- // The key also carries the style name. With one shipped style it is
183
- // a constant segment, kept so that key shapes do not move.
184
- const base = deriveCacheKey(request)
185
- const projected = {}
186
- for (const op of supports) {
187
- if (!style.canPush.has(op)) continue
188
- if (request[op] !== undefined) projected[op] = request[op]
189
- }
190
- if (Object.keys(projected).length === 0 && style.name === 'json-body') {
191
- // Keep back-compat key shape when the ambient default pushes nothing.
192
- return base
193
- }
194
- return base + '::style=' + style.name + '::' + JSON.stringify(projected)
129
+ return deriveCacheKey(request)
195
130
  },
196
131
 
197
132
  async resolve(request, ctx = {}) {
198
133
  if (!request) return { data: null }
199
- const { path, url, endpoint, transform, body: rawBody } = request
134
+ if (request.door) return askDoor(request, ctx)
135
+ const { path, url, transform, body: rawBody } = request
200
136
 
201
137
  // Normalize method. Only GET and POST are supported by the default
202
138
  // fetcher — mutations (PUT/PATCH/DELETE) are a different feature
@@ -208,77 +144,19 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
208
144
  }
209
145
 
210
146
  let target
211
- let isRemote
212
- if (endpoint) {
213
- // A host-declared collection lane, resolved upstream from the pattern
214
- // it published (`@uniweb/core/query-address`). FINAL ON ARRIVAL:
215
- //
216
- // - `baseUrl` is NOT joined. That knob points a site at ITS OWN
217
- // backend; prepending it to an address a host composed would
218
- // corrupt exactly the layout the pattern exists to let them own.
219
- // - the site `base` IS applied to a rooted address, the same rule
220
- // every other site-relative address follows — shared with
221
- // `resolveServiceUrl` rather than spelled a second time here.
222
- //
223
- // Remote semantics otherwise: this answers a QUERY, so operator
224
- // pushdown and static headers both apply, which is what separates it
225
- // from `path` (a static file that can neither filter nor sort).
226
- target = resolveServiceUrl(endpoint, pathPrefix)
227
- isRemote = true
228
- } else if (path) {
147
+ if (path) {
229
148
  // Local file under public/ — basePath applies for subpath deploys.
230
149
  target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
231
150
  ? pathPrefix + path
232
151
  : path
233
- isRemote = false
234
152
  } else if (url) {
235
- // Remote URL baseUrl applies when url is relative (no scheme,
236
- // not protocol-relative). Absolute or protocol-relative pass through.
237
- target = isAbsoluteUrl(url) ? url : joinUrl(baseUrl, url)
238
- isRemote = true
153
+ // A URL the author wrote, sent exactly as written.
154
+ target = url
239
155
  } else {
240
- return { data: [], error: 'No path, url or endpoint specified' }
156
+ return { data: [], error: 'No path, url or door specified' }
241
157
  }
242
158
 
243
159
  const init = { signal: ctx.signal, method }
244
- const headers = {}
245
-
246
- // Static site-level headers go on remote requests only — we don't
247
- // decorate local file reads with tenant/content-type headers.
248
- if (isRemote && staticHeaders) Object.assign(headers, staticHeaders)
249
-
250
- // Push down supported query operators to the source via the active
251
- // request style. Pushdown only applies to remote URLs — local `path:`
252
- // reads are static files that can't filter or sort. Operators the
253
- // style didn't push get applied as a JS fallback after the response
254
- // (see the post-fetch block below).
255
- //
256
- // The style owns the wire format: json-body encodes GET pushdown as
257
- // `?_where=<JSON>&_limit=&_sort=` and POST pushdown as top-level keys
258
- // merged into an object body. It is the only shipped wire — another
259
- // dialect is a named transport.
260
- const pushCandidates = new Set()
261
- if (isRemote) {
262
- for (const op of KNOWN_OPERATORS) {
263
- if (
264
- supports.has(op) &&
265
- style.canPush.has(op) &&
266
- request[op] !== undefined &&
267
- request[op] !== null
268
- ) {
269
- pushCandidates.add(op)
270
- }
271
- }
272
- }
273
-
274
- const encoded = pushCandidates.size > 0
275
- ? style.encode(request, { method, pushCandidates, rename })
276
- : { queryParams: [], bodyMerge: null, pushed: new Set() }
277
- const pushedOperators = encoded.pushed
278
-
279
- if (encoded.queryParams.length > 0 && method === 'GET') {
280
- target = appendStyleQueryParams(target, encoded.queryParams)
281
- }
282
160
 
283
161
  if (method === 'POST') {
284
162
  // Substitute {paramName} placeholders in body strings using the
@@ -286,50 +164,35 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
286
164
  // build it from dynamicContext's { paramName, paramValue } shape.
287
165
  // Strict-brace matcher: GraphQL selection sets pass through unchanged.
288
166
  const dc = request.dynamicContext
289
- const resolvedBody = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
167
+ const body = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
290
168
  ? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false })
291
169
  : rawBody
292
-
293
- // Compose the final body: author-supplied body merged with pushed
294
- // operators from the style. When no body and no pushdown, send
295
- // a body containing just the pushed operators if any exist.
296
- const finalBody = composePostBody(resolvedBody, encoded.bodyMerge)
297
-
298
- if (finalBody !== null) {
299
- // Default Content-Type to JSON unless the site's static headers
300
- // already set one (for application/graphql or form-urlencoded).
301
- if (!hasHeader(headers, 'Content-Type')) {
302
- headers['Content-Type'] = 'application/json'
303
- }
304
- init.body = typeof finalBody === 'string' ? finalBody : JSON.stringify(finalBody)
170
+ if (body !== undefined && body !== null) {
171
+ init.headers = { 'Content-Type': 'application/json' }
172
+ init.body = typeof body === 'string' ? body : JSON.stringify(body)
305
173
  }
306
174
  }
307
175
 
308
- if (Object.keys(headers).length) init.headers = headers
309
-
310
176
  try {
311
177
  const response = await doFetch(target, init)
312
178
 
313
- // Per-request envelope (set by object-form `detail:`) wins over
314
- // site-level envelope. This lets a detail query declare its own
315
- // item/collection/error paths independently of the collection.
316
- const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
179
+ // A per-request envelope (set by the object form of `detail:`) describes
180
+ // this one response.
181
+ const envelope = (request.envelope && typeof request.envelope === 'object')
317
182
  ? request.envelope
318
- : null
319
- const effectiveEnvelope = requestEnvelope
320
- ?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope)
183
+ : {}
321
184
 
322
185
  if (!response.ok) {
323
- // If `envelope.error` is configured, try to extract a human message
186
+ // If `envelope.error` names a path, try to extract a human message
324
187
  // from the parsed body; fall back to status text if the path is
325
188
  // missing or the body isn't JSON.
326
189
  let extracted
327
- if (effectiveEnvelope.error) {
190
+ if (envelope.error) {
328
191
  try {
329
192
  const text = await response.text()
330
193
  const body = safeParseJSON(text)
331
194
  if (body !== undefined) {
332
- const candidate = getNestedValue(body, effectiveEnvelope.error)
195
+ const candidate = getNestedValue(body, envelope.error)
333
196
  if (typeof candidate === 'string' && candidate.length) {
334
197
  extracted = candidate
335
198
  }
@@ -357,25 +220,28 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
357
220
  }
358
221
  }
359
222
 
360
- // Unwrap response envelope. Priority order, highest wins:
361
- // 1. Per-fetch `transform:` (existing, documented knob).
362
- // 2. Per-request `envelope.item` (detail) or `envelope.list`.
363
- // 3. Site-level `envelope.item` (detail) or `envelope.list`.
223
+ // Unwrap the response. Per-fetch `transform:` wins; otherwise the
224
+ // envelope's `item` path on a single-record request, `list` on a list.
364
225
  const isDetailRequest = !!request.dynamicContext
365
226
  const effectiveTransform =
366
227
  transform
367
- || (isDetailRequest ? effectiveEnvelope.item : effectiveEnvelope.list)
228
+ || (isDetailRequest ? envelope.item : envelope.list)
368
229
  if (effectiveTransform && data !== null && data !== undefined) {
369
230
  data = getNestedValue(data, effectiveTransform)
370
231
  }
371
232
 
372
- // Apply runtime fallback for query operators not pushed down.
373
- // Only applies to array data (filtering/sorting/limiting a single
374
- // record doesn't make sense). For non-arrays, operators are
375
- // silently ignored — the source returned what it returned.
376
- data = applyFallbackOperators(data, request, pushedOperators)
377
-
378
- return { data: data ?? [] }
233
+ // Evaluate the query locally. Only applies to array data
234
+ // (filtering/sorting/limiting a single record doesn't make sense).
235
+ // For non-arrays, operators are ignored — the source returned what
236
+ // it returned.
237
+ data = applyOperators(data, request, { dev })
238
+
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.)
243
+ const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
244
+ return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
379
245
  } catch (error) {
380
246
  if (error?.name === 'AbortError') {
381
247
  return { data: [], error: 'aborted' }
@@ -387,168 +253,158 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
387
253
  }
388
254
 
389
255
  /**
390
- * Normalize the `request.rename` map. Returns null if nothing valid.
391
- * Dev-mode warns on operator names that don't exist in the style's
392
- * `canPush` set a rename that targets an operator the style doesn't
393
- * push is silently dead config, and the warning surfaces the mistake.
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.
394
264
  */
395
- function normalizeRename(raw, style, { dev }) {
396
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
397
- const out = {}
398
- for (const [op, wireName] of Object.entries(raw)) {
399
- if (typeof wireName !== 'string' || wireName.length === 0) continue
400
- if (dev && !style.canPush.has(op) && !warnedRenameTargets.has(op)) {
401
- warnedRenameTargets.add(op)
402
- console.warn(
403
- `[default-fetcher] request.rename: operator "${op}" is not pushed by ` +
404
- `style "${style.name}" — rename has no effect. Known operators for ` +
405
- `this style: ${[...style.canPush].join(', ') || '(none)'}.`,
406
- )
407
- }
408
- out[op] = wireName
265
+ function doorQuestion(request) {
266
+ const q = { schema: request.schema }
267
+ let where = request.where && typeof request.where === 'object' ? request.where : null
268
+ let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
269
+ if (where && !scope && where.path && typeof where.path === 'object' && typeof where.path.under === 'string' && where.path.under) {
270
+ const { path, ...rest } = where
271
+ scope = path.under
272
+ where = Object.keys(rest).length ? rest : null
409
273
  }
410
- return Object.keys(out).length ? out : null
274
+ if (scope) q.scope = scope
275
+ if (where) q.where = renameOperators(where)
276
+ const sort = sortToWire(request.sort)
277
+ if (sort) q.sort = sort
278
+ if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
279
+ if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
280
+ return q
411
281
  }
412
- const warnedRenameTargets = new Set()
413
282
 
414
- /**
415
- * Normalize the supports declaration to a Set of known operators. Unknown
416
- * operator names are ignored with a one-time dev warning.
417
- */
418
- function normalizeSupports(raw) {
419
- const out = new Set()
420
- if (!Array.isArray(raw)) return out
421
- for (const op of raw) {
422
- if (typeof op !== 'string') continue
423
- if (KNOWN_OPERATORS.has(op)) out.add(op)
424
- else if (!warnedUnknownOperators.has(op)) {
425
- warnedUnknownOperators.add(op)
426
- console.warn(`[default-fetcher] supports: unknown operator "${op}" — ignored.`)
427
- }
283
+ const DOOR_OPERATOR = { nin: 'not_in' }
284
+ function renameOperators(where) {
285
+ if (Array.isArray(where)) return where.map(renameOperators)
286
+ if (!where || typeof where !== 'object') return where
287
+ const out = {}
288
+ for (const [key, value] of Object.entries(where)) {
289
+ out[DOOR_OPERATOR[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
428
290
  }
429
291
  return out
430
292
  }
431
- const warnedUnknownOperators = new Set()
432
293
 
433
294
  /**
434
- * Append [key, value] pairs emitted by a style to a URL as query
435
- * parameters. Existing query string is preserved; values are URL-encoded.
436
- */
437
- function appendStyleQueryParams(url, pairs) {
438
- if (!pairs || pairs.length === 0) return url
439
- const params = pairs.map(
440
- ([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v),
441
- )
442
- const sep = url.includes('?') ? '&' : '?'
443
- return url + sep + params.join('&')
444
- }
445
-
446
- /**
447
- * Compose a POST body that includes the style's bodyMerge alongside the
448
- * author-supplied body. When neither exists, returns null (no body sent).
295
+ * Send one batch to a door and hand each question its own answer.
449
296
  *
450
- * If the author supplied a string body (typically GraphQL), we can't
451
- * merge the string is sent as-is and the style's bodyMerge is dropped.
452
- * Sites with string POST bodies needing pushdown should write the
453
- * operators into the body themselves.
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.
454
306
  */
455
- function composePostBody(authorBody, bodyMerge) {
456
- if (!bodyMerge) {
457
- return authorBody === undefined ? null : authorBody
307
+ async function flushDoor(url, queue, doFetch) {
308
+ const body = {}
309
+ const keys = []
310
+ for (const entry of queue) {
311
+ const base = entry.request.as || 'q'
312
+ let key = base
313
+ for (let n = 2; key in body; n += 1) key = `${base}#${n}`
314
+ keys.push(key)
315
+ body[key] = doorQuestion(entry.request)
458
316
  }
459
- if (typeof authorBody === 'string') {
460
- return authorBody
317
+ let parsed
318
+ try {
319
+ const response = await doFetch(url, {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify(body),
323
+ })
324
+ if (!response.ok) {
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}`
338
+ for (const entry of queue) entry.resolve({ data: null, error })
339
+ return
340
+ }
341
+ parsed = await response.json()
342
+ } catch (error) {
343
+ const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
344
+ for (const entry of queue) entry.resolve({ data: null, error: message })
345
+ return
461
346
  }
462
- const base = (authorBody && typeof authorBody === 'object') ? authorBody : {}
463
- return { ...base, ...bodyMerge }
347
+ const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
348
+ const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
349
+ const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
350
+ queue.forEach((entry, i) => {
351
+ const key = keys[i]
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.
356
+ const e = errors[key]
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
+ return
362
+ }
363
+ if (!(key in data)) {
364
+ entry.resolve({ data: null, error: `the records door answered without the key "${key}"` })
365
+ return
366
+ }
367
+ const depth = depths[key] === 'brief' || depths[key] === 'full'
368
+ ? depths[key]
369
+ : (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
370
+ entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] })
371
+ })
464
372
  }
465
373
 
466
374
  /**
467
- * Apply query operators that weren't pushed down to the source. The
468
- * source returned `data` unfiltered/unlimited/unsorted for those
469
- * operators; the runtime applies them now in JS.
375
+ * Evaluate the query over what the source returned the ONE evaluator,
376
+ * `@uniweb/core`'s, so the browser orders and filters exactly as the build
377
+ * did when it materialized `/data/<name>.json`.
470
378
  */
471
- function applyFallbackOperators(data, request, pushedOperators) {
379
+ function applyOperators(data, request, { dev = false } = {}) {
472
380
  if (!Array.isArray(data)) return data
473
381
  let result = data
474
-
475
- if (request.where && !pushedOperators.has('where')) {
476
- result = matchWhere(request.where, result)
477
- }
478
- if (request.sort && !pushedOperators.has('sort')) {
479
- result = applySortFallback(result, request.sort)
480
- }
481
- if (typeof request.limit === 'number' && request.limit > 0 && !pushedOperators.has('limit')) {
482
- result = result.slice(0, request.limit)
483
- }
382
+ if (request.where) result = matchWhere(request.where, result)
383
+ if (request.sort) result = applySort(result, request.sort, dev)
384
+ if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
484
385
  return result
485
386
  }
486
387
 
487
388
  /**
488
- * Stable sort by an expression like "date desc" or "order asc, title asc".
489
- * Mirrors the build-time applySort behavior.
389
+ * This was a second sort implementation until 2026-09-04, and it honoured
390
+ * a comma-separated MULTI-KEY sort the language does not have (single-key by
391
+ * ruling). A bad `sort:` is an authoring error: dev throws so it is seen; in
392
+ * production the records are delivered in source order and the reason is
393
+ * logged once — a wrong order is not worth a broken page for a visitor.
490
394
  */
491
- function applySortFallback(items, sortExpr) {
492
- const sorts = String(sortExpr).split(',').map((s) => {
493
- const [field, dir = 'asc'] = s.trim().split(/\s+/)
494
- return { field, desc: dir.toLowerCase() === 'desc' }
495
- })
496
- return [...items].sort((a, b) => {
497
- for (const { field, desc } of sorts) {
498
- const av = getNestedValue(a, field) ?? ''
499
- const bv = getNestedValue(b, field) ?? ''
500
- if (av < bv) return desc ? 1 : -1
501
- if (av > bv) return desc ? -1 : 1
395
+ const warnedBadSorts = new Set()
396
+ function applySort(items, sortExpr, dev) {
397
+ try {
398
+ return sortRecords(items, sortExpr)
399
+ } catch (err) {
400
+ if (dev) throw err
401
+ const key = String(sortExpr)
402
+ if (!warnedBadSorts.has(key)) {
403
+ warnedBadSorts.add(key)
404
+ console.error(`[default-fetcher] ${err.message} Records delivered unsorted.`)
502
405
  }
503
- return 0
504
- })
505
- }
506
-
507
- /**
508
- * Build the static headers object from `site.yml fetcher.headers:`. Returns
509
- * null when none are configured so the caller can skip adding an empty
510
- * `headers` init option.
511
- */
512
- function buildStaticHeaders(headers) {
513
- if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return null
514
- const out = {}
515
- for (const [k, v] of Object.entries(headers)) {
516
- if (v === null || v === undefined) continue
517
- out[k] = String(v)
406
+ return items
518
407
  }
519
- return Object.keys(out).length ? out : null
520
- }
521
-
522
- /**
523
- * Case-insensitive header-key check. Lets a site write `Content-Type` or
524
- * `content-type` and still override the POST default correctly.
525
- */
526
- function hasHeader(headers, name) {
527
- const lower = name.toLowerCase()
528
- return Object.keys(headers).some((k) => k.toLowerCase() === lower)
529
- }
530
-
531
- /**
532
- * Is this URL absolute (has a scheme) or protocol-relative? Those two pass
533
- * through the default fetcher unchanged. Everything else is considered
534
- * relative and resolves against `config.baseUrl` (if set).
535
- */
536
- function isAbsoluteUrl(url) {
537
- if (typeof url !== 'string') return false
538
- if (url.startsWith('//')) return true // protocol-relative
539
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) // scheme://…
540
- }
541
-
542
- /**
543
- * Join `baseUrl` with a relative `url`, avoiding double slashes. If `baseUrl`
544
- * is empty, the url is returned unchanged — even if relative — so sites that
545
- * don't set `baseUrl` behave exactly like they did before this capability
546
- * was added.
547
- */
548
- function joinUrl(baseUrl, url) {
549
- if (!baseUrl) return url
550
- if (url.startsWith('/')) return baseUrl + url
551
- return baseUrl + '/' + url
552
408
  }
553
409
 
554
410
  /**