@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.
Files changed (2) hide show
  1. package/package.json +2 -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.6",
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.0"
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
- * ⛔ **`move` is ASSUMED.** It is in scope as a product requirement — an operator
126
- * arranging authored content by hand, where order is a stored fact and not a sort
127
- * key — but it was read off the **site** lane, and this lane's own documentation
128
- * names only `update` and `delete` as token-carrying. Whether `move` exists here at
129
- * all is unconfirmed.
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
- * ⛔ **ASSUMED, all of them.** These were read off `POST /api/sites/{id}/content/items`
145
- * — a *different route* of the same binary — because the working client of *our*
146
- * route returns its responses unnormalized and so reveals no names at all.
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 shapes are very likely identical: both are `…/items` routes with the same op
149
- * vocabulary, and this lane's documentation says its concurrency is "aligned with
150
- * the sites lane". **Likely is not measured**, and this comment is the difference.
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`",