@uniweb/api 0.1.0 → 0.2.1

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 CHANGED
@@ -1,69 +1,149 @@
1
1
  # @uniweb/api
2
2
 
3
- The client for a Uniweb site's own backend — session, records, entities, writes — in the site's
4
- vocabulary, never in routes. A foundation imports it the way it imports `@uniweb/kit`; it is bundled
5
- into the foundation and is inert on a site that declares no backend.
3
+ The client a foundation uses to talk to **its site's own backend**sign-in, the
4
+ site's members, and the content they create.
6
5
 
7
- **Status: early.** The session, sign-in, sign-out, sign-up, password reset and single-entity reads
8
- are built and pass against a real backend; lists of records, writes, commerce and notifications are
9
- not here yet. The surface may still move before `1.0`.
6
+ You import it the way you import `@uniweb/kit`. It is bundled into your foundation,
7
+ tree-shaken, and **inert on a site that has no backend**: nothing throws, no request
8
+ leaves, and your components render the version of themselves that does not need one.
10
9
 
11
- ## What it will be
10
+ ```bash
11
+ npm install @uniweb/api
12
+ ```
12
13
 
13
- - **A session.** Who the viewer is, and sign-in, sign-up and sign-out as functions a component
14
- calls — never as routes it constructs.
15
- - **Reads in the site's vocabulary.** Named queries over the site's records, and single entities by
16
- id, gated by what the viewer may see.
17
- - **Writes.** Entity creation and per-item updates, with concurrency tokens, optimistic state and
18
- cache invalidation handled for you.
19
- - **Two entry points.** `@uniweb/api` for the React hooks; `@uniweb/api/client` for the plain
20
- functions, with no React import.
14
+ ## Is there a backend?
21
15
 
22
- ## What it does today
16
+ Ask before you draw. This is a synchronous read of the site's own configuration, not
17
+ a probe — there is nothing to await.
23
18
 
