@uniweb/api 0.3.6 → 0.3.7
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/package.json +2 -2
- package/src/wire.js +120 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=20.19"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "^0.29.
|
|
37
|
+
"@uniweb/core": "^0.29.4"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.0.0"
|
package/src/wire.js
CHANGED
|
@@ -37,6 +37,55 @@
|
|
|
37
37
|
* note to MEASURED and delete its entry — the test on that array makes the change
|
|
38
38
|
* deliberate and visible rather than a quiet edit.
|
|
39
39
|
*
|
|
40
|
+
* ## ⭐ THE ENTITY CONTENT MODEL — **MEASURED 2026-09-17**, and it corrects this package
|
|
41
|
+
*
|
|
42
|
+
* Backend read its own source and answered three questions we put to it. The answer
|
|
43
|
+
* to the third replaced a premise this package was built on, so it is recorded here
|
|
44
|
+
* first, before any route or field:
|
|
45
|
+
*
|
|
46
|
+
* ⛔ **THERE IS NO ENTITY-LEVEL DATA. Entity content is ALWAYS items.**
|
|
47
|
+
*
|
|
48
|
+
* An entity stores identity, ownership, flags, timestamps, and a `brief` /
|
|
49
|
+
* `sort_date` **the server maintains itself**. There is no separate data record, and
|
|
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.
|
|
88
|
+
*
|
|
40
89
|
* @module @uniweb/api/wire
|
|
41
90
|
*/
|
|
42
91
|
|
|
@@ -122,11 +171,16 @@ export const PARAM = {
|
|
|
122
171
|
* mismatch is `409` with `current_updated_at`; a gone item is `404`; an absent
|
|
123
172
|
* token is last-writer-wins, guarded same-transaction.
|
|
124
173
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
174
|
+
* ✅ **`move` is MEASURED (2026-09-17).** It exists on this lane, carries a
|
|
175
|
+
* precondition, and takes `{ item_id, parent_item_id, position, if_unmodified_since }`.
|
|
176
|
+
* **Position is decided server-side** — `"first"`, `"last"` or `{ after: <item_id> }` —
|
|
177
|
+
* and the client never computes an order number. Both assumptions that stood here are
|
|
178
|
+
* retired. *(It had been read off the site lane, whose documentation names only
|
|
179
|
+
* `update` and `delete` as token-carrying; the doubt was reasonable and wrong.)*
|
|
180
|
+
*
|
|
181
|
+
* ⚠️ **Batch caveat, MEASURED:** an array of ops runs in ONE transaction — all commit
|
|
182
|
+
* or none — but **an op may not reference an item created earlier in the same batch.**
|
|
183
|
+
* A create-then-position sequence is therefore two round trips, not one batch.
|
|
130
184
|
*/
|
|
131
185
|
export const OP = {
|
|
132
186
|
create: 'create',
|
|
@@ -141,13 +195,24 @@ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
|
|
|
141
195
|
/**
|
|
142
196
|
* Field names on an op and on a write response.
|
|
143
197
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
198
|
+
* ✅ **MEASURED 2026-09-17** — backend confirmed these against its own source, and the
|
|
199
|
+
* four assumptions that stood here are retired (see `ASSUMPTIONS`). Three of the four
|
|
200
|
+
* names held exactly: `item_id`, `parent_item_id`, `if_unmodified_since`.
|
|
147
201
|
*
|
|
148
|
-
* The
|
|
149
|
-
*
|
|
150
|
-
*
|
|
202
|
+
* ⛔ **The fourth did not.** `section` is wrong for the item route — see `section`
|
|
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.)*
|
|
151
216
|
*/
|
|
152
217
|
export const FIELD = {
|
|
153
218
|
/** Names the target item on an op, and the affected item on a response. */
|
|
@@ -162,6 +227,32 @@ export const FIELD = {
|
|
|
162
227
|
* `append_only`, a field set — simply does not apply to an item that landed in
|
|
163
228
|
* another. A create with no section is accepted, stored somewhere, and every
|
|
164
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>`.
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ **And the cost is larger than the field name**, because the id is needed to
|
|
245
|
+
* *find* an item as well as to create one. An entity read returns `items[]` with
|
|
246
|
+
* `id`, `section_id` and `updated_at` — **no section name at all**. Code that
|
|
247
|
+
* locates an item by name (`items.find(i => i.section === 'content')`) works only
|
|
248
|
+
* against a mock that invents the name, and against the real backend returns
|
|
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.
|
|
165
256
|
*/
|
|
166
257
|
section: 'section',
|
|
167
258
|
/** The precondition an op carries. */
|
|
@@ -203,31 +294,25 @@ export const LIST = {
|
|
|
203
294
|
* edit here plus a moved comment above; `tests/wire.test.js` pins the set so the
|
|
204
295
|
* change cannot be quiet.
|
|
205
296
|
*/
|
|
297
|
+
/**
|
|
298
|
+
* ⭐ **Four entries were RETIRED on 2026-09-17** — backend confirmed them against its
|
|
299
|
+
* own source, and the rule above is to move the note to MEASURED and delete the entry:
|
|
300
|
+
*
|
|
301
|
+
* | retired | confirmed as |
|
|
302
|
+
* |---|---|
|
|
303
|
+
* | `write-response-fields` | a write answers `{ entity, item_id, item_uuid, item_updated_at }`; `item_uuid` is set on **create only** |
|
|
304
|
+
* | `move-exists` | `move` is an op here and carries a precondition |
|
|
305
|
+
* | `move-position` | positioned server-side — `"first"` \| `"last"` \| `{ after }`; the client never computes an order |
|
|
306
|
+
* | `op-field-names` | `item_id`, `parent_item_id`, `if_unmodified_since` all confirmed — **but the section field is NOT**, and that is now a recorded DIVERGENCE on `FIELD.section`, not an open question |
|
|
307
|
+
*
|
|
308
|
+
* ⛔ `op-field-names` left this list by being **answered, not by being right**. Three
|
|
309
|
+
* of its four names held; the fourth is wrong and is documented where the wrong value
|
|
310
|
+
* lives. An assumption that turns out false is not an assumption any more — it is a
|
|
311
|
+
* defect, and hiding it in a list of open questions is how it stays unfixed.
|
|
312
|
+
*
|
|
313
|
+
* What remains below is genuinely unconfirmed.
|
|
314
|
+
*/
|
|
206
315
|
export const ASSUMPTIONS = [
|
|
207
|
-
{
|
|
208
|
-
id: 'write-response-fields',
|
|
209
|
-
we: `a write response names its item as '${FIELD.item}' and its next token as '${FIELD.token}'`,
|
|
210
|
-
from: 'the site-editor lane, which is a different route',
|
|
211
|
-
breaks: 'the ledger records nothing, so every second write on an item goes out unguarded — last-writer-wins instead of a 409',
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
id: 'op-field-names',
|
|
215
|
-
we: `an op names its target '${FIELD.item}', its placement '${FIELD.parent}', and its precondition '${FIELD.precondition}'`,
|
|
216
|
-
from: 'the site-editor lane, which is a different route',
|
|
217
|
-
breaks: 'writes are refused, loudly — the cheapest of these to be wrong about',
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
id: 'move-exists',
|
|
221
|
-
we: `'${OP.move}' is an op on this lane, and carries a precondition`,
|
|
222
|
-
from: 'the site-editor lane; this lane documents only update and delete as token-carrying',
|
|
223
|
-
breaks: 'an operator cannot reorder authored content, which is half of what makes an app an app rather than a CMS',
|
|
224
|
-
},
|
|
225
|
-
{
|
|
226
|
-
id: 'move-position',
|
|
227
|
-
we: 'a move is positioned server-side — "first" | "last" | { after } — and the client never computes an order number',
|
|
228
|
-
from: 'the site-editor lane',
|
|
229
|
-
breaks: 'reordering writes the wrong sequence, or needs a client-side order the store does not want',
|
|
230
|
-
},
|
|
231
316
|
{
|
|
232
317
|
id: 'viewer-unit-signal',
|
|
233
318
|
we: "read a viewer's unit membership from `acting_unit_id` on /auth/me, surfaced as `viewer.actingUnitId`",
|