@uniweb/core 0.18.0 → 0.20.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.
@@ -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,61 +0,0 @@
1
- /**
2
- * Request-style registry.
3
- *
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`.
9
- *
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.
14
- *
15
- * Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
16
- */
17
-
18
- import { jsonBody } from './json-body.js'
19
- import { flatQuery } from './flat-query.js'
20
- import { strapi } from './strapi.js'
21
-
22
- const STYLES = new Map([
23
- [jsonBody.name, jsonBody],
24
- [flatQuery.name, flatQuery],
25
- [strapi.name, strapi],
26
- ])
27
-
28
- /**
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
- *
33
- * @param {string|undefined|null} name
34
- * @param {{ dev?: boolean }} [options]
35
- * @returns {Object} A style module.
36
- */
37
- 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
- )
47
- }
48
- return jsonBody
49
- }
50
-
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
- }
60
-
61
- export { jsonBody, flatQuery, strapi }
@@ -1,117 +0,0 @@
1
- /**
2
- * json-body — the framework's general-purpose request style.
3
- *
4
- * Ambient default when the site doesn't pick a style. Speaks the
5
- * framework's own conventions:
6
- *
7
- * GET — operators travel as URL params prefixed with underscore:
8
- * ?_where=<JSON.stringify(predicate)>
9
- * ?_limit=N
10
- * ?_sort=field:dir (comma-separated for multi-key)
11
- * The leading underscore avoids collision with backend-
12
- * specific query params the author may have included in `url:`.
13
- *
14
- * POST — operators merge as top-level keys into an object body,
15
- * alongside any author-supplied body. Content-Type defaults
16
- * to `application/json` unless the site set a different one.
17
- * String POST bodies (rare; typically GraphQL-only) don't
18
- * receive operator merge — the string is sent as-is.
19
- *
20
- * Operator name renames are applied from the `rename:` map passed in
21
- * context. Shallow substitutions only — `rename: { limit: pageSize }`
22
- * swaps the wire name `_limit` → `pageSize` on GET, or the body key
23
- * `limit` → `pageSize` on POST. The operator identity (what `limit`
24
- * means in the query) does not change.
25
- *
26
- * Internal module. Accessed by `default-fetcher.js` via the registry;
27
- * never imported directly from outside `@uniweb/runtime`.
28
- */
29
-
30
- export const jsonBody = {
31
- name: 'json-body',
32
-
33
- // Which operators this style knows how to push. The effective push set
34
- // is the intersection of this and the site's `supports:` list.
35
- canPush: new Set(['where', 'limit', 'sort']),
36
-
37
- // Default response envelope. null = no wrapper (a plain JSON payload).
38
- defaultEnvelope: null,
39
-
40
- /**
41
- * Encode a request against the json-body conventions.
42
- *
43
- * @param {Object} request - The normalized fetch request.
44
- * @param {Object} ctx
45
- * @param {'GET'|'POST'} ctx.method - The method the fetcher chose.
46
- * @param {Set<string>} ctx.pushCandidates - Operators present on the
47
- * request AND listed in the site's `supports:`. Style may choose to
48
- * push all, some, or none of these based on what it knows how to
49
- * express on the wire.
50
- * @param {Object|null} ctx.rename - Optional { operator → wireName } map.
51
- * @returns {{
52
- * queryParams: Array<[string, string]>,
53
- * bodyMerge: Object|null,
54
- * pushed: Set<string>,
55
- * }}
56
- * queryParams — pairs appended to the URL's query string.
57
- * bodyMerge — object merged into the POST body, or null.
58
- * pushed — the operators that actually rode on the wire.
59
- * Feeds the fetcher's cache-key derivation and
60
- * runtime-fallback skip.
61
- */
62
- encode(request, { method, pushCandidates, rename }) {
63
- const pushed = new Set()
64
- const queryParams = []
65
- let bodyMerge = null
66
-
67
- if (method === 'GET') {
68
- if (pushCandidates.has('where') && request.where !== undefined) {
69
- queryParams.push([
70
- wireName('where', '_where', rename),
71
- JSON.stringify(request.where),
72
- ])
73
- pushed.add('where')
74
- }
75
- if (pushCandidates.has('limit') && request.limit !== undefined) {
76
- queryParams.push([
77
- wireName('limit', '_limit', rename),
78
- String(request.limit),
79
- ])
80
- pushed.add('limit')
81
- }
82
- if (pushCandidates.has('sort') && request.sort !== undefined) {
83
- queryParams.push([
84
- wireName('sort', '_sort', rename),
85
- String(request.sort),
86
- ])
87
- pushed.add('sort')
88
- }
89
- } else if (method === 'POST') {
90
- const merged = {}
91
- if (pushCandidates.has('where') && request.where !== undefined) {
92
- merged[wireName('where', 'where', rename)] = request.where
93
- pushed.add('where')
94
- }
95
- if (pushCandidates.has('limit') && request.limit !== undefined) {
96
- merged[wireName('limit', 'limit', rename)] = request.limit
97
- pushed.add('limit')
98
- }
99
- if (pushCandidates.has('sort') && request.sort !== undefined) {
100
- merged[wireName('sort', 'sort', rename)] = request.sort
101
- pushed.add('sort')
102
- }
103
- if (Object.keys(merged).length > 0) bodyMerge = merged
104
- }
105
-
106
- return { queryParams, bodyMerge, pushed }
107
- },
108
- }
109
-
110
- function wireName(operator, defaultWire, rename) {
111
- if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
112
- return rename[operator]
113
- }
114
- return defaultWire
115
- }
116
-
117
- export default jsonBody
@@ -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