@uniweb/api 1.1.1 → 1.2.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/README.md +200 -21
- package/package.json +2 -2
- package/src/client.js +275 -8
- package/src/elevation.js +268 -0
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/hooks/useElevation.js +147 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +5 -0
- package/src/index.js +11 -0
- package/src/mock/definition.js +117 -0
- package/src/mock/index.js +74 -7
- package/src/mock/schema-shape.js +139 -6
- package/src/mock/store.js +442 -61
- package/src/wire.js +247 -34
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Model's definition, as the backend stores and answers it — lowered from a seed's
|
|
3
|
+
* declaration the way a registered schema is lowered (MEASURED 2026-10-14; `SCHEMA` in
|
|
4
|
+
* `../wire.js` is the shape).
|
|
5
|
+
*
|
|
6
|
+
* ⭐ **A field keeps what the backend keeps, and nothing else.** A key the backend's
|
|
7
|
+
* catalog does not hold for the field's kind never reaches a stored declaration — a
|
|
8
|
+
* schema carrying one is refused when it is registered — so the mock's definition
|
|
9
|
+
* leaves it out rather than show a form a rule no backend applies. The catalog, per
|
|
10
|
+
* kind, beside what every field may declare (`label`, `description`, `required`,
|
|
11
|
+
* `multiple`, `localized`, `compact`):
|
|
12
|
+
*
|
|
13
|
+
* | kind | its own keys |
|
|
14
|
+
* |---|---|
|
|
15
|
+
* | `string` · `text` | `max_length`, `languages`, `format` |
|
|
16
|
+
* | `int` | `min`, `max` |
|
|
17
|
+
* | `decimal` | `precision`, `scale`, `min`, `max` |
|
|
18
|
+
* | `item_ref` | `options` (a schema's) → `section_uuid`; `entity_uuid` |
|
|
19
|
+
* | `entity_ref` | `model` (a schema's) → `model_uuid`; `model_uuids`, `contains`, `readable_with` |
|
|
20
|
+
* | `file` | `mime_types`, `max_size` |
|
|
21
|
+
* | `json` | `languages`, `format` |
|
|
22
|
+
* | `bool` · `date` · `datetime` · `secret` | none |
|
|
23
|
+
*
|
|
24
|
+
* And what moves: an `enum` becomes its section's `one_of` rule; `multiple: true` is
|
|
25
|
+
* `multi`; a nested section is a section of its own.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The keys a kind declares beyond the ones every field may (see the table above). */
|
|
29
|
+
const KIND_KEYS = {
|
|
30
|
+
string: ['max_length', 'languages', 'format'],
|
|
31
|
+
text: ['max_length', 'languages', 'format'],
|
|
32
|
+
int: ['min', 'max'],
|
|
33
|
+
decimal: ['precision', 'scale', 'min', 'max'],
|
|
34
|
+
item_ref: ['entity_uuid'],
|
|
35
|
+
entity_ref: ['model_uuids', 'contains', 'readable_with'],
|
|
36
|
+
file: ['mime_types', 'max_size'],
|
|
37
|
+
json: ['languages', 'format'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Prose about a field — a plain string. */
|
|
41
|
+
const DOC_KEYS = ['label', 'description']
|
|
42
|
+
|
|
43
|
+
/** Modifiers any kind that supports them declares. */
|
|
44
|
+
const MODIFIER_KEYS = ['localized', 'compact']
|
|
45
|
+
|
|
46
|
+
/** A field's kind: its `type`, or a list's element type in the normalizer's `array` spelling. */
|
|
47
|
+
export function kindOf(field) {
|
|
48
|
+
if (field?.type === 'array') return field.items?.type ?? 'json'
|
|
49
|
+
return field?.type ?? 'json'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is this field a list — `multiple: true`, or the normalizer's `type: 'array'`? */
|
|
53
|
+
export const isList = (field) => field?.multiple === true || field?.type === 'array'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A field as the definition answers it: `{ key, required, multi, type: { id: -1, name,
|
|
57
|
+
* kind, data } }`, `data` its stored declaration.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} key
|
|
60
|
+
* @param {object} field - the seed's declaration
|
|
61
|
+
* @param {{ section?: string|null, model?: string|null }} pins - an `item_ref`'s section
|
|
62
|
+
* uuid and an `entity_ref`'s Model uuid, resolved by the caller
|
|
63
|
+
*/
|
|
64
|
+
export function fieldRow(key, field, pins = {}) {
|
|
65
|
+
const kind = kindOf(field)
|
|
66
|
+
const data = { key, kind }
|
|
67
|
+
if (field?.required === true) data.required = true
|
|
68
|
+
if (isList(field)) data.multi = true
|
|
69
|
+
for (const k of DOC_KEYS) if (typeof field?.[k] === 'string') data[k] = field[k]
|
|
70
|
+
for (const k of MODIFIER_KEYS) if (field?.[k] !== undefined) data[k] = field[k]
|
|
71
|
+
for (const k of KIND_KEYS[kind] ?? []) if (field?.[k] !== undefined) data[k] = field[k]
|
|
72
|
+
if (kind === 'item_ref' && pins.section) data.section_uuid = pins.section
|
|
73
|
+
if (kind === 'entity_ref' && pins.model) data.model_uuid = pins.model
|
|
74
|
+
return {
|
|
75
|
+
key,
|
|
76
|
+
required: data.required === true,
|
|
77
|
+
multi: data.multi === true,
|
|
78
|
+
type: { id: -1, name: kind, kind, data },
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A section's rules: its own (a bare array, or `{ rules: [...] }`), then a `one_of` for
|
|
84
|
+
* each field that declares an `enum`, in field order.
|
|
85
|
+
*
|
|
86
|
+
* @param {object|null} section - the seed's declaration
|
|
87
|
+
* @param {Object<string, object>|null} leaves - its fields, by key
|
|
88
|
+
*/
|
|
89
|
+
export function rulesOf(section, leaves) {
|
|
90
|
+
const own = section?.constraints
|
|
91
|
+
const rules = Array.isArray(own) ? [...own] : Array.isArray(own?.rules) ? [...own.rules] : []
|
|
92
|
+
for (const [key, field] of Object.entries(leaves || {})) {
|
|
93
|
+
if (Array.isArray(field?.enum) && field.enum.length) rules.push({ kind: 'one_of', field: key, values: field.enum })
|
|
94
|
+
}
|
|
95
|
+
return rules
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What a section's `other_data` holds: what the schema declares of the section. */
|
|
99
|
+
export function otherDataOf(section, { appendOnly = false, nests = false } = {}) {
|
|
100
|
+
const other = {}
|
|
101
|
+
for (const k of DOC_KEYS) if (typeof section?.[k] === 'string') other[k] = section[k]
|
|
102
|
+
if (appendOnly) other.append_only = true
|
|
103
|
+
if (nests) other.allow_self_nesting = true
|
|
104
|
+
if (typeof section?.date_field === 'string') other.date_field = section.date_field
|
|
105
|
+
return other
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `options: '@scope/name/section/path'` — the Model named by its first two segments, and
|
|
110
|
+
* the section path after them — or null when it has the wrong shape.
|
|
111
|
+
*/
|
|
112
|
+
export function parseOptions(options) {
|
|
113
|
+
if (typeof options !== 'string') return null
|
|
114
|
+
const parts = options.split('/')
|
|
115
|
+
if (parts.length < 3 || !parts[0].startsWith('@')) return null
|
|
116
|
+
return { model: `${parts[0]}/${parts[1]}`, path: parts.slice(2).join('/') }
|
|
117
|
+
}
|
package/src/mock/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTH, ENTITIES, MODELS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
|
|
1
|
+
import { AUTH, ENTITIES, MODELS, SECTIONS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL, ELEVATION } from '../wire.js'
|
|
2
2
|
import { MockStore, isUuid, refuse } from './store.js'
|
|
3
3
|
import { DEFAULT_SEED } from './seed.js'
|
|
4
4
|
|
|
@@ -17,6 +17,24 @@ export function printMail(message, url) {
|
|
|
17
17
|
*/
|
|
18
18
|
const ADMIN_PAGE = '/admin'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A page's cursor, opaque as the backend's is: hex of the JSON of where the page ended.
|
|
22
|
+
* Byte work by hand, so the mock stays free of Node's `Buffer`.
|
|
23
|
+
*/
|
|
24
|
+
const encodeCursor = (key) =>
|
|
25
|
+
[...new TextEncoder().encode(JSON.stringify(key))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
26
|
+
|
|
27
|
+
/** The key a cursor names, or null for one this mock did not hand out — "expired". */
|
|
28
|
+
function decodeCursor(token) {
|
|
29
|
+
if (!/^(?:[0-9a-f]{2})+$/.test(token)) return null
|
|
30
|
+
try {
|
|
31
|
+
const key = JSON.parse(new TextDecoder().decode(Uint8Array.from(token.match(/../g).map((h) => parseInt(h, 16)))))
|
|
32
|
+
return Array.isArray(key) && key.length === 5 && key.every(Number.isFinite) ? key : null
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
const escapeHtml = (value) =>
|
|
21
39
|
String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
22
40
|
|
|
@@ -53,7 +71,7 @@ function adminPage(account) {
|
|
|
53
71
|
*
|
|
54
72
|
* ## Where it answers
|
|
55
73
|
*
|
|
56
|
-
* Its routes are the API's own — `/auth/me`, `/entities` — under whatever the
|
|
74
|
+
* Its routes are the API's own — `/auth/me`, `/entities`, `/sections` — under whatever the
|
|
57
75
|
* site's `backend` address is. It accepts a request with the site's `/_api` prefix
|
|
58
76
|
* still on, and one with an extra `/api` after it (how clients before 0.4
|
|
59
77
|
* composed URLs), so it answers the same whether or not the server mounting it
|
|
@@ -69,9 +87,11 @@ function adminPage(account) {
|
|
|
69
87
|
* account the backend's administration admits, so its Admin link answers — with a
|
|
70
88
|
* redirect to a page of the mock's own, since the administration itself is not
|
|
71
89
|
* modelled. An offer, claimed or bound by confirming its address, leaves its
|
|
72
|
-
* invitations waiting, each to answer once.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
90
|
+
* invitations waiting, each to answer once. A field that picks from a list stores an
|
|
91
|
+
* item's uuid, checked against its list, and the list's items are read a page at a time
|
|
92
|
+
* from `/sections/{uuid}/items`. It does not model entitlements behind `via`, second
|
|
93
|
+
* factors, password rules, an offer's grants, what joining an organization gives, or an
|
|
94
|
+
* organization withdrawing an invitation.
|
|
75
95
|
*
|
|
76
96
|
* ## The mail it sends
|
|
77
97
|
*
|
|
@@ -255,8 +275,21 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
255
275
|
const token = q.get('token') || ''
|
|
256
276
|
return token ? answer(store.verify(token)) : badRequest('verify requires `token`')
|
|
257
277
|
}
|
|
278
|
+
if (path.startsWith(`${AUTH.elevate}/`) && method === 'GET') {
|
|
279
|
+
// The provider's elevation — a navigation. The mock has no sign-in providers.
|
|
280
|
+
return problem({ status: 404, title: 'Not Found', detail: 'the mock has no sign-in providers — elevate with the password' })
|
|
281
|
+
}
|
|
258
282
|
const bad = unexpected(q, [])
|
|
259
283
|
if (bad) return bad
|
|
284
|
+
if (path === AUTH.elevate && method === 'POST') {
|
|
285
|
+
if (!store.account) return unauthorized()
|
|
286
|
+
const b = await body(request)
|
|
287
|
+
return fields(b, [ELEVATION.password], '{"password": ..., "code": ...}') ?? answer(store.elevate(b))
|
|
288
|
+
}
|
|
289
|
+
if (path === AUTH.elevate && method === 'DELETE') {
|
|
290
|
+
if (!store.account) return unauthorized()
|
|
291
|
+
return answer(store.lower())
|
|
292
|
+
}
|
|
260
293
|
if (path === AUTH.me && method === 'GET') {
|
|
261
294
|
const viewer = store.viewer()
|
|
262
295
|
return viewer ? json(200, viewer) : unauthorized()
|
|
@@ -339,18 +372,52 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
339
372
|
return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
|
|
340
373
|
}
|
|
341
374
|
|
|
375
|
+
// ── A Section's items ────────────────────────────────────────────────────
|
|
376
|
+
const listed = path.startsWith(`${SECTIONS}/`) ? path.slice(SECTIONS.length).match(/^\/([^/]+)\/items$/) : null
|
|
377
|
+
if (listed && method === 'GET') {
|
|
378
|
+
const bad = unexpected(q, [PARAM.entity, PARAM.parent, PARAM.field, PARAM.q, PARAM.locale, PARAM.cursor, PARAM.limit])
|
|
379
|
+
if (bad) return bad
|
|
380
|
+
const limit = int(q, PARAM.limit, PAGE.size)
|
|
381
|
+
if (limit === undefined) return badRequest('invalid parameter value')
|
|
382
|
+
if (!store.account) return unauthorized()
|
|
383
|
+
const sectionUuid = listed[1]
|
|
384
|
+
if (!isUuid(sectionUuid)) return badRequest(`invalid section uuid: ${sectionUuid}`)
|
|
385
|
+
const token = q.get(PARAM.cursor) || ''
|
|
386
|
+
const after = token ? decodeCursor(token) : null
|
|
387
|
+
if (token && !after) return badRequest('cursor expired, restart pagination')
|
|
388
|
+
for (const name of [PARAM.entity, PARAM.parent]) {
|
|
389
|
+
const value = q.get(name)
|
|
390
|
+
if (value && !isUuid(value)) return badRequest(`invalid ${name} uuid: ${value}`)
|
|
391
|
+
}
|
|
392
|
+
const found = store.sectionByUuid(sectionUuid)
|
|
393
|
+
if (!found) return notFound('section', sectionUuid)
|
|
394
|
+
const outcome = store.sectionItems(found.model, found.section, {
|
|
395
|
+
entity: q.get(PARAM.entity) || null,
|
|
396
|
+
parent: q.get(PARAM.parent) || null,
|
|
397
|
+
field: q.get(PARAM.field) || '',
|
|
398
|
+
q: q.get(PARAM.q) || '',
|
|
399
|
+
locales: locales(q),
|
|
400
|
+
after,
|
|
401
|
+
limit: Math.min(Math.max(limit, 1), PAGE.max),
|
|
402
|
+
})
|
|
403
|
+
if (outcome.problem) return answer(outcome)
|
|
404
|
+
const { next_cursor: next, ...rest } = outcome.body
|
|
405
|
+
return json(200, { ...rest, next_cursor: next ? encodeCursor(next) : null })
|
|
406
|
+
}
|
|
407
|
+
|
|
342
408
|
// ── Entities ────────────────────────────────────────────────────────────
|
|
343
409
|
if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
|
|
344
410
|
const rest = path.slice(ENTITIES.length)
|
|
345
411
|
|
|
346
412
|
if (rest === '' && method === 'GET') {
|
|
347
|
-
|
|
413
|
+
// ⛔ No `include_disabled`: the backend refuses it, and lists a disabled entity like any other.
|
|
414
|
+
const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale])
|
|
348
415
|
if (bad) return bad
|
|
349
416
|
if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
|
|
350
417
|
const limit = int(q, PARAM.limit, PAGE.size)
|
|
351
418
|
const offset = int(q, PARAM.offset, 0)
|
|
352
419
|
const paginate = bool(q, PARAM.paginate, true)
|
|
353
|
-
if (limit === undefined || offset === undefined || paginate === undefined
|
|
420
|
+
if (limit === undefined || offset === undefined || paginate === undefined) {
|
|
354
421
|
return badRequest('invalid parameter value')
|
|
355
422
|
}
|
|
356
423
|
const scope = q.get(PARAM.scope) || SCOPE.accessible
|
package/src/mock/schema-shape.js
CHANGED
|
@@ -148,6 +148,20 @@ function listElement(field) {
|
|
|
148
148
|
return null
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A section of the declaration by name — or by `parent/child` path for a nested one,
|
|
153
|
+
* declared as a field of `type: 'section'` inside its parent.
|
|
154
|
+
*/
|
|
155
|
+
function sectionAt(sections, path) {
|
|
156
|
+
const [first, ...rest] = String(path).split('/')
|
|
157
|
+
let at = sections[first]
|
|
158
|
+
for (const name of rest) {
|
|
159
|
+
const next = at?.fields?.[name]
|
|
160
|
+
at = next?.type === 'section' ? next : undefined
|
|
161
|
+
}
|
|
162
|
+
return at
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* Is this section a multi section of the model's declaration?
|
|
153
167
|
*
|
|
@@ -158,7 +172,7 @@ function listElement(field) {
|
|
|
158
172
|
export function classifySection(decl, section) {
|
|
159
173
|
const sections = decl?.sections
|
|
160
174
|
if (!sections || typeof sections !== 'object') return null
|
|
161
|
-
const found = sections
|
|
175
|
+
const found = sectionAt(sections, section)
|
|
162
176
|
|
|
163
177
|
// ⭐ The debt list is consulted FIRST and is an exact `(model, section)` match, so
|
|
164
178
|
// it can excuse a declared section whose records diverge as well as an undeclared
|
|
@@ -225,8 +239,69 @@ function satisfiesType(type, value) {
|
|
|
225
239
|
}
|
|
226
240
|
}
|
|
227
241
|
|
|
242
|
+
/** The text kinds — what a `localized` value, a `max_length` and a search apply to. */
|
|
243
|
+
const TEXT_TYPES = new Set(['string', 'text', 'html', 'markdown', 'email', 'url'])
|
|
244
|
+
|
|
245
|
+
/** Characters, as the backend counts them — code points, not UTF-16 units. */
|
|
246
|
+
const charCount = (s) => [...s].length
|
|
247
|
+
|
|
248
|
+
/** `YYYY-MM-DD`, a day the calendar has — the one form a `date` holds (MEASURED 2026-10-14: `2024-5-3` passes, `2024-05` and `2024-02-30` do not). */
|
|
249
|
+
function isDate(value) {
|
|
250
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value)
|
|
251
|
+
if (!m) return false
|
|
252
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]
|
|
253
|
+
const day = new Date(Date.UTC(y, mo - 1, d))
|
|
254
|
+
return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A `format` the backend checks, by its own rules — `email`: one `@`, something before it,
|
|
259
|
+
* a dotted domain after it, no space; `url`: a scheme, `://`, something after it, no space.
|
|
260
|
+
* ⚠️ `currency` is checked there too, against ISO 4217's codes, and not here. Every other
|
|
261
|
+
* `format` marks what the text is and checks nothing.
|
|
262
|
+
*/
|
|
263
|
+
function matchesFormat(value, format) {
|
|
264
|
+
if (format === 'email') {
|
|
265
|
+
const parts = value.split('@')
|
|
266
|
+
return parts.length === 2 && parts[0] !== '' && parts[1].includes('.') && !parts[1].startsWith('.') && !parts[1].endsWith('.') && !/\s/.test(value)
|
|
267
|
+
}
|
|
268
|
+
if (format === 'url') {
|
|
269
|
+
const at = value.indexOf('://')
|
|
270
|
+
return at > 0 && /^[A-Za-z0-9+.-]+$/.test(value.slice(0, at)) && value.length > at + 3 && !/\s/.test(value)
|
|
271
|
+
}
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The checks on one text value — a field's own, or one language of a localized one —
|
|
277
|
+
* as the backend makes them: `max_length` in characters, then the `format`. `at` names
|
|
278
|
+
* the value: the field, or `field.lang`.
|
|
279
|
+
*/
|
|
280
|
+
function checkText(field, value, at) {
|
|
281
|
+
if (typeof value !== 'string') return { field: at, rule: 'type', detail: `item.data.${at} must be a string` }
|
|
282
|
+
if (Number.isInteger(field.max_length) && charCount(value) > field.max_length) {
|
|
283
|
+
return {
|
|
284
|
+
field: at,
|
|
285
|
+
rule: 'max_length',
|
|
286
|
+
detail: `item.data.${at} exceeds max_length=${field.max_length}`,
|
|
287
|
+
params: { max_length: field.max_length },
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (typeof field.format === 'string' && value !== '' && !matchesFormat(value, field.format)) {
|
|
291
|
+
return {
|
|
292
|
+
field: at,
|
|
293
|
+
rule: 'format',
|
|
294
|
+
detail: `item.data.${at} = ${JSON.stringify(value)} is not a valid ${field.format}`,
|
|
295
|
+
params: { format: field.format },
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
228
301
|
/**
|
|
229
|
-
* Check a record against a lowered `fields` map. Returns a list of violations
|
|
302
|
+
* Check a record against a lowered `fields` map. Returns a list of violations, each
|
|
303
|
+
* `{ field, rule, detail, params? }` — `rule` named as the backend names it, `params` the
|
|
304
|
+
* declaration's bound it carries beside it (`max_length`, `min`, `max`, `format`).
|
|
230
305
|
*
|
|
231
306
|
* ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
|
|
232
307
|
* that the real store rejects extras, and inventing that rule here would refuse
|
|
@@ -239,16 +314,32 @@ export function checkFields(fields, data, path = '') {
|
|
|
239
314
|
const record = data && typeof data === 'object' ? data : {}
|
|
240
315
|
|
|
241
316
|
for (const [name, field] of Object.entries(fields)) {
|
|
317
|
+
// A nested section is a section of its own, with items of its own — not a field.
|
|
318
|
+
if (field?.type === 'section') continue
|
|
242
319
|
const at = path ? `${path}.${name}` : name
|
|
243
320
|
const value = record[name]
|
|
244
321
|
const missing = value === undefined || value === null
|
|
245
322
|
|
|
246
323
|
if (field.required && missing) {
|
|
247
|
-
problems.push({ field: at, rule: 'required', detail: `
|
|
324
|
+
problems.push({ field: at, rule: 'required', detail: `item.data.${at} is required` })
|
|
248
325
|
continue
|
|
249
326
|
}
|
|
250
327
|
if (missing) continue
|
|
251
328
|
|
|
329
|
+
// ⭐ A localized text field holds a string — its value in every language — or a
|
|
330
|
+
// per-language object, each language's value checked as the field's text is
|
|
331
|
+
// (MEASURED 2026-10-14). `@` is the object's metadata, not a language.
|
|
332
|
+
if (field.localized === true && TEXT_TYPES.has(field.type) && field.multiple !== true) {
|
|
333
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
334
|
+
const found = Object.entries(value)
|
|
335
|
+
.filter(([lang]) => lang !== '@')
|
|
336
|
+
.map(([lang, v]) => checkText(field, v, `${at}.${lang}`))
|
|
337
|
+
.find(Boolean)
|
|
338
|
+
if (found) problems.push(found)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
252
343
|
// A list, in either spelling. The VALUE must be an array; each entry is then
|
|
253
344
|
// checked against the element shape, indexed the way `uniweb validate` names it
|
|
254
345
|
// (`outcomes[1]`), so one vocabulary covers both lanes.
|
|
@@ -260,14 +351,28 @@ export function checkFields(fields, data, path = '') {
|
|
|
260
351
|
}
|
|
261
352
|
value.forEach((entry, i) => {
|
|
262
353
|
const entryAt = `${at}[${i}]`
|
|
354
|
+
// A localized list's entry is a string or a per-language object, as a single value is.
|
|
355
|
+
if (field.localized === true && TEXT_TYPES.has(element.type) && entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
356
|
+
const found = Object.entries(entry)
|
|
357
|
+
.filter(([lang]) => lang !== '@')
|
|
358
|
+
.map(([lang, v]) => checkText(field, v, `${entryAt}.${lang}`))
|
|
359
|
+
.find(Boolean)
|
|
360
|
+
if (found) problems.push(found)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
263
363
|
if (satisfiesType(element.type, entry) === false) {
|
|
264
364
|
problems.push({
|
|
265
365
|
field: entryAt,
|
|
266
366
|
rule: 'type',
|
|
267
|
-
detail: `
|
|
367
|
+
detail: `item.data.${entryAt} expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
|
|
268
368
|
})
|
|
269
369
|
return
|
|
270
370
|
}
|
|
371
|
+
const bounded = boundOf({ ...field, type: element.type }, entry, entryAt)
|
|
372
|
+
if (bounded) {
|
|
373
|
+
problems.push(bounded)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
271
376
|
if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
|
|
272
377
|
})
|
|
273
378
|
continue
|
|
@@ -277,13 +382,20 @@ export function checkFields(fields, data, path = '') {
|
|
|
277
382
|
problems.push({
|
|
278
383
|
field: at,
|
|
279
384
|
rule: 'type',
|
|
280
|
-
detail: `
|
|
385
|
+
detail: `item.data.${at} expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
|
|
281
386
|
})
|
|
282
387
|
continue
|
|
283
388
|
}
|
|
284
389
|
|
|
390
|
+
const bounded = boundOf(field, value, at)
|
|
391
|
+
if (bounded) {
|
|
392
|
+
problems.push(bounded)
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// An `enum` is the backend's `one_of` rule — refused under that name.
|
|
285
397
|
if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
|
|
286
|
-
problems.push({ field: at, rule: '
|
|
398
|
+
problems.push({ field: at, rule: 'one_of', detail: `item.${at} = ${JSON.stringify(value)} is not one of the allowed values` })
|
|
287
399
|
continue
|
|
288
400
|
}
|
|
289
401
|
|
|
@@ -292,6 +404,27 @@ export function checkFields(fields, data, path = '') {
|
|
|
292
404
|
return problems
|
|
293
405
|
}
|
|
294
406
|
|
|
407
|
+
/**
|
|
408
|
+
* The bound a value of a field's type breaks, as the backend checks it — `max_length`
|
|
409
|
+
* on text, `min` and `max` on a number, the one form a `date` takes — or null.
|
|
410
|
+
* (MEASURED 2026-10-14, each with the bound beside the rule.)
|
|
411
|
+
*/
|
|
412
|
+
function boundOf(field, value, at) {
|
|
413
|
+
if (TEXT_TYPES.has(field.type) && typeof value === 'string') return checkText(field, value, at)
|
|
414
|
+
if (typeof value === 'number' && ['int', 'integer', 'decimal', 'number'].includes(field.type)) {
|
|
415
|
+
if (typeof field.min === 'number' && value < field.min) {
|
|
416
|
+
return { field: at, rule: 'min', detail: `item.data.${at}=${value} below min=${field.min}`, params: { min: field.min } }
|
|
417
|
+
}
|
|
418
|
+
if (typeof field.max === 'number' && value > field.max) {
|
|
419
|
+
return { field: at, rule: 'max', detail: `item.data.${at}=${value} above max=${field.max}`, params: { max: field.max } }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (field.type === 'date' && typeof value === 'string' && !isDate(value)) {
|
|
423
|
+
return { field: at, rule: 'format', detail: `item.data.${at} is not a valid date (\`${value}\`)`, params: { format: 'date' } }
|
|
424
|
+
}
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
295
428
|
/** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
|
|
296
429
|
function undeclaredKeys(fields, data) {
|
|
297
430
|
if (!fields || !data || typeof data !== 'object') return []
|