@uniweb/api 0.3.7 → 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 +1 -1
- 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 +314 -226
package/src/wire.js
CHANGED
|
@@ -3,108 +3,85 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ## Why this module exists
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ⛔ **The risk in building ahead is not guessing a shape — that is cheap to fix.
|
|
13
|
-
* It is guessing a shape, spreading it across a dozen files, and writing it into a
|
|
14
|
-
* doc as fact.** A wrong assumption that lives in one module is a one-file diff. The
|
|
15
|
-
* same assumption inlined into six hooks is an archaeology exercise, and by then
|
|
16
|
-
* something will be citing it as though it were measured.
|
|
17
|
-
*
|
|
18
|
-
* ⇒ **So every route, parameter name and response field this package depends on is
|
|
19
|
-
* declared here, with where it came from.** Three provenances, and the difference
|
|
20
|
-
* between them is the whole point:
|
|
6
|
+
* A wrong assumption that lives in one module is a one-file diff. The same
|
|
7
|
+
* assumption inlined into six hooks is an archaeology exercise, and by then
|
|
8
|
+
* something will be citing it as though it were measured. ⇒ **Every route,
|
|
9
|
+
* parameter, body key and response field this package depends on is declared
|
|
10
|
+
* here, with where it came from.** Three provenances, and the difference between
|
|
11
|
+
* them is the whole point:
|
|
21
12
|
*
|
|
22
13
|
* | | means |
|
|
23
14
|
* |---|---|
|
|
24
15
|
* | **RULED** | a person with the authority decided it. Not a measurement — a decision |
|
|
25
|
-
* | **MEASURED** | observed in a
|
|
16
|
+
* | **MEASURED** | observed in a real backend's responses, or read in its source |
|
|
26
17
|
* | **ASSUMED** | ⛔ **we are building ahead. Nobody has confirmed this.** |
|
|
27
18
|
*
|
|
28
|
-
* ⚠️ **`MEASURED` is not `MEASURED HERE`.** A shape read off a *different route*
|
|
29
|
-
* the same
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
50
|
-
* therefore no route that updates one. Everything an author writes is an item in a
|
|
51
|
-
* section.
|
|
52
|
-
*
|
|
53
|
-
* - **A `single` section — `brief: true` included — holds an ORDINARY ITEM**, with
|
|
54
|
-
* its own `item_id`, updated through the same item route as any other. It takes
|
|
55
|
-
* exactly ONE item: a second `create` into it is refused, so the shape is
|
|
56
|
-
* create-once-then-update.
|
|
57
|
-
* - **Never send `brief`.** The server rebuilds `brief` and `sort_date` after a
|
|
58
|
-
* write to the brief section. It is output, not input.
|
|
59
|
-
* - ⚠️ **Two spellings for one flag:** `brief: true` is how a model *file* is
|
|
60
|
-
* written; a schema *read* returns **`is_brief`**.
|
|
61
|
-
* - **Creating with content puts items under `items`, addressed BY NAME:**
|
|
62
|
-
* `POST /entities?model=…` · `{ items: [ { section: 'profile', data: {…} } ] }`.
|
|
63
|
-
* Entity and items commit in one transaction. A nested section uses a
|
|
64
|
-
* parent/child path.
|
|
65
|
-
* - **After creation, item ops address a section by NUMERIC `section_id`** — not by
|
|
66
|
-
* name (see `FIELD.section`).
|
|
67
|
-
* - Site content, folder and deployment entities answer **409** on the item route;
|
|
68
|
-
* they are written through `/api/sites/…` and `/api/folders/…`. That is the same
|
|
69
|
-
* boundary the RULED lane below draws, enforced from the other side.
|
|
70
|
-
* - A batch runs in one transaction, but **an op may not reference an item created
|
|
71
|
-
* earlier in the same batch.**
|
|
72
|
-
*
|
|
73
|
-
* ⛔ **WHAT THIS PACKAGE GETS WRONG TODAY — do not read the code as the contract.**
|
|
74
|
-
* `createEntity({ schema, data })` sends content as a **top-level `data` key**, which
|
|
75
|
-
* the create route does not accept. The create body **does not reject unknown keys**,
|
|
76
|
-
* so that payload is *silently dropped*: **201, and an empty entity, with no error.**
|
|
77
|
-
* Our own mock accepts the same shape, so the fiction is symmetrical and nothing in
|
|
78
|
-
* this repo currently fails because of it. Correcting the client is a separate change
|
|
79
|
-
* (it touches behaviour); this module's job is to make sure the contract is written
|
|
80
|
-
* down before that happens.
|
|
81
|
-
*
|
|
82
|
-
* ⚠️ **Provenance of the above: MEASURED BY BACKEND, reading backend's own source —
|
|
83
|
-
* not observed on the wire by us, and the ignored-`data` behaviour was explicitly
|
|
84
|
-
* described as read-not-tested.** That is stronger than anything else we have on this
|
|
85
|
-
* lane and weaker than a response we have held in our hands. It earns MEASURED under
|
|
86
|
-
* the table above ("or in backend's own source"); the untested corner is flagged
|
|
87
|
-
* where it matters rather than promoted.
|
|
19
|
+
* ⚠️ **`MEASURED` is not `MEASURED HERE`.** A shape read off a *different route*
|
|
20
|
+
* of the same backend is `ASSUMED` for ours, however identical it looks.
|
|
21
|
+
*
|
|
22
|
+
* ## ⭐ MEASURED 2026-09-18 — every shape below, in literal responses
|
|
23
|
+
*
|
|
24
|
+
* The whole surface this package touches was exercised against a real backend set
|
|
25
|
+
* up the way a site's `api` service is: one operator account, members who sign up
|
|
26
|
+
* themselves, and the site's own Models. Where this module quotes a shape it was
|
|
27
|
+
* copied from a response, not described. That pass corrected the package in these
|
|
28
|
+
* places, each now fixed and pinned by a test:
|
|
29
|
+
*
|
|
30
|
+
* | was | is |
|
|
31
|
+
* |---|---|
|
|
32
|
+
* | requests went to `${base}/api/<route>` | the base IS the API route space: `${base}/<route>` — on a hosted site `/_api/entities` reaches the backend's `/api/entities`, and `/_api/api/…` is a 404 |
|
|
33
|
+
* | `createEntity` sent its content as the body | content is items, by section: `{ items: [{ section, data }] }`. Unknown top-level keys are ignored — silently, `201` and an empty entity |
|
|
34
|
+
* | an item create sent `section: '<name>'` | the item route takes the numeric `section_id` (a `400` otherwise); names are resolved from the Model's definition |
|
|
35
|
+
* | `matched` was read as the total before paging | it counts the rows in THIS answer — the page when paging. A full page is the only "maybe more" |
|
|
36
|
+
* | a read's items did not seed the concurrency ledger | they carry `updated_at`, the token the first write of an item is guarded by |
|
|
37
|
+
* | every `409` was reported as an edit conflict | only the one carrying `current_updated_at` is; the rest are rules (insert-only, one-item section, a reference) |
|
|
38
|
+
* | a `409` was rebased onto "the first op with an item id" | the stale `409` names no item — a batch cannot be rebased by guesswork |
|
|
39
|
+
* | a sign-up answered with the account | it answers `202 { status: 'verification_required', email }`; the account cannot sign in until verified (`403 Email Not Verified`) |
|
|
40
|
+
* | `acting_unit_id` was a membership signal | every signed-in member of a site acts in the same unit — it cannot tell an operator from a member; `roles` can |
|
|
88
41
|
*
|
|
89
42
|
* @module @uniweb/api/wire
|
|
90
43
|
*/
|
|
91
44
|
|
|
92
45
|
/**
|
|
93
|
-
* The lane. ⭐ **RULED** *(
|
|
94
|
-
* **entities**, and touches nothing under
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* app's job, not a foundation's, and on a site's own service-provider backend they
|
|
98
|
-
* have nothing to address anyway: no site of that id lives in that database. *"Our
|
|
99
|
-
* recursion ends there, right before creating sites."*
|
|
46
|
+
* The lane. ⭐ **RULED** *(2026-09-01)*: this package reads and writes
|
|
47
|
+
* **entities**, reads the definitions of their Models, and touches nothing under
|
|
48
|
+
* `/sites/*`. Those routes create sites, which is the app's job, not a
|
|
49
|
+
* foundation's, and a site's own `api` service has no site to address.
|
|
100
50
|
*
|
|
101
|
-
* ⛔ Do not add a route
|
|
51
|
+
* ⛔ Do not add a route that is not under `/entities`, `/models` or `/auth`.
|
|
102
52
|
*/
|
|
103
53
|
export const ENTITIES = '/entities'
|
|
104
54
|
|
|
55
|
+
/** The Models lane — read-only, one route: a Model's definition. MEASURED. */
|
|
56
|
+
export const MODELS = '/models'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The base, and how a route joins it. **MEASURED.**
|
|
60
|
+
*
|
|
61
|
+
* The base the site is handed for its `api` service is the address of the
|
|
62
|
+
* backend's API route space, and every route here is relative to it:
|
|
63
|
+
* `${base}/auth/me`, `${base}/entities`. On a hosted site that is `/_api` on the
|
|
64
|
+
* site's own origin, which reaches the backend's `/api/…`; wherever a page reaches
|
|
65
|
+
* a backend directly it is that backend's absolute `…/api`.
|
|
66
|
+
*
|
|
67
|
+
* ⛔ **The backend does not percent-decode path segments.** `/models/%40scope/name`
|
|
68
|
+
* is a `404` where `/models/@scope/name` answers — so `@` goes out raw.
|
|
69
|
+
*/
|
|
70
|
+
function segment(value) {
|
|
71
|
+
return encodeURIComponent(String(value)).replace(/%40/g, '@')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `@scope/name` → `@scope/name`, each half a raw path segment. */
|
|
75
|
+
function modelPath(model) {
|
|
76
|
+
const [scope, ...rest] = String(model).split('/')
|
|
77
|
+
return rest.length ? `${segment(scope)}/${segment(rest.join('/'))}` : segment(scope)
|
|
78
|
+
}
|
|
79
|
+
|
|
105
80
|
/**
|
|
106
|
-
* Auth. **MEASURED
|
|
107
|
-
*
|
|
81
|
+
* Auth routes. **MEASURED.**
|
|
82
|
+
*
|
|
83
|
+
* `/auth/me` is strict about its parameters: `?locale=` on it is a
|
|
84
|
+
* `400 "Unexpected parameters: locale"`. Every route here is.
|
|
108
85
|
*/
|
|
109
86
|
export const AUTH = {
|
|
110
87
|
me: '/auth/me',
|
|
@@ -112,42 +89,112 @@ export const AUTH = {
|
|
|
112
89
|
challenge: '/auth/login/challenge',
|
|
113
90
|
logout: '/auth/logout',
|
|
114
91
|
register: '/auth/register',
|
|
92
|
+
/**
|
|
93
|
+
* `GET /auth/verify?token=` → `{ verified: true }`; a bad token is `400 Validation`.
|
|
94
|
+
* ⚠️ This package does not call it: the link in the verification email is the
|
|
95
|
+
* backend's own, not a page of the site. The mock serves it so a sign-up can be
|
|
96
|
+
* finished locally.
|
|
97
|
+
*/
|
|
98
|
+
verify: '/auth/verify',
|
|
115
99
|
resetRequest: '/auth/reset/request',
|
|
116
100
|
resetConfirm: '/auth/reset/confirm',
|
|
117
101
|
}
|
|
118
102
|
|
|
119
103
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
104
|
+
* The bodies of the auth routes. **MEASURED.** This package hands a caller's
|
|
105
|
+
* fields to the backend unchanged, so these are the names a caller must use — a
|
|
106
|
+
* missing one is a `400` naming it (`missing field \`email\``). `?` marks optional.
|
|
107
|
+
*
|
|
108
|
+
* | route | body | answer |
|
|
109
|
+
* |---|---|---|
|
|
110
|
+
* | login | `{ username, password }` | `200 { token, expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
|
|
111
|
+
* | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
|
|
112
|
+
* | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
|
|
113
|
+
* | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
|
|
114
|
+
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
|
|
115
|
+
* | logout | none | `204`; with no session to end, `401` |
|
|
116
|
+
* | me | none | `200 { account: { uuid, username, handle }, roles, acting_unit_id }` · `401` |
|
|
117
|
+
*/
|
|
118
|
+
export const AUTH_BODY = {
|
|
119
|
+
login: ['username', 'password'],
|
|
120
|
+
challenge: ['challenge_token', 'code'],
|
|
121
|
+
register: ['username', 'email', 'password'],
|
|
122
|
+
resetRequest: ['email'],
|
|
123
|
+
resetConfirm: ['token', 'new_password', 'code?'],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A sign-in that needs a second factor. MEASURED. */
|
|
127
|
+
export const TOTP = {
|
|
128
|
+
status: 'totp_required',
|
|
129
|
+
token: 'challenge_token',
|
|
130
|
+
code: 'code',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The viewer, as `/auth/me` answers. **MEASURED.**
|
|
135
|
+
*
|
|
136
|
+
* `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
|
|
137
|
+
* `unit_admin`, `content_editor`, `user`. An ordinary member holds none: `[]`.
|
|
138
|
+
*
|
|
139
|
+
* ⛔ **`acting_unit_id` is NOT a membership or operator signal.** Every signed-in
|
|
140
|
+
* member of a site acts in the same unit, so it reads the same for the operator
|
|
141
|
+
* and for someone who signed up a minute ago. The operator of a site's `api`
|
|
142
|
+
* service holds `system_admin`. Better than either: ask about the thing — a
|
|
143
|
+
* single-entity read carries `can_edit`, the write gate's own answer.
|
|
144
|
+
*/
|
|
145
|
+
export const VIEWER = {
|
|
146
|
+
account: 'account',
|
|
147
|
+
roles: 'roles',
|
|
148
|
+
actingUnit: 'acting_unit_id',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Entity and Model routes. **MEASURED.**
|
|
122
153
|
*
|
|
123
|
-
*
|
|
124
|
-
* package has confirmed the response bodies — see `ASSUMPTIONS`.
|
|
154
|
+
* Entity ids in a path are UUIDs — anything else is `400 "invalid uuid: …"`.
|
|
125
155
|
*/
|
|
126
156
|
export const ROUTES = {
|
|
127
|
-
/** `GET /entities?model=…` — the
|
|
157
|
+
/** `GET /entities?model=…` — the list. RULED: this one, not a site's. */
|
|
128
158
|
list: () => ENTITIES,
|
|
129
159
|
/** `GET /entities/{uuid}?model=…` — one hydrated entity. */
|
|
130
|
-
read: (uuid) => `${ENTITIES}/${
|
|
131
|
-
/** `POST /entities/batch`
|
|
160
|
+
read: (uuid) => `${ENTITIES}/${segment(uuid)}`,
|
|
161
|
+
/** `POST /entities/batch` `{ uuids, depth?, max_depth? }` → `{ entities: [...] }`, unreadable ones dropped. */
|
|
132
162
|
readBatch: () => `${ENTITIES}/batch`,
|
|
133
|
-
/** `POST /entities?model=…` — create an entity, optionally with its items. */
|
|
163
|
+
/** `POST /entities?model=…` — create an entity, optionally with its items. `201`. */
|
|
134
164
|
create: () => ENTITIES,
|
|
135
|
-
/** `POST /entities/{uuid}/items
|
|
136
|
-
items: (uuid) => `${ENTITIES}/${
|
|
137
|
-
/** `DELETE /entities/{uuid}` — hard-delete; items cascade. */
|
|
138
|
-
remove: (uuid) => `${ENTITIES}/${
|
|
139
|
-
/** `POST /entities/delete`
|
|
165
|
+
/** `POST /entities/{uuid}/items?model=…` — the item op. An array body is one transaction. */
|
|
166
|
+
items: (uuid) => `${ENTITIES}/${segment(uuid)}/items`,
|
|
167
|
+
/** `DELETE /entities/{uuid}` — hard-delete; items cascade. `204`. */
|
|
168
|
+
remove: (uuid) => `${ENTITIES}/${segment(uuid)}`,
|
|
169
|
+
/** `POST /entities/delete` `{ uuids, rev_ref_policy? }` → `{ deleted }`, all-or-nothing on a refusal. */
|
|
140
170
|
removeBatch: () => `${ENTITIES}/delete`,
|
|
171
|
+
/** `GET /models/@scope/name` — the Model's definition, its sections among it. */
|
|
172
|
+
schema: (model) => `${MODELS}/${modelPath(model)}`,
|
|
141
173
|
}
|
|
142
174
|
|
|
143
175
|
/**
|
|
144
|
-
* Query parameter names. **MEASURED
|
|
176
|
+
* Query parameter names. **MEASURED.**
|
|
177
|
+
*
|
|
178
|
+
* ⛔ **Every route refuses a parameter it does not take**, with
|
|
179
|
+
* `400 "Unexpected parameters: …"` — so a parameter goes only where it is declared:
|
|
145
180
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
181
|
+
* | route | takes |
|
|
182
|
+
* |---|---|
|
|
183
|
+
* | list | `model` (required) · `scope` · `limit` · `offset` · `paginate` · `locale` · `include_disabled` |
|
|
184
|
+
* | read | `model` (required) · `via` · `depth` · `max_depth` · `locale` |
|
|
185
|
+
* | items | `model` (required) · `readback` |
|
|
186
|
+
* | create | `model` (required) |
|
|
187
|
+
* | remove | `rev_ref_policy` |
|
|
188
|
+
*
|
|
189
|
+
* ✅ **`via` and `depth` compose** (an assumption retired 2026-09-18): both are
|
|
190
|
+
* parameters of the same read and answer different questions — `via` *who may
|
|
191
|
+
* read* (through a container the viewer is entitled to), `depth` *how much is
|
|
192
|
+
* resolved*. An unentitled `via` is a `404`, never a `403`; a malformed one a `400`.
|
|
193
|
+
*
|
|
194
|
+
* `locale` is an ordered preference list, comma-separated. ⚠️ **The backend has no
|
|
195
|
+
* fallback of its own**: a localized field with no value in any listed locale is
|
|
196
|
+
* omitted, so a list that names only the visitor's language drops every field not
|
|
197
|
+
* yet translated. This package sends the active locale, then the site's default.
|
|
151
198
|
*/
|
|
152
199
|
export const PARAM = {
|
|
153
200
|
model: 'model',
|
|
@@ -156,6 +203,7 @@ export const PARAM = {
|
|
|
156
203
|
offset: 'offset',
|
|
157
204
|
locale: 'locale',
|
|
158
205
|
paginate: 'paginate',
|
|
206
|
+
includeDisabled: 'include_disabled',
|
|
159
207
|
via: 'via',
|
|
160
208
|
depth: 'depth',
|
|
161
209
|
maxDepth: 'max_depth',
|
|
@@ -164,23 +212,42 @@ export const PARAM = {
|
|
|
164
212
|
}
|
|
165
213
|
|
|
166
214
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
215
|
+
* The list's `scope`. **MEASURED.** Default `accessible` — everything the viewer
|
|
216
|
+
* may read. ⚠️ On a site's `api` service that is **every member's** entities of
|
|
217
|
+
* the Model, not only the viewer's own: members of a site read each other's
|
|
218
|
+
* content. `mine` is only what the viewer owns.
|
|
219
|
+
*/
|
|
220
|
+
export const SCOPE = {
|
|
221
|
+
mine: 'mine',
|
|
222
|
+
accessible: 'accessible',
|
|
223
|
+
all: 'all',
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Paging. **MEASURED.** A list with no `limit` answers 50 rows; a `limit` is
|
|
228
|
+
* clamped to `[0, 1000]`; `paginate=false` returns the whole slice and ignores
|
|
229
|
+
* both. This package sends its page size explicitly, so it knows what a full
|
|
230
|
+
* page looks like.
|
|
231
|
+
*/
|
|
232
|
+
export const PAGE = {
|
|
233
|
+
size: 50,
|
|
234
|
+
max: 1000,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Item ops — the kinds, and which of them carry a precondition. **MEASURED.**
|
|
239
|
+
*
|
|
240
|
+
* | op | body | notes |
|
|
241
|
+
* |---|---|---|
|
|
242
|
+
* | `create` | `{ kind, section_id, data, parent_item_id?, position? }` | tokenless; `position` `'first'` · `'last'` · `{ after: <item_id> }`, default last. A second item in a one-item section is `409 Schema Rule Violation` |
|
|
243
|
+
* | `update` | `{ kind, item_id, data, if_unmodified_since? }` | replaces the item's data WHOLE |
|
|
244
|
+
* | `delete` | `{ kind, item_id, if_unmodified_since? }` | |
|
|
245
|
+
* | `move` | `{ kind, item_id, position, parent_item_id?, if_unmodified_since? }` | `position` required. Allowed on an insert-only section |
|
|
246
|
+
*
|
|
247
|
+
* Ids are integers — `"3"` is a `400 … expected i64`. An array of ops is ONE
|
|
248
|
+
* transaction, and **an op may not reference an item created earlier in the same
|
|
249
|
+
* batch**. An absent token is last-writer-wins. ⚠️ An update that writes the data
|
|
250
|
+
* the item already holds is a no-op: the item's token does not move.
|
|
184
251
|
*/
|
|
185
252
|
export const OP = {
|
|
186
253
|
create: 'create',
|
|
@@ -193,136 +260,157 @@ export const OP = {
|
|
|
193
260
|
export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
|
|
194
261
|
|
|
195
262
|
/**
|
|
196
|
-
* Field names on an op and on a write response.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* below, where the divergence is documented in full. It is left wrong deliberately:
|
|
204
|
-
* correcting it changes behaviour, which is a separate change.
|
|
205
|
-
*
|
|
206
|
-
* A write answers `{ entity, item_id, item_uuid, item_updated_at }`, and
|
|
207
|
-
* **`item_uuid` is set on `create` only** — a field this module does not yet name.
|
|
208
|
-
* `item_updated_at` is the token to send back as `if_unmodified_since` on the next
|
|
209
|
-
* write to that item; omitting it is last-writer-wins, and the check is per item, so
|
|
210
|
-
* two people editing different sections do not collide.
|
|
211
|
-
*
|
|
212
|
-
* *(Historical: these were originally read off `POST /api/sites/{id}/content/items` —
|
|
213
|
-
* a different route of the same binary — because the working client of our route
|
|
214
|
-
* returns responses unnormalized and reveals no names. The guess was right on three
|
|
215
|
-
* of four, which is roughly the hit rate this file exists to make visible.)*
|
|
263
|
+
* Field names on an op and on a write response. **MEASURED.**
|
|
264
|
+
*
|
|
265
|
+
* A single-op write answers `{ entity, item_id, item_uuid, item_updated_at }`; a
|
|
266
|
+
* batch `{ entity, results: [{ item_id, item_uuid, item_updated_at }, …] }`, one
|
|
267
|
+
* result per op, in order. `item_uuid` is set on a create only. ⚠️ **A delete
|
|
268
|
+
* answers `item_id: null`** — the row is gone — so the item a delete removed is
|
|
269
|
+
* known only from the op that named it.
|
|
216
270
|
*/
|
|
217
271
|
export const FIELD = {
|
|
218
|
-
/** Names the target item on an op, and the
|
|
272
|
+
/** Names the target item on an op (an integer), and the item on a write result. */
|
|
219
273
|
item: 'item_id',
|
|
220
|
-
/**
|
|
274
|
+
/** The new item's uuid, on a create's result. */
|
|
275
|
+
itemUuid: 'item_uuid',
|
|
276
|
+
/** Placement under another item, for nested content. */
|
|
221
277
|
parent: 'parent_item_id',
|
|
222
278
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* ⛔ REQUIRED on a create, and its absence is silent. An entity has several
|
|
226
|
-
* sections and they are not interchangeable: a rule declared on one — an
|
|
227
|
-
* `append_only`, a field set — simply does not apply to an item that landed in
|
|
228
|
-
* another. A create with no section is accepted, stored somewhere, and every
|
|
229
|
-
* guarantee the author declared is quietly not in force.
|
|
230
|
-
*
|
|
231
|
-
* ⛔ **DIVERGENT FROM THE BACKEND — MEASURED 2026-09-17. This value is wrong for
|
|
232
|
-
* the item route, and is left wrong on purpose until the client change lands.**
|
|
233
|
-
*
|
|
234
|
-
* The backend takes a section **two different ways, on two different routes**:
|
|
235
|
-
*
|
|
236
|
-
* | route | how a section is named |
|
|
237
|
-
* |---|---|
|
|
238
|
-
* | `POST /entities` (create with content) | **by NAME** — `{ items: [{ section: 'profile', … }] }` |
|
|
239
|
-
* | `POST /entities/{uuid}/items` (everything after) | **by NUMERIC `section_id`** |
|
|
240
|
-
*
|
|
241
|
-
* We send `section: '<name>'` on the item route, where the backend wants
|
|
242
|
-
* `section_id: <number>`.
|
|
279
|
+
* The section an item-create op writes into — the numeric id. **MEASURED.**
|
|
243
280
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* `undefined`: the update silently never happens, or a duplicate is created.
|
|
250
|
-
*
|
|
251
|
-
* ⇒ A client needs **`GET /api/models/{scope}/{name}`** — `sections[]` with `id`,
|
|
252
|
-
* `name`, `kind`, `is_brief`, `parent_section_id` — to resolve a name, plus a
|
|
253
|
-
* path-aware resolver for nested sections. That route is **not in `ROUTES`**: it is
|
|
254
|
-
* outside the `/entities` lane this module pins, so adding it is a deliberate
|
|
255
|
-
* change, not a drive-by.
|
|
281
|
+
* ⭐ **Two routes, two spellings, both MEASURED:** creating an entity names a
|
|
282
|
+
* section by NAME (`SECTION_NAME`), a `parent/child` path for a nested one; the
|
|
283
|
+
* item route after it takes this numeric id. This package resolves a name to its
|
|
284
|
+
* id from the Model's definition (`ROUTES.schema`), so a caller only ever says a
|
|
285
|
+
* name.
|
|
256
286
|
*/
|
|
257
|
-
section: '
|
|
287
|
+
section: 'section_id',
|
|
258
288
|
/** The precondition an op carries. */
|
|
259
289
|
precondition: 'if_unmodified_since',
|
|
260
|
-
/** The item's next token, on a write
|
|
290
|
+
/** The item's next token, on a write result. */
|
|
261
291
|
token: 'item_updated_at',
|
|
262
|
-
/** The item's current token, on a `409`. */
|
|
292
|
+
/** The item's current token, on a stale `409`. */
|
|
263
293
|
conflictToken: 'current_updated_at',
|
|
264
294
|
}
|
|
265
295
|
|
|
266
296
|
/**
|
|
267
|
-
* The
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
|
|
280
|
-
|
|
297
|
+
* The create body — `POST /entities?model=…`. **MEASURED.**
|
|
298
|
+
*
|
|
299
|
+
* `{ items: [{ section, data, parent_item_id? }] }`, each `section` a NAME (or a
|
|
300
|
+
* `parent/child` path). The entity and its items commit together, and the answer
|
|
301
|
+
* is `201` with the entity's row (the list's row shape, below). An unknown section
|
|
302
|
+
* name is `404 { kind: 'section' }`; an empty body makes an empty entity.
|
|
303
|
+
*
|
|
304
|
+
* ⛔ **There is no entity-level data.** An entity's content is always items in its
|
|
305
|
+
* sections; its `brief` is maintained by the backend from the brief section's
|
|
306
|
+
* item. ⛔ And the body's other keys are the backend's own — `uuid`, `owner_id` —
|
|
307
|
+
* so content spread at the top level is at best ignored and at worst read as one
|
|
308
|
+
* of them (`{ uuid: 't-1' }` is a `400`).
|
|
309
|
+
*/
|
|
310
|
+
export const CREATE = {
|
|
311
|
+
items: 'items',
|
|
312
|
+
sectionName: 'section',
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The list response — `{ entities: [row, …], matched }`. **MEASURED.**
|
|
317
|
+
*
|
|
318
|
+
* A row: `{ model_uuid, model_name, via, id, uuid, model_id, owner_id, unit_id,
|
|
319
|
+
* sort_date, brief, disabled, created_by, created_at, updated_at }`. A list is
|
|
320
|
+
* brief-only — `brief` is the entity's summary, and there are no items on a row.
|
|
321
|
+
*
|
|
322
|
+
* ⛔ **`matched` counts the rows in THIS answer** — the page, when paging (a
|
|
323
|
+
* `limit=1` over two entities answers `matched: 1`). It is the total only for
|
|
324
|
+
* `paginate=false`, where the answer is the whole slice. There is no total when
|
|
325
|
+
* paging; a page that came back full is the only sign there may be more.
|
|
326
|
+
*
|
|
327
|
+
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
328
|
+
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". ⚠️ An
|
|
329
|
+
* empty list means empty: a lapsed session is a `401`, never `200 []`.
|
|
281
330
|
*/
|
|
282
331
|
export const LIST = {
|
|
283
332
|
records: 'entities',
|
|
284
|
-
/** The count before `limit`/`offset` — the total, not the page. */
|
|
285
333
|
matched: 'matched',
|
|
286
334
|
}
|
|
287
335
|
|
|
288
336
|
/**
|
|
289
|
-
*
|
|
290
|
-
*
|
|
337
|
+
* The single-entity read. **MEASURED.**
|
|
338
|
+
*
|
|
339
|
+
* `{ model_uuid, model_name, can_edit, hydrated: { entity, items } }` — `entity`
|
|
340
|
+
* is the row without `model_*`, and each item is
|
|
341
|
+
* `{ id, section_id, parent_item_id, data, item_date, order_number, updated_at }`.
|
|
342
|
+
* `can_edit` is the write gate's own answer for this viewer. Items name their
|
|
343
|
+
* section by id only; the Model's definition maps ids to names.
|
|
291
344
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* edit here plus a moved comment above; `tests/wire.test.js` pins the set so the
|
|
295
|
-
* change cannot be quiet.
|
|
345
|
+
* `absent` is one word for not-found and not-permitted, by design: both are
|
|
346
|
+
* `404 { kind: 'entity' }`.
|
|
296
347
|
*/
|
|
348
|
+
export const READ = {
|
|
349
|
+
hydrated: 'hydrated',
|
|
350
|
+
entity: 'entity',
|
|
351
|
+
items: 'items',
|
|
352
|
+
itemId: 'id',
|
|
353
|
+
itemSection: 'section_id',
|
|
354
|
+
itemParent: 'parent_item_id',
|
|
355
|
+
itemToken: 'updated_at',
|
|
356
|
+
canEdit: 'can_edit',
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A Model's definition — `GET /models/@scope/name`. **MEASURED.**
|
|
361
|
+
*
|
|
362
|
+
* `{ model: {…}, sections: [{ id, name, kind, is_brief, parent_section_id,
|
|
363
|
+
* fields, other_data, constraints }] }` — `kind` is `single` or `multi`;
|
|
364
|
+
* `other_data.append_only` marks an insert-only section.
|
|
365
|
+
*
|
|
366
|
+
* ⚠️ **Readable by the Models the viewer may create, and by the operator.** A
|
|
367
|
+
* member reading entities of a Model only the operator creates gets `404` here —
|
|
368
|
+
* so a member can read those items but cannot learn their section names.
|
|
369
|
+
*/
|
|
370
|
+
export const SCHEMA = {
|
|
371
|
+
sections: 'sections',
|
|
372
|
+
id: 'id',
|
|
373
|
+
name: 'name',
|
|
374
|
+
kind: 'kind',
|
|
375
|
+
parent: 'parent_section_id',
|
|
376
|
+
}
|
|
377
|
+
|
|
297
378
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
379
|
+
* Refusals this package branches on. **MEASURED.** Problem-JSON:
|
|
380
|
+
* `{ status, title, detail?, …extensions }` — `title` is the stable discriminator.
|
|
300
381
|
*
|
|
301
|
-
* |
|
|
382
|
+
* | answer | means |
|
|
302
383
|
* |---|---|
|
|
303
|
-
* | `
|
|
304
|
-
* | `
|
|
305
|
-
* | `
|
|
306
|
-
* | `
|
|
384
|
+
* | `409 Conflict` + `current_updated_at` | the item changed since this viewer's token — the ONLY stale-token answer |
|
|
385
|
+
* | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
|
|
386
|
+
* | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here |
|
|
387
|
+
* | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
|
|
388
|
+
* | `403 Email Not Verified` | right password, unverified address |
|
|
389
|
+
* | `403 CSRF Header Required` | a cookie-authenticated mutation without `X-Uniweb-Csrf` |
|
|
390
|
+
* | `400 Validation` + `field` | content that does not fit the Model (`field: 'data.minutes'`) |
|
|
391
|
+
* | `404 Not Found` + `kind`, `key` | nothing here for you — not found or not permitted |
|
|
392
|
+
*/
|
|
393
|
+
export const PROBLEM = {
|
|
394
|
+
csrf: 'CSRF Header Required',
|
|
395
|
+
stepUp: 'Step-Up Required',
|
|
396
|
+
notVerified: 'Email Not Verified',
|
|
397
|
+
appendOnly: 'Append-Only Section',
|
|
398
|
+
schemaRule: 'Schema Rule Violation',
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
|
|
403
|
+
* confirmed. Each entry says what we do, and what breaks if we are wrong.
|
|
307
404
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
405
|
+
* ⭐ **Empty since 2026-09-18.** The last two were answered by measurement:
|
|
406
|
+
*
|
|
407
|
+
* | retired | answered as |
|
|
408
|
+
* |---|---|
|
|
409
|
+
* | `viewer-unit-signal` | ⛔ false — every member of a site acts in the same unit; see `VIEWER` |
|
|
410
|
+
* | `via-and-depth-compose` | ✅ true — see `PARAM` |
|
|
312
411
|
*
|
|
313
|
-
*
|
|
412
|
+
* *(Retired 2026-09-17: `write-response-fields`, `move-exists`, `move-position`,
|
|
413
|
+
* `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
|
|
414
|
+
* `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
|
|
314
415
|
*/
|
|
315
|
-
export const ASSUMPTIONS = [
|
|
316
|
-
{
|
|
317
|
-
id: 'viewer-unit-signal',
|
|
318
|
-
we: "read a viewer's unit membership from `acting_unit_id` on /auth/me, surfaced as `viewer.actingUnitId`",
|
|
319
|
-
from: "the field this package already normalizes; whether it is THE membership signal, or one of several, is unconfirmed",
|
|
320
|
-
breaks: 'an app cannot tell an operator from a member, so it either shows authoring controls to everyone or to nobody — and the refusal only arrives at the write',
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
id: 'via-and-depth-compose',
|
|
324
|
-
we: `'${PARAM.via}' and '${PARAM.depth}' are both valid on a single-entity read, answering different questions`,
|
|
325
|
-
from: 'via is this package’s own; depth is what the working client of this route sends. Neither has been seen beside the other',
|
|
326
|
-
breaks: 'an entitled read returns the wrong shape, or one parameter silently wins',
|
|
327
|
-
},
|
|
328
|
-
]
|
|
416
|
+
export const ASSUMPTIONS = []
|