@uniweb/api 0.3.5 → 0.3.6
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 +2 -2
- package/src/mock/index.js +17 -1
- package/src/mock/schema-shape.js +374 -0
- package/src/mock/store.js +99 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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.
|
|
37
|
+
"@uniweb/core": "^0.29.0"
|
|
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
|
-
*
|
|
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:
|
|
50
|
-
//
|
|
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()
|