@uniweb/runtime 0.14.1 → 0.15.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,200 +2,138 @@
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 THREE 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 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
+ * - the host's QUESTION DOOR — `door:`, one POST per tick carrying every
18
+ * question the page asked, answered per key (the records door's contract,
19
+ * as this client reads it).
22
20
  *
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.
21
+ * `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.
27
25
  *
28
- * Every key is optional. When the config is empty, behavior is byte-for-byte
29
- * identical to a plain `fetch()` with JSON parsing.
26
+ * RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
27
+ * `supports`, `request.style` / `request.rename` and the `json-body`
28
+ * request-style registry. *"3rd party endpoints must be supported at the
29
+ * foundation level… making the runtime+core lean."* A backend with its own
30
+ * base, headers, wire or query language is a TRANSPORT — a named
31
+ * `{ resolve, cacheKey? }` the foundation (or an extension) registers and
32
+ * the site selects per schema in `fetcher.transports`. The build warns once
33
+ * and drops a retired key from the payload, so an author's backend does not
34
+ * silently stop being reached.
35
+ *
36
+ * Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
37
+ * backend that takes a query in a body (GraphQL, a search endpoint);
38
+ * `{paramName}` placeholders in body strings are substituted from
39
+ * `request.dynamicContext`, so a template page's detail query can reference
40
+ * its route param. `transform:` and the object form of `detail:` (its own
41
+ * `envelope`) stay per fetch too — they describe ONE response, not a backend.
30
42
  *
31
43
  * Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
32
44
  * runtime-level callers (the editor's preview iframe, custom runtime
33
45
  * 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.
44
- *
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.
46
+ * wants plain URL + JSON behavior simply omits its own transport; the
47
+ * runtime installs this one automatically.
51
48
  *
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.
49
+ * Intentional omission: credentials / secrets. Any value the framework puts
50
+ * into the served HTML is public to the browser. Sites needing private
51
+ * credentials use a deployment-layer proxy the site fetches a same-origin
52
+ * URL, and a layer in front resolves the credential and forwards upstream.
57
53
  */
58
54
 
59
55
  import {
60
56
  substitutePlaceholders,
61
57
  matchWhere,
58
+ sortRecords,
59
+ sortToWire,
62
60
  deriveCacheKey,
63
- resolveRequestStyle,
64
61
  resolveServiceUrl,
65
62
  } from '@uniweb/core'
66
63
 
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
64
  /**
81
65
  * @param {Object} [options]
82
66
  * @param {string} [options.basePath=''] - Prepended to local absolute paths
83
67
  * 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? }> }}
68
+ * @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
69
+ * 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
+ * @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, records = null, 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
-
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.
138
- //
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
84
  // ⭐ The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
149
85
  // that answers a records request, so where the array sits in ITS response is its to
150
86
  // declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
151
87
  // key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
152
88
  // 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.
89
+ // same stamp). It applies only to a request that resolved to that lane (`endpoint` set).
155
90
  // 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.
91
+ // runtime.
158
92
  const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
159
93
  && typeof records.envelope.records === 'string' && records.envelope.records.length)
160
94
  ? records.envelope.records
161
95
  : null
162
96
  const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
163
97
 
98
+ // ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
99
+ //
100
+ // The entity store dispatches every config a page needs in one synchronous
101
+ // loop before awaiting any of them, so a door request enqueued here and
102
+ // flushed on the next microtask carries every miss of that page in one body
103
+ // The batch response is never cached as
104
+ // one: each request gets its own answer, keyed by its own question.
105
+ const doorQueues = new Map()
106
+ const askDoor = (request, ctx) =>
107
+ new Promise((resolve) => {
108
+ const url = resolveServiceUrl(request.door, pathPrefix)
109
+ let queue = doorQueues.get(url)
110
+ if (!queue) {
111
+ queue = []
112
+ doorQueues.set(url, queue)
113
+ queueMicrotask(() => {
114
+ doorQueues.delete(url)
115
+ flushDoor(url, queue, doFetch)
116
+ })
117
+ }
118
+ queue.push({ request, ctx, resolve })
119
+ })
120
+
164
121
  return {
165
122
  /**
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.
123
+ * The cache identity is the request's ADDRESS or, on a question door,
124
+ * the QUESTION (`deriveCacheKey` hashes every operator of an address-less
125
+ * request). Operators evaluated here run over a shared cached value and
126
+ * must NOT split the cache: two pages declaring different `where:` clauses
127
+ * against the same path share one entry — the file is fetched once and
128
+ * each page filters its own copy.
176
129
  */
177
130
  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)
131
+ return deriveCacheKey(request)
195
132
  },
196
133
 
197
134
  async resolve(request, ctx = {}) {
198
135
  if (!request) return { data: null }
136
+ if (request.door) return askDoor(request, ctx)
199
137
  const { path, url, endpoint, transform, body: rawBody } = request
200
138
 
201
139
  // Normalize method. Only GET and POST are supported by the default
@@ -208,77 +146,36 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
208
146
  }
209
147
 
210
148
  let target
211
- let isRemote
212
149
  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).
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.
226
156
  target = resolveServiceUrl(endpoint, pathPrefix)
227
- isRemote = true
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
+ }
228
166
  } else if (path) {
229
167
  // Local file under public/ — basePath applies for subpath deploys.
230
168
  target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
231
169
  ? pathPrefix + path
232
170
  : path
233
- isRemote = false
234
171
  } 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
172
+ // A URL the author wrote, sent exactly as written.
173
+ target = url
239
174
  } else {
240
175
  return { data: [], error: 'No path, url or endpoint specified' }
241
176
  }
242
177
 
243
178
  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
179
 
283
180
  if (method === 'POST') {
284
181
  // Substitute {paramName} placeholders in body strings using the
@@ -286,50 +183,36 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
286
183
  // build it from dynamicContext's { paramName, paramValue } shape.
287
184
  // Strict-brace matcher: GraphQL selection sets pass through unchanged.
288
185
  const dc = request.dynamicContext
289
- const resolvedBody = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
186
+ const body = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
290
187
  ? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false })
291
188
  : 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)
189
+ if (body !== undefined && body !== null) {
190
+ init.headers = { 'Content-Type': 'application/json' }
191
+ init.body = typeof body === 'string' ? body : JSON.stringify(body)
305
192
  }
306
193
  }
307
194
 
308
- if (Object.keys(headers).length) init.headers = headers
309
-
310
195
  try {
311
196
  const response = await doFetch(target, init)
312
197
 
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.
198
+ // 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.
316
200
  const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
317
201
  ? request.envelope
318
202
  : null
319
- const effectiveEnvelope = requestEnvelope
320
- ?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope)
203
+ const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {})
321
204
 
322
205
  if (!response.ok) {
323
- // If `envelope.error` is configured, try to extract a human message
206
+ // If `envelope.error` names a path, try to extract a human message
324
207
  // from the parsed body; fall back to status text if the path is
325
208
  // missing or the body isn't JSON.
326
209
  let extracted
327
- if (effectiveEnvelope.error) {
210
+ if (envelope.error) {
328
211
  try {
329
212
  const text = await response.text()
330
213
  const body = safeParseJSON(text)
331
214
  if (body !== undefined) {
332
- const candidate = getNestedValue(body, effectiveEnvelope.error)
215
+ const candidate = getNestedValue(body, envelope.error)
333
216
  if (typeof candidate === 'string' && candidate.length) {
334
217
  extracted = candidate
335
218
  }
@@ -357,25 +240,29 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
357
240
  }
358
241
  }
359
242
 
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`.
243
+ // Unwrap the response. Per-fetch `transform:` wins; otherwise the
244
+ // envelope's `item` path on a single-record request, `list` on a list.
364
245
  const isDetailRequest = !!request.dynamicContext
