@uniweb/api 0.1.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/LICENSE +201 -0
- package/README.md +70 -0
- package/package.json +49 -0
- package/src/client.js +496 -0
- package/src/components/gates.js +22 -0
- package/src/errors.js +112 -0
- package/src/hooks/useAction.js +57 -0
- package/src/hooks/useEntity.js +78 -0
- package/src/hooks/usePasswordReset.js +30 -0
- package/src/hooks/useSession.js +56 -0
- package/src/hooks/useSignIn.js +54 -0
- package/src/hooks/useSignUp.js +28 -0
- package/src/http.js +82 -0
- package/src/index.js +34 -0
- package/src/ledger.js +97 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const noSubscribe = () => () => {}
|
|
5
|
+
const noSnapshot = () => null
|
|
6
|
+
const DISABLED = Object.freeze({ status: 'absent', entity: null, error: null })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One entity by id — through a container the viewer holds an entitlement on,
|
|
10
|
+
* when `via` names one.
|
|
11
|
+
*
|
|
12
|
+
* ```jsx
|
|
13
|
+
* const { status, entity } = useEntity({ schema: '@/lesson', uuid, via: course.uuid })
|
|
14
|
+
* // status: 'loading' | 'ready' | 'absent' | 'error'
|
|
15
|
+
* if (status === 'absent') return <EnrolWall /> // not found OR not permitted — one word, by design
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Cached in the site's data store under a key scoped to the viewer, so a
|
|
19
|
+
* 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.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ schema: string, uuid: string, via?: string } | null} ref - pass null to skip
|
|
24
|
+
* @returns {{ status: string, entity: object|null, error: Error|null, refresh: Function }}
|
|
25
|
+
*/
|
|
26
|
+
export function useEntity(ref) {
|
|
27
|
+
const client = getClient()
|
|
28
|
+
const website = client?.website ?? null
|
|
29
|
+
const store = website?.dataStore ?? null
|
|
30
|
+
|
|
31
|
+
// Re-key on a viewer change: the session is part of the key.
|
|
32
|
+
useSyncExternalStore(
|
|
33
|
+
client ? client.subscribe : noSubscribe,
|
|
34
|
+
client ? () => client.session : noSnapshot,
|
|
35
|
+
client ? () => client.session : noSnapshot,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const active = !!(client && client.enabled && store && ref && ref.uuid)
|
|
39
|
+
const key = active
|
|
40
|
+
? client.cacheKey({ endpoint: `/entities/${ref.uuid}`, schema: ref.schema, via: ref.via })
|
|
41
|
+
: null
|
|
42
|
+
|
|
43
|
+
const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
|
|
44
|
+
const entry = useSyncExternalStore(
|
|
45
|
+
subscribe,
|
|
46
|
+
() => (key ? store.get(key) : null),
|
|
47
|
+
() => (key ? store.get(key) : null),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const [error, setError] = useState(null)
|
|
51
|
+
const [attempt, setAttempt] = useState(0)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!key || entry) return undefined
|
|
55
|
+
let live = true
|
|
56
|
+
setError(null)
|
|
57
|
+
client.load(key, () => client.readEntity(ref)).catch((err) => {
|
|
58
|
+
if (live) setError(err)
|
|
59
|
+
})
|
|
60
|
+
return () => {
|
|
61
|
+
live = false
|
|
62
|
+
}
|
|
63
|
+
// `ref` is read through `key`, which already encodes schema, uuid and via.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [client, key, entry, attempt])
|
|
66
|
+
|
|
67
|
+
const refresh = useCallback(() => {
|
|
68
|
+
if (key && store) store.delete(key)
|
|
69
|
+
setAttempt((n) => n + 1)
|
|
70
|
+
}, [store, key])
|
|
71
|
+
|
|
72
|
+
if (!active) return { ...DISABLED, refresh }
|
|
73
|
+
if (entry) return { status: entry.data.status, entity: entry.data.entity, error: null, refresh }
|
|
74
|
+
if (error) return { status: 'error', entity: null, error, refresh }
|
|
75
|
+
return { status: 'loading', entity: null, error: null, refresh }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default useEntity
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { useAction } from './useAction.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Password reset in two steps: `request(fields)` asks for one — the backend
|
|
8
|
+
* answers `202` whether or not the account exists, deliberately — and
|
|
9
|
+
* `confirm(fields)` completes it with the token the viewer received. One
|
|
10
|
+
* lifecycle covers whichever step ran last.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ request: Function, confirm: Function, status: string, error: Error|null, response: any, reset: Function }}
|
|
13
|
+
*/
|
|
14
|
+
export function usePasswordReset() {
|
|
15
|
+
const client = getClient()
|
|
16
|
+
const { run, status, error, response, reset } = useAction(
|
|
17
|
+
useCallback(
|
|
18
|
+
(step, fields) => {
|
|
19
|
+
if (!client) throw ApiError.disabled()
|
|
20
|
+
return step === 'confirm' ? client.confirmPasswordReset(fields) : client.requestPasswordReset(fields)
|
|
21
|
+
},
|
|
22
|
+
[client],
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
const request = useCallback((fields) => run('request', fields), [run])
|
|
26
|
+
const confirm = useCallback((fields) => run('confirm', fields), [run])
|
|
27
|
+
return { request, confirm, status, error, response, reset }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default usePasswordReset
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
5
|
+
const noSubscribe = () => () => {}
|
|
6
|
+
const noSnapshot = () => NONE
|
|
7
|
+
const noop = async () => NONE
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The viewer's session.
|
|
11
|
+
*
|
|
12
|
+
* `status` is `anonymous` synchronously on a site that declares no backend —
|
|
13
|
+
* the ordinary case — and `loading` on one that does, until the backend has
|
|
14
|
+
* answered. `canSignIn` is false when there is nothing to sign in to: draw no
|
|
15
|
+
* affordance on false. `error` is set when the backend could not be asked;
|
|
16
|
+
* `refresh()` asks again.
|
|
17
|
+
*
|
|
18
|
+
* Reads the shared snapshot through `useSyncExternalStore`, so every copy of
|
|
19
|
+
* this package on the page sees one session, and the first render on the
|
|
20
|
+
* server matches the first render in the browser.
|
|
21
|
+
*
|
|
22
|
+
* @returns {{
|
|
23
|
+
* status: 'loading' | 'anonymous' | 'authenticated',
|
|
24
|
+
* viewer: object | null,
|
|
25
|
+
* error: Error | null,
|
|
26
|
+
* canSignIn: boolean,
|
|
27
|
+
* signOut: () => Promise<void>,
|
|
28
|
+
* refresh: () => Promise<object>,
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
export function useSession() {
|
|
32
|
+
const client = getClient()
|
|
33
|
+
const session = useSyncExternalStore(
|
|
34
|
+
client ? client.subscribe : noSubscribe,
|
|
35
|
+
client ? () => client.session : noSnapshot,
|
|
36
|
+
client ? () => client.session : noSnapshot,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (client && client.enabled) client.ensureSession()
|
|
41
|
+
}, [client])
|
|
42
|
+
|
|
43
|
+
const signOut = useCallback(() => (client ? client.signOut() : noop()), [client])
|
|
44
|
+
const refresh = useCallback(() => (client ? client.refresh() : noop()), [client])
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
status: session.status,
|
|
48
|
+
viewer: session.viewer,
|
|
49
|
+
error: session.error,
|
|
50
|
+
canSignIn: !!(client && client.enabled),
|
|
51
|
+
signOut,
|
|
52
|
+
refresh,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default useSession
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { useAction } from './useAction.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sign in, with the second factor parked as a `challenge` when the backend
|
|
8
|
+
* asks for one.
|
|
9
|
+
*
|
|
10
|
+
* ```jsx
|
|
11
|
+
* const { signIn, completeChallenge, status, error, challenge, canSignIn } = useSignIn()
|
|
12
|
+
* if (!canSignIn) return null
|
|
13
|
+
* // status: 'idle' | 'submitting' | 'success' | 'error'
|
|
14
|
+
* // challenge: null | { kind: 'totp' } — render the code field and call completeChallenge(code)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The credentials object goes to the backend as the request body, unchanged.
|
|
18
|
+
* A refused credential is an `ApiError` with `kind: 'auth'`.
|
|
19
|
+
*/
|
|
20
|
+
export function useSignIn() {
|
|
21
|
+
const client = getClient()
|
|
22
|
+
const [challenge, setChallenge] = useState(null)
|
|
23
|
+
|
|
24
|
+
const { run, status, error, reset: resetAction } = useAction(
|
|
25
|
+
useCallback(
|
|
26
|
+
async (step, arg) => {
|
|
27
|
+
if (!client) throw ApiError.disabled()
|
|
28
|
+
const result = step === 'challenge' ? await client.completeChallenge(arg) : await client.signIn(arg)
|
|
29
|
+
setChallenge(result.ok ? null : (result.challenge ?? null))
|
|
30
|
+
return result
|
|
31
|
+
},
|
|
32
|
+
[client],
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const signIn = useCallback((credentials) => run('credentials', credentials), [run])
|
|
37
|
+
const completeChallenge = useCallback((code) => run('challenge', code), [run])
|
|
38
|
+
const reset = useCallback(() => {
|
|
39
|
+
setChallenge(null)
|
|
40
|
+
resetAction()
|
|
41
|
+
}, [resetAction])
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
signIn,
|
|
45
|
+
completeChallenge,
|
|
46
|
+
status,
|
|
47
|
+
error,
|
|
48
|
+
challenge,
|
|
49
|
+
canSignIn: !!(client && client.enabled),
|
|
50
|
+
reset,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default useSignIn
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { useAction } from './useAction.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sign up. The fields object goes to the backend as the request body,
|
|
8
|
+
* unchanged. The backend answers `202`: the account is inert until the viewer
|
|
9
|
+
* verifies it, so `status: 'success'` means "check your email", and that copy
|
|
10
|
+
* is the foundation's to write.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ signUp: Function, status: string, error: Error|null, response: any, canSignUp: boolean, reset: Function }}
|
|
13
|
+
*/
|
|
14
|
+
export function useSignUp() {
|
|
15
|
+
const client = getClient()
|
|
16
|
+
const { run, status, error, response, reset } = useAction(
|
|
17
|
+
useCallback(
|
|
18
|
+
(fields) => {
|
|
19
|
+
if (!client) throw ApiError.disabled()
|
|
20
|
+
return client.signUp(fields)
|
|
21
|
+
},
|
|
22
|
+
[client],
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
return { signUp: run, status, error, response, canSignUp: !!(client && client.enabled), reset }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default useSignUp
|
package/src/http.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire, below the client: URL composition, credentials, body reading.
|
|
3
|
+
* Pure functions; the client calls them and nothing else does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:/i
|
|
7
|
+
|
|
8
|
+
/** Methods that carry a body, or change state — the ones the CSRF header rides on. */
|
|
9
|
+
export const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Is the base another origin than the page's?
|
|
13
|
+
*
|
|
14
|
+
* A relative base (`/_uw`) is the page's own origin by definition. An absolute
|
|
15
|
+
* one is compared against `location.origin`; where there is no location — a
|
|
16
|
+
* server, a test — an absolute base is treated as cross-origin, which only
|
|
17
|
+
* makes the request carry credentials it would otherwise carry anyway.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} base
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isCrossOrigin(base) {
|
|
23
|
+
if (!ABSOLUTE_URL_RE.test(base)) return false
|
|
24
|
+
const origin = globalThis.location?.origin
|
|
25
|
+
if (!origin) return true
|
|
26
|
+
try {
|
|
27
|
+
return new URL(base).origin !== origin
|
|
28
|
+
} catch {
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `${base}/api${path}?…` — the one composition this package makes.
|
|
35
|
+
*
|
|
36
|
+
* The base is the prefix under which the backend's own route space appears:
|
|
37
|
+
* the passthrough path on the site's origin, an origin under the subdomain
|
|
38
|
+
* shape, or empty on a deployment where the page's own server is the backend.
|
|
39
|
+
* `null` and `undefined` query values are omitted.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} base
|
|
42
|
+
* @param {string} path - the route, with or without a leading slash
|
|
43
|
+
* @param {object} [query]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function composeUrl(base, path, query) {
|
|
47
|
+
const root = base.replace(/\/+$/, '')
|
|
48
|
+
const p = path.startsWith('/') ? path : `/${path}`
|
|
49
|
+
const params = new URLSearchParams()
|
|
50
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
51
|
+
if (value === undefined || value === null) continue
|
|
52
|
+
params.set(key, String(value))
|
|
53
|
+
}
|
|
54
|
+
const qs = params.toString()
|
|
55
|
+
return `${root}/api${p}${qs ? `?${qs}` : ''}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The parsed body of a response: JSON when the response says so, `null` on
|
|
60
|
+
* `204`, and `{ detail: text }` for a non-JSON body so a refusal without
|
|
61
|
+
* problem-JSON still carries what the server said.
|
|
62
|
+
*
|
|
63
|
+
* @param {Response} res
|
|
64
|
+
* @returns {Promise<*>}
|
|
65
|
+
*/
|
|
66
|
+
export async function readBody(res) {
|
|
67
|
+
if (res.status === 204) return null
|
|
68
|
+
const type = res.headers?.get?.('content-type') || ''
|
|
69
|
+
if (/json/i.test(type)) {
|
|
70
|
+
try {
|
|
71
|
+
return await res.json()
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const text = await res.text()
|
|
78
|
+
return text ? { detail: text } : null
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uniweb/api
|
|
3
|
+
*
|
|
4
|
+
* A foundation's client for the site's own backend: session, records,
|
|
5
|
+
* entities, writes — in the site's vocabulary, never in routes. Imported the
|
|
6
|
+
* way `@uniweb/kit` is, bundled into the foundation, inert on a site that
|
|
7
|
+
* declares no backend.
|
|
8
|
+
*
|
|
9
|
+
* This entry carries the React hooks and the headless gates.
|
|
10
|
+
* `@uniweb/api/client` carries the plain functions and imports no React.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
SERVICE_NAME,
|
|
15
|
+
resolveBase,
|
|
16
|
+
isEnabled,
|
|
17
|
+
probeSession,
|
|
18
|
+
signIn,
|
|
19
|
+
completeChallenge,
|
|
20
|
+
signOut,
|
|
21
|
+
signUp,
|
|
22
|
+
requestPasswordReset,
|
|
23
|
+
confirmPasswordReset,
|
|
24
|
+
readEntity,
|
|
25
|
+
ApiError,
|
|
26
|
+
Ledger,
|
|
27
|
+
} from './client.js'
|
|
28
|
+
|
|
29
|
+
export { useSession } from './hooks/useSession.js'
|
|
30
|
+
export { useSignIn } from './hooks/useSignIn.js'
|
|
31
|
+
export { useSignUp } from './hooks/useSignUp.js'
|
|
32
|
+
export { usePasswordReset } from './hooks/usePasswordReset.js'
|
|
33
|
+
export { useEntity } from './hooks/useEntity.js'
|
|
34
|
+
export { SignedIn, SignedOut } from './components/gates.js'
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concurrency ledger — the last-seen `item_updated_at` per item, and the
|
|
3
|
+
* stamping of `if_unmodified_since` onto the ops that need one.
|
|
4
|
+
*
|
|
5
|
+
* The backend guards writes at the item grain: `update`, `delete` and `move`
|
|
6
|
+
* each carry the target item's last-seen `updated_at`; `create` carries none.
|
|
7
|
+
* A mismatch is a `409` whose `current_updated_at` extension names the item's
|
|
8
|
+
* current token, and every write response carries `item_updated_at` — the
|
|
9
|
+
* next precondition to chain forward. Three sources, one token.
|
|
10
|
+
*
|
|
11
|
+
* That is the single most reinventable thing on the wire, so it lives here
|
|
12
|
+
* once, as a pure structure with no route knowledge. The writer that composes
|
|
13
|
+
* the request is the next slice; it will `stamp()` before sending, `absorb()`
|
|
14
|
+
* what comes back, and `rebase()` on a conflict.
|
|
15
|
+
*
|
|
16
|
+
* Two field names are read from responses — the item's id and its
|
|
17
|
+
* `item_updated_at` — and the first of those is a reading of the design, not
|
|
18
|
+
* yet a pinned wire fact. It is one constant.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ID_FIELDS = ['item', 'item_id', 'id']
|
|
22
|
+
|
|
23
|
+
function itemIdOf(record) {
|
|
24
|
+
for (const field of ID_FIELDS) {
|
|
25
|
+
if (record?.[field] != null) return String(record[field])
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Ledger {
|
|
31
|
+
constructor() {
|
|
32
|
+
this._at = new Map()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Record an item's token, from a read or a write response. */
|
|
36
|
+
note(itemId, updatedAt) {
|
|
37
|
+
if (itemId == null || updatedAt == null) return
|
|
38
|
+
this._at.set(String(itemId), updatedAt)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The last-seen token for an item, or null when none was recorded. */
|
|
42
|
+
get(itemId) {
|
|
43
|
+
return this._at.get(String(itemId)) ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
forget(itemId) {
|
|
47
|
+
this._at.delete(String(itemId))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Stamp an op with the precondition it needs. `create` is tokenless by
|
|
52
|
+
* design; an op on an item this ledger has never seen goes out unguarded
|
|
53
|
+
* — last-writer-wins — exactly as the wire treats an absent token.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ kind: string, item?: string|number }} op
|
|
56
|
+
* @returns {object} the op, with `if_unmodified_since` when known
|
|
57
|
+
*/
|
|
58
|
+
stamp(op) {
|
|
59
|
+
if (!op || op.kind === 'create' || op.item == null) return op
|
|
60
|
+
const at = this.get(op.item)
|
|
61
|
+
return at == null ? op : { ...op, if_unmodified_since: at }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Absorb a write response — one result or a batch of them — recording each
|
|
66
|
+
* item's next token, and forgetting an item whose token came back `null`,
|
|
67
|
+
* which is how a delete reports itself.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} result
|
|
70
|
+
*/
|
|
71
|
+
absorb(result) {
|
|
72
|
+
if (!result || typeof result !== 'object') return
|
|
73
|
+
if (Array.isArray(result.results)) {
|
|
74
|
+
for (const r of result.results) this.absorb(r)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const id = itemIdOf(result)
|
|
78
|
+
if (id == null || !('item_updated_at' in result)) return
|
|
79
|
+
if (result.item_updated_at === null) this.forget(id)
|
|
80
|
+
else this.note(id, result.item_updated_at)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* On a `409`, take the item's current token from the error so the next
|
|
85
|
+
* attempt is guarded by the truth rather than by what this ledger believed.
|
|
86
|
+
*
|
|
87
|
+
* @param {string|number} itemId
|
|
88
|
+
* @param {{ extensions?: { current_updated_at?: * } }} error
|
|
89
|
+
* @returns {boolean} whether a token was recorded
|
|
90
|
+
*/
|
|
91
|
+
rebase(itemId, error) {
|
|
92
|
+
const current = error?.extensions?.current_updated_at
|
|
93
|
+
if (current == null) return false
|
|
94
|
+
this.note(itemId, current)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|