@uniweb/core 0.12.1 → 0.13.1

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,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
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,30 +76,56 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
53
76
  const { paramName, paramValue } = dynamicContext
54
77
  if (!paramName || paramValue === undefined) return null
55
78
 
56
- const baseUrl = collectionConfig.url || collectionConfig.path
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 isLocalPath = !!collectionConfig.path && !collectionConfig.url
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
- ...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
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, { [paramName]: paramValue }, { encode: false })
102
+ out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue), { encode: false })
72
103
  } else if (collectionConfig.body !== undefined) {
73
- out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
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
77
108
  }
78
109
 
79
110
  // String-form: URL-based conventions.
111
+ //
112
+ // ⭐ `rest` and `query` BUILD FROM THE LIST URL, so its query string survives onto
113
+ // the detail request. That is deliberate and it is the safe default: the params
114
+ // that matter most to a single-record read are exactly the ones a list carries —
115
+ // `?lang=`, an API key, a tenancy id. Dropping them would 401 the detail request
116
+ // or return the wrong language, on every detail page.
117
+ //
118
+ // ⚠️ The cost is real and lands on ONE category: a PROJECTION param (`?fields=`,
119
+ // `?select=`) asks the API for a summary, and carrying it truncates the very
120
+ // record the detail fetch exists to get in full. The request still succeeds and
121
+ // only some fields are missing, so it reads as a component or API fault rather
122
+ // than a URL one.
123
+ //
124
+ // ⛔ Framework cannot tell the categories apart — they are the host's vocabulary,
125
+ // not ours. So the default keeps everything and the CUSTOM PATTERN form is the
126
+ // way out: it is used verbatim, so nothing carries over unless the author writes
127
+ // it. Documented for authors in `docs/reference/dynamic-routes.md` § *The list's
128
+ // query string carries over*.
80
129
  let detailUrl
81
130
  if (detail === 'rest') {
82
131
  const [basePath, queryString] = baseUrl.split('?')
@@ -91,11 +140,11 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
91
140
  // Custom pattern like '/articles/{slug}' — substitute placeholders
92
141
  // from the dynamic-route context. Only placeholders matching the
93
142
  // active paramName resolve; others pass through as literal `{name}`.
94
- detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
143
+ detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue))
95
144
  }
96
145
 
97
146
  return {
98
- ...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
147
+ [addressKey]: detailUrl,
99
148
  schema: collectionConfig.schema,
100
149
  transform: collectionConfig.transform,
101
150
  }
@@ -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
  }
@@ -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
- const localized = localizeConfig(cfg, locale, defaultLocale)
154
- configs.set(cfg.schema, applyDeferredDetail(localized, collections))
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, like → containsi,
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