@uniweb/api 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mock/store.js CHANGED
@@ -1,406 +1,691 @@
1
- import { FIELD, OP } from '../wire.js'
2
- import { checkItemWrite, checkEntityDataWrite, OUTCOME, STORAGE } from './schema-shape.js'
1
+ import { FIELD, OP, PROBLEM } from '../wire.js'
2
+ import { checkItemWrite, OUTCOME } from './schema-shape.js'
3
3
 
4
4
  /**
5
- * The mock's state — accounts, one session, entities and their items.
5
+ * The mock's state — accounts, one session, Models with their sections, and
6
+ * entities whose content is items.
6
7
  *
7
- * ⭐ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
8
- * A mock's job is fidelity to what `@uniweb/api` *expects*, not to how a real store
9
- * is built. Reach for SQLite and the mock grows a schema, then migrations that
10
- * mirror someone else's, and it stops being a fixture and starts being a second
11
- * implementation nobody asked for — one that will drift and be believed anyway.
8
+ * ⭐ **It stores what the backend stores, the way the backend stores it** —
9
+ * measured 2026-09-18 against a real one set up as a site's `api` service:
12
10
  *
13
- * ⛔ **It is also not a model of `uniwebd`.** Nothing here is evidence about the
14
- * real backend. It answers what this client asks, in the shapes this client's own
15
- * tests assert, and where those shapes are guesses they are guesses here too —
16
- * see `../wire.js` § ASSUMPTIONS.
11
+ * - **An entity's content is items, each in a section of its Model.** There is no
12
+ * entity-level data. A `single` section holds one item; its `brief` section's item
13
+ * is the entity's `brief`, which the backend maintains — derived here on read.
14
+ * - **Ids are integers, entities are addressed by UUID.** A section, an item, an
15
+ * entity and an account each have an integer id; an entity's path id is its UUID.
16
+ * - **Every signed-in account reads every entity.** On a site's `api` service
17
+ * every member acts in the site's one unit, and a member of it may read what is
18
+ * in it — so members read each other's entities. Writing is for the owner and
19
+ * the operator.
20
+ * - **The operator** — the account that runs the site's service — holds
21
+ * `system_admin`, creates the Models only the operator may, and may write
22
+ * anything. A seeded account is the operator with `operator: true`.
17
23
  *
18
- * What it *does* enforce is the part a demo would otherwise fake: `creatable_by`
19
- * and `append_only` are checked server-side, so a foundation that hides a button
20
- * still cannot write. That is the difference between showing a permission model
21
- * and asserting one.
24
+ * ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
25
+ * A mock that grows a schema and migrations becomes a second implementation that
26
+ * drifts and is believed anyway.
27
+ *
28
+ * ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
29
+ * same read), nested sections, second factors, reference fields.
22
30
  */
23
31
 
24
- let counter = 0
25
- const nextId = (prefix) => `${prefix}-${(counter += 1)}`
26
- const now = () => new Date().toISOString()
32
+ /** The unit every account of a site acts in — `acting_unit_id` on `/auth/me`. */
33
+ export const SITE_UNIT = 1
34
+
35
+ /** The gap between neighbouring items' order numbers, as the backend spaces them. */
36
+ const GAP = 1_000_000
37
+
38
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
39
+ export const isUuid = (value) => typeof value === 'string' && UUID_RE.test(value)
40
+
41
+ const newUuid = () => globalThis.crypto.randomUUID()
42
+
43
+ /** A token that moves on every write — a timestamp with microseconds, as the backend writes one. */
44
+ function makeClock() {
45
+ let last = 0
46
+ return () => {
47
+ const micros = Math.max(Date.now() * 1000, last + 1)
48
+ last = micros
49
+ const frac = String(micros % 1_000_000).padStart(6, '0')
50
+ return new Date(Math.floor(micros / 1000)).toISOString().replace(/\.\d{3}Z$/, `.${frac}Z`)
51
+ }
52
+ }
53
+
54
+ /** A problem answer, as `{ problem }` — the router turns it into the response. */
55
+ export const refuse = (status, title, detail, extensions = {}) => ({
56
+ problem: { status, title, ...(detail ? { detail } : {}), ...extensions },
57
+ })
58
+
59
+ /** JSON with sorted keys — two values that differ only in key order are the same data. */
60
+ function canonical(value) {
61
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
62
+ if (value && typeof value === 'object') {
63
+ return `{${Object.keys(value)
64
+ .sort()
65
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
66
+ .join(',')}}`
67
+ }
68
+ return JSON.stringify(value ?? null)
69
+ }
27
70
 
28
- /** A token that changes on every write — the shape of the value does not matter, only that it moves. */
29
- const stamp = () => `${Date.now().toString(36)}-${(counter += 1).toString(36)}`
71
+ const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
72
+ const shortName = (model) => String(model).split('/').pop() || String(model)
30
73
 
