@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/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, nested sections, second factors —
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 fields, password rules,
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
- model.sections.push({
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
- fields: section?.fields || null,
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
- for (const [sectionName, section] of Object.entries(declared)) {
481
- add(sectionName, { kind: isMulti(section) ? 'multi' : 'single', brief: !!section?.brief, section })
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
- /** `GET /models/@scope/name`, in the backend's shape. */
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 field = ([key, f]) => ({
522
- key,
523
- required: !!f?.required,
524
- multi: f?.multiple === true || f?.type === 'array',
525
- type: { id: -1, name: f?.type ?? 'json', kind: f?.type ?? 'json', data: { key, kind: f?.type ?? 'json' } },
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: { label: model.decl?.label ?? shortName(model.name), grantable: true },
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: model.sections.map((s) => ({
542
- id: s.id,
543
- model_id: model.id,
544
- name: s.name,
545
- kind: s.kind,
546
- is_brief: s.is_brief,
547
- parent_section_id: null,
548
- fields: Object.entries(s.fields || {}).map(field),
549
- other_data: s.append_only ? { append_only: true } : {},
550
- constraints: {},
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
- for (const item of items) {
574
- const section = model.sections.find((s) => s.name === item.section)
575
- if (!section) throw new Error(`[uniweb/api mock] seed: ${modelName} has no section '${item.section}'`)
576
- this.addItem(entity, section, { data: item.data ?? {}, parent: item.parent ?? null })
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.project(item.data, section, locales) : {}
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.project(i.data, sectionOf(i.section_id), locales),
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 = model.sections.find((s) => s.name === item.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 refusal = this.admit(staged, model, section, item.data, null)
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: item.parent_item_id ?? null })
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
- return refuse(400, 'Validation', `item.data.${first.field}: ${first.detail}`, { field: `data.${first.field}` })
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 null
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
- if (parent != null && !entity.items.some((i) => i.id === parent)) {
869
- return refuse(409, PROBLEM.schemaRule, `parent item ${parent} is not an item of this entity`)
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) {