24
- ```js
25
- import { useSession, useSignIn, useEntity, SignedIn, SignedOut } from '@uniweb/api'
19
+ ```jsx
20
+ import { isEnabled } from '@uniweb/api'
21
+
22
+ if (!isEnabled(website)) return <StaticVersion />
23
+ ```
24
+
25
+ ⛔ **And when the answer is no, draw nothing** — not a disabled button, and not an
26
+ explanation. A visitor has no stake in which services the site's operator set up, and
27
+ "sign-in is unavailable" reads like a breakage when it is simply a feature this site
28
+ does not have.
29
+
30
+ ## The session
31
+
32
+ ```jsx
33
+ import { useSession, useSignIn, SignedIn, SignedOut } from '@uniweb/api'
34
+
35
+ function Account() {
36
+ const { viewer, signOut } = useSession()
37
+ const { signIn, status, error } = useSignIn()
38
+
39
+ return (
40
+ <>
41
+ <SignedIn>
42
+ {viewer.handle} <button onClick={signOut}>Sign out</button>
43
+ </SignedIn>
44
+ <SignedOut>
45
+ <button onClick={() => signIn({ username, password })}>Sign in</button>
46
+ {error && <span>{error.detail}</span>}
47
+ </SignedOut>
48
+ </>
49
+ )
50
+ }
51
+ ```
52
+
53
+ `viewer` is flat — `viewer.handle`, `viewer.uuid`, `viewer.roles`. Also
54
+ `useSignUp`, `usePasswordReset`, and `completeChallenge` for two-factor sign-in.
55
+
56
+ ## Reading
57
+
58
+ ```jsx
59
+ import { useRecords, useEntity } from '@uniweb/api'
60
+
61
+ const { status, records, matched, hasMore } = useRecords({ schema: '@/session' })
62
+ ```
26
63
 
27
- const { status, viewer, canSignIn, error, signOut, refresh } = useSession()
28
- // status: 'loading' | 'anonymous' | 'authenticated'`anonymous` synchronously on
29
- // a site with no backend. One session per page, however many foundations it loads.
64
+ **`absent` and an empty `ready` are different answers.** `absent` means there is no
65
+ live source no backend, or nobody signed in so render your site's own content.
66
+ `ready` with `records: []` means the backend answered and there is nothing there.
67
+ Showing "nothing yet" for the first case tells a visitor their content is missing
68
+ when it is simply not being asked for.
30
69
 
31
- const { signIn, completeChallenge, challenge, status: signInStatus } = useSignIn()
32
- // signIn(credentials) posts the object unchanged; a second factor parks in
33
- // `challenge`, and completeChallenge(code) finishes it.
70
+ `useEntity({ schema, uuid, via })` reads one record. Its `absent` covers both
71
+ not-found and not-permitted, on purpose: render your paywall or sign-in prompt on it
72
+ and never say "deleted".
34
73
 
35
- const lesson = useEntity({ schema: '@/lesson', uuid, via: course.uuid })
36
- // status: 'loading' | 'ready' | 'absent' | 'error' — `absent` is one word for
37
- // not-found-and-not-permitted, so render the wall on it and never say "deleted".
74
+ ## Writing
38
75
 
39
- <SignedIn fallback={<Wall />}><Roster /></SignedIn>
76
+ ```jsx
77
+ import { useEntityWriter } from '@uniweb/api'
78
+
79
+ const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
80
+
81
+ await programme.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
82
+ await programme.update(itemId, { ...item.data, room: 'Hall A' })
83
+ await programme.move(itemId, { after: otherItemId })
84
+ await programme.remove(itemId)
85
+ await programme.batch([...]) // one transaction: all of them, or none
86
+ ```
87
+
88
+ Three things it does for you, and one it deliberately does not:
89
+
90
+ - **Concurrency is handled.** Every write carries the item's last-seen version and the
91
+ response updates it. You never touch a token.
92
+ - **`section` is required on `create`.** An entity has several, and a rule declared on
93
+ one — insert-only, say — does not reach an item that landed in another.
94
+ - **A successful write refreshes what it changed**, so a list you are showing reflects
95
+ it without a manual reload.
96
+ - ⛔ **A conflict is reported, never retried.** `writer.conflict` is set when someone
97
+ else changed the item first. A retry would *succeed*, by overwriting a change nobody
98
+ looked at — so what happens next is your application's decision, and usually it is
99
+ to tell the person.
100
+
101
+ ⚠️ `update` replaces the item's data whole. Spread what you are not editing.
102
+
103
+ ## A backend on your machine
104
+
105
+ Building against a live backend is slow and puts a shared database behind your
106
+ experiments. Name a local one in `site.yml`:
107
+
108
+ ```yaml
109
+ api: /_api # where the backend answers — the same in production
110
+ $devApi: ./mock/api.js # what answers it locally; never published
40
111
  ```
41
112
 
42
- Outside React the same calls are plain functions from `@uniweb/api/client` — `probeSession`,
43
- `signIn`, `completeChallenge`, `signOut`, `signUp`, `requestPasswordReset`,
44
- `confirmPasswordReset`, `readEntity`. Every refusal is an `ApiError` with a `kind` to branch on:
45
- `auth`, `absent`, `forbidden`, `invalid`, `conflict`, `csrf`, `step-up`, `rate-limited`,
46
- `unavailable`, `disabled`.
113
+ ```js
114
+ // mock/api.js
115
+ import { createMockBackend } from '@uniweb/api/mock'
116
+
117
+ export default createMockBackend({
118
+ seed: {
119
+ accounts: [{ username: 'me', password: 'me', units: ['staff'] }],
120
+ schemas: { '@/session': { creatable_by: 'unit_members' } },
121
+ entities: [{ uuid: 't-1', model: '@/track', data: { name: 'Main hall' }, items: [] }],
122
+ },
123
+ }).fetch
124
+ ```
47
125
 
48
- Not yet: lists of records, writes, commerce, notifications.
126
+ `uniweb dev` mounts it at your `api:` address — same origin, so cookies behave as they
127
+ will in production, and your site's configuration is identical either way.
49
128
 
50
- ## Testing
129
+ It **enforces** what your schemas declare — who may create entries, and which sections
130
+ are insert-only — so a permission you are relying on fails here rather than in front
131
+ of a user. State is in memory: restart to reset.
51
132
 
52
- `pnpm test` runs the suite against a stubbed `fetch` this package's own logic. The live suite
53
- runs the same calls against a real backend and is skipped unless `UNIWEB_API_BASE` names one. For
54
- the signed-in half, either pass `UNIWEB_API_LOGIN` (the JSON body of a sign-in) or set
55
- `UNIWEB_API_REGISTER=1` and the suite provisions a throwaway account through the backend's own
56
- sign-up path. No fake backend ships here.
133
+ There is also a standalone server, for a frontend that is not a Uniweb site:
57
134
 
58
135
  ```bash
