@uniweb/api 1.2.0 → 1.2.2
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 +139 -2
- package/package.json +1 -1
- package/src/client.js +232 -5
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/index.js +5 -0
- package/src/mock/definition.js +119 -0
- package/src/mock/index.js +61 -7
- package/src/mock/schema-shape.js +151 -6
- package/src/mock/store.js +375 -41
- package/src/wire.js +149 -11
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
* | `date` | `format` — `year-month` or `year` |
|
|
19
|
+
* | `item_ref` | `options` (a schema's) → `section_uuid`; `entity_uuid` |
|
|
20
|
+
* | `entity_ref` | `model` (a schema's) → `model_uuid`; `model_uuids`, `contains`, `readable_with` |
|
|
21
|
+
* | `file` | `mime_types`, `max_size` |
|
|
22
|
+
* | `json` | `languages`, `format` |
|
|
23
|
+
* | `bool` · `datetime` · `secret` | none |
|
|
24
|
+
*
|
|
25
|
+
* And what moves: an `enum` becomes its section's `one_of` rule; `multiple: true` is
|
|
26
|
+
* `multi`; a nested section is a section of its own.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The keys a kind declares beyond the ones every field may (see the table above). */
|
|
30
|
+
const KIND_KEYS = {
|
|
31
|
+
string: ['max_length', 'languages', 'format'],
|
|
32
|
+
text: ['max_length', 'languages', 'format'],
|
|
33
|
+
int: ['min', 'max'],
|
|
34
|
+
decimal: ['precision', 'scale', 'min', 'max'],
|
|
35
|
+
date: ['format'],
|
|
36
|
+
item_ref: ['entity_uuid'],
|
|
37
|
+
entity_ref: ['model_uuids', 'contains', 'readable_with'],
|
|
38
|
+
file: ['mime_types', 'max_size'],
|
|
39
|
+
json: ['languages', 'format'],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Prose about a field — a plain string. */
|
|
43
|
+
const DOC_KEYS = ['label', 'description']
|
|
44
|
+
|
|
45
|
+
/** Modifiers any kind that supports them declares. */
|
|
46
|
+
const MODIFIER_KEYS = ['localized', 'compact']
|
|
47
|
+
|
|
48
|
+
/** A field's kind: its `type`, or a list's element type in the normalizer's `array` spelling. */
|
|
49
|
+
export function kindOf(field) {
|
|
50
|
+
if (field?.type === 'array') return field.items?.type ?? 'json'
|
|
51
|
+
return field?.type ?? 'json'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Is this field a list — `multiple: true`, or the normalizer's `type: 'array'`? */
|
|
55
|
+
export const isList = (field) => field?.multiple === true || field?.type === 'array'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A field as the definition answers it: `{ key, required, multi, type: { id: -1, name,
|
|
59
|
+
* kind, data } }`, `data` its stored declaration.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} key
|
|
62
|
+
* @param {object} field - the seed's declaration
|
|
63
|
+
* @param {{ section?: string|null, model?: string|null }} pins - an `item_ref`'s section
|
|
64
|
+
* uuid and an `entity_ref`'s Model uuid, resolved by the caller
|
|
65
|
+
*/
|
|
66
|
+
export function fieldRow(key, field, pins = {}) {
|
|
67
|
+
const kind = kindOf(field)
|
|
68
|
+
const data = { key, kind }
|
|
69
|
+
if (field?.required === true) data.required = true
|
|
70
|
+
if (isList(field)) data.multi = true
|
|
71
|
+
for (const k of DOC_KEYS) if (typeof field?.[k] === 'string') data[k] = field[k]
|
|
72
|
+
for (const k of MODIFIER_KEYS) if (field?.[k] !== undefined) data[k] = field[k]
|
|
73
|
+
for (const k of KIND_KEYS[kind] ?? []) if (field?.[k] !== undefined) data[k] = field[k]
|
|
74
|
+
if (kind === 'item_ref' && pins.section) data.section_uuid = pins.section
|
|
75
|
+
if (kind === 'entity_ref' && pins.model) data.model_uuid = pins.model
|
|
76
|
+
return {
|
|
77
|
+
key,
|
|
78
|
+
required: data.required === true,
|
|
79
|
+
multi: data.multi === true,
|
|
80
|
+
type: { id: -1, name: kind, kind, data },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A section's rules: its own (a bare array, or `{ rules: [...] }`), then a `one_of` for
|
|
86
|
+
* each field that declares an `enum`, in field order.
|
|
87
|
+
*
|
|
88
|
+
* @param {object|null} section - the seed's declaration
|
|
89
|
+
* @param {Object<string, object>|null} leaves - its fields, by key
|
|
90
|
+
*/
|
|
91
|
+
export function rulesOf(section, leaves) {
|
|
92
|
+
const own = section?.constraints
|
|
93
|
+
const rules = Array.isArray(own) ? [...own] : Array.isArray(own?.rules) ? [...own.rules] : []
|
|
94
|
+
for (const [key, field] of Object.entries(leaves || {})) {
|
|
95
|
+
if (Array.isArray(field?.enum) && field.enum.length) rules.push({ kind: 'one_of', field: key, values: field.enum })
|
|
96
|
+
}
|
|
97
|
+
return rules
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What a section's `other_data` holds: what the schema declares of the section. */
|
|
101
|
+
export function otherDataOf(section, { appendOnly = false, nests = false } = {}) {
|
|
102
|
+
const other = {}
|
|
103
|
+
for (const k of DOC_KEYS) if (typeof section?.[k] === 'string') other[k] = section[k]
|
|
104
|
+
if (appendOnly) other.append_only = true
|
|
105
|
+
if (nests) other.allow_self_nesting = true
|
|
106
|
+
if (typeof section?.date_field === 'string') other.date_field = section.date_field
|
|
107
|
+
return other
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `options: '@scope/name/section/path'` — the Model named by its first two segments, and
|
|
112
|
+
* the section path after them — or null when it has the wrong shape.
|
|
113
|
+
*/
|
|
114
|
+
export function parseOptions(options) {
|
|
115
|
+
if (typeof options !== 'string') return null
|
|
116
|
+
const parts = options.split('/')
|
|
117
|
+
if (parts.length < 3 || !parts[0].startsWith('@')) return null
|
|
118
|
+
return { model: `${parts[0]}/${parts[1]}`, path: parts.slice(2).join('/') }
|
|
119
|
+
}
|
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, ELEVATION } 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
|
*
|
|
@@ -352,18 +372,52 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
352
372
|
return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
|
|
353
373
|
}
|
|
354
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
|
+
|
|
355
408
|
// ── Entities ────────────────────────────────────────────────────────────
|
|
356
409
|
if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
|
|
357
410
|
const rest = path.slice(ENTITIES.length)
|
|
358
411
|
|
|
359
412
|
if (rest === '' && method === 'GET') {
|
|
360
|
-
|
|
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])
|
|
361
415
|
if (bad) return bad
|
|
362
416
|
if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
|
|
363
417
|
const limit = int(q, PARAM.limit, PAGE.size)
|
|
364
418
|
const offset = int(q, PARAM.offset, 0)
|
|
365
419
|
const paginate = bool(q, PARAM.paginate, true)
|
|
366
|
-
if (limit === undefined || offset === undefined || paginate === undefined
|
|
420
|
+
if (limit === undefined || offset === undefined || paginate === undefined) {
|
|
367
421
|
return badRequest('invalid parameter value')
|
|
368
422
|
}
|
|
369
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,80 @@ 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
|
+
/**
|
|
249
|
+
* A `date` at the precision its field declares, as the backend checks it (MEASURED 2026-10-14):
|
|
250
|
+
* `format: year-month` holds `YYYY-MM`, a month `01` to `12`; `format: year` holds `YYYY`; and
|
|
251
|
+
* with no `format`, a day the calendar has (`2024-5-3` passes, `2024-05` and `2024-02-30` do not).
|
|
252
|
+
*/
|
|
253
|
+
function isDateAt(value, format) {
|
|
254
|
+
if (format === 'year-month') return /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
|
255
|
+
if (format === 'year') return /^\d{4}$/.test(value)
|
|
256
|
+
return isDate(value)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `YYYY-MM-DD`, a day the calendar has. */
|
|
260
|
+
function isDate(value) {
|
|
261
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value)
|
|
262
|
+
if (!m) return false
|
|
263
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]
|
|
264
|
+
const day = new Date(Date.UTC(y, mo - 1, d))
|
|
265
|
+
return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A `format` the backend checks, by its own rules — `email`: one `@`, something before it,
|
|
270
|
+
* a dotted domain after it, no space; `url`: a scheme, `://`, something after it, no space.
|
|
271
|
+
* ⚠️ `currency` is checked there too, against ISO 4217's codes, and not here. Every other
|
|
272
|
+
* `format` marks what the text is and checks nothing.
|
|
273
|
+
*/
|
|
274
|
+
function matchesFormat(value, format) {
|
|
275
|
+
if (format === 'email') {
|
|
276
|
+
const parts = value.split('@')
|
|
277
|
+
return parts.length === 2 && parts[0] !== '' && parts[1].includes('.') && !parts[1].startsWith('.') && !parts[1].endsWith('.') && !/\s/.test(value)
|
|
278
|
+
}
|
|
279
|
+
if (format === 'url') {
|
|
280
|
+
const at = value.indexOf('://')
|
|
281
|
+
return at > 0 && /^[A-Za-z0-9+.-]+$/.test(value.slice(0, at)) && value.length > at + 3 && !/\s/.test(value)
|
|
282
|
+
}
|
|
283
|
+
return true
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The checks on one text value — a field's own, or one language of a localized one —
|
|
288
|
+
* as the backend makes them: `max_length` in characters, then the `format`. `at` names
|
|
289
|
+
* the value: the field, or `field.lang`.
|
|
290
|
+
*/
|
|
291
|
+
function checkText(field, value, at) {
|
|
292
|
+
if (typeof value !== 'string') return { field: at, rule: 'type', detail: `item.data.${at} must be a string` }
|
|
293
|
+
if (Number.isInteger(field.max_length) && charCount(value) > field.max_length) {
|
|
294
|
+
return {
|
|
295
|
+
field: at,
|
|
296
|
+
rule: 'max_length',
|
|
297
|
+
detail: `item.data.${at} exceeds max_length=${field.max_length}`,
|
|
298
|
+
params: { max_length: field.max_length },
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (typeof field.format === 'string' && value !== '' && !matchesFormat(value, field.format)) {
|
|
302
|
+
return {
|
|
303
|
+
field: at,
|
|
304
|
+
rule: 'format',
|
|
305
|
+
detail: `item.data.${at} = ${JSON.stringify(value)} is not a valid ${field.format}`,
|
|
306
|
+
params: { format: field.format },
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
228
312
|
/**
|
|
229
|
-
* Check a record against a lowered `fields` map. Returns a list of violations
|
|
313
|
+
* Check a record against a lowered `fields` map. Returns a list of violations, each
|
|
314
|
+
* `{ field, rule, detail, params? }` — `rule` named as the backend names it, `params` the
|
|
315
|
+
* declaration's bound it carries beside it (`max_length`, `min`, `max`, `format`).
|
|
230
316
|
*
|
|
231
317
|
* ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
|
|
232
318
|
* that the real store rejects extras, and inventing that rule here would refuse
|
|
@@ -239,16 +325,32 @@ export function checkFields(fields, data, path = '') {
|
|
|
239
325
|
const record = data && typeof data === 'object' ? data : {}
|
|
240
326
|
|
|
241
327
|
for (const [name, field] of Object.entries(fields)) {
|
|
328
|
+
// A nested section is a section of its own, with items of its own — not a field.
|
|
329
|
+
if (field?.type === 'section') continue
|
|
242
330
|
const at = path ? `${path}.${name}` : name
|
|
243
331
|
const value = record[name]
|
|
244
332
|
const missing = value === undefined || value === null
|
|
245
333
|
|
|
246
334
|
if (field.required && missing) {
|
|
247
|
-
problems.push({ field: at, rule: 'required', detail: `
|
|
335
|
+
problems.push({ field: at, rule: 'required', detail: `item.data.${at} is required` })
|
|
248
336
|
continue
|
|
249
337
|
}
|
|
250
338
|
if (missing) continue
|
|
251
339
|
|
|
340
|
+
// ⭐ A localized text field holds a string — its value in every language — or a
|
|
341
|
+
// per-language object, each language's value checked as the field's text is
|
|
342
|
+
// (MEASURED 2026-10-14). `@` is the object's metadata, not a language.
|
|
343
|
+
if (field.localized === true && TEXT_TYPES.has(field.type) && field.multiple !== true) {
|
|
344
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
345
|
+
const found = Object.entries(value)
|
|
346
|
+
.filter(([lang]) => lang !== '@')
|
|
347
|
+
.map(([lang, v]) => checkText(field, v, `${at}.${lang}`))
|
|
348
|
+
.find(Boolean)
|
|
349
|
+
if (found) problems.push(found)
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
252
354
|
// A list, in either spelling. The VALUE must be an array; each entry is then
|
|
253
355
|
// checked against the element shape, indexed the way `uniweb validate` names it
|
|
254
356
|
// (`outcomes[1]`), so one vocabulary covers both lanes.
|
|
@@ -260,14 +362,28 @@ export function checkFields(fields, data, path = '') {
|
|
|
260
362
|
}
|
|
261
363
|
value.forEach((entry, i) => {
|
|
262
364
|
const entryAt = `${at}[${i}]`
|
|
365
|
+
// A localized list's entry is a string or a per-language object, as a single value is.
|
|
366
|
+
if (field.localized === true && TEXT_TYPES.has(element.type) && entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
367
|
+
const found = Object.entries(entry)
|
|
368
|
+
.filter(([lang]) => lang !== '@')
|
|
369
|
+
.map(([lang, v]) => checkText(field, v, `${entryAt}.${lang}`))
|
|
370
|
+
.find(Boolean)
|
|
371
|
+
if (found) problems.push(found)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
263
374
|
if (satisfiesType(element.type, entry) === false) {
|
|
264
375
|
problems.push({
|
|
265
376
|
field: entryAt,
|
|
266
377
|
rule: 'type',
|
|
267
|
-
detail: `
|
|
378
|
+
detail: `item.data.${entryAt} expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
|
|
268
379
|
})
|
|
269
380
|
return
|
|
270
381
|
}
|
|
382
|
+
const bounded = boundOf({ ...field, type: element.type }, entry, entryAt)
|
|
383
|
+
if (bounded) {
|
|
384
|
+
problems.push(bounded)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
271
387
|
if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
|
|
272
388
|
})
|
|
273
389
|
continue
|
|
@@ -277,13 +393,20 @@ export function checkFields(fields, data, path = '') {
|
|
|
277
393
|
problems.push({
|
|
278
394
|
field: at,
|
|
279
395
|
rule: 'type',
|
|
280
|
-
detail: `
|
|
396
|
+
detail: `item.data.${at} expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
|
|
281
397
|
})
|
|
282
398
|
continue
|
|
283
399
|
}
|
|
284
400
|
|
|
401
|
+
const bounded = boundOf(field, value, at)
|
|
402
|
+
if (bounded) {
|
|
403
|
+
problems.push(bounded)
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// An `enum` is the backend's `one_of` rule — refused under that name.
|
|
285
408
|
if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
|
|
286
|
-
problems.push({ field: at, rule: '
|
|
409
|
+
problems.push({ field: at, rule: 'one_of', detail: `item.${at} = ${JSON.stringify(value)} is not one of the allowed values` })
|
|
287
410
|
continue
|
|
288
411
|
}
|
|
289
412
|
|
|
@@ -292,6 +415,28 @@ export function checkFields(fields, data, path = '') {
|
|
|
292
415
|
return problems
|
|
293
416
|
}
|
|
294
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The bound a value of a field's type breaks, as the backend checks it — `max_length`
|
|
420
|
+
* on text, `min` and `max` on a number, a `date`'s precision — or null.
|
|
421
|
+
* (MEASURED 2026-10-14, each with the bound beside the rule.)
|
|
422
|
+
*/
|
|
423
|
+
function boundOf(field, value, at) {
|
|
424
|
+
if (TEXT_TYPES.has(field.type) && typeof value === 'string') return checkText(field, value, at)
|
|
425
|
+
if (typeof value === 'number' && ['int', 'integer', 'decimal', 'number'].includes(field.type)) {
|
|
426
|
+
if (typeof field.min === 'number' && value < field.min) {
|
|
427
|
+
return { field: at, rule: 'min', detail: `item.data.${at}=${value} below min=${field.min}`, params: { min: field.min } }
|
|
428
|
+
}
|
|
429
|
+
if (typeof field.max === 'number' && value > field.max) {
|
|
430
|
+
return { field: at, rule: 'max', detail: `item.data.${at}=${value} above max=${field.max}`, params: { max: field.max } }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (field.type === 'date' && typeof value === 'string' && !isDateAt(value, field.format)) {
|
|
434
|
+
const shape = ['year-month', 'year'].includes(field.format) ? field.format : 'date'
|
|
435
|
+
return { field: at, rule: 'format', detail: `item.data.${at} is not a valid ${shape} (\`${value}\`)`, params: { format: shape } }
|
|
436
|
+
}
|
|
437
|
+
return null
|
|
438
|
+
}
|
|
439
|
+
|
|
295
440
|
/** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
|
|
296
441
|
function undeclaredKeys(fields, data) {
|
|
297
442
|
if (!fields || !data || typeof data !== 'object') return []
|