@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
package/src/mock/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CLAIM, FIELD, INVITATION, LINK, OP, PROBLEM } from '../wire.js'
|
|
2
2
|
import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
3
|
+
import { fieldRow, rulesOf, otherDataOf, parseOptions, kindOf, isList } from './definition.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The mock's state — accounts, one session, Models with their sections, and
|
|
@@ -38,11 +39,20 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
38
39
|
* A mock that grows a schema and migrations becomes a second implementation that
|
|
39
40
|
* drifts and is believed anyway.
|
|
40
41
|
*
|
|
42
|
+
* - **A choice from a list** — a field of kind `item_ref`, `options:` in a schema —
|
|
43
|
+
* stores an item's uuid, checked against the section the field names, and that
|
|
44
|
+
* section's items are listed by `GET /sections/{uuid}/items` as the backend lists
|
|
45
|
+
* them. A nested section is a section of its own, and its items sit under an item of
|
|
46
|
+
* its parent section. *(Both since 2026-10-14; nested sections were not modelled
|
|
47
|
+
* before.)*
|
|
48
|
+
*
|
|
41
49
|
* ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
|
|
42
|
-
* same read), a Model whose creation is closed,
|
|
50
|
+
* same read), a Model whose creation is closed, second factors —
|
|
43
51
|
* at a sign-in or an elevation, where a `code` is accepted and not checked —
|
|
44
52
|
* sign-in providers, and so no elevation through one, roles other than the operator's,
|
|
45
|
-
* step-up, reference
|
|
53
|
+
* step-up, a reference to an entity (an `entity_ref` value is stored unchecked), a
|
|
54
|
+
* section's own rules — `exclusive`, `unique_field`, `min_items` and the rest are answered
|
|
55
|
+
* in the definition and not applied; a field's `enum` is — password rules,
|
|
46
56
|
* an offer's grants (a count, sharing nothing), what joining an organization
|
|
47
57
|
* gives — accepting an invitation answers, and opens nothing to read — and an
|
|
48
58
|
* organization withdrawing an invitation.
|
|
@@ -113,6 +123,32 @@ function canonical(value) {
|
|
|
113
123
|
const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
|
|
114
124
|
const shortName = (model) => String(model).split('/').pop() || String(model)
|
|
115
125
|
|
|
126
|
+
/** A nested section, declared as a field of its parent. */
|
|
127
|
+
const isSectionNode = (field) => field?.type === 'section'
|
|
128
|
+
|
|
129
|
+
/** A section's own fields, by key — its nested sections left out. */
|
|
130
|
+
function leavesOf(section) {
|
|
131
|
+
const fields = section?.fields
|
|
132
|
+
if (!fields || typeof fields !== 'object') return null
|
|
133
|
+
return Object.fromEntries(Object.entries(fields).filter(([, f]) => !isSectionNode(f)))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A section's nested sections, as `[name, declaration]`. */
|
|
137
|
+
function subSectionsOf(section) {
|
|
138
|
+
const fields = section?.fields
|
|
139
|
+
return fields && typeof fields === 'object' ? Object.entries(fields).filter(([, f]) => isSectionNode(f)) : []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A section's kind, derived from its shape as the backend derives it: `multi` when it
|
|
144
|
+
* holds many items, `binder` when it holds only sections, `single` otherwise.
|
|
145
|
+
*/
|
|
146
|
+
function sectionKind(section) {
|
|
147
|
+
if (isMulti(section)) return 'multi'
|
|
148
|
+
const fields = section?.fields && typeof section.fields === 'object' ? Object.values(section.fields) : []
|
|
149
|
+
return fields.length && fields.every(isSectionNode) ? 'binder' : 'single'
|
|
150
|
+
}
|
|
151
|
+
|
|
116
152
|
/**
|
|
117
153
|
* A `{ locale: value }` map — a plain object, never an array (a localized list's
|
|
118
154
|
* value), and never the value of a field whose OWN type is an object.
|
|
@@ -191,6 +227,22 @@ export class MockStore {
|
|
|
191
227
|
for (const name of new Set([...Object.keys(this.schemas), ...itemSections.keys()])) {
|
|
192
228
|
this.defineModel(name, this.schemas[name], itemSections.get(name))
|
|
193
229
|
}
|
|
230
|
+
// A choice from a list names its list, and a backend refuses to register a schema
|
|
231
|
+
// whose list it cannot find. The mock says so when it starts, and the field offers no
|
|
232
|
+
// choices: refusing to start would stop a site whose seed spells its lists another way.
|
|
233
|
+
/** Fields whose `options:` names no seeded section — `{ model, section, field, options }`. */
|
|
234
|
+
this.unlisted = []
|
|
235
|
+
for (const model of this.models.values()) {
|
|
236
|
+
for (const section of model.sections) {
|
|
237
|
+
for (const [key, field] of Object.entries(section.fields || {})) {
|
|
238
|
+
if (kindOf(field) !== 'item_ref' || field?.options == null || this.choicesSection(field)) continue
|
|
239
|
+
this.unlisted.push({ model: model.name, section: section.path, field: key, options: field.options })
|
|
240
|
+
console.warn(
|
|
241
|
+
`[uniweb/api mock] ${model.name}: field '${key}' of '${section.path}' takes its choices from ${JSON.stringify(field.options)}, which names no section the seed declares — so it offers none. A backend refuses to register it: options is '@scope/name/section', a nested section by its path.`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
194
246
|
|
|
195
247
|
/**
|
|
196
248
|
* Shape findings the mock saw but did not refuse — writes into a section the
|
|
@@ -457,29 +509,50 @@ export class MockStore {
|
|
|
457
509
|
section?.append_only === true ||
|
|
458
510
|
decl?.append_only === true ||
|
|
459
511
|
(Array.isArray(decl?.append_only) && decl.append_only.includes(sectionName))
|
|
512
|
+
const at = this.clock()
|
|
460
513
|
const model = {
|
|
461
514
|
id: (this.ids.model += 1),
|
|
462
515
|
uuid: newUuid(),
|
|
463
516
|
name,
|
|
464
517
|
decl: decl || {},
|
|
465
518
|
sections: [],
|
|
519
|
+
created_at: at,
|
|
520
|
+
updated_at: at,
|
|
466
521
|
}
|
|
467
|
-
const add = (sectionName, { kind, brief = false, section = null, debt = false }) =>
|
|
468
|
-
|
|
522
|
+
const add = (sectionName, { kind, brief = false, section = null, debt = false, parent = null }) => {
|
|
523
|
+
const row = {
|
|
469
524
|
id: (this.ids.section += 1),
|
|
525
|
+
// Never in the definition (`SCHEMA`): a field whose choices are this section's
|
|
526
|
+
// items names it, and `GET /sections/{uuid}/items` is addressed by it.
|
|
527
|
+
uuid: newUuid(),
|
|
470
528
|
name: sectionName,
|
|
529
|
+
// The name a write takes: `parent/child` for a nested section.
|
|
530
|
+
path: parent ? `${parent.path}/${sectionName}` : sectionName,
|
|
531
|
+
parent: parent?.id ?? null,
|
|
471
532
|
kind,
|
|
472
533
|
is_brief: brief,
|
|
473
534
|
append_only: appendOnly(sectionName, section),
|
|
474
|
-
|
|
535
|
+
// Its items nest under items of the same section (`self_nesting: true`).
|
|
536
|
+
nests: section?.self_nesting === true || section?.allow_self_nesting === true,
|
|
537
|
+
// Its own fields, by key — a nested section is a section of its own, not a field.
|
|
538
|
+
fields: leavesOf(section),
|
|
539
|
+
decl: section,
|
|
475
540
|
debt,
|
|
476
|
-
}
|
|
541
|
+
}
|
|
542
|
+
model.sections.push(row)
|
|
543
|
+
return row
|
|
544
|
+
}
|
|
477
545
|
|
|
478
546
|
const declared = decl?.sections && typeof decl.sections === 'object' ? decl.sections : null
|
|
479
547
|
if (declared) {
|
|
480
|
-
|
|
481
|
-
|
|
548
|
+
// Parents first, so a nested section's parent row exists when it is added.
|
|
549
|
+
const walk = (entries, parent) => {
|
|
550
|
+
for (const [sectionName, section] of entries) {
|
|
551
|
+
const row = add(sectionName, { kind: sectionKind(section), brief: !!section?.brief, section, parent })
|
|
552
|
+
walk(subSectionsOf(section), row)
|
|
553
|
+
}
|
|
482
554
|
}
|
|
555
|
+
walk(Object.entries(declared), null)
|
|
483
556
|
// Sections the site KNOWS diverge from the declaration, kept writable while
|
|
484
557
|
// they are unwound — and only the ones it named.
|
|
485
558
|
for (const sectionName of decl.migration_debt || []) {
|
|
@@ -516,20 +589,28 @@ export class MockStore {
|
|
|
516
589
|
return !!this.account
|
|
517
590
|
}
|
|
518
591
|
|
|
519
|
-
/**
|
|
592
|
+
/**
|
|
593
|
+
* `GET /models/@scope/name`, as the backend answers it (`SCHEMA` in `../wire.js`): each
|
|
594
|
+
* field its stored declaration under `type.data` (`./definition.js`), an `enum` as its
|
|
595
|
+
* section's `one_of` rule, a section's `label` and `description` in `other_data`, a
|
|
596
|
+
* nested section as a row naming its parent — top-level rows first, each group by name.
|
|
597
|
+
*/
|
|
520
598
|
schemaOf(model) {
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
599
|
+
const data = { grantable: true }
|
|
600
|
+
for (const k of ['label', 'description', 'icon', 'plural']) {
|
|
601
|
+
if (typeof model.decl?.[k] === 'string') data[k] = model.decl[k]
|
|
602
|
+
}
|
|
603
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
604
|
+
const rows = [...model.sections].sort((a, b) => {
|
|
605
|
+
if ((a.parent == null) !== (b.parent == null)) return a.parent == null ? -1 : 1
|
|
606
|
+
return (a.parent ?? 0) - (b.parent ?? 0) || byName(a, b)
|
|
526
607
|
})
|
|
527
608
|
return {
|
|
528
609
|
model: {
|
|
529
610
|
id: model.id,
|
|
530
611
|
uuid: model.uuid,
|
|
531
612
|
name: model.name,
|
|
532
|
-
data
|
|
613
|
+
data,
|
|
533
614
|
version: 1,
|
|
534
615
|
owner_id: this.operator()?.id ?? null,
|
|
535
616
|
unit_id: null,
|
|
@@ -537,19 +618,235 @@ export class MockStore {
|
|
|
537
618
|
owner_cardinality: 'unbounded',
|
|
538
619
|
unit_cardinality: 'unbounded',
|
|
539
620
|
role: 'content',
|
|
621
|
+
created_at: model.created_at,
|
|
622
|
+
updated_at: model.updated_at,
|
|
540
623
|
},
|
|
541
|
-
sections:
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
624
|
+
sections: rows.map((s) => {
|
|
625
|
+
const rules = rulesOf(s.decl, s.fields)
|
|
626
|
+
return {
|
|
627
|
+
id: s.id,
|
|
628
|
+
model_id: model.id,
|
|
629
|
+
name: s.name,
|
|
630
|
+
kind: s.kind,
|
|
631
|
+
is_brief: s.is_brief,
|
|
632
|
+
parent_section_id: s.parent,
|
|
633
|
+
fields: Object.entries(s.fields || {}).map(([key, f]) =>
|
|
634
|
+
fieldRow(key, f, {
|
|
635
|
+
section: kindOf(f) === 'item_ref' ? this.choicesSection(f)?.uuid : null,
|
|
636
|
+
model: kindOf(f) === 'entity_ref' ? this.model(f?.model)?.uuid : null,
|
|
637
|
+
}),
|
|
638
|
+
),
|
|
639
|
+
other_data: otherDataOf(s.decl, { appendOnly: s.append_only, nests: s.nests }),
|
|
640
|
+
constraints: rules.length ? { rules } : {},
|
|
641
|
+
}
|
|
642
|
+
}),
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A section of a Model by the name a write takes — its `parent/child` path, or its
|
|
648
|
+
* own name when no other section shares it — or null.
|
|
649
|
+
*/
|
|
650
|
+
sectionNamed(model, name) {
|
|
651
|
+
const byPath = model.sections.find((s) => s.path === name)
|
|
652
|
+
if (byPath) return byPath
|
|
653
|
+
const byName = model.sections.filter((s) => s.name === name)
|
|
654
|
+
return byName.length === 1 ? byName[0] : null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `{ model, section }` for a section uuid, or null. */
|
|
658
|
+
sectionByUuid(uuid) {
|
|
659
|
+
for (const model of this.models.values()) {
|
|
660
|
+
const section = model.sections.find((s) => s.uuid === uuid)
|
|
661
|
+
if (section) return { model, section }
|
|
662
|
+
}
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The section an `item_ref` field's `options: '@scope/name/section'` names, or null. */
|
|
667
|
+
choicesSection(field) {
|
|
668
|
+
const named = parseOptions(field?.options)
|
|
669
|
+
const model = named && this.model(named.model)
|
|
670
|
+
return model ? this.sectionNamed(model, named.path) : null
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The nearest section above this one that holds items — past any binder — or null. */
|
|
674
|
+
holdingAncestor(model, section) {
|
|
675
|
+
for (let at = section.parent; at != null; ) {
|
|
676
|
+
const above = model.sections.find((s) => s.id === at)
|
|
677
|
+
if (!above) return null
|
|
678
|
+
if (above.kind !== 'binder') return above
|
|
679
|
+
at = above.parent
|
|
680
|
+
}
|
|
681
|
+
return null
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Where an item of this section may sit, as the backend checks it: under an item of its
|
|
686
|
+
* nearest item-holding parent section — which an item of a nested section must name —
|
|
687
|
+
* or, in a section whose items nest, under one of its own. `null` when it may sit there.
|
|
688
|
+
*/
|
|
689
|
+
parentRefusal(entity, model, section, parentId) {
|
|
690
|
+
const ancestor = this.holdingAncestor(model, section)
|
|
691
|
+
if (parentId == null) {
|
|
692
|
+
return ancestor
|
|
693
|
+
? refuse(
|
|
694
|
+
409,
|
|
695
|
+
PROBLEM.schemaRule,
|
|
696
|
+
`section ${section.id} (\`${section.name}\`) nests under section ${ancestor.id}; its items need a parent_item_id naming an item of that section`,
|
|
697
|
+
)
|
|
698
|
+
: null
|
|
552
699
|
}
|
|
700
|
+
const parent = entity.items.find((i) => i.id === parentId)
|
|
701
|
+
if (!parent) return refuse(409, PROBLEM.schemaRule, `parent item ${parentId} is not an item of this entity`)
|
|
702
|
+
if (section.nests && parent.section_id === section.id) return null
|
|
703
|
+
if (ancestor && parent.section_id === ancestor.id) return null
|
|
704
|
+
return refuse(
|
|
705
|
+
409,
|
|
706
|
+
PROBLEM.schemaRule,
|
|
707
|
+
`parent_item_id ${parentId} lives in section ${parent.section_id}, but section ${section.id}'s items sit under ${ancestor ? `section ${ancestor.id}'s` : 'no item'}`,
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* `GET /sections/{uuid}/items` — one page of a section's items, as the backend answers
|
|
713
|
+
* it (`SECTION_ITEMS` in `../wire.js`), its refusals in the backend's order: a term with
|
|
714
|
+
* no field, the entry, a binder, the field. `{ body }` or `{ problem }`.
|
|
715
|
+
*
|
|
716
|
+
* ⚖️ The backend may answer a short page while more follow (a localized search);
|
|
717
|
+
* the mock filters before it pages, so its pages are full. Both end only where
|
|
718
|
+
* `next_cursor` is `null`.
|
|
719
|
+
*
|
|
720
|
+
* @param {object} model
|
|
721
|
+
* @param {object} section
|
|
722
|
+
* @param {{ entity?: string, parent?: string, field?: string, q?: string, locales?: string[]|null, after?: number[]|null, limit: number }} ask
|
|
723
|
+
*/
|
|
724
|
+
sectionItems(model, section, { entity, parent, field, q, locales, after, limit }) {
|
|
725
|
+
const term = (q || '').trim()
|
|
726
|
+
const named = (field || '').trim()
|
|
727
|
+
if (term && !named) return refuse(400, 'Validation', "`q` searches one field of the Section's items: name it with `field`")
|
|
728
|
+
|
|
729
|
+
let entries
|
|
730
|
+
if (entity) {
|
|
731
|
+
const found = this.entities.get(entity)
|
|
732
|
+
// Read as a single read is: not found, not permitted and another Model are one answer.
|
|
733
|
+
if (!found || found.model !== model.name || !this.mayRead(found)) {
|
|
734
|
+
return refuse(404, 'Not Found', `entity ${entity} not found`, { kind: 'entity', key: entity })
|
|
735
|
+
}
|
|
736
|
+
entries = [found]
|
|
737
|
+
} else {
|
|
738
|
+
entries = [...this.entities.values()].filter((e) => e.model === model.name && this.mayRead(e))
|
|
739
|
+
}
|
|
740
|
+
if (section.kind === 'binder') {
|
|
741
|
+
return refuse(400, 'Validation', `section \`${section.name}\` is a binder: it holds sections, never items`)
|
|
742
|
+
}
|
|
743
|
+
let searched = null
|
|
744
|
+
if (term) {
|
|
745
|
+
searched = section.fields?.[named]
|
|
746
|
+
if (!searched) return refuse(400, 'Validation', `section \`${section.name}\` declares no field \`${named}\``, { field: named })
|
|
747
|
+
const kind = kindOf(searched)
|
|
748
|
+
if (isList(searched) || !['string', 'text'].includes(kind)) {
|
|
749
|
+
return refuse(400, 'Validation', `\`${named}\` is ${isList(searched) ? 'a multiple ' : 'a '}${kind}: a search reads one \`string\` or \`text\` value`, {
|
|
750
|
+
field: named,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const needle = term.toLowerCase()
|
|
756
|
+
const holds = (value) => {
|
|
757
|
+
if (typeof value === 'string') return value.toLowerCase().includes(needle)
|
|
758
|
+
if (!isLocaleMap(searched, value)) return false
|
|
759
|
+
// A localized value in the read's language, as the read shows it — any language without one.
|
|
760
|
+
if (locales?.length) {
|
|
761
|
+
const hit = locales.find((l) => value[l] != null)
|
|
762
|
+
return hit != null && holds(value[hit])
|
|
763
|
+
}
|
|
764
|
+
return Object.entries(value).some(([lang, v]) => lang !== '@' && typeof v === 'string' && v.toLowerCase().includes(needle))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const rows = []
|
|
768
|
+
for (const e of [...entries].sort((a, b) => a.id - b.id)) {
|
|
769
|
+
const byId = new Map(e.items.map((i) => [i.id, i]))
|
|
770
|
+
for (const i of e.items) {
|
|
771
|
+
if (i.section_id !== section.id) continue
|
|
772
|
+
const up = i.parent_item_id != null ? byId.get(i.parent_item_id) ?? null : null
|
|
773
|
+
// One level: the items under `parent`, or the section's top level — its items whose
|
|
774
|
+
// parent is not an item of the same section.
|
|
775
|
+
if (parent ? up?.uuid !== parent : up?.section_id === section.id) continue
|
|
776
|
+
if (searched && !holds(i.data?.[named])) continue
|
|
777
|
+
rows.push({ e, i, up, key: [e.id, up?.order_number ?? 0, up?.id ?? 0, i.order_number ?? Number.MAX_SAFE_INTEGER, i.id] })
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const before = (a, b) => {
|
|
781
|
+
for (let n = 0; n < a.length; n += 1) if (a[n] !== b[n]) return a[n] < b[n]
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
rows.sort((a, b) => (before(a.key, b.key) ? -1 : before(b.key, a.key) ? 1 : 0))
|
|
785
|
+
const rest = after ? rows.filter((r) => before(after, r.key)) : rows
|
|
786
|
+
const page = rest.slice(0, limit)
|
|
787
|
+
const children = (e, i) => e.items.some((c) => c.parent_item_id === i.id && c.section_id === section.id)
|
|
788
|
+
return {
|
|
789
|
+
body: {
|
|
790
|
+
items: page.map(({ e, i, up }) => ({
|
|
791
|
+
uuid: i.uuid,
|
|
792
|
+
entity: e.uuid,
|
|
793
|
+
...(up ? { parent: up.uuid } : {}),
|
|
794
|
+
data: this.project(i.data, section, locales),
|
|
795
|
+
...(section.nests ? { has_children: children(e, i) } : {}),
|
|
796
|
+
})),
|
|
797
|
+
next_cursor: rest.length > page.length ? page[page.length - 1].key : null,
|
|
798
|
+
},
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** `{ entity, item }` for an item uuid, or null. */
|
|
803
|
+
itemByUuid(uuid) {
|
|
804
|
+
for (const entity of this.entities.values()) {
|
|
805
|
+
const item = entity.items.find((i) => i.uuid === uuid)
|
|
806
|
+
if (item) return { entity, item }
|
|
807
|
+
}
|
|
808
|
+
return null
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* An `item_ref` value the backend would refuse — not a uuid string, an item that does not
|
|
813
|
+
* exist, or an item of another section than the field's list (MEASURED 2026-10-14) — or
|
|
814
|
+
* null. A list of them is checked one by one.
|
|
815
|
+
*/
|
|
816
|
+
refRefusal(section, data) {
|
|
817
|
+
for (const [key, field] of Object.entries(section.fields || {})) {
|
|
818
|
+
if (kindOf(field) !== 'item_ref' || data?.[key] == null) continue
|
|
819
|
+
const values = isList(field) && Array.isArray(data[key]) ? data[key] : [data[key]]
|
|
820
|
+
const pinned = this.choicesSection(field)
|
|
821
|
+
for (const value of values) {
|
|
822
|
+
if (typeof value !== 'string') {
|
|
823
|
+
return refuse(400, 'Validation', `item.data.${key} must be a uuid string (item_ref value is the target item's uuid)`, {
|
|
824
|
+
reason: 'invalid_field',
|
|
825
|
+
field: `data.${key}`,
|
|
826
|
+
rule: 'type',
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
if (!isUuid(value)) {
|
|
830
|
+
return refuse(400, 'Validation', `item.data.${key}=\`${value}\` is not a valid uuid`, {
|
|
831
|
+
reason: 'invalid_field',
|
|
832
|
+
field: `data.${key}`,
|
|
833
|
+
rule: 'format',
|
|
834
|
+
format: 'uuid',
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
const target = this.itemByUuid(value)
|
|
838
|
+
if (!target) return refuse(409, 'Bad Reference', `item.data.${key} → item ${value}, but no item with that uuid exists`)
|
|
839
|
+
if (pinned && target.item.section_id !== pinned.id) {
|
|
840
|
+
const actual = this.model(target.entity.model)?.sections.find((s) => s.id === target.item.section_id)
|
|
841
|
+
return refuse(
|
|
842
|
+
409,
|
|
843
|
+
'Bad Reference',
|
|
844
|
+
`item.data.${key} → item ${value} is in section ${actual?.uuid ?? target.item.section_id}, but the type pins section ${pinned.uuid}`,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return null
|
|
553
850
|
}
|
|
554
851
|
|
|
555
852
|
operator() {
|
|
@@ -570,11 +867,17 @@ export class MockStore {
|
|
|
570
867
|
if (uuid) entity.uuid = uuid
|
|
571
868
|
const brief = model.sections.find((s) => s.is_brief)
|
|
572
869
|
if (data && brief && Object.keys(data).length) this.addItem(entity, brief, { data })
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
870
|
+
// An item may carry the items that sit under it — a nested section's, or its own
|
|
871
|
+
// section's when its items nest — as `items`, so a seed never needs an item id.
|
|
872
|
+
const place = (list, parent) => {
|
|
873
|
+
for (const item of list) {
|
|
874
|
+
const section = this.sectionNamed(model, item.section)
|
|
875
|
+
if (!section) throw new Error(`[uniweb/api mock] seed: ${modelName} has no section '${item.section}'`)
|
|
876
|
+
const made = this.addItem(entity, section, { data: item.data ?? {}, parent: parent ?? item.parent ?? null })
|
|
877
|
+
if (Array.isArray(item.items)) place(item.items, made.id)
|
|
878
|
+
}
|
|
577
879
|
}
|
|
880
|
+
place(items, null)
|
|
578
881
|
this.entities.set(entity.uuid, entity)
|
|
579
882
|
return entity
|
|
580
883
|
}
|
|
@@ -678,7 +981,7 @@ export class MockStore {
|
|
|
678
981
|
const model = this.model(entity.model)
|
|
679
982
|
const section = model?.sections.find((s) => s.is_brief)
|
|
680
983
|
const item = section && entity.items.find((i) => i.section_id === section.id)
|
|
681
|
-
return item ? this.
|
|
984
|
+
return item ? this.present(item.data, section, locales) : {}
|
|
682
985
|
}
|
|
683
986
|
|
|
684
987
|
/**
|
|
@@ -705,6 +1008,29 @@ export class MockStore {
|
|
|
705
1008
|
return out
|
|
706
1009
|
}
|
|
707
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* An item's data as a read shows it — each `item_ref` value resolved one hop to
|
|
1013
|
+
* `{ item, brief }`, the item it names and that item's data, as the backend reads one
|
|
1014
|
+
* (MEASURED 2026-10-14: a brief's `country` comes back `{ item, brief: { code, label } }`)
|
|
1015
|
+
* — then projected to the read's locale. A value that names no item stays as stored.
|
|
1016
|
+
*/
|
|
1017
|
+
present(data, section, locales) {
|
|
1018
|
+
if (!data || typeof data !== 'object' || !section?.fields) return this.project(data, section, locales)
|
|
1019
|
+
let out = data
|
|
1020
|
+
for (const [key, field] of Object.entries(section.fields)) {
|
|
1021
|
+
if (kindOf(field) !== 'item_ref' || data[key] == null) continue
|
|
1022
|
+
const resolve = (value) => {
|
|
1023
|
+
const target = typeof value === 'string' ? this.itemByUuid(value) : null
|
|
1024
|
+
if (!target) return value
|
|
1025
|
+
const own = this.model(target.entity.model)?.sections.find((s) => s.id === target.item.section_id)
|
|
1026
|
+
return { item: value, brief: this.project(target.item.data, own, locales) }
|
|
1027
|
+
}
|
|
1028
|
+
if (out === data) out = { ...data }
|
|
1029
|
+
out[key] = Array.isArray(data[key]) ? data[key].map(resolve) : resolve(data[key])
|
|
1030
|
+
}
|
|
1031
|
+
return this.project(out, section, locales)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
708
1034
|
/** The entity row without its Model's identity — as it sits inside a write's answer. */
|
|
709
1035
|
core(entity, locales) {
|
|
710
1036
|
const model = this.model(entity.model)
|
|
@@ -751,7 +1077,7 @@ export class MockStore {
|
|
|
751
1077
|
id: i.id,
|
|
752
1078
|
section_id: i.section_id,
|
|
753
1079
|
parent_item_id: i.parent_item_id,
|
|
754
|
-
data: this.
|
|
1080
|
+
data: this.present(i.data, sectionOf(i.section_id), locales),
|
|
755
1081
|
item_date: null,
|
|
756
1082
|
order_number: i.order_number,
|
|
757
1083
|
updated_at: i.updated_at,
|
|
@@ -779,11 +1105,12 @@ export class MockStore {
|
|
|
779
1105
|
const entity = this.newEntity(model, this.account)
|
|
780
1106
|
const staged = { ...entity, items: [] }
|
|
781
1107
|
for (const item of items) {
|
|
782
|
-
const section =
|
|
1108
|
+
const section = this.sectionNamed(model, item.section)
|
|
783
1109
|
if (!section) return refuse(404, 'Not Found', `section ${model.id}:${item.section} not found`, { kind: 'section', key: `${model.id}:${item.section}` })
|
|
784
|
-
const
|
|
1110
|
+
const parent = item.parent_item_id ?? null
|
|
1111
|
+
const refusal = this.parentRefusal(staged, model, section, parent) ?? this.admit(staged, model, section, item.data, null)
|
|
785
1112
|
if (refusal) return refusal
|
|
786
|
-
this.addItem(staged, section, { data: item.data, parent
|
|
1113
|
+
this.addItem(staged, section, { data: item.data, parent })
|
|
787
1114
|
}
|
|
788
1115
|
Object.assign(entity, { items: staged.items, updated_at: staged.updated_at })
|
|
789
1116
|
this.entities.set(entity.uuid, entity)
|
|
@@ -819,15 +1146,23 @@ export class MockStore {
|
|
|
819
1146
|
*/
|
|
820
1147
|
shapeGuard(model, section, data, itemId) {
|
|
821
1148
|
if (!model.decl?.sections) return null
|
|
822
|
-
const check = checkItemWrite({ decl: model.decl, section: section.name, data })
|
|
1149
|
+
const check = checkItemWrite({ decl: model.decl, section: section.path ?? section.name, data })
|
|
823
1150
|
if (check.outcome === OUTCOME.VIOLATES) {
|
|
824
1151
|
const first = check.problems[0]
|
|
825
|
-
|
|
1152
|
+
// As the backend refuses it (MEASURED 2026-10-14): `reason`, the field, the `rule` and the
|
|
1153
|
+
// bound beside it — an `enum`'s `one_of` naming its field too, as it has since that day.
|
|
1154
|
+
if (first.rule === 'undeclared-section') return refuse(400, 'Validation', first.detail, { field: first.field })
|
|
1155
|
+
return refuse(400, 'Validation', first.detail, {
|
|
1156
|
+
reason: 'invalid_field',
|
|
1157
|
+
field: `data.${first.field}`,
|
|
1158
|
+
rule: first.rule,
|
|
1159
|
+
...(first.params || {}),
|
|
1160
|
+
})
|
|
826
1161
|
}
|
|
827
1162
|
if (check.outcome === OUTCOME.DIAGNOSED) {
|
|
828
1163
|
this.diagnose(model.name, { op: itemId == null ? 'create-item' : 'update-item', section: section.name }, check)
|
|
829
1164
|
}
|
|
830
|
-
return
|
|
1165
|
+
return this.refRefusal(section, data)
|
|
831
1166
|
}
|
|
832
1167
|
|
|
833
1168
|
/** Record a write the mock let through on the seed's say-so (`migration_debt`), deduped with a count. */
|
|
@@ -865,9 +1200,8 @@ export class MockStore {
|
|
|
865
1200
|
: refuse(404, 'Not Found', `section ${op[FIELD.section]} not found`, { kind: 'section', key: String(op[FIELD.section]) })
|
|
866
1201
|
}
|
|
867
1202
|
const parent = op[FIELD.parent] ?? null
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
}
|
|
1203
|
+
const misplaced = this.parentRefusal(entity, model, section, parent)
|
|
1204
|
+
if (misplaced) return misplaced
|
|
871
1205
|
const refusal = this.admit(entity, model, section, op.data, null)
|
|
872
1206
|
if (refusal) return refusal
|
|
873
1207
|
if (op.position != null && this.orderFor(entity, section.id, parent, op.position, null) == null) {
|