365
246
  const effectiveTransform =
366
247
  transform
367
- || (isDetailRequest ? effectiveEnvelope.item : effectiveEnvelope.list)
248
+ || (isDetailRequest ? envelope.item : envelope.list)
368
249
  if (effectiveTransform && data !== null && data !== undefined) {
369
250
  data = getNestedValue(data, effectiveTransform)
370
251
  }
371
252
 
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 ?? [] }
253
+ // Evaluate the query locally. Only applies to array data
254
+ // (filtering/sorting/limiting a single record doesn't make sense).
255
+ // For non-arrays, operators are ignored — the source returned what
256
+ // it returned.
257
+ data = applyOperators(data, request, { dev })
258
+
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.
264
+ const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
265
+ return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
379
266
  } catch (error) {
380
267
  if (error?.name === 'AbortError') {
381
268
  return { data: [], error: 'aborted' }
@@ -387,168 +274,137 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
387
274
  }
388
275
 
389
276
  /**
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.
277
+ * One question of a door batch, in the door's own vocabulary
278
+ * (the records door's contract, §2): `schema` required, `scope` a bare
279
+ * path, `sort` one key spelled `date` / `-date`, `depth` brief or full. The
280
+ * where-object crosses as authored except for the two spellings the language
281
+ * settled differently from the evaluator's: `nin` is `not_in` there, and a
282
+ * top-level `path: { under }` — the file lane's way of naming a folder branch —
283
+ * is the door's `scope`. Anything the door does not accept (`like`, a dotted
284
+ * path) is sent as written and refused there by name: loud, never approximated.
394
285
  */
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
286
+ function doorQuestion(request) {
287
+ const q = { schema: request.schema }
288
+ let where = request.where && typeof request.where === 'object' ? request.where : null
289
+ let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
290
+ if (where && !scope && where.path && typeof where.path === 'object' && typeof where.path.under === 'string' && where.path.under) {
291
+ const { path, ...rest } = where
292
+ scope = path.under
293
+ where = Object.keys(rest).length ? rest : null
409
294
  }
410
- return Object.keys(out).length ? out : null
295
+ if (scope) q.scope = scope
296
+ if (where) q.where = renameOperators(where)
297
+ const sort = sortToWire(request.sort)
298
+ if (sort) q.sort = sort
299
+ if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
300
+ if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
301
+ return q
411
302
  }
412
- const warnedRenameTargets = new Set()
413
303
 
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
- }
304
+ const DOOR_OPERATOR = { nin: 'not_in' }
305
+ function renameOperators(where) {
306
+ if (Array.isArray(where)) return where.map(renameOperators)
307
+ if (!where || typeof where !== 'object') return where
308
+ const out = {}
309
+ for (const [key, value] of Object.entries(where)) {
310
+ out[DOOR_OPERATOR[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
428
311
  }
429
312
  return out
430
313
  }
431
- const warnedUnknownOperators = new Set()
432
314
 
433
315
  /**
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).
316
+ * Send one batch to a door and hand each question its own answer.
449
317
  *
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.
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.
454
324
  */
455
- function composePostBody(authorBody, bodyMerge) {
456
- if (!bodyMerge) {
457
- return authorBody === undefined ? null : authorBody
325
+ async function flushDoor(url, queue, doFetch) {
326
+ const body = {}
327
+ const keys = []
328
+ for (const entry of queue) {
329
+ const base = entry.request.as || 'q'
330
+ let key = base
331
+ for (let n = 2; key in body; n += 1) key = `${base}#${n}`
332
+ keys.push(key)
333
+ body[key] = doorQuestion(entry.request)
458
334
  }
459
- if (typeof authorBody === 'string') {
460
- return authorBody
335
+ let parsed
336
+ try {
337
+ const response = await doFetch(url, {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify(body),
341
+ })
342
+ if (!response.ok) {
343
+ const error = `HTTP ${response.status}: ${response.statusText}`
344
+ for (const entry of queue) entry.resolve({ data: null, error })
345
+ return
346
+ }
347
+ parsed = await response.json()
348
+ } catch (error) {
349
+ const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
350
+ for (const entry of queue) entry.resolve({ data: null, error: message })
351
+ return
461
352
  }
462
- const base = (authorBody && typeof authorBody === 'object') ? authorBody : {}
463
- return { ...base, ...bodyMerge }
353
+ const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
354
+ const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
355
+ const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
356
+ queue.forEach((entry, i) => {
357
+ const key = keys[i]
358
+ if (key in errors) {
359
+ const e = errors[key]
360
+ entry.resolve({ data: null, error: typeof e === 'string' ? e : (e?.message || JSON.stringify(e)) })
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
  /**