@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 +125 -45
- package/bin/uniweb-api-mock.js +34 -0
- package/package.json +11 -4
- package/src/client.js +229 -16
- package/src/hooks/useEntity.js +1 -1
- package/src/hooks/useEntityWriter.js +141 -0
- package/src/hooks/useRecords.js +97 -0
- package/src/index.js +6 -0
- package/src/ledger.js +48 -20
- package/src/mock/index.js +194 -0
- package/src/mock/node.js +106 -0
- package/src/mock/seed.js +51 -0
- package/src/mock/store.js +288 -0
- package/src/wire.js +243 -0
package/README.md
CHANGED
|
@@ -1,69 +1,149 @@
|
|
|
1
1
|
# @uniweb/api
|
|
2
2
|
|
|
3
|
-
The client
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
10
|
+
```bash
|
|
11
|
+
npm install @uniweb/api
|
|
12
|
+
```
|
|
12
13
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
25
|
-
import {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
npx uniweb-api-mock --port 8787
|
|
60
137
|
```
|
|
61
138
|
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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.
|
|
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
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
/**
|
|
365
|
-
|
|
366
|
-
|
|
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',
|
|
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'
|
package/src/hooks/useEntity.js
CHANGED
|
@@ -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 () => {
|