@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 +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/src/wire.js +120 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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
|
-
*
|
|
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()
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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`",
|