@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.
- package/README.md +96 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +1 -1
- package/src/client.js +237 -84
- package/src/errors.js +39 -12
- package/src/hooks/useEntity.js +9 -4
- package/src/hooks/useEntityWriter.js +42 -26
- package/src/hooks/useRecords.js +18 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +330 -116
- package/src/mock/node.js +6 -2
- package/src/mock/schema-shape.js +8 -0
- package/src/mock/seed.js +39 -22
- package/src/mock/store.js +580 -295
- package/src/wire.js +314 -226
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
|
|
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
|
|
240
|
-
|
|
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
|
|
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 ===
|
|
317
|
-
this._challenge = body.
|
|
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: {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
|
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.**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
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.
|
|
540
|
-
* @param {
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
|
571
|
-
* absorbed, so a caller never handles a precondition itself.
|
|
572
|
-
*
|
|
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
|
|
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
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
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] -
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
const stamped =
|
|
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.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const id =
|
|
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,
|
|
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
|
|
632
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
*
|
|
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
|
|
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
|
}
|
package/src/hooks/useEntity.js
CHANGED
|
@@ -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
|
|
21
|
-
* nothing
|
|
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
|
-
|
|
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
|