@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.
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useRef, useState } from 'react'
2
2
  import { getClient } from '../client.js'
3
3
  import { ApiError } from '../errors.js'
4
- import { FIELD, OP } from '../wire.js'
4
+ import { CREATE, FIELD, OP } from '../wire.js'
5
5
 
6
6
  const IDLE = 'idle'
7
7
  const SAVING = 'saving'
@@ -11,13 +11,17 @@ const ERROR = 'error'
11
11
  * Write the items of one entity, in domain terms.
12
12
  *
13
13
  * ```jsx
14
- * const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
15
- * await programme.create({ title: 'Keynote' }) // appended
16
- * await programme.update(itemId, { room: 'Hall A' })
17
- * await programme.move(itemId, { after: otherItemId }) // the organiser arranges
18
- * await programme.remove(itemId)
14
+ * const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
15
+ * await programme.create({ title: 'Keynote' }, { section: 'sessions' }) // appended
16
+ * await programme.update(item.id, { ...item.data, room: 'Hall A' }) // whole data
17
+ * await programme.move(item.id, { after: other.id }) // the organiser arranges
18
+ * await programme.remove(item.id)
19
19
  * ```
20
20
  *
21
+ * An item id is the `id` of an item on a read (`entity.hydrated.items`), an integer.
22
+ * A section is named as the Model names it; the package resolves it to the id the
23
+ * backend's item route takes.
24
+ *
21
25
  * ## ⭐ Why a hook wraps ops at all — it is a CCA argument, not an ergonomic one
22
26
  *
23
27
  * A component that composes `{ kind: 'update', item_id, if_unmodified_since }` has
@@ -40,6 +44,10 @@ const ERROR = 'error'
40
44
  * overwriting a change nobody looked at. What to do about someone else's edit is
41
45
  * the application's question, and it is usually "tell the person".
42
46
  *
47
+ * ⚠️ `conflict` is ONLY that case. A write the Model's rules refuse — an
48
+ * insert-only section, a second item in a one-item section — is an `error` of kind
49
+ * `rule`: nobody else is involved, and the same write will fail the same way.
50
+ *
43
51
  * @param {{ schema: string, uuid: string } | null} target
