@uniweb/api 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=20.19"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "^0.28.0"
37
+ "@uniweb/core": "^0.29.4"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/mock/index.js CHANGED
@@ -42,7 +42,12 @@ import { DEFAULT_SEED } from './seed.js'
42
42
  * demo whose subject is the signed-in view. Development-only by construction, since
43
43
  * this whole module is; throws if the username is not seeded.
44
44
  * @param {string} [options.prefix] - the path the API is mounted under (default `/api`)
45
- * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore }}
45
+ * Supply `seed.schemas[model].sections` (the framework's LOWERED sections map) and
46
+ * item writes are shape-checked: a `many:` section is ENFORCED, everything else is
47
+ * recorded on `.diagnostics` and allowed — see `./schema-shape.js` for why that
48
+ * asymmetry is deliberate rather than lenient.
49
+ *
50
+ * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[] }}
46
51
  */
47
52
  export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signedInAs = null } = {}) {
48
53
  const store = new MockStore(seed, { signedInAs })
@@ -181,6 +186,16 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
181
186
 
182
187
  return {
183
188
  store,
189
+ /**
190
+ * Writes the mock shape-checked but did not refuse.
191
+ *
192
+ * ⛔ Each entry is a write whose storage mapping is an OPEN QUESTION
193
+ * (`./schema-shape.js`), not one that broke a rule and was let through. Empty
194
+ * is the good state; a long list is a map of what we are still guessing about.
195
+ */
196
+ get diagnostics() {
197
+ return store.diagnostics
198
+ },
184
199
  async fetch(request) {
185
200
  try {
186
201
  return await route(request)
@@ -195,3 +210,4 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
195
210
 
196
211
  export { MockStore } from './store.js'
197
212
  export { DEFAULT_SEED } from './seed.js'
213
+ export { STORAGE, UNRESOLVED_REASON, ENFORCEMENT, OUTCOME } from './schema-shape.js'
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Field-shape checking for writes, and an HONEST NAME for what we cannot check yet.
3
+ *
4
+ * ⭐ **Why this exists.** Until now the mock enforced exactly two things —
5
+ * `creatable_by` and `append_only` — and performed no field-shape validation at
6
+ * all. A demo could therefore write `{ minutes: "twelve" }` into a field the
7
+ * schema calls an int and find out at integration. This module closes that for
8
+ * the one storage shape we have evidence about, and refuses to guess about the
9
+ * rest.
10
+ *
11
+ * ## The evidence boundary — this is the whole design
12
+ *
13
+ * | shape | what we know | what we do |
14
+ * |---|---|---|
15
+ * | a `many:` (multi) section | **MEASURED** — stored as entity items | **ENFORCE** field shapes |
16
+ * | a single/`brief` section | ⛔ **OPEN** — see below | **DIAGNOSE**, never refuse |
17
+ * | a section the schema does not declare | ⛔ **OPEN** — same question | **DIAGNOSE**, never refuse |
18
+ * | the create-time entity `data` payload | ⛔ **OPEN** — same question | **DIAGNOSE**, never refuse |
19
+ *
20
+ * ⛔ **The open question (one question, three faces).** A schema written with flat
21
+ * `fields:` is lowered to exactly one `kind: 'single'`, `brief: true` section named
22
+ * after the model — MEASURED in `@uniweb/build@0.44.4`
23
+ * `src/uwx/data-schema.js::lowerFieldsForm`, which also notes "the registry's root
24
+ * is always `sections:`". Separately, this API's entity routes are
25
+ * list / read / readBatch / create / items / remove / removeBatch: entity `data` is
26
+ * supplied at creation and there is no route to update it, while items are mutable.
27
+ *
28
+ * So: **what is the intended backend representation for the fields of a
29
+ * single/brief section once the entity exists** — mutable entity data, an item, or
30
+ * another update mechanism? Nobody has answered that yet. It is why a real
31
+ * consumer (the Courses solution) keeps editable lesson fields in an item and
32
+ * overlays course metadata with a mutable item over create-time data.
33
+ *
34
+ * ⛔ **Do NOT read "diagnose" as a permanent mock semantic.** It is not a policy
35
+ * that writes of unknown shape are allowed; it is the absence of an answer, named.
36
+ * That is why the state is called `UNRESOLVED_STORAGE_MAPPING` rather than
37
+ * "warn" or "allow": when the question is answered, the fix is to change this
38
+ * model's entry in `ENFORCEMENT` (and, if the answer says so, teach
39
+ * `classifySection` the new mapping) — not to redesign the validator or hunt for
40
+ * the places that quietly let writes through.
41
+ */
42
+
43
+ /** Where a written section lives, and therefore what we are entitled to do about it. */
44
+ export const STORAGE = {
45
+ /**
46
+ * A section the schema declares — `single` or `multi` alike.
47
+ *
48
+ * ⭐ **RULED** *(Diego, 2026-09-11)*: sections ARE the storage model and hold
49
+ * items ("section => abstract, item => concrete"); a `single` section simply holds
50
+ * one. `brief: true` is an optional designation about what a card needs, **not** a
51
+ * storage class. So a declared section's records are checkable whatever its kind —
52
+ * which is why `single` no longer lives in the unresolved branch.
53
+ */
54
+ ITEMS: 'items',
55
+ /**
56
+ * ⛔ **Known divergence, on an explicit list — never a catch-all.**
57
+ *
58
+ * A `(model, section)` pair the site has declared as `migration_debt`: storage we
59
+ * know does not match its declaration, kept working while the thing that forced it
60
+ * is unwound. Two shapes qualify, and both are OUR debt, not the framework's:
61
+ * • a section the schema does not declare at all (`@/course` `meta`), and
62
+ * • a declared section whose records diverge (`@/quiz-key` `answers` holds one
63
+ * record carrying a map, where the schema declares many).
64
+ *
65
+ * Both exist because `@uniweb/api` had no route to update entity data — an
66
+ * omission, per Diego, not a backend rule. They retire with that route.
67
+ */
68
+ MIGRATION_DEBT: 'migration-debt',
69
+ /**
70
+ * A section that is neither declared nor listed as debt. **A violation** — a typo
71
+ * or an accidental section, which must not be able to hide in the debt bucket.
72
+ */
73
+ UNDECLARED: 'undeclared-section',
74
+ /**
75
+ * ⛔ STILL OPEN. The create-time `data` payload: the one shape whose wire contract
76
+ * is genuinely unknown, pending the entity-update route.
77
+ */
78
+ UNRESOLVED: 'unresolved-storage-mapping',
79
+ }
80
+
81
+ /** Why a write was tolerated rather than judged. Reported; never changes what happens. */
82
+ export const UNRESOLVED_REASON = {
83
+ /** On the site's `migration_debt` list: the schema declares no such section. */
84
+ UNDECLARED_SECTION: 'undeclared-section',
85
+ /** On the list: declared, but the records stored there do not match the declaration. */
86
+ DIVERGENT_SECTION: 'divergent-section',
87
+ /** The `data` supplied at creation. Awaiting the entity-update wire contract. */
88
+ ENTITY_DATA_PAYLOAD: 'entity-data-payload',
89
+ }
90
+
91
+ /**
92
+ * What to DO about each storage classification.
93
+ *
94
+ * ⭐ **This table is the resolution point.** Answering the open question means
95
+ * changing `UNRESOLVED` here (to `'enforce'`, or to whatever the answer implies),
96
+ * and nothing else in this file's callers moves.
97
+ */
98
+ export const ENFORCEMENT = {
99
+ [STORAGE.ITEMS]: 'enforce',
100
+ [STORAGE.UNDECLARED]: 'enforce',
101
+ [STORAGE.MIGRATION_DEBT]: 'diagnose',
102
+ [STORAGE.UNRESOLVED]: 'diagnose',
103
+ }
104
+
105
+ /** Outcomes a check can produce. `VIOLATES` is the only one a caller may refuse on. */
106
+ export const OUTCOME = {
107
+ CONFORMS: 'conforms',
108
+ VIOLATES: 'violates',
109
+ /** Shape problems found, but under a classification we do not enforce. */
110
+ DIAGNOSED: 'diagnosed',
111
+ /** No declaration to check against — the model is not described to the mock. */
112
+ UNCHECKED: 'unchecked',
113
+ }
114
+
115
+ /**
116
+ * ⚠️ **TWO LOWERED SPELLINGS, and this module accepts both.**
117
+ *
118
+ * The framework lowers a schema twice, and the two forms disagree on how they say
119
+ * "many" — MEASURED 2026-09-11 against `@uniweb/schemas@0.2.13` and
120
+ * `@uniweb/build@0.44.4`:
121
+ *
122
+ * | | a repeating section | a list field |
123
+ * |---|---|---|
124
+ * | `validateAndNormalizeSchema` | `kind: 'multi'` | `{ type: 'array', items: {…} }` |
125
+ * | `toDataSchemaDeclaration` (the **registry** form, what a backend is actually sent) | `multiple: true` | `{ type: 'string', multiple: true }` |
126
+ *
127
+ * ⛔ Reading only the first spelling is not a cosmetic bug: every multi section in a
128
+ * REGISTRY declaration would fall through to "single", so the mock would classify
129
+ * all of them as unresolved and enforce **nothing**, silently. Caught by feeding it
130
+ * real declarations rather than only hand-written fixtures.
131
+ */
132
+ function isMultiSection(section) {
133
+ return section?.multiple === true || section?.kind === 'multi'
134
+ }
135
+
136
+ /** The element shape of a list field, in either spelling, or null if it is not a list. */
137
+ function listElement(field) {
138
+ if (field?.multiple === true) return { type: field.type, fields: field.fields }
139
+ if (field?.type === 'array') return { type: field.items?.type, fields: field.items?.fields }
140
+ return null
141
+ }
142
+
143
+ /**
144
+ * Is this section a multi section of the model's declaration?
145
+ *
146
+ * `decl.sections` is the lowered sections map (`{ [name]: { kind, brief, fields } }`)
147
+ * as produced by the framework's own normalizer — the mock never parses schema
148
+ * source, so there is no second copy of the authoring format here to drift.
149
+ */
150
+ export function classifySection(decl, section) {
151
+ const sections = decl?.sections
152
+ if (!sections || typeof sections !== 'object') return null
153
+ const found = sections[section]
154
+
155
+ // ⭐ The debt list is consulted FIRST and is an exact `(model, section)` match, so
156
+ // it can excuse a declared section whose records diverge as well as an undeclared
157
+ // one — but only ever the pairs the site named. Anything else falls through.
158
+ const isDebt = (decl.migration_debt || []).includes(section)
159
+ if (isDebt) {
160
+ return {
161
+ storage: STORAGE.MIGRATION_DEBT,
162
+ reason: found ? UNRESOLVED_REASON.DIVERGENT_SECTION : UNRESOLVED_REASON.UNDECLARED_SECTION,
163
+ section: found || null,
164
+ }
165
+ }
166
+
167
+ // ⛔ Not declared and not owned as debt: a typo or an accidental section. Refused,
168
+ // so it cannot disappear into the debt bucket — the reason the list is explicit.
169
+ if (!found) return { storage: STORAGE.UNDECLARED, section: null }
170
+
171
+ // Declared — single or multi alike. Sections hold items; `single` holds one.
172
+ return { storage: STORAGE.ITEMS, section: found }
173
+ }
174
+
175
+ // ── Field shapes ──────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Does a value satisfy a lowered type?
179
+ *
180
+ * Returns `null` for a type this does not know. ⭐ Unknown is TOLERATED, not
181
+ * failed: the type vocabulary belongs to `@uniweb/schemas` and grows there, and a
182
+ * mock that rejected a type it had not heard of would fail writes the real store
183
+ * accepts — the expensive direction of a fidelity error.
184
+ */
185
+ function satisfiesType(type, value) {
186
+ switch (type) {
187
+ case 'string':
188
+ case 'text':
189
+ case 'html':
190
+ case 'markdown':
191
+ case 'email':
192
+ case 'url':
193
+ case 'image':
194
+ case 'file':
195
+ case 'ref':
196
+ return typeof value === 'string'
197
+ case 'int':
198
+ case 'integer':
199
+ return typeof value === 'number' && Number.isInteger(value)
200
+ case 'number':
201
+ case 'decimal':
202
+ return typeof value === 'number' && Number.isFinite(value)
203
+ case 'bool':
204
+ case 'boolean':
205
+ return typeof value === 'boolean'
206
+ case 'date':
207
+ case 'datetime':
208
+ return typeof value === 'string' || value instanceof Date
209
+ case 'array':
210
+ return Array.isArray(value)
211
+ case 'object':
212
+ case 'json':
213
+ case 'group':
214
+ return typeof value === 'object' && value !== null
215
+ default:
216
+ return null
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Check a record against a lowered `fields` map. Returns a list of violations.
222
+ *
223
+ * ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
224
+ * that the real store rejects extras, and inventing that rule here would refuse
225
+ * writes production accepts. Unknown keys are reported by the caller as
226
+ * diagnostics instead.
227
+ */
228
+ export function checkFields(fields, data, path = '') {
229
+ const problems = []
230
+ if (!fields || typeof fields !== 'object') return problems
231
+ const record = data && typeof data === 'object' ? data : {}
232
+
233
+ for (const [name, field] of Object.entries(fields)) {
234
+ const at = path ? `${path}.${name}` : name
235
+ const value = record[name]
236
+ const missing = value === undefined || value === null
237
+
238
+ if (field.required && missing) {
239
+ problems.push({ field: at, rule: 'required', detail: `'${at}' is required` })
240
+ continue
241
+ }
242
+ if (missing) continue
243
+
244
+ // A list, in either spelling. The VALUE must be an array; each entry is then
245
+ // checked against the element shape, indexed the way `uniweb validate` names it
246
+ // (`outcomes[1]`), so one vocabulary covers both lanes.
247
+ const element = listElement(field)
248
+ if (element) {
249
+ if (!Array.isArray(value)) {
250
+ problems.push({ field: at, rule: 'type', detail: `'${at}' expects a list of ${element.type}` })
251
+ continue
252
+ }
253
+ value.forEach((entry, i) => {
254
+ const entryAt = `${at}[${i}]`
255
+ if (satisfiesType(element.type, entry) === false) {
256
+ problems.push({
257
+ field: entryAt,
258
+ rule: 'type',
259
+ detail: `'${entryAt}' expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
260
+ })
261
+ return
262
+ }
263
+ if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
264
+ })
265
+ continue
266
+ }
267
+
268
+ if (satisfiesType(field.type, value) === false) {
269
+ problems.push({
270
+ field: at,
271
+ rule: 'type',
272
+ detail: `'${at}' expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
273
+ })
274
+ continue
275
+ }
276
+
277
+ if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
278
+ problems.push({ field: at, rule: 'enum', detail: `'${at}' must be one of ${field.enum.join(', ')}` })
279
+ continue
280
+ }
281
+
282
+ if (field.fields) problems.push(...checkFields(field.fields, value, at))
283
+ }
284
+ return problems
285
+ }
286
+
287
+ /** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
288
+ function undeclaredKeys(fields, data) {
289
+ if (!fields || !data || typeof data !== 'object') return []
290
+ return Object.keys(data).filter((k) => !(k in fields))
291
+ }
292
+
293
+ // ── The two entry points the store calls ──────────────────────────────────────
294
+
295
+ /**
296
+ * Check one item write (create or update) against the model's declaration.
297
+ *
298
+ * @returns {{outcome: string, storage?: string, reason?: string, problems: object[], undeclared: string[]}}
299
+ * `outcome: 'violates'` is the ONLY result a caller may refuse on.
300
+ */
301
+ export function checkItemWrite({ decl, section, data }) {
302
+ if (!decl?.sections) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
303
+
304
+ const where = classifySection(decl, section)
305
+ if (!where) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
306
+
307
+ // An undeclared section has no field set to conform to, so the violation is the
308
+ // section itself — stated here rather than falling out of an empty field check.
309
+ if (where.storage === STORAGE.UNDECLARED) {
310
+ return {
311
+ outcome: OUTCOME.VIOLATES,
312
+ storage: where.storage,
313
+ problems: [
314
+ {
315
+ field: section,
316
+ rule: 'undeclared-section',
317
+ detail: `'${section}' is not a section of this model`,
318
+ },
319
+ ],
320
+ undeclared: [],
321
+ }
322
+ }
323
+
324
+ const fields = where.section?.fields
325
+ const problems = fields ? checkFields(fields, data) : []
326
+ const undeclared = fields ? undeclaredKeys(fields, data) : []
327
+ const enforcement = ENFORCEMENT[where.storage]
328
+
329
+ if (enforcement === 'enforce') {
330
+ return {
331
+ outcome: problems.length ? OUTCOME.VIOLATES : OUTCOME.CONFORMS,
332
+ storage: where.storage,
333
+ problems,
334
+ undeclared,
335
+ }
336
+ }
337
+ // ⭐ ALWAYS diagnosed, even when the data looks fine and even when there were no
338
+ // fields to check it against. The finding is not "these values are wrong" — it is
339
+ // "this write's storage mapping is unanswered". A clean-looking write under an
340
+ // unresolved classification is still riding on the open question, and the whole
341
+ // value of the list is that it maps exactly which writes those are.
342
+ return {
343
+ outcome: OUTCOME.DIAGNOSED,
344
+ storage: where.storage,
345
+ reason: where.reason,
346
+ problems,
347
+ undeclared,
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Check the `data` supplied at entity creation.
353
+ *
354
+ * ⛔ **Always the unresolved branch, by construction.** Whether this payload is the
355
+ * brief section's fields (and therefore checkable against them) is precisely the
356
+ * open question. We report what we see and refuse nothing.
357
+ */
358
+ export function checkEntityDataWrite({ decl, data }) {
359
+ if (!decl?.sections) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
360
+
361
+ const briefName = Object.keys(decl.sections).find((n) => decl.sections[n]?.brief)
362
+ const brief = briefName ? decl.sections[briefName] : null
363
+ const problems = brief?.fields ? checkFields(brief.fields, data) : []
364
+ const undeclared = brief?.fields ? undeclaredKeys(brief.fields, data) : []
365
+
366
+ // Always diagnosed, for the same reason as `checkItemWrite` — see there.
367
+ return {
368
+ outcome: OUTCOME.DIAGNOSED,
369
+ storage: STORAGE.UNRESOLVED,
370
+ reason: UNRESOLVED_REASON.ENTITY_DATA_PAYLOAD,
371
+ problems,
372
+ undeclared,
373
+ }
374
+ }
package/src/mock/store.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { FIELD, OP } from '../wire.js'
2
+ import { checkItemWrite, checkEntityDataWrite, OUTCOME, STORAGE } from './schema-shape.js'
2
3
 
3
4
  /**
4
5
  * The mock's state — accounts, one session, entities and their items.
@@ -31,7 +32,14 @@ export class MockStore {
31
32
  /**
32
33
  * @param {object} seed
33
34
  * @param {object[]} [seed.accounts] - `{ username, password, handle, roles?, units? }`
34
- * @param {object} [seed.schemas] - `{ '@/session': { creatable_by?, append_only? } }`
35
+ * @param {object} [seed.schemas] - `{ '@/session': { creatable_by?, append_only?, sections?, migration_debt? } }`.
36
+ * `migration_debt` is a list of section names this site KNOWS diverge from the
37
+ * declaration and is unwinding — tolerated and recorded, never silently passed.
38
+ * Anything undeclared and NOT on that list is refused, so a typo cannot hide in it.
39
+ * `sections` is the LOWERED sections map (`{ [name]: { kind, brief, fields } }`) as the
40
+ * framework's own normalizer produces it — supply it and writes are shape-checked
41
+ * (see `./schema-shape.js`). The mock never parses schema source itself, so there is
42
+ * no second copy of the authoring format here to drift from the real one.
35
43
  * @param {object[]} [seed.entities] - `{ uuid?, model, data?, items? }`
36
44
  * @param {object} [options]
37
45
  * @param {string} [options.signedInAs] - start with this account already signed in.
@@ -46,9 +54,18 @@ export class MockStore {
46
54
  units: a.units || [],
47
55
  ...a,
48
56
  }))
49
- // What the mock knows about a Model: only the two things it must ENFORCE.
50
- // Everything else about a schema is the site's business, not the server's.
57
+ // What the mock knows about a Model: the two permission rules it must ENFORCE,
58
+ // and — when the seed supplies `sections` — the field shapes to check writes
59
+ // against. Everything else about a schema is the site's business.
51
60
  this.schemas = seed.schemas || {}
61
+ /**
62
+ * Shape findings the mock saw but did not refuse.
63
+ *
64
+ * ⛔ Every entry here is a write under `STORAGE.UNRESOLVED` — the open storage
65
+ * question in `./schema-shape.js`, not a lenient policy. Read it to see exactly
66
+ * which writes are riding on an unanswered question; `mock.diagnostics` exposes it.
67
+ */
68
+ this.diagnostics = []
52
69
  this.entities = new Map()
53
70
  for (const e of seed.entities || []) this.seedEntity(e)
54
71
  /** The one session. A mock serves one developer, so one is the honest number. */
@@ -168,6 +185,42 @@ export class MockStore {
168
185
  return false
169
186
  }
170
187
 
188
+ /**
189
+ * Record a shape finding the mock chose not to refuse.
190
+ *
191
+ * ⛔ Called ONLY for `STORAGE.UNRESOLVED` writes. A finding here is "we could not
192
+ * judge this because the storage mapping is an open question", never "this was
193
+ * wrong but allowed" — see `./schema-shape.js` for the question and for the one
194
+ * table that resolves it.
195
+ */
196
+ diagnose(model, where, result) {
197
+ // ⭐ Deduped by (model, op, section, reason) with a count, so the list stays a
198
+ // MAP of what is unresolved rather than a log of every keystroke. An editor
199
+ // saving a lesson every few seconds would otherwise bury the distinct findings
200
+ // under thousands of identical rows, and the distinct set is the whole point.
201
+ const key = `${model}|${where.op}|${where.section ?? ''}|${result.reason}`
202
+ // `storage` travels with the finding: migration debt and a still-open mapping
203
+ // read the same in a list otherwise, and they have different futures.
204
+ const seen = this.diagnostics.find((d) => d.key === key)
205
+ if (seen) {
206
+ seen.count += 1
207
+ seen.lastAt = now()
208
+ return
209
+ }
210
+ this.diagnostics.push({
211
+ key,
212
+ model,
213
+ ...where,
214
+ storage: result.storage,
215
+ reason: result.reason,
216
+ problems: result.problems,
217
+ undeclared: result.undeclared,
218
+ count: 1,
219
+ firstAt: now(),
220
+ lastAt: now(),
221
+ })
222
+ }
223
+
171
224
  /** Is this section insert-only? Existing items may not be edited or removed. */
172
225
  isAppendOnly(model, section) {
173
226
  const decl = this.schemas[model]?.append_only
@@ -207,6 +260,10 @@ export class MockStore {
207
260
  // ── Writes ──────────────────────────────────────────────────────────────────
208
261
 
209
262
  create(model, data) {
263
+ // ⛔ Never refused. Whether this payload is the brief section's fields — and so
264
+ // whether it is checkable against them at all — IS the open question.
265
+ const check = checkEntityDataWrite({ decl: this.schemas[model], data })
266
+ if (check.outcome === OUTCOME.DIAGNOSED) this.diagnose(model, { op: 'create-entity' }, check)
210
267
  const entity = this.seedEntity({ model, data, owner: this.account?.uuid ?? null })
211
268
  return this.hydrate(entity)
212
269
  }
@@ -215,6 +272,41 @@ export class MockStore {
215
272
  return this.entities.delete(uuid)
216
273
  }
217
274
 
275
+ /**
276
+ * Shape-check an item write. Returns a refusal to hand straight back, or `null`.
277
+ *
278
+ * ⭐ **Refuses a declared section whose data does not fit, and an undeclared
279
+ * section that nobody owns.** It tolerates exactly two things: a `(model, section)`
280
+ * the site listed as `migration_debt`, and the create-time `data` payload — the
281
+ * one shape whose wire contract is still unsettled. See `./schema-shape.js`.
282
+ *
283
+ * The debt list is an explicit allowlist rather than a mode precisely so a typo
284
+ * cannot hide in it: `moduels` is a 422, `modules` is recorded and allowed.
285
+ */
286
+ shapeGuard(model, section, data, itemId) {
287
+ const decl = this.schemas[model]
288
+ if (!decl?.sections) return null
289
+ const check = checkItemWrite({ decl, section, data })
290
+
291
+ if (check.outcome === OUTCOME.VIOLATES) {
292
+ const first = check.problems[0]
293
+ return {
294
+ ok: false,
295
+ problem: {
296
+ status: 422,
297
+ title: 'SchemaViolation',
298
+ detail: `section '${section}' of ${model}: ${first.detail}`,
299
+ violations: check.problems,
300
+ ...(itemId != null ? { [FIELD.item]: itemId } : {}),
301
+ },
302
+ }
303
+ }
304
+ if (check.outcome === OUTCOME.DIAGNOSED) {
305
+ this.diagnose(model, { op: itemId == null ? 'create-item' : 'update-item', section }, check)
306
+ }
307
+ return null
308
+ }
309
+
218
310
  /**
219
311
  * Apply one op. Returns `{ ok, result }` or `{ ok: false, problem }` — the caller
220
312
  * turns a problem into the response, so a batch can stop at the first one and
@@ -252,11 +344,15 @@ export class MockStore {
252
344
  if (!op[FIELD.section]) {
253
345
  return { ok: false, problem: { status: 400, title: 'Validation', detail: 'create needs a section' } }
254
346
  }
347
+ const refusal = this.shapeGuard(entity.model, op[FIELD.section], op.data, null)
348
+ if (refusal) return refusal
255
349
  const made = this.makeItem({ section: op[FIELD.section], data: op.data, parent: op[FIELD.parent] ?? null })
256
350
  this.place(entity, made, op.position)
257
351
  return { ok: true, result: { [FIELD.item]: made[FIELD.item], [FIELD.token]: made[FIELD.token] } }
258
352
  }
259
353
  if (kind === OP.update) {
354
+ const refusal = this.shapeGuard(entity.model, item.section, op.data, itemId)
355
+ if (refusal) return refusal
260
356
  // Whole-data replace, like the real write: round-trip what you do not edit.
261
357
  item.data = op.data ?? {}
262
358
  item[FIELD.token] = stamp()
package/src/wire.js CHANGED
@@ -37,6 +37,55 @@
37
37
  * note to MEASURED and delete its entry — the test on that array makes the change
38
38
  * deliberate and visible rather than a quiet edit.
39
39
  *
40
+ * ## ⭐ THE ENTITY CONTENT MODEL — **MEASURED 2026-09-17**, and it corrects this package
41
+ *
42
+ * Backend read its own source and answered three questions we put to it. The answer
43
+ * to the third replaced a premise this package was built on, so it is recorded here
44
+ * first, before any route or field:
45
+ *
46
+ * ⛔ **THERE IS NO ENTITY-LEVEL DATA. Entity content is ALWAYS items.**
47
+ *
48
+ * An entity stores identity, ownership, flags, timestamps, and a `brief` /
49
+ * `sort_date` **the server maintains itself**. There is no separate data record, and
50
+ * therefore no route that updates one. Everything an author writes is an item in a
51
+ * section.
52
+ *
53
+ * - **A `single` section — `brief: true` included — holds an ORDINARY ITEM**, with
54
+ * its own `item_id`, updated through the same item route as any other. It takes
55
+ * exactly ONE item: a second `create` into it is refused, so the shape is
56
+ * create-once-then-update.
57
+ * - **Never send `brief`.** The server rebuilds `brief` and `sort_date` after a
58
+ * write to the brief section. It is output, not input.
59
+ * - ⚠️ **Two spellings for one flag:** `brief: true` is how a model *file* is
60
+ * written; a schema *read* returns **`is_brief`**.
61
+ * - **Creating with content puts items under `items`, addressed BY NAME:**
62
+ * `POST /entities?model=…` · `{ items: [ { section: 'profile', data: {…} } ] }`.
63
+ * Entity and items commit in one transaction. A nested section uses a
64
+ * parent/child path.
65
+ * - **After creation, item ops address a section by NUMERIC `section_id`** — not by
66
+ * name (see `FIELD.section`).
67
+ * - Site content, folder and deployment entities answer **409** on the item route;
68
+ * they are written through `/api/sites/…` and `/api/folders/…`. That is the same
69
+ * boundary the RULED lane below draws, enforced from the other side.
70
+ * - A batch runs in one transaction, but **an op may not reference an item created
71
+ * earlier in the same batch.**
72
+ *
73
+ * ⛔ **WHAT THIS PACKAGE GETS WRONG TODAY — do not read the code as the contract.**
74
+ * `createEntity({ schema, data })` sends content as a **top-level `data` key**, which
75
+ * the create route does not accept. The create body **does not reject unknown keys**,
76
+ * so that payload is *silently dropped*: **201, and an empty entity, with no error.**
77
+ * Our own mock accepts the same shape, so the fiction is symmetrical and nothing in
78
+ * this repo currently fails because of it. Correcting the client is a separate change
79
+ * (it touches behaviour); this module's job is to make sure the contract is written
80
+ * down before that happens.
81
+ *
82
+ * ⚠️ **Provenance of the above: MEASURED BY BACKEND, reading backend's own source —
83
+ * not observed on the wire by us, and the ignored-`data` behaviour was explicitly
84
+ * described as read-not-tested.** That is stronger than anything else we have on this
85
+ * lane and weaker than a response we have held in our hands. It earns MEASURED under
86
+ * the table above ("or in backend's own source"); the untested corner is flagged
87
+ * where it matters rather than promoted.
88
+ *
40
89
  * @module @uniweb/api/wire
41
90
  */
42
91
 
@@ -122,11 +171,16 @@ export const PARAM = {
122
171
  * mismatch is `409` with `current_updated_at`; a gone item is `404`; an absent
123
172
  * token is last-writer-wins, guarded same-transaction.
124
173
  *
125
- * ⛔ **`move` is ASSUMED.** It is in scope as a product requirement — an operator
126
- * arranging authored content by hand, where order is a stored fact and not a sort
127
- * key — but it was read off the **site** lane, and this lane's own documentation
128
- * names only `update` and `delete` as token-carrying. Whether `move` exists here at
129
- * all is unconfirmed.
174
+ * ✅ **`move` is MEASURED (2026-09-17).** It exists on this lane, carries a
175
+ * precondition, and takes `{ item_id, parent_item_id, position, if_unmodified_since }`.
176
+ * **Position is decided server-side** — `"first"`, `"last"` or `{ after: <item_id> }` —
177
+ * and the client never computes an order number. Both assumptions that stood here are
178
+ * retired. *(It had been read off the site lane, whose documentation names only
179
+ * `update` and `delete` as token-carrying; the doubt was reasonable and wrong.)*
180
+ *
181
+ * ⚠️ **Batch caveat, MEASURED:** an array of ops runs in ONE transaction — all commit
182
+ * or none — but **an op may not reference an item created earlier in the same batch.**
183
+ * A create-then-position sequence is therefore two round trips, not one batch.
130
184
  */
131
185
  export const OP = {
132
186
  create: 'create',
@@ -141,13 +195,24 @@ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
141
195
  /**
142
196
  * Field names on an op and on a write response.
143
197
  *
144
- * ⛔ **ASSUMED, all of them.** These were read off `POST /api/sites/{id}/content/items`
145
- * — a *different route* of the same binary — because the working client of *our*
146
- * route returns its responses unnormalized and so reveals no names at all.
198
+ * ✅ **MEASURED 2026-09-17** — backend confirmed these against its own source, and the
199
+ * four assumptions that stood here are retired (see `ASSUMPTIONS`). Three of the four
200
+ * names held exactly: `item_id`, `parent_item_id`, `if_unmodified_since`.
147
201
  *
148
- * The shapes are very likely identical: both are `…/items` routes with the same op
149
- * vocabulary, and this lane's documentation says its concurrency is "aligned with
150
- * the sites lane". **Likely is not measured**, and this comment is the difference.
202
+ * ⛔ **The fourth did not.** `section` is wrong for the item route — see `section`
203
+ * below, where the divergence is documented in full. It is left wrong deliberately:
204
+ * correcting it changes behaviour, which is a separate change.
205
+ *
206
+ * A write answers `{ entity, item_id, item_uuid, item_updated_at }`, and
207
+ * **`item_uuid` is set on `create` only** — a field this module does not yet name.
208
+ * `item_updated_at` is the token to send back as `if_unmodified_since` on the next
209
+ * write to that item; omitting it is last-writer-wins, and the check is per item, so
210
+ * two people editing different sections do not collide.
211
+ *
212
+ * *(Historical: these were originally read off `POST /api/sites/{id}/content/items` —
213
+ * a different route of the same binary — because the working client of our route
214
+ * returns responses unnormalized and reveals no names. The guess was right on three
215
+ * of four, which is roughly the hit rate this file exists to make visible.)*
151
216
  */
152
217
  export const FIELD = {
153
218
  /** Names the target item on an op, and the affected item on a response. */
@@ -162,6 +227,32 @@ export const FIELD = {
162
227
  * `append_only`, a field set — simply does not apply to an item that landed in
163
228
  * another. A create with no section is accepted, stored somewhere, and every
164
229
  * guarantee the author declared is quietly not in force.
230
+ *
231
+ * ⛔ **DIVERGENT FROM THE BACKEND — MEASURED 2026-09-17. This value is wrong for
232
+ * the item route, and is left wrong on purpose until the client change lands.**
233
+ *
234
+ * The backend takes a section **two different ways, on two different routes**:
235
+ *
236
+ * | route | how a section is named |
237
+ * |---|---|
238
+ * | `POST /entities` (create with content) | **by NAME** — `{ items: [{ section: 'profile', … }] }` |
239
+ * | `POST /entities/{uuid}/items` (everything after) | **by NUMERIC `section_id`** |
240
+ *
241
+ * We send `section: '<name>'` on the item route, where the backend wants
242
+ * `section_id: <number>`.
243
+ *
244
+ * ⚠️ **And the cost is larger than the field name**, because the id is needed to
245
+ * *find* an item as well as to create one. An entity read returns `items[]` with
246
+ * `id`, `section_id` and `updated_at` — **no section name at all**. Code that
247
+ * locates an item by name (`items.find(i => i.section === 'content')`) works only
248
+ * against a mock that invents the name, and against the real backend returns
249
+ * `undefined`: the update silently never happens, or a duplicate is created.
250
+ *
251
+ * ⇒ A client needs **`GET /api/models/{scope}/{name}`** — `sections[]` with `id`,
252
+ * `name`, `kind`, `is_brief`, `parent_section_id` — to resolve a name, plus a
253
+ * path-aware resolver for nested sections. That route is **not in `ROUTES`**: it is
254
+ * outside the `/entities` lane this module pins, so adding it is a deliberate
255
+ * change, not a drive-by.
165
256
  */
166
257
  section: 'section',
167
258
  /** The precondition an op carries. */
@@ -203,31 +294,25 @@ export const LIST = {
203
294
  * edit here plus a moved comment above; `tests/wire.test.js` pins the set so the
204
295
  * change cannot be quiet.
205
296
  */
297
+ /**
298
+ * ⭐ **Four entries were RETIRED on 2026-09-17** — backend confirmed them against its
299
+ * own source, and the rule above is to move the note to MEASURED and delete the entry:
300
+ *
301
+ * | retired | confirmed as |
302
+ * |---|---|
303
+ * | `write-response-fields` | a write answers `{ entity, item_id, item_uuid, item_updated_at }`; `item_uuid` is set on **create only** |
304
+ * | `move-exists` | `move` is an op here and carries a precondition |
305
+ * | `move-position` | positioned server-side — `"first"` \| `"last"` \| `{ after }`; the client never computes an order |
306
+ * | `op-field-names` | `item_id`, `parent_item_id`, `if_unmodified_since` all confirmed — **but the section field is NOT**, and that is now a recorded DIVERGENCE on `FIELD.section`, not an open question |
307
+ *
308
+ * ⛔ `op-field-names` left this list by being **answered, not by being right**. Three
309
+ * of its four names held; the fourth is wrong and is documented where the wrong value
310
+ * lives. An assumption that turns out false is not an assumption any more — it is a
311
+ * defect, and hiding it in a list of open questions is how it stays unfixed.
312
+ *
313
+ * What remains below is genuinely unconfirmed.
314
+ */
206
315
  export const ASSUMPTIONS = [
207
- {
208
- id: 'write-response-fields',
209
- we: `a write response names its item as '${FIELD.item}' and its next token as '${FIELD.token}'`,
210
- from: 'the site-editor lane, which is a different route',
211
- breaks: 'the ledger records nothing, so every second write on an item goes out unguarded — last-writer-wins instead of a 409',
212
- },
213
- {
214
- id: 'op-field-names',
215
- we: `an op names its target '${FIELD.item}', its placement '${FIELD.parent}', and its precondition '${FIELD.precondition}'`,
216
- from: 'the site-editor lane, which is a different route',
217
- breaks: 'writes are refused, loudly — the cheapest of these to be wrong about',
218
- },
219
- {
220
- id: 'move-exists',
221
- we: `'${OP.move}' is an op on this lane, and carries a precondition`,
222
- from: 'the site-editor lane; this lane documents only update and delete as token-carrying',
223
- breaks: 'an operator cannot reorder authored content, which is half of what makes an app an app rather than a CMS',
224
- },
225
- {
226
- id: 'move-position',
227
- we: 'a move is positioned server-side — "first" | "last" | { after } — and the client never computes an order number',
228
- from: 'the site-editor lane',
229
- breaks: 'reordering writes the wrong sequence, or needs a client-side order the store does not want',
230
- },
231
316
  {
232
317
  id: 'viewer-unit-signal',
233
318
  we: "read a viewer's unit membership from `acting_unit_id` on /auth/me, surfaced as `viewer.actingUnitId`",