@uniweb/core 0.12.1 → 0.13.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 +2 -1
- package/src/collection-address.js +143 -0
- package/src/datastore.js +2 -2
- package/src/detail-url.js +39 -8
- package/src/entity-store.js +4 -0
- package/src/fetch-config.js +61 -4
- package/src/index.js +6 -0
- package/src/request-styles/strapi.js +18 -2
- package/src/where.js +45 -1
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./base-path": "./src/base-path.js",
|
|
9
|
+
"./collection-address": "./src/collection-address.js",
|
|
9
10
|
"./data-paths": "./src/data-paths.js",
|
|
10
11
|
"./detail-url": "./src/detail-url.js",
|
|
11
12
|
"./fetch-config": "./src/fetch-config.js",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a collection request to an address the fetcher can call.
|
|
3
|
+
*
|
|
4
|
+
* ## The one idea
|
|
5
|
+
*
|
|
6
|
+
* A site names a collection; it never names where the collection lives. Where
|
|
7
|
+
* it lives is a **deployment** fact, and the two possible answers have different
|
|
8
|
+
* owners:
|
|
9
|
+
*
|
|
10
|
+
* 1. **A host that serves records live** declares a pair of URL *patterns*
|
|
11
|
+
* at `config.records`. It owns every segment of them.
|
|
12
|
+
* 2. **Nobody** — and the answer is the artifact the build itself emitted,
|
|
13
|
+
* `/data/<name>.json`, which is not an address at all but a path in the
|
|
14
|
+
* site's own URL space.
|
|
15
|
+
*
|
|
16
|
+
* Absence of (1) is therefore not an error and not a decline: it falls THROUGH
|
|
17
|
+
* to (2). That is what makes a site with no backend the default rather than a
|
|
18
|
+
* special case, and it is why this does not go through `resolveService` — a
|
|
19
|
+
* service's absence means the site has no such feature and the caller draws
|
|
20
|
+
* nothing, which is right for `submit` and wrong here, where the fallback is a
|
|
21
|
+
* file the build knows it wrote.
|
|
22
|
+
*
|
|
23
|
+
* ## ⛔ Patterns, not a base — and the reason is a deleted function
|
|
24
|
+
*
|
|
25
|
+
* A base assumes the layout is "root plus one segment". A pattern assumes
|
|
26
|
+
* nothing, so a host can carry a site id, a locale segment, a different root
|
|
27
|
+
* for records than for the collection, or none of those, and move any of it
|
|
28
|
+
* without a framework release.
|
|
29
|
+
*
|
|
30
|
+
* This is the `config.assets.url` rule applied to records. That pattern exists
|
|
31
|
+
* because the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
|
|
32
|
+
* path layout, inside a published CLI, on a release cadence the backend could
|
|
33
|
+
* not move. It was deleted rather than parameterized. Composing a segment of
|
|
34
|
+
* our own here would rebuild exactly that coupling, on a lane where the wrong
|
|
35
|
+
* answer is *stale or missing content* rather than a visible 404.
|
|
36
|
+
*
|
|
37
|
+
* ⇒ Substituting `{path}` and `{param}` is the WHOLE of what this does.
|
|
38
|
+
*
|
|
39
|
+
* Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
|
|
40
|
+
* isolate can both import it.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The placeholder a list pattern must carry.
|
|
47
|
+
*
|
|
48
|
+
* ⛔ IT IS `{path}`, NOT `{collection}`, AND THAT IS NOT COSMETIC. A *collection* is
|
|
49
|
+
* framework's own build concept — a named set our build compiles to one file. A host
|
|
50
|
+
* serving records has no such thing: it has content organised somewhere, and what we
|
|
51
|
+
* substitute is a **path** to it. Naming the slot for our file vocabulary put that
|
|
52
|
+
* vocabulary into a string a HOST writes, which makes them reason in our shape.
|
|
53
|
+
* See `kb/framework/architecture/backend-boundary.md` §2.
|
|
54
|
+
*/
|
|
55
|
+
const PATH_SLOT = '{path}'
|
|
56
|
+
/** The placeholder a record pattern must carry to address a specific record. */
|
|
57
|
+
const PARAM_SLOT = '{param}'
|
|
58
|
+
|
|
59
|
+
const warnedPatterns = new Set()
|
|
60
|
+
|
|
61
|
+
function warnOnce(key, message) {
|
|
62
|
+
if (warnedPatterns.has(key)) return
|
|
63
|
+
warnedPatterns.add(key)
|
|
64
|
+
console.warn(`[collection-address] ${message}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Test seam — reset the once-per-pattern memo so suites do not leak. */
|
|
68
|
+
export function _resetCollectionAddressWarnings() {
|
|
69
|
+
warnedPatterns.clear()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is this a usable lane declaration?
|
|
74
|
+
*
|
|
75
|
+
* A declaration present with no pattern is a host saying "not for this site" —
|
|
76
|
+
* indistinguishable, for a caller, from no declaration at all. Both fall
|
|
77
|
+
* through to the artifact.
|
|
78
|
+
*/
|
|
79
|
+
function readPattern(lane, key) {
|
|
80
|
+
if (!lane || typeof lane !== 'object' || Array.isArray(lane)) return null
|
|
81
|
+
const pattern = lane[key]
|
|
82
|
+
return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The address for a whole collection, or `null` to fall through to the artifact.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ A pattern that does not carry `{path}` is REFUSED rather than used.
|
|
89
|
+
* Substituting nothing would yield one identical URL for every collection on the
|
|
90
|
+
* site — every schema reading the same records, with a 200 on each request. That
|
|
91
|
+
* is the failure this check exists for; an unusable pattern must degrade to the
|
|
92
|
+
* artifact, which is at least correct.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} collection - the collection's authored name (the wiring key).
|
|
95
|
+
* @param {Object|null} lane - `config.records`.
|
|
96
|
+
* @returns {string|null} the address, or null when nothing usable is declared.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveCollectionAddress(collection, lane) {
|
|
99
|
+
if (typeof collection !== 'string' || collection.length === 0) return null
|
|
100
|
+
const pattern = readPattern(lane, 'list')
|
|
101
|
+
if (!pattern) return null
|
|
102
|
+
if (!pattern.includes(PATH_SLOT)) {
|
|
103
|
+
warnOnce(
|
|
104
|
+
`list:${pattern}`,
|
|
105
|
+
`config.records.list carries no ${PATH_SLOT} placeholder, so every ` +
|
|
106
|
+
`collection would resolve to the same address. Ignoring it and reading the ` +
|
|
107
|
+
`compiled collection file instead.`
|
|
108
|
+
)
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
return substitutePlaceholders(pattern, { path: collection })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The address pattern for ONE record of a collection, with `{param}` left in
|
|
116
|
+
* place for the dynamic-route substitution that happens later.
|
|
117
|
+
*
|
|
118
|
+
* Returning a pattern rather than a finished URL is deliberate: the route param
|
|
119
|
+
* is not known here, and the framework already has one place that resolves it
|
|
120
|
+
* (`buildDetailConfig` / `substitutePlaceholders` at fetch time). Resolving it
|
|
121
|
+
* twice, in two places, is how the two copies drift.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} collection
|
|
124
|
+
* @param {Object|null} lane - `config.records`.
|
|
125
|
+
* @returns {string|null} a pattern still containing `{param}`, or null.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveRecordAddressPattern(collection, lane) {
|
|
128
|
+
if (typeof collection !== 'string' || collection.length === 0) return null
|
|
129
|
+
const pattern = readPattern(lane, 'record')
|
|
130
|
+
if (!pattern) return null
|
|
131
|
+
if (!pattern.includes(PARAM_SLOT)) {
|
|
132
|
+
warnOnce(
|
|
133
|
+
`record:${pattern}`,
|
|
134
|
+
`config.records.record carries no ${PARAM_SLOT} placeholder, so every record ` +
|
|
135
|
+
`would resolve to the same address. Ignoring it and reading the per-record ` +
|
|
136
|
+
`file instead.`
|
|
137
|
+
)
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
// Only `{path}` is substituted here — `{param}` survives for the
|
|
141
|
+
// dynamic-route resolution that owns it.
|
|
142
|
+
return substitutePlaceholders(pattern, { path: collection })
|
|
143
|
+
}
|
package/src/datastore.js
CHANGED
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
* @returns {string} A stable JSON string usable as a cache-Map key
|
|
36
36
|
*/
|
|
37
37
|
export function deriveCacheKey(request) {
|
|
38
|
-
const { path, url, schema, transform } = request || {}
|
|
38
|
+
const { path, url, endpoint, schema, transform } = request || {}
|
|
39
39
|
const method = request?.method && request.method.toUpperCase() !== 'GET'
|
|
40
40
|
? request.method.toUpperCase()
|
|
41
41
|
: undefined
|
|
42
42
|
const body = method === 'POST' ? request?.body : undefined
|
|
43
|
-
return JSON.stringify({ path, url, schema, transform, method, body })
|
|
43
|
+
return JSON.stringify({ path, url, endpoint, schema, transform, method, body })
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export default class DataStore {
|
package/src/detail-url.js
CHANGED
|
@@ -24,13 +24,36 @@
|
|
|
24
24
|
|
|
25
25
|
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The substitution context for a detail pattern: the route's own param name,
|
|
29
|
+
* plus a generic `param` alias bound to the same value.
|
|
30
|
+
*
|
|
31
|
+
* ⭐ Why the alias exists. An AUTHOR writing a detail pattern knows their route
|
|
32
|
+
* and writes `{slug}` or `{id}` — that convention is unchanged and must stay,
|
|
33
|
+
* because it is what every existing site and the auto-injected per-record
|
|
34
|
+
* pattern use. A HOST declaring a record address cannot know it: `param_name`
|
|
35
|
+
* is the site's routing choice, and the host is publishing one pattern for
|
|
36
|
+
* every site it serves. So the host writes `{param}`.
|
|
37
|
+
*
|
|
38
|
+
* Binding both names to one value is what lets the two conventions coexist
|
|
39
|
+
* without a translation step between them — and a translation step is exactly
|
|
40
|
+
* where the framework has twice grown a second copy of a rule that then drifted
|
|
41
|
+
* (`route-match`, `data-paths`). `substitutePlaceholders` only resolves keys
|
|
42
|
+
* present in the context, so an unrelated `{name}` still passes through
|
|
43
|
+
* literally, as it always has.
|
|
44
|
+
*/
|
|
45
|
+
function paramContext(paramName, paramValue) {
|
|
46
|
+
return { [paramName]: paramValue, param: paramValue }
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
/**
|
|
28
50
|
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
29
51
|
*
|
|
30
52
|
* Four forms of `detail:`:
|
|
31
53
|
* - `'rest'` — append paramValue as a path segment.
|
|
32
54
|
* - `'query'` — append `?paramName=paramValue`.
|
|
33
|
-
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders
|
|
55
|
+
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders,
|
|
56
|
+
* or the generic `{param}` alias (see below).
|
|
34
57
|
* - `{ body, envelope }` — object form. Reuses the collection's url /
|
|
35
58
|
* method / headers / auth; adds per-detail
|
|
36
59
|
* body (with placeholder substitution) and
|
|
@@ -53,24 +76,32 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
|
|
|
53
76
|
const { paramName, paramValue } = dynamicContext
|
|
54
77
|
if (!paramName || paramValue === undefined) return null
|
|
55
78
|
|
|
56
|
-
|
|
79
|
+
// Three address kinds now, and the detail request must come back as the SAME
|
|
80
|
+
// kind: an `endpoint` carries remote semantics the fetcher decides on, so
|
|
81
|
+
// returning a detail as `path` would silently drop operator pushdown and the
|
|
82
|
+
// site's static headers for exactly the request that is one record.
|
|
83
|
+
const baseUrl = collectionConfig.endpoint || collectionConfig.url || collectionConfig.path
|
|
57
84
|
if (!baseUrl) return null
|
|
58
|
-
const
|
|
85
|
+
const addressKey = collectionConfig.endpoint
|
|
86
|
+
? 'endpoint'
|
|
87
|
+
: collectionConfig.url
|
|
88
|
+
? 'url'
|
|
89
|
+
: 'path'
|
|
59
90
|
|
|
60
91
|
// Object form: `detail: { body, envelope }`. Reuses collection's URL +
|
|
61
92
|
// method + headers + auth. The body is placeholder-substituted against
|
|
62
93
|
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
63
94
|
if (detail && typeof detail === 'object') {
|
|
64
95
|
const out = {
|
|
65
|
-
|
|
96
|
+
[addressKey]: baseUrl,
|
|
66
97
|
schema: collectionConfig.schema,
|
|
67
98
|
transform: collectionConfig.transform,
|
|
68
99
|
}
|
|
69
100
|
if (collectionConfig.method) out.method = collectionConfig.method
|
|
70
101
|
if (detail.body !== undefined) {
|
|
71
|
-
out.body = substitutePlaceholders(detail.body,
|
|
102
|
+
out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue), { encode: false })
|
|
72
103
|
} else if (collectionConfig.body !== undefined) {
|
|
73
|
-
out.body = substitutePlaceholders(collectionConfig.body,
|
|
104
|
+
out.body = substitutePlaceholders(collectionConfig.body, paramContext(paramName, paramValue), { encode: false })
|
|
74
105
|
}
|
|
75
106
|
if (detail.envelope) out.envelope = detail.envelope
|
|
76
107
|
return out
|
|
@@ -91,11 +122,11 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
|
|
|
91
122
|
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
92
123
|
// from the dynamic-route context. Only placeholders matching the
|
|
93
124
|
// active paramName resolve; others pass through as literal `{name}`.
|
|
94
|
-
detailUrl = substitutePlaceholders(detail,
|
|
125
|
+
detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue))
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
return {
|
|
98
|
-
|
|
129
|
+
[addressKey]: detailUrl,
|
|
99
130
|
schema: collectionConfig.schema,
|
|
100
131
|
transform: collectionConfig.transform,
|
|
101
132
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -128,6 +128,10 @@ export default class EntityStore {
|
|
|
128
128
|
locale: website?.getActiveLocale?.() ?? null,
|
|
129
129
|
defaultLocale: website?.getDefaultLocale?.() ?? null,
|
|
130
130
|
collections: website?.config?.collections ?? null,
|
|
131
|
+
// A host's live-collection lane. Absent on every static site and on
|
|
132
|
+
// local dev, which is why `resolveCollectionSource` treats absence as
|
|
133
|
+
// the ordinary case and reads the compiled artifact without comment.
|
|
134
|
+
records: website?.config?.records ?? null,
|
|
131
135
|
},
|
|
132
136
|
)
|
|
133
137
|
}
|
package/src/fetch-config.js
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* `resolveFetchConfigs`. That difference is real and stays with the caller.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { isDataUrl, recordDataUrl } from './data-paths.js'
|
|
30
|
+
import { collectionDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
31
|
+
import { resolveCollectionAddress, resolveRecordAddressPattern } from './collection-address.js'
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Is this fetch declaration a per-instance *refinement* of an ancestor's
|
|
@@ -95,8 +96,23 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
95
96
|
* @param {Object|null} collections - the site's `config.collections` map
|
|
96
97
|
* @returns {Object} the original config, or a copy carrying `detail`
|
|
97
98
|
*/
|
|
98
|
-
function applyDeferredDetail(cfg, collections) {
|
|
99
|
+
function applyDeferredDetail(cfg, collections, records) {
|
|
99
100
|
if (cfg.detail !== undefined) return cfg
|
|
101
|
+
|
|
102
|
+
// ⭐ A lane's record address is injected whenever the lane declares one —
|
|
103
|
+
// NOT only for a `deferred:` collection, and the difference is load-bearing.
|
|
104
|
+
//
|
|
105
|
+
// A live lane answers a list request at brief depth and a record request in
|
|
106
|
+
// full, so a detail page that filtered the list would render the brief and
|
|
107
|
+
// silently miss the body. And it cannot fall back to the rule below: the
|
|
108
|
+
// `deferred:` declaration lives in `config.collections`, which a host's
|
|
109
|
+
// projection is not obliged to carry — so on such a host that rule can never
|
|
110
|
+
// fire, and this is the only way a detail page reaches a whole record.
|
|
111
|
+
if (cfg.endpoint) {
|
|
112
|
+
const recordPattern = resolveRecordAddressPattern(cfg.collection ?? cfg.schema, records)
|
|
113
|
+
if (recordPattern) return { ...cfg, detail: recordPattern }
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
const schema = cfg.schema
|
|
101
117
|
if (!schema || !collections) return cfg
|
|
102
118
|
const collConfig = collections[schema]
|
|
@@ -109,6 +125,40 @@ function applyDeferredDetail(cfg, collections) {
|
|
|
109
125
|
return { ...cfg, detail: pattern }
|
|
110
126
|
}
|
|
111
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a `collection:` reference to something the fetcher can call.
|
|
130
|
+
*
|
|
131
|
+
* The author names a collection; this decides where that collection lives, and
|
|
132
|
+
* there are exactly two answers:
|
|
133
|
+
*
|
|
134
|
+
* - a host declared a live lane (`config.records`) → an `endpoint`, final on
|
|
135
|
+
* arrival, which the fetcher calls without composing anything further;
|
|
136
|
+
* - nobody did → the `path` of the artifact the build emitted.
|
|
137
|
+
*
|
|
138
|
+
* ⭐ The second is not a fallback in the apologetic sense. It is the answer for
|
|
139
|
+
* every site with no backend, which is the framework's default rather than a
|
|
140
|
+
* degraded mode — so an absent lane is silent, not warned.
|
|
141
|
+
*
|
|
142
|
+
* ⭐ `collection` OUTRANKS a `path` sitting beside it, which matters because the
|
|
143
|
+
* sync producer emits both during the transition — `collection` for a consumer
|
|
144
|
+
* that resolves it, `path` for one that has not been taught to yet. Resolving
|
|
145
|
+
* whenever `collection` is present is also what the build-time parser has always
|
|
146
|
+
* done (`parseFetchConfig` returns early on `collection`, ignoring any `path`),
|
|
147
|
+
* so the two agree rather than disagreeing on a shape nobody hand-writes.
|
|
148
|
+
*/
|
|
149
|
+
function resolveCollectionSource(cfg, records) {
|
|
150
|
+
if (typeof cfg.collection !== 'string' || cfg.collection.length === 0) return cfg
|
|
151
|
+
|
|
152
|
+
const endpoint = resolveCollectionAddress(cfg.collection, records)
|
|
153
|
+
if (endpoint) {
|
|
154
|
+
// Drop the transitional `path`: two addresses on one request is an
|
|
155
|
+
// ambiguity the fetcher would have to break by accident of field order.
|
|
156
|
+
const { path, url, ...rest } = cfg
|
|
157
|
+
return { ...rest, endpoint }
|
|
158
|
+
}
|
|
159
|
+
return { ...cfg, path: collectionDataUrl(cfg.collection) }
|
|
160
|
+
}
|
|
161
|
+
|
|
112
162
|
/**
|
|
113
163
|
* Resolve the applicable fetch configs from an ordered list of sources.
|
|
114
164
|
*
|
|
@@ -130,6 +180,9 @@ function applyDeferredDetail(cfg, collections) {
|
|
|
130
180
|
* @param {string|null} [options.locale] - the locale being rendered
|
|
131
181
|
* @param {string|null} [options.defaultLocale] - the site's default locale
|
|
132
182
|
* @param {Object|null} [options.collections] - the site's `config.collections`
|
|
183
|
+
* @param {Object|null} [options.records] - the site's `config.records`, a host's
|
|
184
|
+
* live-collection lane. Absent means the compiled artifact answers, which is
|
|
185
|
+
* the whole of what a site with no backend needs.
|
|
133
186
|
* @returns {Map<string, Object>} schema name → resolved config
|
|
134
187
|
*/
|
|
135
188
|
export function resolveFetchConfigs(sources, options = {}) {
|
|
@@ -138,6 +191,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
138
191
|
locale = null,
|
|
139
192
|
defaultLocale = null,
|
|
140
193
|
collections = null,
|
|
194
|
+
records = null,
|
|
141
195
|
} = options
|
|
142
196
|
|
|
143
197
|
const configs = new Map()
|
|
@@ -150,8 +204,11 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
150
204
|
if (!cfg?.schema) continue
|
|
151
205
|
if (configs.has(cfg.schema)) continue
|
|
152
206
|
if (!collectAll && !schemas.includes(cfg.schema)) continue
|
|
153
|
-
|
|
154
|
-
|
|
207
|
+
// Address first: localization and deferred-detail both key on `path`,
|
|
208
|
+
// which a `collection:` ref does not have until this runs.
|
|
209
|
+
const sourced = resolveCollectionSource(cfg, records)
|
|
210
|
+
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
211
|
+
configs.set(cfg.schema, applyDeferredDetail(localized, collections, records))
|
|
155
212
|
}
|
|
156
213
|
}
|
|
157
214
|
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,12 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
20
20
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
23
|
+
export {
|
|
24
|
+
resolveCollectionAddress,
|
|
25
|
+
resolveRecordAddressPattern,
|
|
26
|
+
} from './collection-address.js'
|
|
27
|
+
export { resolveFetchConfigs } from './fetch-config.js'
|
|
28
|
+
export { buildDetailConfig } from './detail-url.js'
|
|
23
29
|
export {
|
|
24
30
|
normalizeLanguageList,
|
|
25
31
|
isWildcardLanguages,
|
|
@@ -33,9 +33,26 @@
|
|
|
33
33
|
* via `envelope:` at the site or per-fetch level.
|
|
34
34
|
*
|
|
35
35
|
* Operators mapped (where-object → Strapi `$op`):
|
|
36
|
-
* eq, ne, gt, gte, lt, lte, in, nin → notIn,
|
|
36
|
+
* eq, ne, gt, gte, lt, lte, in, nin → notIn,
|
|
37
37
|
* exists → notNull, and → $and, or → $or, not → $not.
|
|
38
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
|
+
*
|
|
39
56
|
* For operators the where-object supports but Strapi doesn't have a
|
|
40
57
|
* clean equivalent for, the style leaves them untouched (the default
|
|
41
58
|
* fetcher applies them as a runtime fallback).
|
|
@@ -95,7 +112,6 @@ const OPERATOR_MAP = {
|
|
|
95
112
|
lte: '[$lte]',
|
|
96
113
|
in: '[$in]',
|
|
97
114
|
nin: '[$notIn]',
|
|
98
|
-
like: '[$containsi]', // case-insensitive substring — closest Strapi analog
|
|
99
115
|
exists: '[$notNull]', // overridden below for exists:false → [$null]
|
|
100
116
|
}
|
|
101
117
|
|
package/src/where.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* nin Value is not in the listed array.
|
|
37
37
|
* like Glob match (`*` any run, `?` one char). String fields only.
|
|
38
38
|
* exists Field is truthy (boolean toggle).
|
|
39
|
+
* under Path containment at SEGMENT boundaries. String fields only.
|
|
39
40
|
*
|
|
40
41
|
* Composition keys:
|
|
41
42
|
*
|
|
@@ -43,6 +44,23 @@
|
|
|
43
44
|
* or Array of sub-predicates; at least one must match.
|
|
44
45
|
* not Single sub-predicate; must not match.
|
|
45
46
|
*
|
|
47
|
+
* `under` is for fields holding a slash-separated location — a record's
|
|
48
|
+
* position inside its collection, a category path, a docs section. It matches
|
|
49
|
+
* the value itself and anything below it, and it respects segment boundaries so
|
|
50
|
+
* a sibling with a shared prefix does not match:
|
|
51
|
+
*
|
|
52
|
+
* { path: { under: '2024' } } matches '2024' and '2024/spring'
|
|
53
|
+
* does NOT match '2024b'
|
|
54
|
+
* { path: { under: '' } } matches everything (the root contains all)
|
|
55
|
+
* { path: '2024' } plain equality — that level only
|
|
56
|
+
*
|
|
57
|
+
* ⭐ Why this is an operator and not a separate `recursive:` flag on a fetch
|
|
58
|
+
* declaration: the architecture holds that a query says WHICH records the
|
|
59
|
+
* author wants, and who evaluates it is a capability question. Recursion is
|
|
60
|
+
* then the difference between `eq` and `under` — no new vocabulary above the
|
|
61
|
+
* predicate, and a source free to satisfy `under` by walking one branch instead
|
|
62
|
+
* of scanning is doing query planning, which is its business.
|
|
63
|
+
*
|
|
46
64
|
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
|
|
47
65
|
*
|
|
48
66
|
* Type safety: type mismatches return `false` rather than throwing
|
|
@@ -53,7 +71,7 @@
|
|
|
53
71
|
|
|
54
72
|
const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
|
|
55
73
|
const OPERATORS = new Set([
|
|
56
|
-
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
|
|
74
|
+
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists', 'under',
|
|
57
75
|
])
|
|
58
76
|
|
|
59
77
|
/**
|
|
@@ -120,6 +138,30 @@ function evaluateClause(key, value, record) {
|
|
|
120
138
|
return matchEqual(fieldValue, value)
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Path containment at segment boundaries.
|
|
143
|
+
*
|
|
144
|
+
* Both sides are compared with leading and trailing `/` trimmed, so an author
|
|
145
|
+
* who writes `'/2024'` or `'2024/'` gets what they meant. An empty ancestor is
|
|
146
|
+
* the root and contains everything — which is what makes "the whole collection"
|
|
147
|
+
* expressible as a predicate rather than as the absence of one.
|
|
148
|
+
*
|
|
149
|
+
* ⛔ The `+ '/'` is the whole point: a plain `startsWith` would match `2024b`
|
|
150
|
+
* against `2024`, which is the classic prefix bug and silently returns records
|
|
151
|
+
* from a sibling the author never named.
|
|
152
|
+
*/
|
|
153
|
+
function matchUnder(fieldValue, ancestor) {
|
|
154
|
+
if (typeof fieldValue !== 'string' || typeof ancestor !== 'string') return false
|
|
155
|
+
const a = trimSlashes(ancestor)
|
|
156
|
+
if (a === '') return true
|
|
157
|
+
const f = trimSlashes(fieldValue)
|
|
158
|
+
return f === a || f.startsWith(a + '/')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function trimSlashes(s) {
|
|
162
|
+
return s.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
function isOperatorObject(value) {
|
|
124
166
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
125
167
|
// An operator-object's keys are all in OPERATORS. If even one key isn't
|
|
@@ -164,6 +206,8 @@ function evaluateOperator(op, opValue, fieldValue) {
|
|
|
164
206
|
return globMatch(opValue, fieldValue)
|
|
165
207
|
case 'exists':
|
|
166
208
|
return Boolean(fieldValue) === Boolean(opValue)
|
|
209
|
+
case 'under':
|
|
210
|
+
return matchUnder(fieldValue, opValue)
|
|
167
211
|
default:
|
|
168
212
|
// Unknown operator → fail closed.
|
|
169
213
|
return false
|