44
52
  * @returns {{
45
53
  * create: Function, update: Function, remove: Function, move: Function,
@@ -70,7 +78,7 @@ export function useEntityWriter(target) {
70
78
  if (seq.current === mine) setState({ status: IDLE, error: null, conflict: null })
71
79
  return result
72
80
  } catch (err) {
73
- const conflict = err instanceof ApiError && err.status === 409 ? err : null
81
+ const conflict = err instanceof ApiError && err.kind === 'conflict' ? err : null
74
82
  if (seq.current === mine) setState({ status: ERROR, error: err, conflict })
75
83
  throw err
76
84
  }
@@ -81,53 +89,61 @@ export function useEntityWriter(target) {
81
89
  const api = useMemo(
82
90
  () => ({
83
91
  /**
84
- * Append an item to a section. Tokenless by design — there is no existing item
92
+ * Add an item to a section. Tokenless by design — there is no existing item
85
93
  * to guard. `position` and `parent` are the server's ordering vocabulary,
86
94
  * passed through rather than turned into an order number here.
87
95
  *
88
- * ⛔ `section` is REQUIRED and refused when missing, because getting it wrong
89
- * fails SILENTLY: an entity has several sections, the item lands in whichever
90
- * one the server defaults to, and every rule the author declared on the
91
- * intended section — `append_only` above all — is quietly not in force. The
92
- * write succeeds, the data looks present, and the guarantee is gone.
96
+ * ⛔ `section` is REQUIRED, because an entity has several and they are not
97
+ * interchangeable: a rule declared on one — insert-only above all — does not
98
+ * reach an item that landed in another. Name it as the Model does; the client
99
+ * resolves the name to the id the backend's item route takes. A section that
100
+ * holds one item takes one create, and is `update`d after that.
93
101
  *
94
102
  * @param {object} data - the item's content
95
103
  * @param {object} opts
96
104
  * @param {string} opts.section - which section of the entity this belongs to
97
- * @param {string|number} [opts.parent] - a parent item, for nested sections
98
- * @param {'first'|'last'|{after: string}} [opts.position]
105
+ * @param {number} [opts.parent] - a parent item's id, for nested content
106
+ * @param {'first'|'last'|{after: number}} [opts.position] - default: last
99
107
  */
100
108
  create: (data, { section, parent, position } = {}) => {
101
109
  if (!section) {
102
110
  return Promise.reject(
103
- new ApiError({
104
- status: 0,
105
- title: 'No Section',
106
- detail: 'create needs a section — an item with no section lands outside the rules declared for it',
107
- kind: 'invalid',
108
- }),
111
+ ApiError.invalid(
112
+ 'No Section',
113
+ 'create needs a section — an item with no section lands outside the rules declared for it',
114
+ ),
109
115
  )
110
116
  }
111
117
  return send({
112
118
  kind: OP.create,
113
- [FIELD.section]: section,
114
- data,
119
+ [CREATE.sectionName]: section,
120
+ data: data ?? {},
115
121
  ...(parent != null ? { [FIELD.parent]: parent } : {}),
116
122
  ...(position != null ? { position } : {}),
117
123
  })
118
124
  },
119
125
  /** Replace an item's data. ⚠️ Whole-data replace — round-trip what you do not edit. */
120
- update: (itemId, data) => send({ kind: OP.update, [FIELD.item]: itemId, data }),
126
+ update: (itemId, data) => {
127
+ if (data == null || typeof data !== 'object') {
128
+ return Promise.reject(ApiError.invalid('No Data', "update replaces the item's data whole — pass all of it"))
129
+ }
130
+ return send({ kind: OP.update, [FIELD.item]: itemId, data })
131
+ },
121
132
  /** Delete one item. */
122
133
  remove: (itemId) => send({ kind: OP.delete, [FIELD.item]: itemId }),
123
134
  /**
124
- * Reposition an item — `'first' | 'last' | { after: <itemId> }`.
135
+ * Reposition an item — `'first' | 'last' | { after: <itemId> }`, required.
125
136
  *
126
137
  * ⛔ The client never computes an order number. Ordering is the server's, and
127
138
  * two clients arranging the same list from local sequence numbers is how a
128
139
  * list ends up in an order neither of them chose.
129
140
  */
130
- move: (itemId, position) => send({ kind: OP.move, [FIELD.item]: itemId, position }),
141
+ move: (itemId, position) => {
142
+ if (position == null) {
143
+ return Promise.reject(ApiError.invalid('No Position', "move needs a position — 'first', 'last' or { after }"))
144
+ }
145
+ return send({ kind: OP.move, [FIELD.item]: itemId, position })
146
+ },
131
147
  /** Send several ops as ONE transaction — all of them land, or none do. */
132
148
  batch: (ops) => send(ops),
133
149
  reset: () => setState({ status: IDLE, error: null, conflict: null }),
@@ -17,9 +17,11 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
17
17
  *
18
18
  * ## ⭐ `absent` and an empty `ready` are DIFFERENT, and conflating them is the bug
19
19
  *
20
- * `absent` means **there is no live source** — a site with no service-provider
21
- * backend, which is the ordinary standalone case and not a failure. `ready` with
22
- * `records: []` means **the source answered, and there is nothing there.**
20
+ * `absent` means **there is no live source for this viewer** — a site with no
21
+ * `api` service, which is the ordinary standalone case, or nobody signed in, since
22
+ * the backend lists nothing to an anonymous caller (it answers `401`, so no request
23
+ * is sent once the session is known to be anonymous). `ready` with `records: []`
24
+ * means **the source answered, and there is nothing there.**
23
25
  *
24
26
  * A component renders its own static content for the first and an empty state for
25
27
  * the second, and they are not interchangeable: telling a visitor "no sessions yet"
@@ -27,10 +29,18 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
27
29
  * that once answered a lapsed session with an empty list — it reports absence of
28
30
  * *access* as absence of *content*.
29
31
  *
32
+ * A record is the entity's summary — `record.brief` holds the brief section's
33
+ * fields, beside `uuid`, `owner_id` and `via` — and carries no items; read one
34
+ * entity for those. ⚠️ `matched` counts the records in this answer, not a total:
35
+ * `hasMore` is true when the page came back full.
36
+ *
37
+ * ⚠️ The default scope is everything the viewer may read, which on a site's `api`
38
+ * service includes other members' entities. `scope: 'mine'` is only the viewer's.
39
+ *
30
40
  * Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
31
41
  * is now looking, and a write through `useEntityWriter` drops it.
32
42
  *
33
- * @param {{ schema: string, scope?: string, limit?: number, offset?: number, all?: boolean } | null} query
43
+ * @param {{ schema: string, scope?: 'accessible'|'mine'|'all', limit?: number, offset?: number, all?: boolean } | null} query
34
44
  * pass null to skip
35
45
  * @returns {{ status: string, records: object[], matched: number, hasMore: boolean, error: Error|null, refresh: Function }}
36
46
  */
@@ -40,13 +50,15 @@ export function useRecords(query) {
40
50
  const store = website?.dataStore ?? null
41
51
 
42
52
  // Re-key on a viewer change: what the viewer may see is part of the answer.
43
- useSyncExternalStore(
53
+ const session = useSyncExternalStore(
44
54
  client ? client.subscribe : noSubscribe,
45
55
  client ? () => client.session : noSnapshot,
46
56
  client ? () => client.session : noSnapshot,
47
57
  )
48
58
 
49
- const active = !!(client && client.enabled && store && query && query.schema)
59
+ // Signed out, there is no live source: the backend lists nothing to an anonymous
60
+ // caller, so asking would only buy a `401`.
61
+ const active = !!(client && client.enabled && store && query && query.schema && session?.status !== 'anonymous')
50
62
  const spec = active
51
63
  ? {
52
64
  endpoint: '/entities',
package/src/http.js CHANGED
@@ -11,7 +11,7 @@ export const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
11
11
  /**
12
12
  * Is the base another origin than the page's?
13
13
  *
14
- * A relative base (`/_uw`) is the page's own origin by definition. An absolute
14
+ * A relative base (`/_api`) is the page's own origin by definition. An absolute
15
15
  * one is compared against `location.origin`; where there is no location — a
16
16
  * server, a test — an absolute base is treated as cross-origin, which only
17
17
  * makes the request carry credentials it would otherwise carry anyway.
@@ -31,11 +31,16 @@ export function isCrossOrigin(base) {
31
31
  }
32
32
 
33
33
  /**
34
- * `${base}/api${path}?…` — the one composition this package makes.
34
+ * `${base}${path}?…` — the one composition this package makes.
35
35
  *
36
- * The base is the prefix under which the backend's own route space appears:
37
- * the passthrough path on the site's origin, an origin under the subdomain
38
- * shape, or empty on a deployment where the page's own server is the backend.
36
+ * The base IS the backend's API route space, and every route joins it directly:
37
+ * `/_api` + `/entities`. On a hosted site `/_api` is a path on the site's own
38
+ * origin that reaches the backend's `/api/…`; wherever the page reaches a backend
39
+ * directly, the base is that backend's absolute `…/api`.
40
+ *
41
+ * ⛔ **Until 0.4 this inserted `/api` after the base**, reading the base as the
42
+ * backend's root. On a hosted site that sent every request to `/_api/api/…`, a
43
+ * route that does not exist — measured, and the reason this comment is here.
39
44
  * `null` and `undefined` query values are omitted.
40
45
  *
41
46
  * @param {string} base
@@ -52,7 +57,7 @@ export function composeUrl(base, path, query) {
52
57
  params.set(key, String(value))
53
58
  }
54
59
  const qs = params.toString()
55
- return `${root}/api${p}${qs ? `?${qs}` : ''}`
60
+ return `${root}${p}${qs ? `?${qs}` : ''}`
56
61
  }
57
62
 
58
63
  /**
package/src/ledger.js CHANGED
@@ -1,53 +1,42 @@
1
1
  /**
2
- * The concurrency ledger — the last-seen `item_updated_at` per item, and the
3
- * stamping of `if_unmodified_since` onto the ops that need one.
2
+ * The concurrency ledger — the last-seen `updated_at` per item, and the stamping
3
+ * of `if_unmodified_since` onto the ops that need one.
4
4
  *
5
5
  * The backend guards writes at the item grain: `update`, `delete` and `move` each
6
6
  * carry the target item's last-seen `updated_at`; `create` carries none. A
7
7
  * mismatch is a `409` whose `current_updated_at` extension names the item's
8
- * current token, and every write response carries `item_updated_at` — the next
9
- * precondition to chain forward. Three sources, one token.
8
+ * current token, and every write result carries `item_updated_at` — the next
9
+ * precondition to chain forward. **Three sources, one token: a read, a write, a
10
+ * conflict.**
10
11
  *
11
- * ⭐ `move` IS IN SCOPE, and the reasoning that briefly removed it is kept here
12
- * because it is the mistake this package invites. It was dropped on 2026-09-01 as
13
- * "an editor concern — an app's order is a property of the query, sort by a
14
- * field". **That is true of a MEMBER LIST and false of the apps this package
15
- * exists for.** An LMS instructor authors a course whose lessons are a curriculum
16
- * SEQUENCE: the order is authored, stored, and repositioned by hand.
12
+ * ⛔ **Reads are a source, and until 0.4 nothing fed them in.** A hydrated read
13
+ * carries each item's `updated_at`, which is exactly the token the FIRST write of
14
+ * that item must carry. Without it the first edit after a read went out
15
+ * unguarded — last-writer-wins — and silently overwrote whatever another person
16
+ * had saved in between, which is the one thing this ledger exists to prevent.
17
17
  *
18
- * ⇒ The trap is generalising from the CONSUMING surface. These apps have two, and
19
- * both are ours: members read and append (progress, submissions), while OPERATORS
20
- * author the app's own content — full CRUD over developer-defined schemas,
21
- * hierarchy included. [Diego, 2026-09-01.]
18
+ * ⚠️ **A read may not roll a token back.** A read that was sent before this client
19
+ * wrote an item, and answered after, carries the item's OLDER token. Taking it
20
+ * would make the next write fail against this client's own change. So a read is
21
+ * marked when it starts (`mark()`), and its tokens are taken only for items this
22
+ * client has not written since (`observe()`).
22
23
  *
23
- * ⚠️ UNVERIFIED ON OUR LANE: `move` and its server-managed `position` were read
24
- * off the site-editor's route, which is not ours. Whether
25
- * `POST /api/entities/{uuid}/items` offers `move`, and in what shape, is a
26
- * measurement nobody has taken. `stamp()` needs no branch either way — it guards
27
- * every non-`create` op — so this docstring is the only thing a finding moves.
24
+ * ⭐ `move` IS IN SCOPE. It was dropped on 2026-09-01 as "an editor concern — an
25
+ * app's order is a property of the query". **That is true of a MEMBER LIST and
26
+ * false of the apps this package exists for**: an instructor authors a course
27
+ * whose lessons are a sequence, ordered and re-ordered by hand. `move` carries a
28
+ * precondition like any other op on an existing item.
28
29
  *
29
- * That is the single most reinventable thing on the wire, so it lives here
30
- * once, as a pure structure with no route knowledge. The writer that composes
31
- * the request is the next slice; it will `stamp()` before sending, `absorb()`
32
- * what comes back, and `rebase()` on a conflict.
33
- *
34
- * ⛔ **Every field name here now comes from `./wire.js`, and that fixed a real
35
- * defect rather than tidying one.** This module read an op's target as `op.item`
36
- * and probed responses through a guess list, `['item', 'item_id', 'id']`. The wire
37
- * field is `item_id`. So a writer composing a correct op would have handed
38
- * `stamp()` something whose target it could not see — and `stamp()` returns an
39
- * unguarded op when it cannot find one, **by design, because an item it has never
40
- * seen is legitimately last-writer-wins.** The two behaviours are identical from
41
- * here and opposite in effect: one is "no token known", the other is "the
42
- * precondition was silently dropped from every write."
43
- *
44
- * ⇒ That is the argument for one home per name, in miniature. A guess list cannot
45
- * fail loudly, because guessing is what it is for.
30
+ * ⛔ **Every field name here comes from `./wire.js`.** This module once read an
31
+ * op's target as `op.item` while the wire says `item_id`, so a correct op looked
32
+ * target-less and went out unguarded — by design, because an item never seen is
33
+ * legitimately last-writer-wins. The two are identical from here and opposite in
34
+ * effect. One home per name is what makes that impossible.
46
35
  */
47
36
 
48
- import { FIELD, OP } from './wire.js'
37
+ import { FIELD, OP, READ } from './wire.js'
49
38
 
50
- /** An op's or a response's item id, by the one name the wire uses. */
39
+ /** An op's or a result's item id, by the one name the wire uses. */
51
40
  function itemIdOf(record) {
52
41
  const id = record?.[FIELD.item]
53
42
  return id == null ? null : String(id)
@@ -56,9 +45,11 @@ function itemIdOf(record) {
56
45
  export class Ledger {
57
46
  constructor() {
58
47
  this._at = new Map()
48
+ this._wrote = new Map()
49
+ this._clock = 0
59
50
  }
60
51
 
61
- /** Record an item's token, from a read or a write response. */
52
+ /** Record an item's token — from a write, a conflict, or a caller that knows. */
62
53
  note(itemId, updatedAt) {
63
54
  if (itemId == null || updatedAt == null) return
64
55
  this._at.set(String(itemId), updatedAt)
@@ -73,12 +64,37 @@ export class Ledger {
73
64
  this._at.delete(String(itemId))
74
65
  }
75
66
 
67
+ /** A point in this ledger's history — take one when a read STARTS. */
68
+ mark() {
69
+ this._clock += 1
70
+ return this._clock
71
+ }
72
+
73
+ /**
74
+ * Take the tokens a read carried — each item's `id` and `updated_at` — except for
75
+ * items this client wrote after the read began.
76
+ *
77
+ * @param {object[]} [items] - a hydrated read's items
78
+ * @param {number} [mark] - `mark()` taken before the read was sent
79
+ */
80
+ observe(items, mark = Infinity) {
81
+ if (!Array.isArray(items)) return
82
+ for (const item of items) {
83
+ const id = item?.[READ.itemId]
84
+ const at = item?.[READ.itemToken]
85
+ if (id == null || at == null) continue
86
+ const key = String(id)
87
+ if ((this._wrote.get(key) ?? 0) > mark) continue
88
+ this._at.set(key, at)
89
+ }
90
+ }
91
+
76
92
  /**
77
93
  * Stamp an op with the precondition it needs. `create` is tokenless by
78
94
  * design; an op on an item this ledger has never seen goes out unguarded
79
95
  * — last-writer-wins — exactly as the wire treats an absent token.
80
96
  *
81
- * @param {{ kind: string, item?: string|number }} op
97
+ * @param {{ kind: string, item_id?: number }} op
82
98
  * @returns {object} the op, with `if_unmodified_since` when known
83
99
  */
84
100
  stamp(op) {
@@ -90,26 +106,41 @@ export class Ledger {
90
106
  }
91
107
 
92
108
  /**
93
- * Absorb a write response — one result or a batch of them — recording each
94
- * item's next token, and forgetting an item whose token came back `null`,
95
- * which is how a delete reports itself.
109
+ * Absorb a write — one result, or a batch's `results` — recording each item's
110
+ * next token.
111
+ *
112
+ * ⭐ **Pass the ops, and each result is read against the op in its position.**
113
+ * That is the only way to know what a delete removed: its result names no item
114
+ * (`item_id: null` — the row is gone), so the item is the one the op named.
115
+ * Without the ops, a result is read on its own — a create's or an update's
116
+ * result names its item; a delete's cannot be placed.
96
117
  *
97
- * @param {object} result
118
+ * @param {object} result - the write's answer
119
+ * @param {object|object[]} [ops] - the ops that were sent, in order
98
120
  */
99
- absorb(result) {
121
+ absorb(result, ops) {
100
122
  if (!result || typeof result !== 'object') return
101
- if (Array.isArray(result.results)) {
102
- for (const r of result.results) this.absorb(r)
103
- return
104
- }
105
- const id = itemIdOf(result)
106
- if (id == null || !(FIELD.token in result)) return
107
- if (result[FIELD.token] === null) this.forget(id)
108
- else this.note(id, result[FIELD.token])
123
+ const results = Array.isArray(result.results) ? result.results : [result]
124
+ const sent = ops == null ? [] : Array.isArray(ops) ? ops : [ops]
125
+ results.forEach((r, i) => {
126
+ const op = sent[i]
127
+ if (op?.kind === OP.delete) {
128
+ if (itemIdOf(op) != null) this.forget(itemIdOf(op))
129
+ return
130
+ }
131
+ const id = itemIdOf(r) ?? itemIdOf(op)
132
+ if (id == null || !r || !(FIELD.token in r)) return
133
+ if (r[FIELD.token] === null) {
134
+ this.forget(id)
135
+ return
136
+ }
137
+ this.note(id, r[FIELD.token])
138
+ this._wrote.set(id, this.mark())
139
+ })
109
140
  }
110
141
 
111
142
  /**
112
- * On a `409`, take the item's current token from the error so the next
143
+ * On a stale `409`, take the item's current token from the error so the next
113
144
  * attempt is guarded by the truth rather than by what this ledger believed.
114
145
  *
115
146
  * @param {string|number} itemId
@@ -118,8 +149,9 @@ export class Ledger {
118
149
  */
119
150
  rebase(itemId, error) {
120
151
  const current = error?.extensions?.[FIELD.conflictToken]
121
- if (current == null) return false
152
+ if (itemId == null || current == null) return false
122
153
  this.note(itemId, current)
154
+ this._wrote.set(String(itemId), this.mark())
123
155
  return true
124
156
  }
125
157
  }