@uniweb/core 0.18.0 → 0.19.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/core",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,8 +43,8 @@
43
43
  "vitest": "^4.1.7"
44
44
  },
45
45
  "dependencies": {
46
- "@uniweb/semantic-parser": "^1.4.0",
47
- "@uniweb/theming": "^0.1.15"
46
+ "@uniweb/theming": "^0.1.15",
47
+ "@uniweb/semantic-parser": "^1.4.0"
48
48
  },
49
49
  "scripts": {
50
50
  "test": "vitest run"
package/src/datastore.js CHANGED
@@ -35,12 +35,11 @@
35
35
  * @returns {string} A stable JSON string usable as a cache-Map key
36
36
  */
37
37
  export function deriveCacheKey(request) {
38
- // ⭐ `as` is the binding key; `schema` is the name it had until 2026-09-02 and
39
- // still arrives on every payload published before then. Normalised HERE so one
40
- // logical request hashes to one key whichever spelling reached us — a consumer
41
- // deriving a key must not get two entries for the same fetch.
38
+ // ⭐ `as` is the binding key the name it has had since 2026-09-02, when the
39
+ // compatibility alias for the older `schema` spelling was removed alongside
40
+ // frontend's and hosting's.
42
41
  const { path, url, endpoint, transform } = request || {}
43
- const as = request?.as ?? request?.schema
42
+ const as = request?.as
44
43
  const method = request?.method && request.method.toUpperCase() !== 'GET'
45
44
  ? request.method.toUpperCase()
46
45
  : undefined
package/src/detail-url.js CHANGED
@@ -94,7 +94,7 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
94
94
  if (detail && typeof detail === 'object') {
95
95
  const out = {
96
96
  [addressKey]: baseUrl,
97
- schema: queryConfig.schema,
97
+ as: queryConfig.as,
98
98
  transform: queryConfig.transform,
99
99
  }
100
100
  if (queryConfig.method) out.method = queryConfig.method
@@ -145,7 +145,7 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
145
145
 
146
146
  return {
147
147
  [addressKey]: detailUrl,
148
- schema: queryConfig.schema,
148
+ as: queryConfig.as,
149
149
  transform: queryConfig.transform,
150
150
  }
151
151
  }
@@ -21,40 +21,48 @@ import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
21
21
  * `as` is the name; `schema` is what it was called until 2026-09-02 and still
22
22
  * arrives on any payload published before then. See `fetch-config.js`.
23
23
  */
24
- const bindingKeyOf = (cfg) => cfg?.as ?? cfg?.schema
24
+ const bindingKeyOf = (cfg) => cfg?.as
25
25
  import { buildDetailConfig } from './detail-url.js'
26
26
 
27
27
  /**
28
28
  * Is `block.fetch` a per-instance refinement of the ancestor's fetch config
29
- * rather than a new source? The canonical spelling is `refine: true`; the
30
- * legacy spelling `inherit: true` is still honored for one release with a
31
- * dev-mode warning.
29
+ * rather than a new source? The spelling is `refine: true`.
32
30
  *
33
31
  * The predicate itself lives in `./fetch-config.js` with the rest of the
34
32
  * cascade rule; this alias keeps the local call sites reading as they did.
35
33
  */
36
34
  const isRefinement = isFetchRefinement
37
35
 
38
- let inheritDeprecationWarned = false
39
- function warnInheritDeprecation(block) {
40
- if (inheritDeprecationWarned) return
41
- inheritDeprecationWarned = true
42
- // Dev-only; production builds typically strip console.warn. We gate on
43
- // the presence of the deprecated key and fire once per process.
44
- console.warn(
45
- "[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
46
- 'Accepted for one release; will be removed in the next minor. ' +
47
- `First seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
48
- )
36
+ /**
37
+ * `fetch: { inherit: true }` was the earlier spelling of `refine: true`, kept
38
+ * "for one release" from April 2026 and removed on 2026-09-02. It is refused
39
+ * rather than ignored: ignored, the declaration would read as a source with no
40
+ * location and the block would render empty with nothing to say why. Dev
41
+ * throws, so a page does not render on the old spelling; production logs once
42
+ * and the caller drops the declaration, so the block receives the cascaded
43
+ * data unrefined.
44
+ */
45
+ let inheritRefusalLogged = false
46
+ function refuseInheritAlias(block, dev) {
47
+ const message =
48
+ "[uniweb] 'fetch: { inherit: true }' is no longer accepted; write 'fetch: { refine: true }'. " +
49
+ `Seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
50
+ if (dev) throw new Error(message)
51
+ if (inheritRefusalLogged) return
52
+ inheritRefusalLogged = true
53
+ console.error(message)
49
54
  }
50
55
 
51
56
  export default class EntityStore {
52
57
  /**
53
58
  * @param {Object} options
54
59
  * @param {import('./website.js').default} options.website
60
+ * @param {boolean} [options.dev=false] - dev mode: a retired spelling throws
61
+ * instead of logging once.
55
62
  */
56
- constructor({ website }) {
63
+ constructor({ website, dev = false }) {
57
64
  this.website = website
65
+ this.dev = dev
58
66
  Object.seal(this)
59
67
  }
60
68
 
@@ -116,8 +124,10 @@ export default class EntityStore {
116
124
  * exists to prevent.
117
125
  */
118
126
  _findFetchConfigs(block, requested) {
119
- if (block.fetch?.inherit === true && block.fetch?.refine !== true) {
120
- warnInheritDeprecation(block)
127
+ let blockFetch = block.fetch
128
+ if (blockFetch?.inherit !== undefined) {
129
+ refuseInheritAlias(block, this.dev)
130
+ blockFetch = null
121
131
  }
122
132
 
123
133
  const page = block.page
@@ -125,7 +135,7 @@ export default class EntityStore {
125
135
 
126
136
  return resolveFetchConfigs(
127
137
  [
128
- block.fetch && !isRefinement(block.fetch) ? block.fetch : null,
138
+ blockFetch && !isRefinement(blockFetch) ? blockFetch : null,
129
139
  page?.fetch,
130
140
  page?.parent?.fetch,
131
141
  website?.config?.fetch,
@@ -34,15 +34,16 @@ import { resolveQueryAddress, resolveRecordAddressPattern } from './query-addres
34
34
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
35
35
  * config rather than a new source of its own?
36
36
  *
37
- * The canonical spelling is `refine: true`. The legacy spelling `inherit: true`
38
- * is still honored; callers that want to warn about it should test for the key
39
- * themselves this predicate stays silent so it is safe in any environment.
37
+ * The spelling is `refine: true`. Its earlier alias, `inherit: true`, was
38
+ * accepted with a warning from April 2026 and removed on 2026-09-02: the build
39
+ * refuses it with an error, and `EntityStore` refuses it in dev. This predicate
40
+ * stays silent so it is safe in any environment.
40
41
  *
41
42
  * @param {Object} cfg - a fetch declaration
42
43
  * @returns {boolean}
43
44
  */
44
45
  export function isFetchRefinement(cfg) {
45
- return cfg?.refine === true || cfg?.inherit === true
46
+ return cfg?.refine === true
46
47
  }
47
48
 
48
49
  /**
@@ -185,23 +186,31 @@ function resolveQuerySource(cfg, records) {
185
186
  /**
186
187
  * The binding key of a fetch config — the `content.data.<key>` a component reads.
187
188
  *
188
- * ⭐ **`as` is the name; `schema` is what it was called until 2026-09-02.** The old
189
- * spelling still arrives on every payload published before then and on any seed
190
- * built against an older release, so this is not a deprecation window it is a
191
- * permanent reader of stored data. *(Renaming it was not cosmetic: `schema`
192
- * already means the MODEL REF one record over, on a `queries` declaration, and
193
- * one word for two things is what let a binding-key override silently break
194
- * detail resolution.)*
189
+ * ⭐ **`as` is the name.** It was called `schema` until 2026-09-02, which
190
+ * collided with the MODEL REF of the same name on a `queries` declaration — one
191
+ * word for two things, which is what let a binding-key override silently break
192
+ * detail resolution.
195
193
  *
196
- * ⛔ Do not "simplify" this to `cfg.as`. The `??` here is earned it spans
197
- * stored payloads we cannot rewrite unlike the one deleted from
198
- * `applyDeferredDetail`, which spanned two producers we control.
194
+ * ⛔ **The `?? cfg.schema` alias that briefly rode alongside it is GONE**
195
+ * (2026-09-02, ruled by Diego: *"they are not in prod so I saw no point in it.
196
+ * We need to move forward."*). It was removed in the same pass as frontend's and
197
+ * hosting's, and every producer here now emits `as` alone.
198
+ *
199
+ * ⚠️ **The consequence, stated plainly: a payload synced before that carries
200
+ * `schema` and resolves to NOTHING here.** No data, no error — this is the
201
+ * silent class, and the remedy is a re-push, not a code change. If a
202
+ * seed or a dev site renders a section empty, check what its stored payload
203
+ * spells before looking anywhere else.
204
+ *
205
+ * ⭐ The one place `schema` is still read is `parseFetchConfig` in
206
+ * `@uniweb/build`, and it is a different thing: normalizing an AUTHOR's older
207
+ * spelling in a content file at the boundary, so that one name travels inside.
199
208
  *
200
209
  * @param {Object} cfg
201
210
  * @returns {string|undefined}
202
211
  */
203
212
  function bindingKey(cfg) {
204
- return cfg?.as ?? cfg?.schema
213
+ return cfg?.as
205
214
  }
206
215
 
207
216
  /**
@@ -160,7 +160,12 @@ export default class FetcherDispatcher {
160
160
 
161
161
  const transportsConfig = ctx?.website?.config?.fetcher?.transports
162
162
  if (transportsConfig && typeof transportsConfig === 'object') {
163
- const schema = request?.schema
163
+ // `as`, the binding key. This read `request.schema` — and ONLY that —
164
+ // so from the 2026-09-02 rename until this line was fixed, a site's
165
+ // `transports:` selection silently missed on every `as`-keyed payload and
166
+ // fell through to the default transport. No warning: the miss is
167
+ // indistinguishable from "this site declared no transport for it".
168
+ const schema = request?.as
164
169
  const name = (schema && transportsConfig[schema]) || transportsConfig.default
165
170
  if (name) {
166
171
  const t = this._namedTransports.get(name)
package/src/index.js CHANGED
@@ -60,10 +60,7 @@ export { isRichSchema } from './schemas.js'
60
60
  // since it must not pull the package root into an SSR/Worker bundle.
61
61
  export { resolveService, resolveServiceUrl, readServiceOptions } from './services.js'
62
62
  export { applyBasePath } from './base-path.js'
63
- export {
64
- resolveStyle as resolveRequestStyle,
65
- listStyleNames as listRequestStyleNames
66
- } from './request-styles/index.js'
63
+ export { resolveStyle as resolveRequestStyle } from './request-styles/index.js'
67
64
 
68
65
  /**
69
66
  * The singleton Uniweb instance.
@@ -1,61 +1,59 @@
1
1
  /**
2
- * Request-style registry.
2
+ * Request style — how the default fetcher reshapes a normalized request
3
+ * into wire format: which operators become URL params, which go into a
4
+ * body, what envelope the response carries.
3
5
  *
4
- * A "style" describes how the default fetcher reshapes a normalized
5
- * request into wire format which operators become URL params, which
6
- * go into a body, what envelope the response carries. Sites pick a
7
- * style on `site.yml fetcher.request.style`; when unset, the ambient
8
- * default is `json-body`.
6
+ * One style ships: `json-body`, the framework's own conventions, and it
7
+ * is the only wire the default fetcher speaks. A backend with another
8
+ * dialect is reached through a named transport shipped by the
9
+ * foundation, or by an extension the site selects per schema in
10
+ * `site.yml fetcher.transports` — never through a second built-in style.
11
+ * Two vendor dialects, `flat-query` and `strapi`, shipped here from
12
+ * `@uniweb/core` 0.7.1 and were removed: a third party's wire is not a
13
+ * framework concern, and core is loaded by every site and never
14
+ * tree-shaken.
9
15
  *
10
- * Styles are shipped by the framework. There is no `registerStyle()`
11
- * API for foundations or sites custom wire shapes are expressed via
12
- * foundation-level named transports. Styles are specifically for
13
- * reshapings of the framework's own fetcher behavior.
16
+ * `site.yml fetcher.request.style` is still read, for one reason: a site
17
+ * that names a style the framework does not ship must be told. Falling
18
+ * back silently would send the default wire to a backend that does not
19
+ * speak it the request succeeds and the data is wrong.
14
20
  *
15
21
  * Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
16
22
  */
17
23
 
18
24
  import { jsonBody } from './json-body.js'
19
- import { flatQuery } from './flat-query.js'
20
- import { strapi } from './strapi.js'
21
25
 
22
- const STYLES = new Map([
23
- [jsonBody.name, jsonBody],
24
- [flatQuery.name, flatQuery],
25
- [strapi.name, strapi],
26
- ])
26
+ const unknownStyleMessage = (name) =>
27
+ `[default-fetcher] unknown request style "${name}". The framework ships one wire, ` +
28
+ `"json-body"; a backend with a different dialect is reached through a named transport ` +
29
+ `(site.yml fetcher.transports), shipped by the foundation or by an extension.`
27
30
 
28
31
  /**
29
- * Resolve a style by name. Returns the requested style if registered,
30
- * otherwise the ambient default (`json-body`). Unknown names trigger a
31
- * one-time dev warning; production silently falls back.
32
+ * Resolve the request style. No name, or `json-body`, returns the one
33
+ * shipped style. Any other name is a site declaring a wire dialect the
34
+ * framework does not ship: in dev this throws, so the site does not boot
35
+ * on the wrong wire; in production it logs an error once and falls back
36
+ * to `json-body`, so the site still renders.
32
37
  *
33
38
  * @param {string|undefined|null} name
34
39
  * @param {{ dev?: boolean }} [options]
35
40
  * @returns {Object} A style module.
41
+ * @throws {Error} in dev, on a name that is not `json-body`.
36
42
  */
37
43
  export function resolveStyle(name, { dev = false } = {}) {
38
- if (!name) return jsonBody
39
- const style = STYLES.get(name)
40
- if (style) return style
41
- if (dev && !warnedUnknownStyles.has(name)) {
42
- warnedUnknownStyles.add(name)
43
- console.warn(
44
- `[default-fetcher] unknown request style "${name}"; falling back to "json-body". ` +
45
- `Known styles: ${[...STYLES.keys()].join(', ')}.`,
46
- )
44
+ if (!name || name === jsonBody.name) return jsonBody
45
+ if (dev) {
46
+ const err = new Error(unknownStyleMessage(name))
47
+ err.code = 'UNKNOWN_REQUEST_STYLE'
48
+ throw err
49
+ }
50
+ if (!erroredUnknownStyles.has(name)) {
51
+ erroredUnknownStyles.add(name)
52
+ console.error(unknownStyleMessage(name) + ' Falling back to "json-body".')
47
53
  }
48
54
  return jsonBody
49
55
  }
50
56
 
51
- const warnedUnknownStyles = new Set()
52
-
53
- /**
54
- * Sentinel list of names registered at the registry level. Used by tests
55
- * and by dev-mode diagnostics; not part of the site-facing contract.
56
- */
57
- export function listStyleNames() {
58
- return [...STYLES.keys()]
59
- }
57
+ const erroredUnknownStyles = new Set()
60
58
 
61
- export { jsonBody, flatQuery, strapi }
59
+ export { jsonBody }
package/src/website.js CHANGED
@@ -57,7 +57,7 @@ export default class Website {
57
57
  transport,
58
58
  dev,
59
59
  })
60
- this.entityStore = new EntityStore({ website: this })
60
+ this.entityStore = new EntityStore({ website: this, dev })
61
61
 
62
62
  // Observable site-wide state — allocated on first access via the `state`
63
63
  // getter, survives content rebuilds. Read-only prop (no `website.state = X`
@@ -591,9 +591,14 @@ export default class Website {
591
591
  let items = []
592
592
 
593
593
  if (parentFetch && this.fetcher) {
594
+ // ⛔ `as` is the binding key. This matched on `schema` alone until
595
+ // 2026-09-02 — which, once the alias went, would have found nothing:
596
+ // `items` stays `[]` and the page reports "Not found" for a record that
597
+ // exists. Silent, and on a visitor's page.
598
+ const keyOf = (f) => f?.as
594
599
  const fetchConfig = Array.isArray(parentFetch)
595
- ? parentFetch.find(f => f.schema === pluralSchema)
596
- : (parentFetch.schema === pluralSchema ? parentFetch : null)
600
+ ? parentFetch.find((f) => keyOf(f) === pluralSchema)
601
+ : (keyOf(parentFetch) === pluralSchema ? parentFetch : null)
597
602
  if (fetchConfig) {
598
603
  const cached = this.fetcher.peek(fetchConfig, { website: this })
599
604
  items = Array.isArray(cached?.data) ? cached.data : []
@@ -1,146 +0,0 @@
1
- /**
2
- * flat-query — plain URL query-string style.
3
- *
4
- * GET-only. Operators become bare query params:
5
- *
6
- * ?limit=10
7
- * ?sort=-date (single-key, "-" prefix for desc)
8
- * ?sort=-date,title (multi-key, comma-separated)
9
- * ?dept=biology&tenured=true (where, flat AND of equalities only)
10
- *
11
- * The `where:` operator is pushed **only** when the predicate is a flat
12
- * AND of equalities on top-level fields. Nested operator objects
13
- * (`{ age: { gte: 18 } }`), composition (`and` / `or` / `not`), and
14
- * dotted field paths disqualify pushdown — the wire format can't
15
- * express them. In those cases flat-query silently skips `where`, and
16
- * the default fetcher applies it as a runtime fallback after the
17
- * response arrives. This is the documented trade-off for the style's
18
- * simplicity: flat-query fits simple REST APIs; richer queries either
19
- * evaluate client-side or pick a different style.
20
- *
21
- * POST requests encode nothing — flat-query is a URL-params shape and
22
- * has no meaningful POST representation.
23
- *
24
- * Use case: public REST APIs like `?dept=biology&limit=10&sort=-date`.
25
- * For GraphQL or body-shaped backends, use json-body. For Strapi v4,
26
- * use the strapi style.
27
- */
28
-
29
- export const flatQuery = {
30
- name: 'flat-query',
31
- canPush: new Set(['where', 'limit', 'sort']),
32
- defaultEnvelope: null,
33
-
34
- encode(request, { method, pushCandidates, rename }) {
35
- const pushed = new Set()
36
- const queryParams = []
37
-
38
- if (method !== 'GET') {
39
- // POST has no flat-query representation; silent no-op.
40
- return { queryParams, bodyMerge: null, pushed }
41
- }
42
-
43
- // where — only flat AND of equalities.
44
- if (pushCandidates.has('where') && request.where !== undefined) {
45
- const pairs = encodeFlatEqualities(request.where)
46
- if (pairs !== null) {
47
- for (const [k, v] of pairs) queryParams.push([applyRename(k, rename), v])
48
- pushed.add('where')
49
- }
50
- }
51
-
52
- if (pushCandidates.has('limit') && request.limit !== undefined) {
53
- queryParams.push([wireName('limit', 'limit', rename), String(request.limit)])
54
- pushed.add('limit')
55
- }
56
-
57
- if (pushCandidates.has('sort') && request.sort !== undefined) {
58
- const encoded = encodeSort(request.sort)
59
- if (encoded !== null) {
60
- queryParams.push([wireName('sort', 'sort', rename), encoded])
61
- pushed.add('sort')
62
- }
63
- }
64
-
65
- return { queryParams, bodyMerge: null, pushed }
66
- },
67
- }
68
-
69
- /**
70
- * Encode a where-object as flat [field, value] pairs. Returns null when
71
- * the predicate doesn't fit the flat-AND-equalities shape — caller
72
- * falls back to runtime evaluation.
73
- */
74
- function encodeFlatEqualities(where) {
75
- if (!where || typeof where !== 'object' || Array.isArray(where)) return null
76
- const out = []
77
- for (const [key, raw] of Object.entries(where)) {
78
- // Composition and operator shorthand disqualify the whole predicate.
79
- if (key === 'and' || key === 'or' || key === 'not') return null
80
- if (key.includes('.')) return null // dotted path — flat-query can't express nesting
81
-
82
- // Bare primitive → implicit equality.
83
- if (isPrimitive(raw)) {
84
- out.push([key, stringifyPrimitive(raw)])
85
- continue
86
- }
87
-
88
- // { eq: primitive } → equality. Anything else disqualifies.
89
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
90
- const opKeys = Object.keys(raw)
91
- if (opKeys.length === 1 && opKeys[0] === 'eq' && isPrimitive(raw.eq)) {
92
- out.push([key, stringifyPrimitive(raw.eq)])
93
- continue
94
- }
95
- }
96
-
97
- return null
98
- }
99
- return out
100
- }
101
-
102
- /**
103
- * Encode `sort: 'date desc'` → `-date`; `sort: 'date desc, title asc'` →
104
- * `-date,title`. Returns null on malformed input (caller falls back).
105
- */
106
- function encodeSort(sortExpr) {
107
- if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
108
- const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
109
- if (parts.length === 0) return null
110
- const encoded = []
111
- for (const part of parts) {
112
- const [field, dirRaw] = part.split(/\s+/)
113
- if (!field) return null
114
- const desc = (dirRaw || 'asc').toLowerCase() === 'desc'
115
- encoded.push(desc ? `-${field}` : field)
116
- }
117
- return encoded.join(',')
118
- }
119
-
120
- function isPrimitive(value) {
121
- if (value === null) return true
122
- const t = typeof value
123
- return t === 'string' || t === 'number' || t === 'boolean'
124
- }
125
-
126
- function stringifyPrimitive(value) {
127
- if (value === null) return ''
128
- return String(value)
129
- }
130
-
131
- function wireName(operator, defaultWire, rename) {
132
- if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
133
- return rename[operator]
134
- }
135
- return defaultWire
136
- }
137
-
138
- // `rename` on flat-query's where pushdown doesn't apply to individual
139
- // field names — authors who need `dept → department` should write
140
- // their where-objects with the backend's field name directly. rename
141
- // remains available for `limit` and `sort` wire names.
142
- function applyRename(fieldName, _rename) {
143
- return fieldName
144
- }
145
-
146
- export default flatQuery
@@ -1,248 +0,0 @@
1
- /**
2
- * strapi — Strapi v4 REST API query style.
3
- *
4
- * GET-only. Encodes a full where-object into Strapi's bracket-notation
5
- * filters:
6
- *
7
- * where: { dept: 'biology' }
8
- * → filters[dept][$eq]=biology
9
- *
10
- * where: { age: { gte: 18 } }
11
- * → filters[age][$gte]=18
12
- *
13
- * where: { 'tenure.start': { gte: 2015 } }
14
- * → filters[tenure][start][$gte]=2015
15
- *
16
- * where: { or: [{ a: 1 }, { b: 2 }] }
17
- * → filters[$or][0][a][$eq]=1 & filters[$or][1][b][$eq]=2
18
- *
19
- * where: { not: { dept: 'emeritus' } }
20
- * → filters[$not][dept][$eq]=emeritus
21
- *
22
- * limit: 10
23
- * → pagination[limit]=10
24
- *
25
- * sort: 'date desc'
26
- * → sort=date:desc
27
- *
28
- * sort: 'date desc, title asc'
29
- * → sort[0]=date:desc & sort[1]=title:asc
30
- *
31
- * Response envelope defaults to `{ list: 'data', item: 'data' }` —
32
- * Strapi v4 wraps every response in `{ data, meta }`. Sites can override
33
- * via `envelope:` at the site or per-fetch level.
34
- *
35
- * Operators mapped (where-object → Strapi `$op`):
36
- * eq, ne, gt, gte, lt, lte, in, nin → notIn,
37
- * exists → notNull, and → $and, or → $or, not → $not.
38
- *
39
- * ⛔ `like` is deliberately NOT mapped, and the mapping it used to have was a
40
- * correctness bug rather than an approximation. Our `like` is an ANCHORED glob
41
- * (`^…$`, `*` = any run, `?` = one char); `$containsi` is an UNANCHORED
42
- * substring test with no wildcard syntax. The two disagree in both directions
43
- * and the pattern was forwarded verbatim, asterisks included:
44
- *
45
- * like: 'Dr. *' → filters[name][$containsi]=Dr. * asks for the LITERAL
46
- * text "Dr. *"
47
- * like: 'abc' → matches 'xabcx' at the source, which our own evaluator
48
- * would reject — anchored means exactly 'abc'
49
- *
50
- * Either way the request succeeds and the caller gets a WRONG set with a 200,
51
- * which is the failure this style's fail-closed default exists to prevent.
52
- * Unmapped, a predicate containing `like` is unencodable, nothing is pushed,
53
- * and the default fetcher applies the whole predicate as a runtime fallback —
54
- * correct records, at the cost of fetching more of them.
55
- *
56
- * For operators the where-object supports but Strapi doesn't have a
57
- * clean equivalent for, the style leaves them untouched (the default
58
- * fetcher applies them as a runtime fallback).
59
- */
60
-
61
- export const strapi = {
62
- name: 'strapi',
63
- canPush: new Set(['where', 'limit', 'sort']),
64
- defaultEnvelope: { list: 'data', item: 'data' },
65
-
66
- encode(request, { method, pushCandidates, rename }) {
67
- const pushed = new Set()
68
- const queryParams = []
69
-
70
- if (method !== 'GET') {
71
- // Strapi v4 REST is GET-only for reads.
72
- return { queryParams, bodyMerge: null, pushed }
73
- }
74
-
75
- if (pushCandidates.has('where') && request.where !== undefined) {
76
- const filterKey = wireName('where', 'filters', rename)
77
- const pairs = encodeStrapiFilters(request.where, filterKey)
78
- if (pairs !== null) {
79
- for (const pair of pairs) queryParams.push(pair)
80
- pushed.add('where')
81
- }
82
- }
83
-
84
- if (pushCandidates.has('limit') && request.limit !== undefined) {
85
- const limitKey = wireName('limit', 'pagination[limit]', rename)
86
- queryParams.push([limitKey, String(request.limit)])
87
- pushed.add('limit')
88
- }
89
-
90
- if (pushCandidates.has('sort') && request.sort !== undefined) {
91
- const sortPairs = encodeStrapiSort(request.sort, wireName('sort', 'sort', rename))
92
- if (sortPairs !== null) {
93
- for (const pair of sortPairs) queryParams.push(pair)
94
- pushed.add('sort')
95
- }
96
- }
97
-
98
- return { queryParams, bodyMerge: null, pushed }
99
- },
100
- }
101
-
102
- // ─── where encoding ────────────────────────────────────────────────────────
103
-
104
- // Values are the Strapi operator *with* bracket delimiters, so they
105
- // concatenate cleanly into a bracketed path like `filters[field][$eq]`.
106
- const OPERATOR_MAP = {
107
- eq: '[$eq]',
108
- ne: '[$ne]',
109
- gt: '[$gt]',
110
- gte: '[$gte]',
111
- lt: '[$lt]',
112
- lte: '[$lte]',
113
- in: '[$in]',
114
- nin: '[$notIn]',
115
- exists: '[$notNull]', // overridden below for exists:false → [$null]
116
- }
117
-
118
- /**
119
- * Walk a where-object and emit [paramName, paramValue] pairs encoding
120
- * Strapi's bracket syntax. Returns null if any branch is unencodable
121
- * (caller falls back to runtime evaluation for the whole predicate).
122
- */
123
- function encodeStrapiFilters(where, rootKey) {
124
- const out = []
125
- try {
126
- walkPredicate(where, [rootKey], out)
127
- } catch (err) {
128
- if (err && err.unencodable) return null
129
- throw err
130
- }
131
- return out.length > 0 ? out : null
132
- }
133
-
134
- function walkPredicate(node, path, out) {
135
- if (!node || typeof node !== 'object' || Array.isArray(node)) {
136
- throwUnencodable()
137
- }
138
-
139
- // Top-level each key is either composition ($and/$or/$not) or a field.
140
- for (const [key, value] of Object.entries(node)) {
141
- if (key === 'and' || key === 'or') {
142
- if (!Array.isArray(value)) throwUnencodable()
143
- value.forEach((sub, i) => {
144
- walkPredicate(sub, [...path, `[$${key}]`, `[${i}]`], out)
145
- })
146
- } else if (key === 'not') {
147
- walkPredicate(value, [...path, '[$not]'], out)
148
- } else {
149
- emitFieldPredicate(key, value, path, out)
150
- }
151
- }
152
- }
153
-
154
- function emitFieldPredicate(field, value, path, out) {
155
- // Dotted paths become nested bracket segments: `tenure.start` →
156
- // `[tenure][start]`.
157
- const fieldSegments = field.split('.').map((seg) => `[${seg}]`)
158
- const fieldPath = [...path, ...fieldSegments]
159
-
160
- // Bare primitive → implicit eq.
161
- if (isPrimitive(value)) {
162
- out.push([joinPath(fieldPath) + '[$eq]', stringifyPrimitive(value)])
163
- return
164
- }
165
-
166
- if (value && typeof value === 'object' && !Array.isArray(value)) {
167
- for (const [opName, opValue] of Object.entries(value)) {
168
- const strapiOp = OPERATOR_MAP[opName]
169
- if (!strapiOp) throwUnencodable()
170
-
171
- if (opName === 'in' || opName === 'nin') {
172
- if (!Array.isArray(opValue)) throwUnencodable()
173
- opValue.forEach((item, i) => {
174
- if (!isPrimitive(item)) throwUnencodable()
175
- out.push([joinPath(fieldPath) + strapiOp + `[${i}]`, stringifyPrimitive(item)])
176
- })
177
- continue
178
- }
179
-
180
- if (opName === 'exists') {
181
- // { exists: true } → field IS NOT NULL → [field][$notNull]=true
182
- // { exists: false } → field IS NULL → [field][$null]=true
183
- const wantsPresent = !!opValue
184
- out.push([joinPath(fieldPath) + (wantsPresent ? '[$notNull]' : '[$null]'), 'true'])
185
- continue
186
- }
187
-
188
- if (!isPrimitive(opValue)) throwUnencodable()
189
- out.push([joinPath(fieldPath) + strapiOp, stringifyPrimitive(opValue)])
190
- }
191
- return
192
- }
193
-
194
- throwUnencodable()
195
- }
196
-
197
- function joinPath(segments) {
198
- return segments.join('')
199
- }
200
-
201
- function throwUnencodable() {
202
- const err = new Error('unencodable predicate')
203
- err.unencodable = true
204
- throw err
205
- }
206
-
207
- // ─── sort encoding ─────────────────────────────────────────────────────────
208
-
209
- function encodeStrapiSort(sortExpr, baseKey) {
210
- if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
211
- const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
212
- if (parts.length === 0) return null
213
-
214
- const encoded = []
215
- for (const part of parts) {
216
- const [field, dirRaw] = part.split(/\s+/)
217
- if (!field) return null
218
- const dir = (dirRaw || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
219
- encoded.push(`${field}:${dir}`)
220
- }
221
-
222
- if (encoded.length === 1) {
223
- return [[baseKey, encoded[0]]]
224
- }
225
- return encoded.map((v, i) => [`${baseKey}[${i}]`, v])
226
- }
227
-
228
- // ─── helpers ───────────────────────────────────────────────────────────────
229
-
230
- function isPrimitive(value) {
231
- if (value === null) return true
232
- const t = typeof value
233
- return t === 'string' || t === 'number' || t === 'boolean'
234
- }
235
-
236
- function stringifyPrimitive(value) {
237
- if (value === null) return ''
238
- return String(value)
239
- }
240
-
241
- function wireName(operator, defaultWire, rename) {
242
- if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
243
- return rename[operator]
244
- }
245
- return defaultWire
246
- }
247
-
248
- export default strapi