@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 +3 -3
- package/src/datastore.js +4 -5
- package/src/detail-url.js +2 -2
- package/src/entity-store.js +29 -19
- package/src/fetch-config.js +24 -15
- package/src/fetcher-dispatcher.js +6 -1
- package/src/index.js +1 -4
- package/src/request-styles/index.js +37 -39
- package/src/website.js +8 -3
- package/src/request-styles/flat-query.js +0 -146
- package/src/request-styles/strapi.js +0 -248
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "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/
|
|
47
|
-
"@uniweb/
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
+
as: queryConfig.as,
|
|
149
149
|
transform: queryConfig.transform,
|
|
150
150
|
}
|
|
151
151
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
138
|
+
blockFetch && !isRefinement(blockFetch) ? blockFetch : null,
|
|
129
139
|
page?.fetch,
|
|
130
140
|
page?.parent?.fetch,
|
|
131
141
|
website?.config?.fetch,
|
package/src/fetch-config.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
* ⛔
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
23
|
-
[
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
|
596
|
-
: (parentFetch
|
|
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
|