@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/README.md +96 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +2 -2
- package/src/client.js +237 -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 +18 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +330 -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 +580 -295
- package/src/wire.js +312 -139
package/src/mock/store.js
CHANGED
|
@@ -1,406 +1,691 @@
|
|
|
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
|
+
* - **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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @param {object[]} [seed.entities] - `{ uuid?, model,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
131
|
+
this.session = { account }
|
|
93
132
|
}
|
|
94
133
|
}
|
|
95
134
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
156
|
+
/** The viewer, in the shape `/auth/me` answers. */
|
|
157
|
+
viewer(account = this.account) {
|
|
158
|
+
if (!account) return null
|
|
110
159
|
return {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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(
|
|
272
|
-
|
|
531
|
+
remove(entity) {
|
|
532
|
+
this.entities.delete(entity.uuid)
|
|
273
533
|
}
|
|
274
534
|
|
|
535
|
+
// ── Item writes ─────────────────────────────────────────────────────────────
|
|
536
|
+
|
|
275
537
|
/**
|
|
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.
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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 (
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
397
|
-
if (
|
|
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.
|
|
404
|
-
|
|
687
|
+
entity.items = working.items
|
|
688
|
+
entity.updated_at = working.updated_at
|
|
689
|
+
return { results }
|
|
405
690
|
}
|
|
406
691
|
}
|