@uniweb/api 0.3.7 → 0.4.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 +99 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +1 -1
- package/src/client.js +239 -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 +19 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +333 -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 +604 -295
- package/src/wire.js +327 -226
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { getClient } from '../client.js'
|
|
3
3
|
import { ApiError } from '../errors.js'
|
|
4
|
-
import { FIELD, OP } from '../wire.js'
|
|
4
|
+
import { CREATE, FIELD, OP } from '../wire.js'
|
|
5
5
|
|
|
6
6
|
const IDLE = 'idle'
|
|
7
7
|
const SAVING = 'saving'
|
|
@@ -11,13 +11,17 @@ const ERROR = 'error'
|
|
|
11
11
|
* Write the items of one entity, in domain terms.
|
|
12
12
|
*
|
|
13
13
|
* ```jsx
|
|
14
|
-
* const programme = useEntityWriter({ schema: '
|
|
15
|
-
* await programme.create({ title: 'Keynote' })
|
|
16
|
-
* await programme.update(
|
|
17
|
-
* await programme.move(
|
|
18
|
-
* await programme.remove(
|
|
14
|
+
* const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
|
|
15
|
+
* await programme.create({ title: 'Keynote' }, { section: 'sessions' }) // appended
|
|
16
|
+
* await programme.update(item.id, { ...item.data, room: 'Hall A' }) // whole data
|
|
17
|
+
* await programme.move(item.id, { after: other.id }) // the organiser arranges
|
|
18
|
+
* await programme.remove(item.id)
|
|
19
19
|
* ```
|
|
20
20
|
*
|
|
21
|
+
* An item id is the `id` of an item on a read (`entity.hydrated.items`), an integer.
|
|
22
|
+
* A section is named as the Model names it; the package resolves it to the id the
|
|
23
|
+
* backend's item route takes.
|
|
24
|
+
*
|
|
21
25
|
* ## ⭐ Why a hook wraps ops at all — it is a CCA argument, not an ergonomic one
|
|
22
26
|
*
|
|
23
27
|
* A component that composes `{ kind: 'update', item_id, if_unmodified_since }` has
|
|
@@ -40,6 +44,10 @@ const ERROR = 'error'
|
|
|
40
44
|
* overwriting a change nobody looked at. What to do about someone else's edit is
|
|
41
45
|
* the application's question, and it is usually "tell the person".
|
|
42
46
|
*
|
|
47
|
+
* ⚠️ `conflict` is ONLY that case. A write the Model's rules refuse — an
|
|
48
|
+
* insert-only section, a second item in a one-item section — is an `error` of kind
|
|
49
|
+
* `rule`: nobody else is involved, and the same write will fail the same way.
|
|
50
|
+
*
|
|
43
51
|
* @param {{ schema: string, uuid: string } | null} target
|
|
44
52
|
* @returns {{
|
|
45
53
|
* create: Function, update: Function, remove: Function, move: Function,
|
|
@@ -70,7 +78,7 @@ export function useEntityWriter(target) {
|
|
|
70
78
|
if (seq.current === mine) setState({ status: IDLE, error: null, conflict: null })
|
|
71
79
|
return result
|
|
72
80
|
} catch (err) {
|
|
73
|
-
const conflict = err instanceof ApiError && err.
|
|
81
|
+
const conflict = err instanceof ApiError && err.kind === 'conflict' ? err : null
|
|
74
82
|
if (seq.current === mine) setState({ status: ERROR, error: err, conflict })
|
|
75
83
|
throw err
|
|
76
84
|
}
|
|
@@ -81,53 +89,61 @@ export function useEntityWriter(target) {
|
|
|
81
89
|
const api = useMemo(
|
|
82
90
|
() => ({
|
|
83
91
|
/**
|
|
84
|
-
*
|
|
92
|
+
* Add an item to a section. Tokenless by design — there is no existing item
|
|
85
93
|
* to guard. `position` and `parent` are the server's ordering vocabulary,
|
|
86
94
|
* passed through rather than turned into an order number here.
|
|
87
95
|
*
|
|
88
|
-
* ⛔ `section` is REQUIRED
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
96
|
+
* ⛔ `section` is REQUIRED, because an entity has several and they are not
|
|
97
|
+
* interchangeable: a rule declared on one — insert-only above all — does not
|
|
98
|
+
* reach an item that landed in another. Name it as the Model does; the client
|
|
99
|
+
* resolves the name to the id the backend's item route takes. A section that
|
|
100
|
+
* holds one item takes one create, and is `update`d after that.
|
|
93
101
|
*
|
|
94
102
|
* @param {object} data - the item's content
|
|
95
103
|
* @param {object} opts
|
|
96
104
|
* @param {string} opts.section - which section of the entity this belongs to
|
|
97
|
-
* @param {
|
|
98
|
-
* @param {'first'|'last'|{after:
|
|
105
|
+
* @param {number} [opts.parent] - a parent item's id, for nested content
|
|
106
|
+
* @param {'first'|'last'|{after: number}} [opts.position] - default: last
|
|
99
107
|
*/
|
|
100
108
|
create: (data, { section, parent, position } = {}) => {
|
|
101
109
|
if (!section) {
|
|
102
110
|
return Promise.reject(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
kind: 'invalid',
|
|
108
|
-
}),
|
|
111
|
+
ApiError.invalid(
|
|
112
|
+
'No Section',
|
|
113
|
+
'create needs a section — an item with no section lands outside the rules declared for it',
|
|
114
|
+
),
|
|
109
115
|
)
|
|
110
116
|
}
|
|
111
117
|
return send({
|
|
112
118
|
kind: OP.create,
|
|
113
|
-
[
|
|
114
|
-
data,
|
|
119
|
+
[CREATE.sectionName]: section,
|
|
120
|
+
data: data ?? {},
|
|
115
121
|
...(parent != null ? { [FIELD.parent]: parent } : {}),
|
|
116
122
|
...(position != null ? { position } : {}),
|
|
117
123
|
})
|
|
118
124
|
},
|
|
119
125
|
/** Replace an item's data. ⚠️ Whole-data replace — round-trip what you do not edit. */
|
|
120
|
-
update: (itemId, data) =>
|
|
126
|
+
update: (itemId, data) => {
|
|
127
|
+
if (data == null || typeof data !== 'object') {
|
|
128
|
+
return Promise.reject(ApiError.invalid('No Data', "update replaces the item's data whole — pass all of it"))
|
|
129
|
+
}
|
|
130
|
+
return send({ kind: OP.update, [FIELD.item]: itemId, data })
|
|
131
|
+
},
|
|
121
132
|
/** Delete one item. */
|
|
122
133
|
remove: (itemId) => send({ kind: OP.delete, [FIELD.item]: itemId }),
|
|
123
134
|
/**
|
|
124
|
-
* Reposition an item — `'first' | 'last' | { after: <itemId> }
|
|
135
|
+
* Reposition an item — `'first' | 'last' | { after: <itemId> }`, required.
|
|
125
136
|
*
|
|
126
137
|
* ⛔ The client never computes an order number. Ordering is the server's, and
|
|
127
138
|
* two clients arranging the same list from local sequence numbers is how a
|
|
128
139
|
* list ends up in an order neither of them chose.
|
|
129
140
|
*/
|
|
130
|
-
move: (itemId, position) =>
|
|
141
|
+
move: (itemId, position) => {
|
|
142
|
+
if (position == null) {
|
|
143
|
+
return Promise.reject(ApiError.invalid('No Position', "move needs a position — 'first', 'last' or { after }"))
|
|
144
|
+
}
|
|
145
|
+
return send({ kind: OP.move, [FIELD.item]: itemId, position })
|
|
146
|
+
},
|
|
131
147
|
/** Send several ops as ONE transaction — all of them land, or none do. */
|
|
132
148
|
batch: (ops) => send(ops),
|
|
133
149
|
reset: () => setState({ status: IDLE, error: null, conflict: null }),
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -17,9 +17,11 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
17
17
|
*
|
|
18
18
|
* ## ⭐ `absent` and an empty `ready` are DIFFERENT, and conflating them is the bug
|
|
19
19
|
*
|
|
20
|
-
* `absent` means **there is no live source** — a site with no
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* `absent` means **there is no live source for this viewer** — a site with no
|
|
21
|
+
* `api` service, which is the ordinary standalone case, or nobody signed in, since
|
|
22
|
+
* the backend lists nothing to an anonymous caller (it answers `401`, so no request
|
|
23
|
+
* is sent once the session is known to be anonymous). `ready` with `records: []`
|
|
24
|
+
* means **the source answered, and there is nothing there.**
|
|
23
25
|
*
|
|
24
26
|
* A component renders its own static content for the first and an empty state for
|
|
25
27
|
* the second, and they are not interchangeable: telling a visitor "no sessions yet"
|
|
@@ -27,10 +29,19 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
27
29
|
* that once answered a lapsed session with an empty list — it reports absence of
|
|
28
30
|
* *access* as absence of *content*.
|
|
29
31
|
*
|
|
32
|
+
* A record is the entity's summary — `record.brief` holds the brief section's
|
|
33
|
+
* fields, beside `uuid`, `owner_id` and `via` — and carries no items; read one
|
|
34
|
+
* entity for those. ⚠️ `matched` counts the records in this answer, not a total:
|
|
35
|
+
* `hasMore` is true when the page came back full.
|
|
36
|
+
*
|
|
37
|
+
* The default scope is everything the viewer may read — for a member, their own
|
|
38
|
+
* entities and what was shared with them; for the operator, everything.
|
|
39
|
+
* `scope: 'mine'` is only the viewer's own.
|
|
40
|
+
*
|
|
30
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
|
31
42
|
* is now looking, and a write through `useEntityWriter` drops it.
|
|
32
43
|
*
|
|
33
|
-
* @param {{ schema: string, scope?:
|
|
44
|
+
* @param {{ schema: string, scope?: 'accessible'|'mine'|'all', limit?: number, offset?: number, all?: boolean } | null} query
|
|
34
45
|
* pass null to skip
|
|
35
46
|
* @returns {{ status: string, records: object[], matched: number, hasMore: boolean, error: Error|null, refresh: Function }}
|
|
36
47
|
*/
|
|
@@ -40,13 +51,15 @@ export function useRecords(query) {
|
|
|
40
51
|
const store = website?.dataStore ?? null
|
|
41
52
|
|
|
42
53
|
// Re-key on a viewer change: what the viewer may see is part of the answer.
|
|
43
|
-
useSyncExternalStore(
|
|
54
|
+
const session = useSyncExternalStore(
|
|
44
55
|
client ? client.subscribe : noSubscribe,
|
|
45
56
|
client ? () => client.session : noSnapshot,
|
|
46
57
|
client ? () => client.session : noSnapshot,
|
|
47
58
|
)
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
// Signed out, there is no live source: the backend lists nothing to an anonymous
|
|
61
|
+
// caller, so asking would only buy a `401`.
|
|
62
|
+
const active = !!(client && client.enabled && store && query && query.schema && session?.status !== 'anonymous')
|
|
50
63
|
const spec = active
|
|
51
64
|
? {
|
|
52
65
|
endpoint: '/entities',
|
package/src/http.js
CHANGED
|
@@ -11,7 +11,7 @@ export const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
|
11
11
|
/**
|
|
12
12
|
* Is the base another origin than the page's?
|
|
13
13
|
*
|
|
14
|
-
* A relative base (`/
|
|
14
|
+
* A relative base (`/_api`) is the page's own origin by definition. An absolute
|
|
15
15
|
* one is compared against `location.origin`; where there is no location — a
|
|
16
16
|
* server, a test — an absolute base is treated as cross-origin, which only
|
|
17
17
|
* makes the request carry credentials it would otherwise carry anyway.
|
|
@@ -31,11 +31,16 @@ export function isCrossOrigin(base) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* `${base}
|
|
34
|
+
* `${base}${path}?…` — the one composition this package makes.
|
|
35
35
|
*
|
|
36
|
-
* The base
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* The base IS the backend's API route space, and every route joins it directly:
|
|
37
|
+
* `/_api` + `/entities`. On a hosted site `/_api` is a path on the site's own
|
|
38
|
+
* origin that reaches the backend's `/api/…`; wherever the page reaches a backend
|
|
39
|
+
* directly, the base is that backend's absolute `…/api`.
|
|
40
|
+
*
|
|
41
|
+
* ⛔ **Until 0.4 this inserted `/api` after the base**, reading the base as the
|
|
42
|
+
* backend's root. On a hosted site that sent every request to `/_api/api/…`, a
|
|
43
|
+
* route that does not exist — measured, and the reason this comment is here.
|
|
39
44
|
* `null` and `undefined` query values are omitted.
|
|
40
45
|
*
|
|
41
46
|
* @param {string} base
|
|
@@ -52,7 +57,7 @@ export function composeUrl(base, path, query) {
|
|
|
52
57
|
params.set(key, String(value))
|
|
53
58
|
}
|
|
54
59
|
const qs = params.toString()
|
|
55
|
-
return `${root}
|
|
60
|
+
return `${root}${p}${qs ? `?${qs}` : ''}`
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
package/src/ledger.js
CHANGED
|
@@ -1,53 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The concurrency ledger — the last-seen `
|
|
3
|
-
*
|
|
2
|
+
* The concurrency ledger — the last-seen `updated_at` per item, and the stamping
|
|
3
|
+
* of `if_unmodified_since` onto the ops that need one.
|
|
4
4
|
*
|
|
5
5
|
* The backend guards writes at the item grain: `update`, `delete` and `move` each
|
|
6
6
|
* carry the target item's last-seen `updated_at`; `create` carries none. A
|
|
7
7
|
* mismatch is a `409` whose `current_updated_at` extension names the item's
|
|
8
|
-
* current token, and every write
|
|
9
|
-
* precondition to chain forward. Three sources, one token
|
|
8
|
+
* current token, and every write result carries `item_updated_at` — the next
|
|
9
|
+
* precondition to chain forward. **Three sources, one token: a read, a write, a
|
|
10
|
+
* conflict.**
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* SEQUENCE: the order is authored, stored, and repositioned by hand.
|
|
12
|
+
* ⛔ **Reads are a source, and until 0.4 nothing fed them in.** A hydrated read
|
|
13
|
+
* carries each item's `updated_at`, which is exactly the token the FIRST write of
|
|
14
|
+
* that item must carry. Without it the first edit after a read went out
|
|
15
|
+
* unguarded — last-writer-wins — and silently overwrote whatever another person
|
|
16
|
+
* had saved in between, which is the one thing this ledger exists to prevent.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* ⚠️ **A read may not roll a token back.** A read that was sent before this client
|
|
19
|
+
* wrote an item, and answered after, carries the item's OLDER token. Taking it
|
|
20
|
+
* would make the next write fail against this client's own change. So a read is
|
|
21
|
+
* marked when it starts (`mark()`), and its tokens are taken only for items this
|
|
22
|
+
* client has not written since (`observe()`).
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* ⭐ `move` IS IN SCOPE. It was dropped on 2026-09-01 as "an editor concern — an
|
|
25
|
+
* app's order is a property of the query". **That is true of a MEMBER LIST and
|
|
26
|
+
* false of the apps this package exists for**: an instructor authors a course
|
|
27
|
+
* whose lessons are a sequence, ordered and re-ordered by hand. `move` carries a
|
|
28
|
+
* precondition like any other op on an existing item.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* ⛔ **Every field name here now comes from `./wire.js`, and that fixed a real
|
|
35
|
-
* defect rather than tidying one.** This module read an op's target as `op.item`
|
|
36
|
-
* and probed responses through a guess list, `['item', 'item_id', 'id']`. The wire
|
|
37
|
-
* field is `item_id`. So a writer composing a correct op would have handed
|
|
38
|
-
* `stamp()` something whose target it could not see — and `stamp()` returns an
|
|
39
|
-
* unguarded op when it cannot find one, **by design, because an item it has never
|
|
40
|
-
* seen is legitimately last-writer-wins.** The two behaviours are identical from
|
|
41
|
-
* here and opposite in effect: one is "no token known", the other is "the
|
|
42
|
-
* precondition was silently dropped from every write."
|
|
43
|
-
*
|
|
44
|
-
* ⇒ That is the argument for one home per name, in miniature. A guess list cannot
|
|
45
|
-
* fail loudly, because guessing is what it is for.
|
|
30
|
+
* ⛔ **Every field name here comes from `./wire.js`.** This module once read an
|
|
31
|
+
* op's target as `op.item` while the wire says `item_id`, so a correct op looked
|
|
32
|
+
* target-less and went out unguarded — by design, because an item never seen is
|
|
33
|
+
* legitimately last-writer-wins. The two are identical from here and opposite in
|
|
34
|
+
* effect. One home per name is what makes that impossible.
|
|
46
35
|
*/
|
|
47
36
|
|
|
48
|
-
import { FIELD, OP } from './wire.js'
|
|
37
|
+
import { FIELD, OP, READ } from './wire.js'
|
|
49
38
|
|
|
50
|
-
/** An op's or a
|
|
39
|
+
/** An op's or a result's item id, by the one name the wire uses. */
|
|
51
40
|
function itemIdOf(record) {
|
|
52
41
|
const id = record?.[FIELD.item]
|
|
53
42
|
return id == null ? null : String(id)
|
|
@@ -56,9 +45,11 @@ function itemIdOf(record) {
|
|
|
56
45
|
export class Ledger {
|
|
57
46
|
constructor() {
|
|
58
47
|
this._at = new Map()
|
|
48
|
+
this._wrote = new Map()
|
|
49
|
+
this._clock = 0
|
|
59
50
|
}
|
|
60
51
|
|
|
61
|
-
/** Record an item's token
|
|
52
|
+
/** Record an item's token — from a write, a conflict, or a caller that knows. */
|
|
62
53
|
note(itemId, updatedAt) {
|
|
63
54
|
if (itemId == null || updatedAt == null) return
|
|
64
55
|
this._at.set(String(itemId), updatedAt)
|
|
@@ -73,12 +64,37 @@ export class Ledger {
|
|
|
73
64
|
this._at.delete(String(itemId))
|
|
74
65
|
}
|
|
75
66
|
|
|
67
|
+
/** A point in this ledger's history — take one when a read STARTS. */
|
|
68
|
+
mark() {
|
|
69
|
+
this._clock += 1
|
|
70
|
+
return this._clock
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Take the tokens a read carried — each item's `id` and `updated_at` — except for
|
|
75
|
+
* items this client wrote after the read began.
|
|
76
|
+
*
|
|
77
|
+
* @param {object[]} [items] - a hydrated read's items
|
|
78
|
+
* @param {number} [mark] - `mark()` taken before the read was sent
|
|
79
|
+
*/
|
|
80
|
+
observe(items, mark = Infinity) {
|
|
81
|
+
if (!Array.isArray(items)) return
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const id = item?.[READ.itemId]
|
|
84
|
+
const at = item?.[READ.itemToken]
|
|
85
|
+
if (id == null || at == null) continue
|
|
86
|
+
const key = String(id)
|
|
87
|
+
if ((this._wrote.get(key) ?? 0) > mark) continue
|
|
88
|
+
this._at.set(key, at)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
/**
|
|
77
93
|
* Stamp an op with the precondition it needs. `create` is tokenless by
|
|
78
94
|
* design; an op on an item this ledger has never seen goes out unguarded
|
|
79
95
|
* — last-writer-wins — exactly as the wire treats an absent token.
|
|
80
96
|
*
|
|
81
|
-
* @param {{ kind: string,
|
|
97
|
+
* @param {{ kind: string, item_id?: number }} op
|
|
82
98
|
* @returns {object} the op, with `if_unmodified_since` when known
|
|
83
99
|
*/
|
|
84
100
|
stamp(op) {
|
|
@@ -90,26 +106,41 @@ export class Ledger {
|
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
/**
|
|
93
|
-
* Absorb a write
|
|
94
|
-
*
|
|
95
|
-
*
|
|
109
|
+
* Absorb a write — one result, or a batch's `results` — recording each item's
|
|
110
|
+
* next token.
|
|
111
|
+
*
|
|
112
|
+
* ⭐ **Pass the ops, and each result is read against the op in its position.**
|
|
113
|
+
* That is the only way to know what a delete removed: its result names no item
|
|
114
|
+
* (`item_id: null` — the row is gone), so the item is the one the op named.
|
|
115
|
+
* Without the ops, a result is read on its own — a create's or an update's
|
|
116
|
+
* result names its item; a delete's cannot be placed.
|
|
96
117
|
*
|
|
97
|
-
* @param {object} result
|
|
118
|
+
* @param {object} result - the write's answer
|
|
119
|
+
* @param {object|object[]} [ops] - the ops that were sent, in order
|
|
98
120
|
*/
|
|
99
|
-
absorb(result) {
|
|
121
|
+
absorb(result, ops) {
|
|
100
122
|
if (!result || typeof result !== 'object') return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
const results = Array.isArray(result.results) ? result.results : [result]
|
|
124
|
+
const sent = ops == null ? [] : Array.isArray(ops) ? ops : [ops]
|
|
125
|
+
results.forEach((r, i) => {
|
|
126
|
+
const op = sent[i]
|
|
127
|
+
if (op?.kind === OP.delete) {
|
|
128
|
+
if (itemIdOf(op) != null) this.forget(itemIdOf(op))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const id = itemIdOf(r) ?? itemIdOf(op)
|
|
132
|
+
if (id == null || !r || !(FIELD.token in r)) return
|
|
133
|
+
if (r[FIELD.token] === null) {
|
|
134
|
+
this.forget(id)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
this.note(id, r[FIELD.token])
|
|
138
|
+
this._wrote.set(id, this.mark())
|
|
139
|
+
})
|
|
109
140
|
}
|
|
110
141
|
|
|
111
142
|
/**
|
|
112
|
-
* On a `409`, take the item's current token from the error so the next
|
|
143
|
+
* On a stale `409`, take the item's current token from the error so the next
|
|
113
144
|
* attempt is guarded by the truth rather than by what this ledger believed.
|
|
114
145
|
*
|
|
115
146
|
* @param {string|number} itemId
|
|
@@ -118,8 +149,9 @@ export class Ledger {
|
|
|
118
149
|
*/
|
|
119
150
|
rebase(itemId, error) {
|
|
120
151
|
const current = error?.extensions?.[FIELD.conflictToken]
|
|
121
|
-
if (current == null) return false
|
|
152
|
+
if (itemId == null || current == null) return false
|
|
122
153
|
this.note(itemId, current)
|
|
154
|
+
this._wrote.set(String(itemId), this.mark())
|
|
123
155
|
return true
|
|
124
156
|
}
|
|
125
157
|
}
|