59
- UNIWEB_API_BASE=http://localhost:8080 UNIWEB_API_REGISTER=1 pnpm test
136
+ npx uniweb-api-mock --port 8787
60
137
  ```
61
138
 
62
- ## How a site declares its backend
139
+ **The mock is a fixture of what this package expects, not a model of any real
140
+ backend.** Behaviour it happens to have is evidence about the mock and nothing else.
141
+
142
+ ## Outside React
63
143
 
64
- The address is a site service named `api`, resolved like every other service: the site's own `api:`
65
- in `site.yml` wins, then the host's `services.api`; absent from both, the site has no backend and the
66
- package does nothing. A foundation never writes the address.
144
+ `@uniweb/api/client` carries the same operations as plain functions and imports no
145
+ React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
146
+ `createEntity`, `deleteEntity`.
67
147
 
68
148
  ## License
69
149
 
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx uniweb-api-mock` — the mock backend on a port.
4
+ *
5
+ * ⚠️ Prefer mounting `middleware()` in the dev server you already run: the API is
6
+ * then same-origin with the site, which is what a real deployment looks like. Use
7
+ * this when the frontend is not a Uniweb site, or when proxying to it.
8
+ *
9
+ * uniweb-api-mock [--port 8787] [--prefix /_api] [--seed ./seed.js]
10
+ */
11
+ import { createMockBackend } from '../src/mock/index.js'
12
+ import { serve } from '../src/mock/node.js'
13
+
14
+ const argv = process.argv.slice(2)
15
+ const flag = (name, fallback) => {
16
+ const at = argv.indexOf(`--${name}`)
17
+ return at >= 0 && argv[at + 1] ? argv[at + 1] : fallback
18
+ }
19
+
20
+ const seedPath = flag('seed', null)
21
+ const seed = seedPath ? (await import(new URL(seedPath, `file://${process.cwd()}/`).href)).default : undefined
22
+
23
+ const mock = createMockBackend({ ...(seed ? { seed } : {}) })
24
+ const server = await serve(mock, { port: Number(flag('port', 8787)), prefix: flag('prefix', '') })
25
+
26
+ console.log(`uniweb-api-mock listening on ${server.url}`)
27
+ console.log(` accounts: ${mock.store.accounts.map((a) => a.username).join(', ')}`)
28
+ console.log(' state is in memory — restart to reset')
29
+
30
+ for (const signal of ['SIGINT', 'SIGTERM']) {
31
+ process.on(signal, () => {
32
+ server.close().then(() => process.exit(0))
33
+ })
34
+ }
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": {
7
7
  ".": "./src/index.js",
8
- "./client": "./src/client.js"
8
+ "./client": "./src/client.js",
9
+ "./wire": "./src/wire.js",
10
+ "./mock": "./src/mock/index.js",
11
+ "./mock/node": "./src/mock/node.js"
9
12
  },
10
13
  "files": [
11
- "src"
14
+ "src",
15
+ "bin"
12
16
  ],
13
17
  "sideEffects": false,
14
18
  "keywords": [
@@ -30,7 +34,7 @@
30
34
  "node": ">=20.19"
31
35
  },
32
36
  "dependencies": {
33
- "@uniweb/core": "^0.14.1"
37
+ "@uniweb/core": "^0.15.0"
34
38
  },
35
39
  "peerDependencies": {
36
40
  "react": "^19.0.0"
@@ -42,6 +46,9 @@
42
46
  "react-dom": "^19.0.0",
43
47
  "vitest": "^4.1.7"
44
48
  },
