@uniweb/api 0.3.6 → 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/src/client.js CHANGED
@@ -15,7 +15,7 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
15
15
  import { resolveService } from '@uniweb/core'
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'
18
+ import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP } from './wire.js'
19
19
  import { Ledger } from './ledger.js'
20
20
 
21
21
  /** The site service this package reads its base from — the only name it owns. */
@@ -109,6 +109,10 @@ export class ApiClient {
109
109
  this._challenge = null
110
110
  this._keys = new Map()
111
111
  this._inflight = new Map()
112
+ // A Model's sections by name, per Model — what an item create needs to say
113
+ // which section it writes into. Readability depends on the viewer, so it is
114
+ // dropped with the viewer's other entries.
115
+ this._schemas = new Map()
112
116
  // One ledger per client, which is one per page — the right grain, since it is
113
117
  // keyed by item and an item is the same item whoever is looking at it.
114
118
  this.ledger = new Ledger()
@@ -187,7 +191,7 @@ export class ApiClient {
187
191
  * cache entries leave memory and the session turns anonymous.
188
192
  *
189
193
  * @param {string} method
190
- * @param {string} path - the route under `/api`
194
+ * @param {string} path - the route, relative to the base (`/entities`)
191
195
  * @param {object} [options]
192
196
  * @param {object} [options.query]
193
197
  * @param {*} [options.body]
@@ -234,10 +238,23 @@ export class ApiClient {
234
238
  throw error
235
239
  }
236
240
 
241
+ /**
242
+ * The locale preference list for a read of localized values: the active locale,
243
+ * then the site's default.
244
+ *
245
+ * ⛔ **The backend applies the list in order and has no fallback of its own** — a
246
+ * localized field with no value in any listed locale is omitted from the answer
247
+ * (measured 2026-09-18: `locale=de` drops an English-only field, `locale=de,en`
248
+ * returns it in English). Sending the active locale alone therefore hid every
249
+ * field not yet translated from a visitor in another language; the site's default
250
+ * is the same fallback its own static content uses.
251
+ */
237
252
  _localeQuery() {
238
253
  const website = this.website
239
- const locale = website?.getActiveLocale?.() ?? website?.activeLocale ?? null
240
- return locale ? { locale } : null
254
+ const active = website?.getActiveLocale?.() ?? website?.activeLocale ?? null
255
+ const fallback = website?.getDefaultLocale?.() ?? website?.defaultLocale ?? null
256
+ const locales = [active, fallback].filter((l, i, all) => l && all.indexOf(l) === i)
257
+ return locales.length ? { [PARAM.locale]: locales.join(',') } : null
241
258
  }
242
259
 
243
260
  // ── The session ───────────────────────────────────────────────────────────
@@ -304,17 +321,18 @@ export class ApiClient {
304
321
 
305
322
  /**
306
323
  * Sign in. The credentials object is handed to the backend as the request
307
- * body, unchanged — this package does not decide its field names.
324
+ * body, unchanged: `{ username, password }` (`AUTH_BODY` in `./wire.js`).
308
325
  *
309
326
  * @param {object} credentials
310
327
  * @returns {Promise<{ ok: boolean, viewer?: object|null, challenge?: { kind: 'totp' } }>}
311
328
  * `ok: false` with a `challenge` when a second factor is required — finish
312
- * with `completeChallenge(code)`. A refused credential throws (`kind: 'auth'`).
329
+ * with `completeChallenge(code)`. A refused credential throws (`kind: 'auth'`);
330
+ * the right password for an address not yet verified throws `kind: 'unverified'`.
313
331
  */
314
332
  async signIn(credentials) {
315
333
  const body = await this.request('POST', AUTH.login, { body: credentials, onUnauthorized: 'ignore' })
316
- if (body?.status === 'totp_required') {
317
- this._challenge = body.challenge_token ?? null
334
+ if (body?.status === TOTP.status) {
335
+ this._challenge = body[TOTP.token] ?? null
318
336
  return { ok: false, challenge: { kind: 'totp' } }
319
337
  }
320
338
  this._challenge = null
@@ -333,7 +351,7 @@ export class ApiClient {
333
351
  throw new ApiError({ status: 0, title: 'No Challenge', detail: 'no sign-in challenge is pending', kind: 'invalid' })
334
352
  }
335
353
  await this.request('POST', AUTH.challenge, {
336
- body: { challenge_token: this._challenge, code },
354
+ body: { [TOTP.token]: this._challenge, [TOTP.code]: code },
337
355
  onUnauthorized: 'ignore',
338
356
  })
339
357
  this._challenge = null
@@ -345,26 +363,43 @@ export class ApiClient {
345
363
  * Sign out. The session turns anonymous locally whatever the backend
346
364
  * answers — the viewer asked to leave — and the viewer's entries leave the
347
365
  * cache.
366
+ *
367
+ * A `401` is not an error here: it is the backend saying there was no session
368
+ * to end — a cookie that expired while the page stayed open — which is exactly
369
+ * the state being asked for. Anything else still throws, because then a live
370
+ * session may be left behind on the server.
348
371
  */
349
372
  async signOut() {
350
373
  try {
351
374
  await this.request('POST', AUTH.logout, { onUnauthorized: 'ignore' })
375
+ } catch (err) {
376
+ if (!(err instanceof ApiError && err.status === 401)) throw err
352
377
  } finally {
353
378
  this._sessionLost()
354
379
  }
355
380
  }
356
381
 
357
- /** Sign up. `202` semantics: the account is inert until verified. */
382
+ /**
383
+ * Sign up — `{ username, email, password }`, passed through unchanged.
384
+ *
385
+ * Answers `202 { status: 'verification_required', email }`, and the SAME answer
386
+ * when the address is already taken, so it never confirms who has an account.
387
+ * The account cannot sign in until the address is verified from the email the
388
+ * backend sends. A taken username is refused (`409`).
389
+ */
358
390
  signUp(fields) {
359
391
  return this.request('POST', AUTH.register, { body: fields, onUnauthorized: 'ignore' })
360
392
  }
361
393
 
362
- /** Ask for a password reset. The backend answers `202` whether or not the account exists. */
394
+ /** Ask for a password reset — `{ email }`. `202` whether or not the address is known. */
363
395
  requestPasswordReset(fields) {
364
396
  return this.request('POST', AUTH.resetRequest, { body: fields, onUnauthorized: 'ignore' })
365
397
  }
366
398
 
367
- /** Confirm a password reset with the token the viewer received. */
399
+ /**
400
+ * Confirm a password reset — `{ token, new_password, code? }`, with the token
401
+ * the viewer received and `code` when a second factor is enrolled.
402
+ */
368
403
  confirmPasswordReset(fields) {
369
404
  return this.request('POST', AUTH.resetConfirm, { body: fields, onUnauthorized: 'ignore' })
370
405
  }
@@ -422,6 +457,7 @@ export class ApiClient {
422
457
  this._inflight.delete(key)
423
458
  }
424
459
  this._keys.clear()
460
+ this._schemas.clear()
425
461
  }
426
462
 
427
463
  /**
@@ -487,24 +523,36 @@ export class ApiClient {
487
523
  * Read one entity by id — through a container the viewer holds an
488
524
  * entitlement on, when `via` names one.
489
525
  *
526
+ * The entity comes back as the backend answers it:
527
+ * `{ model_uuid, model_name, can_edit, hydrated: { entity, items } }` — the
528
+ * content is `hydrated.items`, each `{ id, section_id, data, updated_at, … }`,
529
+ * and `hydrated.entity.brief` is its summary. `can_edit` is the write gate's
530
+ * own answer for this viewer.
531
+ *
532
+ * ⭐ **The read seeds the concurrency ledger** with each item's `updated_at`, so
533
+ * the first edit of an item is guarded by the version the viewer was shown.
534
+ *
490
535
  * `absent` is one word for not-found-and-not-permitted, by the backend's
491
536
  * design; a component renders its enrol or paywall on it and never says
492
537
  * "deleted". Any other refusal throws.
493
538
  *
494
539
  * @param {object} args
495
- * @param {string} args.schema - the entity's Model, e.g. `@/lesson`
540
+ * @param {string} args.schema - the entity's Model, e.g. `@acme/lesson` — required; the backend reads by Model
496
541
  * @param {string} args.uuid
497
542
  * @param {string} [args.via] - the granting container's uuid
498
543
  * @param {AbortSignal} [args.signal]
499
544
  * @returns {Promise<{ status: 'ready'|'absent', entity: object|null }>}
500
545
  */
501
546
  async readEntity({ schema, uuid, via, signal } = {}) {
502
- if (!uuid) throw new ApiError({ status: 0, title: 'No Entity', detail: 'readEntity needs a uuid', kind: 'invalid' })
547
+ if (!uuid) throw ApiError.invalid('No Entity', 'readEntity needs a uuid')
548
+ if (!schema) throw ApiError.invalid('No Model', 'readEntity needs a schema — the backend reads an entity by its Model')
549
+ const mark = this.ledger.mark()
503
550
  try {
504
551
  const entity = await this.request('GET', ROUTES.read(uuid), {
505
552
  query: { [PARAM.model]: schema, [PARAM.via]: via, ...this._localeQuery() },
506
553
  signal,
507
554
  })
555
+ this.ledger.observe(entity?.[READ.hydrated]?.[READ.items], mark)
508
556
  return { status: 'ready', entity }
509
557
  } catch (err) {
510
558
  if (err instanceof ApiError && err.kind === 'absent') return { status: 'absent', entity: null }
@@ -513,95 +561,166 @@ export class ApiClient {
513
561
  }
514
562
 
515
563
  /**
516
- * List the entities of a Model the viewer may see.
564
+ * List the entities of a Model the viewer may read.
517
565
  *
518
- * ⭐ **Scoped by the session, not by a filter this package adds.** The answer is
519
- * what the viewer may see — an anonymous caller gets what is public, and that is
520
- * the gate working rather than an empty result to explain away. ⚠️ A lapsed
521
- * session is a `401` and not an empty list (backend, 2026-08-29): treating
522
- * `records: []` as "perhaps you are signed out" would re-implement a bug they
523
- * already fixed, and tell someone their content was gone when it was not.
566
+ * ⭐ **Scoped by the session, not by a filter this package adds.** Signed out,
567
+ * there is nothing to list — the backend answers `401`, and a lapsed session is
568
+ * a `401` too, never an empty list (so `records: []` means empty).
524
569
  *
525
- * ## Paging is absorbed as far as it can honestly be
570
+ * ⚠️ **`scope` decides whose.** The default, `accessible`, is everything the
571
+ * viewer may read — on a site's `api` service that is every member's entities
572
+ * of the Model, not only the viewer's. `mine` is only what the viewer owns.
526
573
  *
527
- * `matched` is the count *before* paging, so `hasMore` is derivable without a
528
- * second request. `all: true` asks the server for its own all-mode rather than
529
- * looping pages from here — a loop this package ran would be slower, racier, and
530
- * a reimplementation of something the route already does.
574
+ * ## Paging, as far as it can honestly be absorbed
531
575
  *
532
- * ⛔ **No cursor, and no auto-following.** A caller that wants every page of a
533
- * large Model says `all: true` and gets one request; a caller that wants pages
534
- * gets pages. Inventing a third thing in between would hide which one is
535
- * happening, and the cost of "it fetched everything" should be visible in the
536
- * call.
576
+ * A row is the entity's summary: `{ uuid, brief, owner_id, via, … }` — no items.
577
+ * ⛔ **`matched` counts the rows in THIS answer** — the page, when paging — and
578
+ * the backend offers no total while paging. So `hasMore` is the one honest
579
+ * signal: the page came back full, and a next page may hold more. `all: true`
580
+ * asks the backend for the whole slice in one request; then `matched` is the
581
+ * total and `hasMore` is false.
537
582
  *
538
583
  * @param {object} args
539
- * @param {string} args.schema - the Model, e.g. `@/session`
540
- * @param {string} [args.scope] - the visibility scope the route accepts
541
- * @param {number} [args.limit]
584
+ * @param {string} args.schema - the Model, e.g. `@acme/session`
585
+ * @param {'accessible'|'mine'|'all'} [args.scope] - default `accessible`
586
+ * @param {number} [args.limit] - the page size; default 50, at most 1000
542
587
  * @param {number} [args.offset]
543
588
  * @param {boolean} [args.all] - one request for the whole slice; ignores limit/offset
544
589
  * @param {AbortSignal} [args.signal]
545
590
  * @returns {Promise<{ records: object[], matched: number, hasMore: boolean }>}
546
591
  */
547
592
  async listEntities({ schema, scope, limit, offset, all = false, signal } = {}) {
548
- if (!schema) {
549
- throw new ApiError({ status: 0, title: 'No Model', detail: 'listEntities needs a schema', kind: 'invalid' })
550
- }
593
+ if (!schema) throw ApiError.invalid('No Model', 'listEntities needs a schema')
551
594
  const query = { [PARAM.model]: schema, [PARAM.scope]: scope, ...this._localeQuery() }
595
+ let pageSize = null
552
596
  if (all) query[PARAM.paginate] = false
553
597
  else {
554
- if (limit != null) query[PARAM.limit] = limit
598
+ pageSize = limit ?? PAGE.size
599
+ query[PARAM.limit] = pageSize
555
600
  if (offset != null) query[PARAM.offset] = offset
556
601
  }
557
602
 
558
603
  const body = await this.request('GET', ROUTES.list(), { query, signal })
559
604
  const records = Array.isArray(body?.[LIST.records]) ? body[LIST.records] : []
560
- // `matched` absent is not zero — it is unknown, and a caller reading zero would
561
- // conclude "empty" from a body that just did not say. Fall back to what we hold.
562
605
  const matched = typeof body?.[LIST.matched] === 'number' ? body[LIST.matched] : records.length
563
- const seen = (offset || 0) + records.length
564
- return { records, matched, hasMore: !all && seen < matched }
606
+ return { records, matched, hasMore: pageSize != null && pageSize > 0 && records.length >= pageSize }
607
+ }
608
+
609
+ /**
610
+ * A Model's sections — `{ id, name, parent }` each — read from its definition
611
+ * once per viewer and kept.
612
+ *
613
+ * @param {string} schema
614
+ * @param {AbortSignal} [signal]
615
+ * @returns {Promise<Array<{ id: number, name: string, parent: number|null }>>}
616
+ */
617
+ sections(schema, signal) {
618
+ if (!schema) return Promise.reject(ApiError.invalid('No Model', 'sections needs a schema'))
619
+ const held = this._schemas.get(schema)
620
+ if (held) return held
621
+ const pending = this.request('GET', ROUTES.schema(schema), { signal }).then((body) =>
622
+ (Array.isArray(body?.[SCHEMA.sections]) ? body[SCHEMA.sections] : []).map((s) => ({
623
+ id: s[SCHEMA.id],
624
+ name: s[SCHEMA.name],
625
+ parent: s[SCHEMA.parent] ?? null,
626
+ })),
627
+ )
628
+ this._schemas.set(schema, pending)
629
+ // A failure is not kept: the next write asks again.
630
+ pending.catch(() => {
631
+ if (this._schemas.get(schema) === pending) this._schemas.delete(schema)
632
+ })
633
+ return pending
634
+ }
635
+
636
+ /**
637
+ * The numeric id of a section, by name — or by `parent/child` path for a nested
638
+ * one — which is how the item route names a section.
639
+ *
640
+ * ⭐ **This is why a caller never meets a section id.** Creating an entity takes
641
+ * section names; the item route after it takes ids, and ids differ from one
642
+ * backend to the next. So a name is resolved here, from the Model's definition.
643
+ *
644
+ * ⚠️ The definition is readable for the Models the viewer may create, and by the
645
+ * site's operator. Anywhere else this cannot resolve a name, and says so — pass
646
+ * the section's numeric id instead (an item's `section_id` on a read).
647
+ *
648
+ * @param {string} schema
649
+ * @param {string|number} section - a name, a `parent/child` path, or an id
650
+ * @param {AbortSignal} [signal]
651
+ * @returns {Promise<number>}
652
+ */
653
+ async sectionId(schema, section, signal) {
654
+ if (typeof section === 'number') return section
655
+ let sections
656
+ try {
657
+ sections = await this.sections(schema, signal)
658
+ } catch (err) {
659
+ if (err instanceof ApiError && err.kind === 'absent') {
660
+ throw ApiError.invalid(
661
+ 'Unknown Section',
662
+ `section '${section}' of ${schema} cannot be resolved: this viewer cannot read the Model's definition — pass the section's numeric id`,
663
+ )
664
+ }
665
+ throw err
666
+ }
667
+ const path = String(section).split('/').filter(Boolean)
668
+ let candidates = sections.filter((s) => s.name === path[0] && (path.length === 1 || s.parent == null))
669
+ for (const name of path.slice(1)) {
670
+ const parents = new Set(candidates.map((s) => s.id))
671
+ candidates = sections.filter((s) => s.name === name && parents.has(s.parent))
672
+ }
673
+ if (candidates.length === 1) return candidates[0].id
674
+ throw ApiError.invalid(
675
+ 'Unknown Section',
676
+ candidates.length
677
+ ? `section '${section}' of ${schema} is ambiguous — name it by its path, parent/child`
678
+ : `${schema} has no section '${section}'`,
679
+ )
680
+ }
681
+
682
+ /** An op, with a section NAME on a create resolved to the id the item route takes. */
683
+ async _resolveOp(schema, op, signal) {
684
+ if (op?.kind !== OP.create || op[FIELD.section] != null || op[CREATE.sectionName] == null) return op
685
+ const { [CREATE.sectionName]: section, ...rest } = op
686
+ return { ...rest, [FIELD.section]: await this.sectionId(schema, section, signal) }
565
687
  }
566
688
 
567
689
  /**
568
690
  * Write items of one entity — create, update, delete, move — as ONE transaction.
569
691
  *
570
- * The ops go out stamped with each item's last-seen token and the response is
571
- * absorbed, so a caller never handles a precondition itself. That is the single
572
- * most reinventable thing on this wire, and the reason it is absorbed rather
573
- * than documented.
692
+ * The ops go out stamped with each item's last-seen token and the answer is
693
+ * absorbed, so a caller never handles a precondition itself. A create may name
694
+ * its section (`section: 'sessions'`); it is resolved to the id the route takes.
574
695
  *
575
696
  * ## ⛔ A conflict is REBASED, never retried
576
697
  *
577
- * A `409` means someone else changed the item since this viewer last read it.
578
- * The ledger takes the current token off the error, so the caller's *next*
698
+ * A stale `409` means someone else changed the item since this viewer last saw
699
+ * it. The ledger takes the current token off the error, so the caller's *next*
579
700
  * attempt is guarded by the truth rather than by what we believed — and then the
580
- * error is thrown.
701
+ * error is thrown. Retrying automatically would succeed by overwriting a change
702
+ * nobody looked at. ⇒ We remove the *bookkeeping* and leave the *decision*.
581
703
  *
582
- * ⚖️ **Retrying automatically would be the wrong kind of helpful.** The write
583
- * would then succeed, and it would succeed by overwriting a change nobody looked
584
- * at. Concurrency is the one place where finishing the job for the caller
585
- * destroys the thing the guard exists to protect. ⇒ We remove the *bookkeeping*
586
- * and leave the *decision*.
704
+ * ⚠️ **The stale `409` does not say which item was stale.** For one guarded op
705
+ * that is the op's item; in a batch of several it is unknowable, and rebasing a
706
+ * guessed item would put one item's token on another — so a batch is not rebased.
587
707
  *
588
708
  * @param {object} args
589
709
  * @param {string} args.schema - the entity's Model
590
710
  * @param {string} args.uuid - the entity whose items these are
591
711
  * @param {object|object[]} args.ops - one op, or a batch run all-or-nothing
592
- * @param {boolean} [args.readback] - ask for the written items back
712
+ * @param {boolean} [args.readback] - answer with the entity as it is after the write
593
713
  * @param {AbortSignal} [args.signal]
594
- * @returns {Promise<*>} the write response, already absorbed
714
+ * @returns {Promise<*>} the write's answer, already absorbed
595
715
  */
596
716
  async writeItems({ schema, uuid, ops, readback = false, signal } = {}) {
597
- if (!uuid) {
598
- throw new ApiError({ status: 0, title: 'No Entity', detail: 'writeItems needs a uuid', kind: 'invalid' })
599
- }
717
+ if (!uuid) throw ApiError.invalid('No Entity', 'writeItems needs a uuid')
718
+ if (!schema) throw ApiError.invalid('No Model', 'writeItems needs a schema')
600
719
  const list = Array.isArray(ops) ? ops : [ops]
601
- if (list.length === 0) {
602
- throw new ApiError({ status: 0, title: 'No Ops', detail: 'writeItems needs at least one op', kind: 'invalid' })
603
- }
604
- const stamped = list.map((op) => this.ledger.stamp(op))
720
+ if (list.length === 0) throw ApiError.invalid('No Ops', 'writeItems needs at least one op')
721
+
722
+ const resolved = await Promise.all(list.map((op) => this._resolveOp(schema, op, signal)))
723
+ const stamped = resolved.map((op) => this.ledger.stamp(op))
605
724
  const query = { [PARAM.model]: schema }
606
725
  if (readback) query[PARAM.readback] = true
607
726
 
@@ -611,13 +730,13 @@ export class ApiClient {
611
730
  body: Array.isArray(ops) ? stamped : stamped[0],
612
731
  signal,
613
732
  })
614
- this.ledger.absorb(result)
733
+ this.ledger.absorb(result, stamped)
615
734
  return result
616
735
  } catch (err) {
617
- if (err instanceof ApiError && err.status === 409) {
618
- // Rebase whichever item the server named. A batch reports one conflict at a
619
- // time — the transaction stopped there — so one id is the whole answer.
620
- const id = err.extensions?.[FIELD.item] ?? stamped.find((op) => op?.[FIELD.item] != null)?.[FIELD.item]
736
+ if (err instanceof ApiError && err.kind === 'conflict') {
737
+ const guarded = stamped.filter((op) => GUARDED_OPS.has(op?.kind) && op?.[FIELD.item] != null)
738
+ const named = err.extensions?.[FIELD.item]
739
+ const id = named ?? (guarded.length === 1 ? guarded[0][FIELD.item] : null)
621
740
  if (id != null) this.ledger.rebase(id, err)
622
741
  }
623
742
  throw err
@@ -625,27 +744,59 @@ export class ApiClient {
625
744
  }
626
745
 
627
746
  /**
628
- * Create an entity of a Model, optionally with its first items.
747
+ * Create an entity of a Model, with its first items.
748
+ *
749
+ * ```js
750
+ * await createEntity({
751
+ * schema: '@acme/course',
752
+ * items: [{ section: 'course', data: { title: 'Intro' } }],
753
+ * })
754
+ * ```
755
+ *
756
+ * ⛔ **An entity has no content of its own — its content is items, each in a
757
+ * section.** Name each item's section; the entity and its items commit together,
758
+ * or neither does. The backend maintains the entity's `brief` from its brief
759
+ * section's item. `data` at the top level is refused here, because the backend
760
+ * would ignore it — an empty entity and no error.
629
761
  *
630
762
  * ⚠️ Not idempotent, and deliberately not made so: two calls make two entities.
631
- * A caller that must not double-create holds the result, the way it would with
632
- * any other create.
763
+ * A successful create drops this Model's cached reads, so a list on the page
764
+ * shows it.
633
765
  *
634
766
  * @param {object} args
635
767
  * @param {string} args.schema
636
- * @param {object} [args.data] - the initial content, in the Model's own shape
768
+ * @param {Array<{ section: string, data?: object, parent?: number }>} [args.items]
637
769
  * @param {AbortSignal} [args.signal]
638
- * @returns {Promise<*>}
770
+ * @returns {Promise<object>} the new entity's row — `{ uuid, brief, … }`
639
771
  */
640
- async createEntity({ schema, data, signal } = {}) {
641
- if (!schema) {
642
- throw new ApiError({ status: 0, title: 'No Model', detail: 'createEntity needs a schema', kind: 'invalid' })
772
+ async createEntity({ schema, items, data, signal } = {}) {
773
+ if (!schema) throw ApiError.invalid('No Model', 'createEntity needs a schema')
774
+ if (data !== undefined) {
775
+ throw ApiError.invalid(
776
+ 'No Entity Data',
777
+ "an entity's content is items in its sections — pass items: [{ section, data }]",
778
+ )
643
779
  }
644
- return this.request('POST', ROUTES.create(), {
780
+ const list = items ?? []
781
+ if (!Array.isArray(list) || list.some((item) => !item || !item[CREATE.sectionName])) {
782
+ throw ApiError.invalid('No Section', 'every item of createEntity needs a section')
783
+ }
784
+ const body = list.length
785
+ ? {
786
+ [CREATE.items]: list.map((item) => ({
787
+ [CREATE.sectionName]: item[CREATE.sectionName],
788
+ data: item.data ?? {},
789
+ ...(item.parent != null ? { [FIELD.parent]: item.parent } : {}),
790
+ })),
791
+ }
792
+ : undefined
793
+ const created = await this.request('POST', ROUTES.create(), {
645
794
  query: { [PARAM.model]: schema },
646
- body: data ?? {},
795
+ body,
647
796
  signal,
648
797
  })
798
+ this.invalidate((spec) => spec?.schema === schema)
799
+ return created
649
800
  }
650
801
 
651
802
  /**
@@ -653,21 +804,23 @@ export class ApiClient {
653
804
  *
654
805
  * ⚠️ `revRefPolicy` decides what happens when another entity references this
655
806
  * one. The route's own default refuses — which is the safe direction, and the
656
- * one this package keeps by not choosing for the caller.
807
+ * one this package keeps by not choosing for the caller. Cached reads of the
808
+ * entity are dropped, and those of its Model when `schema` is given.
657
809
  *
658
810
  * @param {object} args
659
811
  * @param {string} args.uuid
812
+ * @param {string} [args.schema] - its Model, so this Model's lists re-read
660
813
  * @param {'abort'|'orphan_refs'} [args.revRefPolicy]
661
814
  * @param {AbortSignal} [args.signal]
662
815
  */
663
- async deleteEntity({ uuid, revRefPolicy, signal } = {}) {
664
- if (!uuid) {
665
- throw new ApiError({ status: 0, title: 'No Entity', detail: 'deleteEntity needs a uuid', kind: 'invalid' })
666
- }
667
- return this.request('DELETE', ROUTES.remove(uuid), {
816
+ async deleteEntity({ uuid, schema, revRefPolicy, signal } = {}) {
817
+ if (!uuid) throw ApiError.invalid('No Entity', 'deleteEntity needs a uuid')
818
+ const answer = await this.request('DELETE', ROUTES.remove(uuid), {
668
819
  query: { [PARAM.revRefPolicy]: revRefPolicy },
669
820
  signal,
670
821
  })
822
+ this.invalidate((spec) => spec?.uuid === uuid || (schema != null && spec?.schema === schema))
823
+ return answer
671
824
  }
672
825
  }
673
826
 
package/src/errors.js CHANGED
@@ -9,12 +9,23 @@
9
9
  * What each kind means to a component:
10
10
  *
11
11
  * auth not signed in, or the credential is dead → offer sign-in
12
+ * unverified the right password for an address not yet verified → say
13
+ * "check your email"; a sign-in cannot succeed until it is
12
14
  * absent nothing here for you — not found OR not permitted, which the
13
15
  * backend keeps indistinguishable on purpose. Never say "deleted"
14
- * forbidden you may see it, not do this to it → a capability message
15
- * invalid a malformed request — a bug in the caller, raised before auth
16
- * conflict a stale concurrency token; `extensions.current_updated_at`
17
- * carries the item's current one
16
+ * forbidden you may see it, not do this to it → a capability message.
17
+ * `extensions.op` names what was refused (`edit`, `delete`,
18
+ * `use_model` for a create)
19
+ * invalid the request does not fit — a malformed call (a bug in the
20
+ * caller), or content the Model refuses: `extensions.field`
21
+ * names the field then (`data.minutes`)
22
+ * conflict someone changed the item since this viewer last saw it;
23
+ * `extensions.current_updated_at` carries its current token.
24
+ * ONLY this. Retrying blind would overwrite their change
25
+ * rule the write breaks a rule the Model declares — an insert-only
26
+ * section, a section that holds one item, a reference that still
27
+ * points here. `detail` says which; the same write fails the same
28
+ * way again
18
29
  * csrf the mutation lacked the header this package always sends —
19
30
  * a bug, not a state to handle
20
31
  * step-up the credential must be re-proven for this mutation
@@ -24,26 +35,37 @@
24
35
  * unknown none of the above
25
36
  */
26
37
 
38
+ import { FIELD, PROBLEM } from './wire.js'
39
+
27
40
  const TITLE_KINDS = {
28
- 'CSRF Header Required': 'csrf',
29
- 'Step-Up Required': 'step-up',
41
+ [PROBLEM.csrf]: 'csrf',
42
+ [PROBLEM.stepUp]: 'step-up',
43
+ [PROBLEM.notVerified]: 'unverified',
30
44
  }
31
45
 
32
46
  /**
33
- * The kind for a refusal. Title first — the two titles that refine a `403` —
34
- * then the status.
47
+ * The kind for a refusal. Title first — the titles that refine a `403` — then
48
+ * the status.
49
+ *
50
+ * ⛔ **A `409` is a `conflict` only when it carries `current_updated_at`.** The
51
+ * backend answers `409` for several unrelated refusals — an insert-only section,
52
+ * a second item in a one-item section, a reference that pins an entity — and
53
+ * reporting those as "someone else changed this first" tells a person to reload
54
+ * and try again when nothing they do will make the write succeed. The token is
55
+ * what marks the stale case; nothing else carries it (measured 2026-09-18).
35
56
  *
36
57
  * @param {number} status
37
58
  * @param {string} [title]
59
+ * @param {object} [extensions] - the problem's other keys
38
60
  * @returns {string}
39
61
  */
40
- export function kindOf(status, title) {
62
+ export function kindOf(status, title, extensions) {
41
63
  if (title && TITLE_KINDS[title]) return TITLE_KINDS[title]
42
64
  if (status === 401) return 'auth'
43
65
  if (status === 403) return 'forbidden'
44
66
  if (status === 404) return 'absent'
45
67
  if (status === 400 || status === 422) return 'invalid'
46
- if (status === 409) return 'conflict'
68
+ if (status === 409) return extensions?.[FIELD.conflictToken] != null ? 'conflict' : 'rule'
47
69
  if (status === 429) return 'rate-limited'
48
70
  if (status === 0 || status >= 500) return 'unavailable'
49
71
  return 'unknown'
@@ -56,7 +78,7 @@ export class ApiError extends Error {
56
78
  * @param {string} [fields.title]
57
79
  * @param {string} [fields.detail]
58
80
  * @param {object} [fields.extensions] - every problem-JSON key beyond the three above
59
- * @param {string} [fields.kind] - derived from status and title when omitted
81
+ * @param {string} [fields.kind] - derived from status, title and extensions when omitted
60
82
  * @param {Error} [fields.cause]
61
83
  */
62
84
  constructor({ status = 0, title = '', detail = '', extensions = {}, kind, cause } = {}) {
@@ -66,7 +88,7 @@ export class ApiError extends Error {
66
88
  this.title = title
67
89
  this.detail = detail
68
90
  this.extensions = extensions
69
- this.kind = kind ?? kindOf(status, title)
91
+ this.kind = kind ?? kindOf(status, title, extensions)
70
92
  this.retryAfter =
71
93
  typeof extensions.retry_after_seconds === 'number' ? extensions.retry_after_seconds : null
72
94
  }
@@ -109,4 +131,9 @@ export class ApiError extends Error {
109
131
  kind: 'disabled',
110
132
  })
111
133
  }
134
+
135
+ /** A call this package refuses before sending, because it cannot be right. */
136
+ static invalid(title, detail) {
137
+ return new ApiError({ status: 0, title, detail, kind: 'invalid' })
138
+ }
112
139
  }
@@ -15,10 +15,14 @@ const DISABLED = Object.freeze({ status: 'absent', entity: null, error: null })
15
15
  * if (status === 'absent') return <EnrolWall /> // not found OR not permitted — one word, by design
16
16
  * ```
17
17
  *
18
+ * The entity is the backend's answer: `entity.hydrated.items` is its content —
19
+ * `{ id, section_id, data, … }` each — `entity.hydrated.entity.brief` its summary,
20
+ * and `entity.can_edit` whether this viewer may write to it.
21
+ *
18
22
  * Cached in the site's data store under a key scoped to the viewer, so a
19
23
  * sign-in or sign-out changes the key and the record is read again for who
20
- * is now looking. On a site with no backend the answer is `absent`: there is
21
- * nothing to read.
24
+ * is now looking. On a site with no backend, or with nobody signed in, the
25
+ * answer is `absent`: there is nothing this viewer can read.
22
26
  *
23
27
  * @param {{ schema: string, uuid: string, via?: string } | null} ref - pass null to skip
24
28
  * @returns {{ status: string, entity: object|null, error: Error|null, refresh: Function }}
@@ -29,13 +33,14 @@ export function useEntity(ref) {
29
33
  const store = website?.dataStore ?? null
30
34
 
31
35
  // Re-key on a viewer change: the session is part of the key.
32
- useSyncExternalStore(
36
+ const session = useSyncExternalStore(
33
37
  client ? client.subscribe : noSubscribe,
34
38
  client ? () => client.session : noSnapshot,
35
39
  client ? () => client.session : noSnapshot,
36
40
  )
37
41
 
38
- const active = !!(client && client.enabled && store && ref && ref.uuid)
42
+ // Signed out, the backend reads nothing to this viewer (`401`) — absent, unasked.
43
+ const active = !!(client && client.enabled && store && ref && ref.uuid && session?.status !== 'anonymous')
39
44
  const key = active
40
45
  ? client.cacheKey({ endpoint: `/entities/${ref.uuid}`, schema: ref.schema, via: ref.via })
41
46
  : null