@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/README.md +99 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +1 -1
- package/src/client.js +239 -84
- package/src/errors.js +39 -12
- package/src/hooks/useEntity.js +9 -4
- package/src/hooks/useEntityWriter.js +42 -26
- package/src/hooks/useRecords.js +19 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +333 -116
- package/src/mock/node.js +6 -2
- package/src/mock/schema-shape.js +8 -0
- package/src/mock/seed.js +39 -22
- package/src/mock/store.js +604 -295
- package/src/wire.js +327 -226
package/src/mock/store.js
CHANGED
|
@@ -1,406 +1,715 @@
|
|
|
1
|
-
import { FIELD, OP } from '../wire.js'
|
|
2
|
-
import { checkItemWrite,
|
|
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,
|
|
5
|
+
* The mock's state — accounts, one session, Models with their sections, and
|
|
6
|
+
* entities whose content is items.
|
|
6
7
|
*
|
|
7
|
-
* ⭐ **
|
|
8
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @param {object[]} [seed.entities] - `{ uuid?, model,
|
|
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.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
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
|
|
141
|
+
this.session = { account }
|
|
93
142
|
}
|
|
94
143
|
}
|
|
95
144
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
134
|
-
if (this.accounts.some((a) => a.username ===
|
|
135
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
// ──
|
|
342
|
+
// ── Entities ────────────────────────────────────────────────────────────────
|
|
232
343
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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 (
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
397
|
-
if (
|
|
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.
|
|
404
|
-
|
|
711
|
+
entity.items = working.items
|
|
712
|
+
entity.updated_at = working.updated_at
|
|
713
|
+
return { results }
|
|
405
714
|
}
|
|
406
715
|
}
|