@uniweb/api 0.3.7 → 0.4.1

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