49
+ "bin": {
50
+ "uniweb-api-mock": "./bin/uniweb-api-mock.js"
51
+ },
45
52
  "scripts": {
46
53
  "test": "vitest run",
47
54
  "test:watch": "vitest"
package/src/client.js CHANGED
@@ -15,6 +15,8 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
15
15
  import { resolveService } from '@uniweb/core/services'
16
16
  import { ApiError } from './errors.js'
17
17
  import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
18
+ import { AUTH, ROUTES, PARAM, FIELD, LIST, OP } from './wire.js'
19
+ import { Ledger } from './ledger.js'
18
20
 
19
21
  /** The site service this package reads its base from — the only name it owns. */
20
22
  export const SERVICE_NAME = 'api'
@@ -87,8 +89,11 @@ export class ApiClient {
87
89
  this._listeners = new Set()
88
90
  this._pending = null
89
91
  this._challenge = null
90
- this._keys = new Set()
92
+ this._keys = new Map()
91
93
  this._inflight = new Map()
94
+ // One ledger per client, which is one per page — the right grain, since it is
95
+ // keyed by item and an item is the same item whoever is looking at it.
96
+ this.ledger = new Ledger()
92
97
  this._session = this.enabled ? LOADING : ANONYMOUS
93
98
  // Stable identity: `useSyncExternalStore` re-subscribes when this changes.
94
99
  this.subscribe = this.subscribe.bind(this)
@@ -253,7 +258,7 @@ export class ApiClient {
253
258
 
254
259
  async _probe() {
255
260
  try {
256
- const me = await this.request('GET', '/auth/me', { onUnauthorized: 'ignore' })
261
+ const me = await this.request('GET', AUTH.me, { onUnauthorized: 'ignore' })
257
262
  return this._authenticated(me)
258
263
  } catch (err) {
259
264
  if (err instanceof ApiError && err.status === 401) return this._sessionLost()
@@ -289,7 +294,7 @@ export class ApiClient {
289
294
  * with `completeChallenge(code)`. A refused credential throws (`kind: 'auth'`).
290
295
  */
291
296
  async signIn(credentials) {
292
- const body = await this.request('POST', '/auth/login', { body: credentials, onUnauthorized: 'ignore' })
297
+ const body = await this.request('POST', AUTH.login, { body: credentials, onUnauthorized: 'ignore' })
293
298
  if (body?.status === 'totp_required') {
294
299
  this._challenge = body.challenge_token ?? null
295
300
  return { ok: false, challenge: { kind: 'totp' } }
@@ -309,7 +314,7 @@ export class ApiClient {
309
314
  if (!this._challenge) {
310
315
  throw new ApiError({ status: 0, title: 'No Challenge', detail: 'no sign-in challenge is pending', kind: 'invalid' })
311
316
  }
312
- await this.request('POST', '/auth/login/challenge', {
317
+ await this.request('POST', AUTH.challenge, {
313
318
  body: { challenge_token: this._challenge, code },
314
319
  onUnauthorized: 'ignore',
315
320
  })
@@ -325,7 +330,7 @@ export class ApiClient {
325
330
  */
326
331
  async signOut() {
327
332
  try {
328
- await this.request('POST', '/auth/logout', { onUnauthorized: 'ignore' })
333
+ await this.request('POST', AUTH.logout, { onUnauthorized: 'ignore' })
329
334
  } finally {
330
335
  this._sessionLost()
331
336
  }
@@ -333,17 +338,17 @@ export class ApiClient {
333
338
 
334
339
  /** Sign up. `202` semantics: the account is inert until verified. */
335
340
  signUp(fields) {
336
- return this.request('POST', '/auth/register', { body: fields, onUnauthorized: 'ignore' })
341
+ return this.request('POST', AUTH.register, { body: fields, onUnauthorized: 'ignore' })
337
342
  }
338
343
 
339
344
  /** Ask for a password reset. The backend answers `202` whether or not the account exists. */
340
345
  requestPasswordReset(fields) {
341
- return this.request('POST', '/auth/reset/request', { body: fields, onUnauthorized: 'ignore' })
346
+ return this.request('POST', AUTH.resetRequest, { body: fields, onUnauthorized: 'ignore' })
342
347
  }
343
348
 
344
349
  /** Confirm a password reset with the token the viewer received. */
345
350
  confirmPasswordReset(fields) {
346
- return this.request('POST', '/auth/reset/confirm', { body: fields, onUnauthorized: 'ignore' })
351
+ return this.request('POST', AUTH.resetConfirm, { body: fields, onUnauthorized: 'ignore' })
347
352
  }
348
353
 
349
354
  // ── The cache ─────────────────────────────────────────────────────────────
@@ -361,37 +366,79 @@ export class ApiClient {
361
366
  return deriveCacheKey({ ...spec, endpoint: `api:${this.viewerId}:${spec.endpoint ?? ''}` })
362
367
  }
363
368
 
364
- /** Note a key this client wrote, so sign-out can remove it. */
365
- remember(key) {
366
- this._keys.add(key)
369
+ /**
370
+ * Note a key this client wrote, so sign-out can remove it — and remember the
371
+ * SPEC beside it, so a write can drop what it invalidated.
372
+ *
373
+ * ⚠️ The spec is kept because a key is a derived hash: nothing can be recovered
374
+ * from the key itself, so a cache that only holds keys can be cleared entirely
375
+ * or not at all.
376
+ *
377
+ * @param {string} key
378
+ * @param {object} [spec] - the spec the key was derived from
379
+ */
380
+ remember(key, spec) {
381
+ this._keys.set(key, spec || null)
367
382
  }
368
383
 
369
384
  /** Remove every entry written for the current viewer. */
370
385
  forgetViewer() {
371
386
  const store = this.website?.dataStore
372
- for (const key of this._keys) {
387
+ for (const key of this._keys.keys()) {
373
388
  store?.delete(key)
374
389
  this._inflight.delete(key)
375
390
  }
376
391
  this._keys.clear()
377
392
  }
378
393
 
394
+ /**
395
+ * Drop the cached reads a predicate matches — how a write makes its own effect
396
+ * visible without every caller hand-rolling it.
397
+ *
398
+ * ```js
399
+ * client.invalidate((spec) => spec.schema === '@/session')
400
+ * ```
401
+ *
402
+ * ⛔ **A key with no remembered spec is never matched, and never swept.** It is
403
+ * not knowable whether it belongs, and dropping an entry a caller still relies on
404
+ * to be safe about one it might not is the wrong trade: a stale read is visible
405
+ * and recoverable, an over-eager sweep is a refetch storm nobody attributes to
406
+ * this line.
407
+ *
408
+ * @param {(spec: object) => boolean} match
409
+ * @returns {number} how many entries were dropped
410
+ */
411
+ invalidate(match) {
412
+ if (typeof match !== 'function') return 0
413
+ const store = this.website?.dataStore
414
+ let dropped = 0
415
+ for (const [key, spec] of this._keys) {
416
+ if (!spec || !match(spec)) continue
417
+ store?.delete(key)
418
+ this._inflight.delete(key)
419
+ this._keys.delete(key)
420
+ dropped += 1
421
+ }
422
+ return dropped
423
+ }
424
+
379
425
  /**
380
426
  * Read through the cache: a hit answers at once, a miss runs `run` once for
381
427
  * every concurrent caller and writes what it returns.
382
428
  *
383
429
  * @param {string} key
384
430
  * @param {() => Promise<*>} run
431
+ * @param {object} [spec] - what the key was derived from, so `invalidate` can match it
385
432
  * @returns {Promise<*>}
386
433
  */
387
- load(key, run) {
434
+ load(key, run, spec) {
388
435
  const store = this.website?.dataStore
389
436
  if (store?.has(key)) return Promise.resolve(store.get(key).data)
390
437
  if (this._inflight.has(key)) return this._inflight.get(key)
391
438
  const pending = run()
392
439
  .then((data) => {
393
440
  store?.set(key, { data })
394
- this.remember(key)
441
+ this.remember(key, spec)
395
442
  return data
396
443
  })
397
444
  .finally(() => {
@@ -421,8 +468,8 @@ export class ApiClient {
421
468
  async readEntity({ schema, uuid, via, signal } = {}) {
422
469
  if (!uuid) throw new ApiError({ status: 0, title: 'No Entity', detail: 'readEntity needs a uuid', kind: 'invalid' })
423
470
  try {
424
- const entity = await this.request('GET', `/entities/${encodeURIComponent(uuid)}`, {
425
- query: { model: schema, via, ...this._localeQuery() },
471
+ const entity = await this.request('GET', ROUTES.read(uuid), {
472
+ query: { [PARAM.model]: schema, [PARAM.via]: via, ...this._localeQuery() },
426
473
  signal,
427
474
  })
428
475
  return { status: 'ready', entity }
@@ -431,6 +478,164 @@ export class ApiClient {
431
478
  throw err
432
479
  }
433
480
  }
481
+
482
+ /**
483
+ * List the entities of a Model the viewer may see.
484
+ *
485
+ * ⭐ **Scoped by the session, not by a filter this package adds.** The answer is
486
+ * what the viewer may see — an anonymous caller gets what is public, and that is
487
+ * the gate working rather than an empty result to explain away. ⚠️ A lapsed
488
+ * session is a `401` and not an empty list (backend, 2026-08-29): treating
489
+ * `records: []` as "perhaps you are signed out" would re-implement a bug they
490
+ * already fixed, and tell someone their content was gone when it was not.
491
+ *
492
+ * ## Paging is absorbed as far as it can honestly be
493
+ *
494
+ * `matched` is the count *before* paging, so `hasMore` is derivable without a
495
+ * second request. `all: true` asks the server for its own all-mode rather than
496
+ * looping pages from here — a loop this package ran would be slower, racier, and
497
+ * a reimplementation of something the route already does.
498
+ *
499
+ * ⛔ **No cursor, and no auto-following.** A caller that wants every page of a
500
+ * large Model says `all: true` and gets one request; a caller that wants pages
501
+ * gets pages. Inventing a third thing in between would hide which one is
502
+ * happening, and the cost of "it fetched everything" should be visible in the
503
+ * call.
504
+ *
505
+ * @param {object} args
506
+ * @param {string} args.schema - the Model, e.g. `@/session`
507
+ * @param {string} [args.scope] - the visibility scope the route accepts
508
+ * @param {number} [args.limit]
509
+ * @param {number} [args.offset]
510
+ * @param {boolean} [args.all] - one request for the whole slice; ignores limit/offset
511
+ * @param {AbortSignal} [args.signal]
512
+ * @returns {Promise<{ records: object[], matched: number, hasMore: boolean }>}
513
+ */
514
+ async listEntities({ schema, scope, limit, offset, all = false, signal } = {}) {
515
+ if (!schema) {
516
+ throw new ApiError({ status: 0, title: 'No Model', detail: 'listEntities needs a schema', kind: 'invalid' })
517
+ }
518
+ const query = { [PARAM.model]: schema, [PARAM.scope]: scope, ...this._localeQuery() }
519
+ if (all) query[PARAM.paginate] = false
520
+ else {
521
+ if (limit != null) query[PARAM.limit] = limit
522
+ if (offset != null) query[PARAM.offset] = offset
523
+ }
524
+
525
+ const body = await this.request('GET', ROUTES.list(), { query, signal })
526
+ const records = Array.isArray(body?.[LIST.records]) ? body[LIST.records] : []
527
+ // `matched` absent is not zero — it is unknown, and a caller reading zero would
528
+ // conclude "empty" from a body that just did not say. Fall back to what we hold.
529
+ const matched = typeof body?.[LIST.matched] === 'number' ? body[LIST.matched] : records.length
530
+ const seen = (offset || 0) + records.length
531
+ return { records, matched, hasMore: !all && seen < matched }
532
+ }
533
+
534
+ /**
535
+ * Write items of one entity — create, update, delete, move — as ONE transaction.
536
+ *
537
+ * The ops go out stamped with each item's last-seen token and the response is
538
+ * absorbed, so a caller never handles a precondition itself. That is the single
539
+ * most reinventable thing on this wire, and the reason it is absorbed rather
540
+ * than documented.
541
+ *
542
+ * ## ⛔ A conflict is REBASED, never retried
543
+ *
544
+ * A `409` means someone else changed the item since this viewer last read it.
545
+ * The ledger takes the current token off the error, so the caller's *next*
546
+ * attempt is guarded by the truth rather than by what we believed — and then the
547
+ * error is thrown.
548
+ *
549
+ * ⚖️ **Retrying automatically would be the wrong kind of helpful.** The write
550
+ * would then succeed, and it would succeed by overwriting a change nobody looked
551
+ * at. Concurrency is the one place where finishing the job for the caller
552
+ * destroys the thing the guard exists to protect. ⇒ We remove the *bookkeeping*
553
+ * and leave the *decision*.
554
+ *
555
+ * @param {object} args
556
+ * @param {string} args.schema - the entity's Model
557
+ * @param {string} args.uuid - the entity whose items these are
558
+ * @param {object|object[]} args.ops - one op, or a batch run all-or-nothing
559
+ * @param {boolean} [args.readback] - ask for the written items back
560
+ * @param {AbortSignal} [args.signal]
561
+ * @returns {Promise<*>} the write response, already absorbed
562
+ */
563
+ async writeItems({ schema, uuid, ops, readback = false, signal } = {}) {
564
+ if (!uuid) {
565
+ throw new ApiError({ status: 0, title: 'No Entity', detail: 'writeItems needs a uuid', kind: 'invalid' })
566
+ }
567
+ const list = Array.isArray(ops) ? ops : [ops]
568
+ if (list.length === 0) {
569
+ throw new ApiError({ status: 0, title: 'No Ops', detail: 'writeItems needs at least one op', kind: 'invalid' })
570
+ }
571
+ const stamped = list.map((op) => this.ledger.stamp(op))
572
+ const query = { [PARAM.model]: schema }
573
+ if (readback) query[PARAM.readback] = true
574
+
575
+ try {
576
+ const result = await this.request('POST', ROUTES.items(uuid), {
577
+ query,
578
+ body: Array.isArray(ops) ? stamped : stamped[0],
579
+ signal,
580
+ })
581
+ this.ledger.absorb(result)
582
+ return result
583
+ } catch (err) {
584
+ if (err instanceof ApiError && err.status === 409) {
585
+ // Rebase whichever item the server named. A batch reports one conflict at a
586
+ // time — the transaction stopped there — so one id is the whole answer.
587
+ const id = err.extensions?.[FIELD.item] ?? stamped.find((op) => op?.[FIELD.item] != null)?.[FIELD.item]
588
+ if (id != null) this.ledger.rebase(id, err)
589
+ }
590
+ throw err
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Create an entity of a Model, optionally with its first items.
596
+ *
597
+ * ⚠️ Not idempotent, and deliberately not made so: two calls make two entities.
598
+ * A caller that must not double-create holds the result, the way it would with
599
+ * any other create.
600
+ *
601
+ * @param {object} args
602
+ * @param {string} args.schema
603
+ * @param {object} [args.data] - the initial content, in the Model's own shape
604
+ * @param {AbortSignal} [args.signal]
605
+ * @returns {Promise<*>}
606
+ */
607
+ async createEntity({ schema, data, signal } = {}) {
608
+ if (!schema) {
609
+ throw new ApiError({ status: 0, title: 'No Model', detail: 'createEntity needs a schema', kind: 'invalid' })
610
+ }
611
+ return this.request('POST', ROUTES.create(), {
612
+ query: { [PARAM.model]: schema },
613
+ body: data ?? {},
614
+ signal,
615
+ })
616
+ }
617
+
618
+ /**
619
+ * Delete an entity. Its items cascade.
620
+ *
621
+ * ⚠️ `revRefPolicy` decides what happens when another entity references this
622
+ * one. The route's own default refuses — which is the safe direction, and the
623
+ * one this package keeps by not choosing for the caller.
624
+ *
625
+ * @param {object} args
626
+ * @param {string} args.uuid
627
+ * @param {'abort'|'orphan_refs'} [args.revRefPolicy]
628
+ * @param {AbortSignal} [args.signal]
629
+ */
630
+ async deleteEntity({ uuid, revRefPolicy, signal } = {}) {
631
+ if (!uuid) {
632
+ throw new ApiError({ status: 0, title: 'No Entity', detail: 'deleteEntity needs a uuid', kind: 'invalid' })
633
+ }
634
+ return this.request('DELETE', ROUTES.remove(uuid), {
635
+ query: { [PARAM.revRefPolicy]: revRefPolicy },
636
+ signal,
637
+ })
638
+ }
434
639
  }
435
640
 
436
641
  // Reached only on a `@uniweb/core` older than the `api` slot, where the sealed
@@ -491,6 +696,14 @@ export const requestPasswordReset = (fields) => required().requestPasswordReset(
491
696
  export const confirmPasswordReset = (fields) => required().confirmPasswordReset(fields)
492
697
  /** @see ApiClient#readEntity */
493
698
  export const readEntity = (args) => required().readEntity(args)
699
+ /** @see ApiClient#listEntities */
700
+ export const listEntities = (args) => required().listEntities(args)
701
+ /** @see ApiClient#writeItems */
702
+ export const writeItems = (args) => required().writeItems(args)
703
+ /** @see ApiClient#createEntity */
704
+ export const createEntity = (args) => required().createEntity(args)
705
+ /** @see ApiClient#deleteEntity */
706
+ export const deleteEntity = (args) => required().deleteEntity(args)
494
707
 
495
708
  export { ApiError, kindOf } from './errors.js'
496
709
  export { Ledger } from './ledger.js'
@@ -54,7 +54,7 @@ export function useEntity(ref) {
54
54
  if (!key || entry) return undefined
55
55
  let live = true
56
56
  setError(null)
57
- client.load(key, () => client.readEntity(ref)).catch((err) => {
57
+ client.load(key, () => client.readEntity(ref), { schema: ref.schema, uuid: ref.uuid }).catch((err) => {
58
58
  if (live) setError(err)
59
59
  })
60
60
  return () => {