31
74
  export class MockStore {
32
75
  /**
33
76
  * @param {object} seed
34
- * @param {object[]} [seed.accounts] - `{ username, password, handle, roles?, units? }`
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.
43
- * @param {object[]} [seed.entities] - `{ uuid?, model, data?, items? }`
77
+ * @param {object[]} [seed.accounts] - `{ username, password, email?, handle?, operator? }`.
78
+ * Seeded accounts are verified. (`units: [...]` non-empty, the older spelling,
79
+ * also marks the operator.)
80
+ * @param {object} [seed.schemas] - `{ '@scope/name': { creatable_by?, sections?, append_only?, migration_debt? } }`.
81
+ * `sections` is the LOWERED sections map (`{ [name]: { kind | multiple, brief, append_only, fields } }`)
82
+ * as the framework's normalizer produces it; with it, writes are shape-checked
83
+ * (`./schema-shape.js`). Without it the sections are inferred: a one-item brief
84
+ * section named for the Model, and a many-item section for every section name the
85
+ * seed's items and `append_only` use.
86
+ * @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
87
+ * `uuid` must be a UUID. (`data`, the older spelling, becomes the brief section's item.)
44
88
  * @param {object} [options]
45
89
  * @param {string} [options.signedInAs] - start with this account already signed in.
46
90
  */
47
91
  constructor(seed = {}, { signedInAs = null } = {}) {
48
- this.accounts = (seed.accounts || []).map((a) => ({
49
- uuid: a.uuid || nextId('acct'),
50
- username: a.username,
51
- password: a.password,
52
- handle: a.handle || a.username,
53
- roles: a.roles || ['member'],
54
- units: a.units || [],
55
- ...a,
56
- }))
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.
92
+ this.clock = makeClock()
93
+ this.ids = { account: 1, model: 0, section: 0, entity: 0, item: 0 }
94
+ this.accounts = []
95
+ for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
96
+
60
97
  this.schemas = seed.schemas || {}
98
+ this.models = new Map()
99
+ const itemSections = new Map()
100
+ for (const e of seed.entities || []) {
101
+ const names = itemSections.get(e.model) ?? new Set()
102
+ for (const item of e.items || []) if (item?.section) names.add(item.section)
103
+ itemSections.set(e.model, names)
104
+ }
105
+ for (const name of new Set([...Object.keys(this.schemas), ...itemSections.keys()])) {
106
+ this.defineModel(name, this.schemas[name], itemSections.get(name))
107
+ }
108
+
61
109
  /**
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.
110
+ * Shape findings the mock saw but did not refuse — writes into a section the
111
+ * seed lists as `migration_debt`. Empty is the good state.
67
112
  */
68
113
  this.diagnostics = []
114
+ /** Mail the backend would send: `{ to, subject, token, verify? }`, newest last. */
115
+ this.outbox = []
116
+ this.pending = { verify: new Map(), reset: new Map() }
69
117
  this.entities = new Map()
70
118
  for (const e of seed.entities || []) this.seedEntity(e)
119
+
71
120
  /** The one session. A mock serves one developer, so one is the honest number. */
72
121
  this.session = null
73
- this.resets = new Map()
74
-
75
- // ⭐ `signedInAs` — start already signed in, for a demo whose whole point is the
76
- // signed-in view. Without it a visitor must type credentials before seeing
77
- // anything, which for a lived-in demo is the experience itself.
78
- //
79
- // ⛔ THROWS on an unknown username rather than leaving the session null. A typo
80
- // here produces "why am I not logged in?" — a question with no visible cause, in
81
- // the one place where the answer is a string three lines away. The mock is
82
- // development-only, so failing at construction costs nothing and a silent
83
- // anonymous session costs an afternoon.
122
+
123
+ // ⛔ THROWS on an unknown username rather than leaving the session null: a typo
124
+ // here produces "why am I not logged in?", with the answer three lines away.
84
125
  if (signedInAs) {
85
126
  const account = this.accounts.find((a) => a.username === signedInAs)
86
127
  if (!account) {
87
128
  const known = this.accounts.map((a) => a.username).join(', ') || '(none)'
88
- throw new Error(
89
- `[uniweb/api mock] signedInAs: '${signedInAs}' is not a seeded account. Seeded: ${known}`
90
- )
129
+ throw new Error(`[uniweb/api mock] signedInAs: '${signedInAs}' is not a seeded account. Seeded: ${known}`)
91
130
  }
92
- this.session = { account, at: now() }
131
+ this.session = { account }
93
132
  }
94
133
  }
95
134
 
96
- seedEntity({ uuid, model, data = {}, items = [], owner = null }) {
97
- const id = uuid || nextId('ent')
98
- this.entities.set(id, {
99
- uuid: id,
100
- model,
101
- owner,
102
- data,
103
- updated_at: now(),
104
- items: items.map((item) => this.makeItem(item)),
105
- })
106
- return this.entities.get(id)
135
+ // ── Accounts ────────────────────────────────────────────────────────────────
136
+
137
+ addAccount(a, { verified }) {
138
+ const account = {
139
+ id: (this.ids.account += 1),
140
+ uuid: a.uuid || newUuid(),
141
+ username: a.username,
142
+ password: a.password,
143
+ email: a.email ?? `${a.username}@example.test`,
144
+ handle: a.handle ?? a.username,
145
+ operator: a.operator ?? (Array.isArray(a.units) && a.units.length > 0),
146
+ verified,
147
+ }
148
+ this.accounts.push(account)
149
+ return account
150
+ }
151
+
152
+ get account() {
153
+ return this.session?.account ?? null
107
154
  }
108
155
 
109
- makeItem({ section = 'items', data = {}, parent = null, id } = {}) {
156
+ /** The viewer, in the shape `/auth/me` answers. */
157
+ viewer(account = this.account) {
158
+ if (!account) return null
110
159
  return {
111
- [FIELD.item]: id || nextId('item'),
112
- section,
113
- [FIELD.parent]: parent,
114
- data,
115
- created_at: now(),
116
- [FIELD.token]: stamp(),
160
+ account: this.identity(account),
161
+ roles: account.operator ? [{ role: 'system_admin', scope_unit_id: null }] : [],
162
+ acting_unit_id: SITE_UNIT,
117
163
  }
118
164
  }
119
165
 
120
- // ── Identity ────────────────────────────────────────────────────────────────
166
+ identity(account) {
167
+ return { uuid: account.uuid, username: account.username, handle: account.handle }
168
+ }
121
169
 
122
- signIn(username, password) {
170
+ signIn({ username, password }) {
123
171
  const account = this.accounts.find((a) => a.username === username)
124
- if (!account || account.password !== password) return null
125
- this.session = { account, at: now() }
126
- return this.viewer()
172
+ if (!account || account.password !== password) return refuse(401, 'Unauthorized')
173
+ if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
174
+ this.session = { account }
175
+ const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
176
+ return { body: { token: `mock-${account.uuid}`, expires_at: expires, account: this.identity(account) } }
127
177
  }
128
178
 
129
179
  signOut() {
130
180
  this.session = null
131
181
  }
132
182
 
133
- register(fields) {
134
- if (this.accounts.some((a) => a.username === fields.username)) return null
135
- const account = {
136
- uuid: nextId('acct'),
137
- handle: fields.handle || fields.username,
138
- roles: ['member'],
139
- units: [],
140
- ...fields,
183
+ register({ username, email, password }) {
184
+ if (this.accounts.some((a) => a.username === username)) {
185
+ return refuse(409, 'Conflict', `account username already exists: ${JSON.stringify(username)}`)
141
186
  }
142
- this.accounts.push(account)
143
- return account
187
+ // A taken address answers exactly like a fresh one, so sign-up never confirms
188
+ // who has an account — and nothing is created.
189
+ if (!this.accounts.some((a) => a.email === email)) {
190
+ const account = this.addAccount({ username, email, password }, { verified: false })
191
+ const token = newUuid()
192
+ this.pending.verify.set(token, account)
193
+ this.outbox.push({ to: email, subject: 'Confirm your email address', token, verify: `/auth/verify?token=${token}` })
194
+ }
195
+ return { status: 202, body: { status: 'verification_required', email } }
144
196
  }
145
197
 
146
- /**
147
- * The viewer, in the shape `/auth/me` answers.
148
- *
149
- * ⚠️ `acting_unit_id` is the unit signal, and it is the field the CLIENT already
150
- * models (`viewer.actingUnitId`) — so a UI asks the package rather than inventing
151
- * its own idea of membership. A mock that omitted it would push every consumer to
152
- * invent one, which is how two apps end up disagreeing about who an organiser is.
153
- */
154
- viewer() {
155
- if (!this.session) return null
156
- const { uuid, username, handle, roles, units } = this.session.account
157
- return {
158
- account: { uuid, username, handle },
159
- roles,
160
- acting_unit_id: units?.length ? units[0] : null,
198
+ verify(token) {
199
+ const account = this.pending.verify.get(token)
200
+ if (!account) return refuse(400, 'Validation', 'invalid or expired verification token')
201
+ this.pending.verify.delete(token)
202
+ account.verified = true
203
+ return { body: { verified: true } }
204
+ }
205
+
206
+ requestReset(email) {
207
+ const account = this.accounts.find((a) => a.email === email && a.verified)
208
+ if (account) {
209
+ const token = newUuid()
210
+ this.pending.reset.set(token, account)
211
+ this.outbox.push({ to: email, subject: 'Reset your password', token })
161
212
  }
213
+ return { status: 202, body: { status: 'reset_requested' } }
162
214
  }
163
215
 
164
- get account() {
165
- return this.session?.account ?? null
216
+ confirmReset({ token, new_password: password }) {
217
+ const account = this.pending.reset.get(token)
218
+ if (!account) return refuse(400, 'Validation', 'invalid or expired reset token')
219
+ this.pending.reset.delete(token)
220
+ account.password = password
221
+ // A reset revokes the account's sessions.
222
+ if (this.session?.account === account) this.session = null
223
+ return { body: { reset: true } }
166
224
  }
167
225
 
168
- // ── The rules the mock actually enforces ────────────────────────────────────
226
+ // ── Models ──────────────────────────────────────────────────────────────────
227
+
228
+ defineModel(name, decl = {}, itemSections = new Set()) {
229
+ const appendOnly = (sectionName, section) =>
230
+ section?.append_only === true ||
231
+ decl?.append_only === true ||
232
+ (Array.isArray(decl?.append_only) && decl.append_only.includes(sectionName))
233
+ const model = {
234
+ id: (this.ids.model += 1),
235
+ uuid: newUuid(),
236
+ name,
237
+ creatable_by: decl?.creatable_by || 'any_user',
238
+ decl: decl || {},
239
+ sections: [],
240
+ }
241
+ const add = (sectionName, { kind, brief = false, section = null, debt = false }) =>
242
+ model.sections.push({
243
+ id: (this.ids.section += 1),
244
+ name: sectionName,
245
+ kind,
246
+ is_brief: brief,
247
+ append_only: appendOnly(sectionName, section),
248
+ fields: section?.fields || null,
249
+ debt,
250
+ })
251
+
252
+ const declared = decl?.sections && typeof decl.sections === 'object' ? decl.sections : null
253
+ if (declared) {
254
+ for (const [sectionName, section] of Object.entries(declared)) {
255
+ add(sectionName, { kind: isMulti(section) ? 'multi' : 'single', brief: !!section?.brief, section })
256
+ }
257
+ // Sections the site KNOWS diverge from the declaration, kept writable while
258
+ // they are unwound — and only the ones it named.
259
+ for (const sectionName of decl.migration_debt || []) {
260
+ if (!declared[sectionName]) add(sectionName, { kind: 'multi', debt: true })
261
+ }
262
+ } else {
263
+ const own = shortName(name)
264
+ add(own, { kind: 'single', brief: true })
265
+ const many = new Set([...itemSections, ...(Array.isArray(decl?.append_only) ? decl.append_only : [])])
266
+ for (const sectionName of many) if (sectionName !== own) add(sectionName, { kind: 'multi' })
267
+ }
268
+ this.models.set(name, model)
269
+ return model
270
+ }
271
+
272
+ model(name) {
273
+ return this.models.get(name) ?? null
274
+ }
275
+
276
+ /** The Model a section id belongs to, or null. */
277
+ modelOfSection(sectionId) {
278
+ for (const model of this.models.values()) if (model.sections.some((s) => s.id === sectionId)) return model
279
+ return null
280
+ }
169
281
 
170
- /**
171
- * May the viewer create entities of this Model?
172
- *
173
- * ⭐ The default is OPEN — anyone with an account — and only a schema's
174
- * `creatable_by` narrows it. That matches the real store, and it matters that the
175
- * mock copies the DIRECTION rather than inventing a safer one: a demo whose mock
176
- * denies by default would hide exactly the mistake `creatable_by` exists to
177
- * prevent, and someone would ship a Model that anyone can write to having
178
- * "tested" it here.
179
- */
180
282
  mayCreate(model) {
181
283
  if (!this.account) return false
182
- const rule = this.schemas[model]?.creatable_by || 'any_user'
183
- if (rule === 'any_user') return true
184
- if (rule === 'unit_members') return (this.account.units || []).length > 0
185
- return false
284
+ return model.creatable_by === 'any_user' || this.account.operator
186
285
  }
187
286
 
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({
287
+ /** The definition is readable for the Models the viewer may create, and by the operator. */
288
+ maySeeSchema(model) {
289
+ return !!this.account && (this.account.operator || model.creatable_by === 'any_user')
290
+ }
291
+
292
+ /** `GET /models/@scope/name`, in the backend's shape. */
293
+ schemaOf(model) {
294
+ const field = ([key, f]) => ({
211
295
  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(),
296
+ required: !!f?.required,
297
+ multi: f?.multiple === true || f?.type === 'array',
298
+ type: { id: -1, name: f?.type ?? 'json', kind: f?.type ?? 'json', data: { key, kind: f?.type ?? 'json' } },
221
299
  })
300
+ return {
301
+ model: {
302
+ id: model.id,
303
+ uuid: model.uuid,
304
+ name: model.name,
305
+ data: { label: model.decl?.label ?? shortName(model.name), grantable: true },
306
+ version: 1,
307
+ owner_id: this.operator()?.id ?? null,
308
+ unit_id: null,
309
+ owned: true,
310
+ owner_cardinality: 'unbounded',
311
+ unit_cardinality: 'unbounded',
312
+ role: 'content',
313
+ },
314
+ sections: model.sections.map((s) => ({
315
+ id: s.id,
316
+ model_id: model.id,
317
+ name: s.name,
318
+ kind: s.kind,
319
+ is_brief: s.is_brief,
320
+ parent_section_id: null,
321
+ fields: Object.entries(s.fields || {}).map(field),
322
+ other_data: s.append_only ? { append_only: true } : {},
323
+ constraints: {},
324
+ })),
325
+ }
326
+ }
327
+
328
+ operator() {
329
+ return this.accounts.find((a) => a.operator) ?? null
222
330
  }
223
331
 
224
- /** Is this section insert-only? Existing items may not be edited or removed. */
225
- isAppendOnly(model, section) {
226
- const decl = this.schemas[model]?.append_only
227
- if (decl === true) return true
228
- return Array.isArray(decl) ? decl.includes(section) : false
332
+ // ── Entities ────────────────────────────────────────────────────────────────
333
+
334
+ seedEntity({ uuid, model: modelName, owner = null, items = [], data }) {
335
+ if (uuid != null && !isUuid(uuid)) {
336
+ throw new Error(
337
+ `[uniweb/api mock] seed entity uuid '${uuid}' is not a UUID — the backend addresses entities by UUID, so an id like this one would be a 400 there`,
338
+ )
339
+ }
340
+ const model = this.model(modelName) ?? this.defineModel(modelName)
341
+ const who = (owner && this.accounts.find((a) => a.username === owner)) || this.operator() || this.accounts[0] || null
342
+ const entity = this.newEntity(model, who)
343
+ if (uuid) entity.uuid = uuid
344
+ const brief = model.sections.find((s) => s.is_brief)
345
+ if (data && brief && Object.keys(data).length) this.addItem(entity, brief, { data })
346
+ for (const item of items) {
347
+ const section = model.sections.find((s) => s.name === item.section)
348
+ if (!section) throw new Error(`[uniweb/api mock] seed: ${modelName} has no section '${item.section}'`)
349
+ this.addItem(entity, section, { data: item.data ?? {}, parent: item.parent ?? null })
350
+ }
351
+ this.entities.set(entity.uuid, entity)
352
+ return entity
229
353
  }
230
354
 
231
- // ── Reads ───────────────────────────────────────────────────────────────────
355
+ newEntity(model, owner) {
356
+ const at = this.clock()
357
+ return {
358
+ id: (this.ids.entity += 1),
359
+ uuid: newUuid(),
360
+ model: model.name,
361
+ owner_id: owner?.id ?? null,
362
+ created_by: owner?.id ?? null,
363
+ created_at: at,
364
+ updated_at: at,
365
+ items: [],
366
+ }
367
+ }
232
368
 
233
- list({ model, limit, offset, all }) {
234
- // Scoped by the session the way the real route is: what the viewer may see.
235
- // A mock that returned everything would make an entitlement bug invisible.
236
- const rows = [...this.entities.values()].filter(
237
- (e) => e.model === model && (e.owner === null || e.owner === this.account?.uuid),
238
- )
239
- const matched = rows.length
240
- const page = all ? rows : rows.slice(offset || 0, (offset || 0) + (limit ?? rows.length))
241
- return { entities: page.map((e) => this.hydrate(e)), matched }
369
+ addItem(entity, section, { data, parent = null, position = null }) {
370
+ const at = this.clock()
371
+ const item = {
372
+ id: (this.ids.item += 1),
373
+ uuid: newUuid(),
374
+ section_id: section.id,
375
+ parent_item_id: parent,
376
+ data,
377
+ order_number: this.orderFor(entity, section.id, parent, position, null),
378
+ created_at: at,
379
+ updated_at: at,
380
+ }
381
+ entity.items.push(item)
382
+ entity.updated_at = at
383
+ return item
242
384
  }
243
385
 
244
- read(uuid) {
245
- const entity = this.entities.get(uuid)
246
- if (!entity) return null
247
- if (entity.owner && entity.owner !== this.account?.uuid) return null
248
- return this.hydrate(entity)
386
+ /** The order number a position asks for, among an item's siblings. `null` ⇒ the item was not found. */
387
+ orderFor(entity, sectionId, parent, position, excludeId) {
388
+ const siblings = entity.items
389
+ .filter((i) => i.section_id === sectionId && (i.parent_item_id ?? null) === (parent ?? null) && i.id !== excludeId)
390
+ .sort((a, b) => a.order_number - b.order_number)
391
+ if (!siblings.length) return GAP
392
+ if (position === 'first') return siblings[0].order_number - GAP
393
+ if (position && typeof position === 'object' && position.after != null) {
394
+ const at = siblings.findIndex((i) => i.id === position.after)
395
+ if (at < 0) return null
396
+ const next = siblings[at + 1]
397
+ if (!next) return siblings[at].order_number + GAP
398
+ const mid = Math.floor((siblings[at].order_number + next.order_number) / 2)
399
+ if (mid > siblings[at].order_number) return mid
400
+ // The gap is used up: respace the level and place again.
401
+ siblings.forEach((s, i) => {
402
+ s.order_number = (i + 1) * GAP
403
+ })
404
+ return this.orderFor(entity, sectionId, parent, position, excludeId)
405
+ }
406
+ return siblings[siblings.length - 1].order_number + GAP
407
+ }
408
+
409
+ mayEdit(entity) {
410
+ const me = this.account
411
+ return !!me && (me.operator || entity.owner_id === me.id)
412
+ }
413
+
414
+ /** Which branch lets the viewer read a row, as the backend names it. */
415
+ via(entity) {
416
+ const me = this.account
417
+ if (me?.operator) return 'rbac'
418
+ return entity.owner_id === me?.id ? 'owner' : 'unit_member'
249
419
  }
250
420
 
251
- hydrate(entity) {
421
+ /** The entity's summary: its brief section's item, projected like any read. */
422
+ brief(entity, locales) {
423
+ const model = this.model(entity.model)
424
+ const section = model?.sections.find((s) => s.is_brief)
425
+ const item = section && entity.items.find((i) => i.section_id === section.id)
426
+ return item ? this.project(item.data, section, locales) : {}
427
+ }
428
+
429
+ /**
430
+ * A localized field — declared `localized: true` — answered in the first listed
431
+ * locale it has, and omitted when it has none: the backend has no fallback of
432
+ * its own. Without a locale, the whole `{ locale: value }` map.
433
+ */
434
+ project(data, section, locales) {
435
+ if (!locales?.length || !section?.fields || !data || typeof data !== 'object') return data
436
+ const out = { ...data }
437
+ for (const [key, field] of Object.entries(section.fields)) {
438
+ if (!field?.localized || !out[key] || typeof out[key] !== 'object') continue
439
+ const hit = locales.find((l) => out[key][l] != null)
440
+ if (hit) out[key] = out[key][hit]
441
+ else delete out[key]
442
+ }
443
+ return out
444
+ }
445
+
446
+ /** The entity row without its Model's identity — as it sits inside a write's answer. */
447
+ core(entity, locales) {
448
+ const model = this.model(entity.model)
252
449
  return {
450
+ id: entity.id,
253
451
  uuid: entity.uuid,
254
- model: entity.model,
255
- ...entity.data,
256
- items: entity.items.map((i) => ({ ...i })),
452
+ model_id: model.id,
453
+ owner_id: entity.owner_id,
454
+ unit_id: SITE_UNIT,
455
+ sort_date: null,
456
+ brief: this.brief(entity, locales),
457
+ disabled: false,
458
+ created_by: entity.created_by,
459
+ created_at: entity.created_at,
460
+ updated_at: entity.updated_at,
461
+ }
462
+ }
463
+
464
+ /** A list row, a create's answer: the Model's identity, then the row. */
465
+ row(entity, { via = false, locales } = {}) {
466
+ const model = this.model(entity.model)
467
+ return {
468
+ model_uuid: model.uuid,
469
+ model_name: model.name,
470
+ ...(via ? { via: this.via(entity) } : {}),
471
+ ...this.core(entity, locales),
472
+ }
473
+ }
474
+
475
+ /** The single-entity read. */
476
+ read(entity, { locales, withItems = true, canEdit = true } = {}) {
477
+ const model = this.model(entity.model)
478
+ const sectionOf = (id) => model.sections.find((s) => s.id === id)
479
+ const order = new Map(model.sections.map((s, i) => [s.id, i]))
480
+ const items = withItems
481
+ ? [...entity.items]
482
+ .sort(
483
+ (a, b) =>
484
+ (order.get(a.section_id) ?? 0) - (order.get(b.section_id) ?? 0) ||
485
+ a.order_number - b.order_number ||
486
+ a.id - b.id,
487
+ )
488
+ .map((i) => ({
489
+ id: i.id,
490
+ section_id: i.section_id,
491
+ parent_item_id: i.parent_item_id,
492
+ data: this.project(i.data, sectionOf(i.section_id), locales),
493
+ item_date: null,
494
+ order_number: i.order_number,
495
+ updated_at: i.updated_at,
496
+ }))
497
+ : []
498
+ return {
499
+ model_uuid: model.uuid,
500
+ model_name: model.name,
501
+ ...(canEdit ? { can_edit: this.mayEdit(entity) } : {}),
502
+ hydrated: { entity: this.core(entity, locales), items },
257
503
  }
258
504
  }
259
505
 
260
- // ── Writes ──────────────────────────────────────────────────────────────────
506
+ list(model, { scope, limit, offset, paginate, locales }) {
507
+ const me = this.account
508
+ const rows = [...this.entities.values()]
509
+ .filter((e) => e.model === model.name && (scope !== 'mine' || e.owner_id === me?.id))
510
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : b.id - a.id))
511
+ const page = paginate ? rows.slice(offset, offset + limit) : rows
512
+ // `matched` counts the rows in THIS answer — the page, when paging.
513
+ return { entities: page.map((e) => this.row(e, { via: true, locales })), matched: page.length }
514
+ }
261
515
 
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)
267
- const entity = this.seedEntity({ model, data, owner: this.account?.uuid ?? null })
268
- return this.hydrate(entity)
516
+ create(model, items) {
517
+ const entity = this.newEntity(model, this.account)
518
+ const staged = { ...entity, items: [] }
519
+ for (const item of items) {
520
+ const section = model.sections.find((s) => s.name === item.section)
521
+ if (!section) return refuse(404, 'Not Found', `section ${model.id}:${item.section} not found`, { kind: 'section', key: `${model.id}:${item.section}` })
522
+ const refusal = this.admit(staged, model, section, item.data, null)
523
+ if (refusal) return refusal
524
+ this.addItem(staged, section, { data: item.data, parent: item.parent_item_id ?? null })
525
+ }
526
+ Object.assign(entity, { items: staged.items, updated_at: staged.updated_at })
527
+ this.entities.set(entity.uuid, entity)
528
+ return { status: 201, body: this.row(entity) }
269
529
  }
270
530
 
271
- remove(uuid) {
272
- return this.entities.delete(uuid)
531
+ remove(entity) {
532
+ this.entities.delete(entity.uuid)
273
533
  }
274
534
 
535
+ // ── Item writes ─────────────────────────────────────────────────────────────
536
+
275
537
  /**
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.
538
+ * May this data go into this section? The two refusals a create meets before it
539
+ * lands: a one-item section that already has its item, and content that does not
540
+ * fit the section's declared fields.
285
541
  */
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 })
542
+ admit(entity, model, section, data, itemId) {
543
+ if (itemId == null && section.kind === 'single' && entity.items.some((i) => i.section_id === section.id)) {
544
+ return refuse(
545
+ 409,
546
+ PROBLEM.schemaRule,
547
+ `section ${section.id} (kind=single) accepts at most one item — create it once, then update it`,
548
+ )
549
+ }
550
+ return this.shapeGuard(model, section, data, itemId)
551
+ }
290
552
 
553
+ /**
554
+ * Shape-check a write against the seed's declaration, when it gave one. A
555
+ * violation is the backend's `400 Validation` naming the field; a write into a
556
+ * section on the seed's `migration_debt` list is recorded, not refused.
557
+ */
558
+ shapeGuard(model, section, data, itemId) {
559
+ if (!model.decl?.sections) return null
560
+ const check = checkItemWrite({ decl: model.decl, section: section.name, data })
291
561
  if (check.outcome === OUTCOME.VIOLATES) {
292
562
  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
- }
563
+ return refuse(400, 'Validation', `item.data.${first.field}: ${first.detail}`, { field: `data.${first.field}` })
303
564
  }
304
565
  if (check.outcome === OUTCOME.DIAGNOSED) {
305
- this.diagnose(model, { op: itemId == null ? 'create-item' : 'update-item', section }, check)
566
+ this.diagnose(model.name, { op: itemId == null ? 'create-item' : 'update-item', section: section.name }, check)
306
567
  }
307
568
  return null
308
569
  }
309
570
 
310
- /**
311
- * Apply one op. Returns `{ ok, result }` or `{ ok: false, problem }` — the caller
312
- * turns a problem into the response, so a batch can stop at the first one and
313
- * report which op failed.
314
- */
315
- applyOp(entity, op) {
316
- const kind = op?.kind
317
- const itemId = op?.[FIELD.item]
318
- const item = itemId != null ? entity.items.find((i) => String(i[FIELD.item]) === String(itemId)) : null
319
-
320
- if (kind !== OP.create) {
321
- if (!item) {
322
- return { ok: false, problem: { status: 404, title: 'NotFound', kind: 'item', [FIELD.item]: itemId } }
323
- }
324
- // Append-only guards EDIT and DELETE. Not `move`: `created_at` is the
325
- // chronology and a reader orders by it, so repositioning loses no truth.
326
- if (kind !== OP.move && this.isAppendOnly(entity.model, item.section)) {
327
- return {
328
- ok: false,
329
- problem: { status: 409, title: 'AppendOnly', detail: `items of '${item.section}' may be added but not changed`, [FIELD.item]: itemId },
330
- }
331
- }
332
- const expected = op?.[FIELD.precondition]
333
- if (expected != null && expected !== item[FIELD.token]) {
334
- return {
335
- ok: false,
336
- problem: { status: 409, title: 'Conflict', [FIELD.item]: itemId, [FIELD.conflictToken]: item[FIELD.token] },
337
- }
338
- }
571
+ /** Record a write the mock let through on the seed's say-so (`migration_debt`), deduped with a count. */
572
+ diagnose(model, where, result) {
573
+ const key = `${model}|${where.op}|${where.section ?? ''}|${result.reason}`
574
+ const at = new Date().toISOString()
575
+ const seen = this.diagnostics.find((d) => d.key === key)
576
+ if (seen) {
577
+ seen.count += 1
578
+ seen.lastAt = at
579
+ return
339
580
  }
581
+ this.diagnostics.push({
582
+ key,
583
+ model,
584
+ ...where,
585
+ storage: result.storage,
586
+ reason: result.reason,
587
+ problems: result.problems,
588
+ undeclared: result.undeclared,
589
+ count: 1,
590
+ firstAt: at,
591
+ lastAt: at,
592
+ })
593
+ }
340
594
 
341
- if (kind === OP.create) {
342
- // ⛔ No default. A create with no section is a client bug, and defaulting it
343
- // would place the item outside the rules its author declared — silently.
344
- if (!op[FIELD.section]) {
345
- return { ok: false, problem: { status: 400, title: 'Validation', detail: 'create needs a section' } }
595
+ /** Apply one parsed op to an entity. `{ result }` or `{ problem }`. */
596
+ applyOp(entity, model, op) {
597
+ if (op.kind === OP.create) {
598
+ const section = model.sections.find((s) => s.id === op[FIELD.section])
599
+ if (!section) {
600
+ const other = this.modelOfSection(op[FIELD.section])
601
+ return other
602
+ ? refuse(409, PROBLEM.schemaRule, `section ${op[FIELD.section]} belongs to model ${other.id}, not the entity's model ${model.id}`)
603
+ : refuse(404, 'Not Found', `section ${op[FIELD.section]} not found`, { kind: 'section', key: String(op[FIELD.section]) })
346
604
  }
347
- const refusal = this.shapeGuard(entity.model, op[FIELD.section], op.data, null)
348
- if (refusal) return refusal
349
- const made = this.makeItem({ section: op[FIELD.section], data: op.data, parent: op[FIELD.parent] ?? null })
350
- this.place(entity, made, op.position)
351
- return { ok: true, result: { [FIELD.item]: made[FIELD.item], [FIELD.token]: made[FIELD.token] } }
352
- }
353
- if (kind === OP.update) {
354
- const refusal = this.shapeGuard(entity.model, item.section, op.data, itemId)
605
+ const parent = op[FIELD.parent] ?? null
606
+ if (parent != null && !entity.items.some((i) => i.id === parent)) {
607
+ return refuse(409, PROBLEM.schemaRule, `parent item ${parent} is not an item of this entity`)
608
+ }
609
+ const refusal = this.admit(entity, model, section, op.data, null)
355
610
  if (refusal) return refusal
356
- // Whole-data replace, like the real write: round-trip what you do not edit.
357
- item.data = op.data ?? {}
358
- item[FIELD.token] = stamp()
359
- return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: item[FIELD.token] } }
611
+ if (op.position != null && this.orderFor(entity, section.id, parent, op.position, null) == null) {
612
+ return refuse(404, 'Not Found', `item ${op.position.after} not found`, { kind: 'item', key: String(op.position.after) })
613
+ }
614
+ const made = this.addItem(entity, section, { data: op.data, parent, position: op.position })
615
+ return { result: { [FIELD.item]: made.id, [FIELD.itemUuid]: made.uuid, [FIELD.token]: made.updated_at } }
360
616
  }
361
- if (kind === OP.delete) {
362
- entity.items = entity.items.filter((i) => i !== item)
363
- // A null token is how a delete reports itself, so a ledger forgets the item.
364
- return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: null } }
617
+
618
+ const item = entity.items.find((i) => i.id === op[FIELD.item])
619
+ if (!item) return refuse(404, 'Not Found', `item ${op[FIELD.item]} not found`, { kind: 'item', key: String(op[FIELD.item]) })
620
+ const expected = op[FIELD.precondition]
621
+ if (expected != null && expected !== item.updated_at) {
622
+ return refuse(409, 'Conflict', 'item changed since your last read — refetch and retry', {
623
+ [FIELD.conflictToken]: item.updated_at,
624
+ })
365
625
  }
366
- if (kind === OP.move) {
367
- entity.items = entity.items.filter((i) => i !== item)
368
- this.place(entity, item, op.position)
369
- item[FIELD.token] = stamp()
370
- return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: item[FIELD.token] } }
626
+ const section = model.sections.find((s) => s.id === item.section_id)
627
+
628
+ if (op.kind === OP.move) {
629
+ const parent = op[FIELD.parent] ?? null
630
+ const order = this.orderFor(entity, item.section_id, parent, op.position, item.id)
631
+ if (order == null) return refuse(404, 'Not Found', `item ${op.position.after} not found`, { kind: 'item', key: String(op.position.after) })
632
+ item.parent_item_id = parent
633
+ item.order_number = order
634
+ item.updated_at = this.clock()
635
+ entity.updated_at = item.updated_at
636
+ return { result: { [FIELD.item]: item.id, [FIELD.itemUuid]: null, [FIELD.token]: item.updated_at } }
371
637
  }
372
- return { ok: false, problem: { status: 400, title: 'Validation', detail: `unknown op kind '${kind}'` } }
373
- }
374
638
 
375
- /** Ordering is the server's: `'first' | 'last' | { after }`, never a number from the client. */
376
- place(entity, item, position) {
377
- if (position === 'first') {
378
- entity.items.unshift(item)
379
- return
639
+ // An insert-only section: its items can be added and moved, never edited or deleted.
640
+ if (section?.append_only) {
641
+ return refuse(409, PROBLEM.appendOnly, `section \`${section.name}\` is insert-only: existing items cannot be edited or deleted`, {
642
+ section: section.name,
643
+ })
380
644
  }
381
- if (position && typeof position === 'object' && position.after != null) {
382
- const at = entity.items.findIndex((i) => String(i[FIELD.item]) === String(position.after))
383
- if (at >= 0) {
384
- entity.items.splice(at + 1, 0, item)
385
- return
645
+ if (op.kind === OP.update) {
646
+ const refusal = this.shapeGuard(model, section, op.data, item.id)
647
+ if (refusal) return refusal
648
+ // Whole-data replace, like the backend: round-trip what you do not edit. And
649
+ // like the backend, an update that changes nothing is a no-op — the token stays.
650
+ if (canonical(item.data) !== canonical(op.data)) {
651
+ item.data = op.data
652
+ item.updated_at = this.clock()
653
+ entity.updated_at = item.updated_at
386
654
  }
655
+ return { result: { [FIELD.item]: item.id, [FIELD.itemUuid]: null, [FIELD.token]: item.updated_at } }
387
656
  }
388
- entity.items.push(item)
657
+ // delete — the item and anything nested under it.
658
+ const gone = new Set([item.id])
659
+ let grew = true
660
+ while (grew) {
661
+ grew = false
662
+ for (const i of entity.items) {
663
+ if (!gone.has(i.id) && gone.has(i.parent_item_id)) {
664
+ gone.add(i.id)
665
+ grew = true
666
+ }
667
+ }
668
+ }
669
+ entity.items = entity.items.filter((i) => !gone.has(i.id))
670
+ entity.updated_at = this.clock()
671
+ // A delete names no item — the row is gone.
672
+ return { result: { [FIELD.item]: null, [FIELD.itemUuid]: null, [FIELD.token]: null } }
389
673
  }
390
674
 
391
675
  /** A batch is all-or-nothing: apply to a copy, and keep it only if every op lands. */
392
- applyOps(entity, ops) {
393
- const snapshot = entity.items.map((i) => ({ ...i }))
676
+ applyOps(entity, model, ops) {
677
+ const working = {
678
+ ...entity,
679
+ items: entity.items.map((i) => ({ ...i, data: i.data })),
680
+ }
394
681
  const results = []
395
682
  for (const op of ops) {
396
- const outcome = this.applyOp(entity, op)
397
- if (!outcome.ok) {
398
- entity.items = snapshot
399
- return { ok: false, problem: outcome.problem }
400
- }
683
+ const outcome = this.applyOp(working, model, op)
684
+ if (outcome.problem) return outcome
401
685
  results.push(outcome.result)
402
686
  }
403
- entity.updated_at = now()
404
- return { ok: true, results }
687
+ entity.items = working.items
688
+ entity.updated_at = working.updated_at
689
+ return { results }
405
690
  }
406
691
  }