@hydranium/protocol 1.0.0-next.280 → 1.0.0-next.281
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/lib/patch-merge.d.ts +35 -10
- package/lib/patch-merge.d.ts.map +1 -1
- package/lib/patch-merge.js +121 -19
- package/lib/patch-merge.js.map +1 -1
- package/lib/reconcile-write.d.ts +7 -5
- package/lib/reconcile-write.d.ts.map +1 -1
- package/lib/reconcile-write.js +2 -0
- package/lib/reconcile-write.js.map +1 -1
- package/package.json +1 -1
- package/src/patch-merge.ts +154 -24
- package/src/reconcile-write.ts +9 -5
package/lib/patch-merge.d.ts
CHANGED
|
@@ -32,12 +32,16 @@ import { type Operation as JsonPatchOperation } from 'fast-json-patch';
|
|
|
32
32
|
export declare function augmentWithTestOps(base: object, userPatch: ReadonlyArray<JsonPatchOperation>): JsonPatchOperation[];
|
|
33
33
|
/**
|
|
34
34
|
* Outcome of {@link reconcileByPatchReplay}. The caller persists `merged` and
|
|
35
|
-
* takes it as the new base,
|
|
35
|
+
* takes it as the new base, takes `theirs` as the new base on
|
|
36
|
+
* `already-applied`, drops + surfaces the `theirs` root on `conflict`,
|
|
36
37
|
* catches up with the server on `no-op`, and decides its own fallback (e.g.
|
|
37
38
|
* force-retry) on `unavailable`.
|
|
38
39
|
*/
|
|
39
40
|
export type ReconcileOutcome<T> = {
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The replay succeeded: the foreign writer touched no path the user
|
|
43
|
+
* did, or wrote the user's value at one that runs through no array.
|
|
44
|
+
*/
|
|
41
45
|
status: 'merged';
|
|
42
46
|
/**
|
|
43
47
|
* A new root built on theirs, carrying both intents. It is NOT the
|
|
@@ -54,7 +58,22 @@ export type ReconcileOutcome<T> = {
|
|
|
54
58
|
*/
|
|
55
59
|
status: 'no-op';
|
|
56
60
|
} | {
|
|
57
|
-
/**
|
|
61
|
+
/** The refetch already holds every path the user changed at the user's value: nothing is left to write. */
|
|
62
|
+
status: 'already-applied';
|
|
63
|
+
/**
|
|
64
|
+
* The server's current root, refetched and unmodified. Take it as the
|
|
65
|
+
* new base, not `ours`: it can also hold another writer's edit to a
|
|
66
|
+
* path the user did not change.
|
|
67
|
+
*/
|
|
68
|
+
theirs: T;
|
|
69
|
+
} | {
|
|
70
|
+
/**
|
|
71
|
+
* User and foreign writer changed one path to different values, or
|
|
72
|
+
* both changed one inside an array the refetch holds otherwise than
|
|
73
|
+
* the user, even to the same value; both inserted the same array
|
|
74
|
+
* element; or the foreign writer removed or replaced what the user
|
|
75
|
+
* edited inside.
|
|
76
|
+
*/
|
|
58
77
|
status: 'conflict';
|
|
59
78
|
/**
|
|
60
79
|
* The server's current root, refetched and unmodified — the user's
|
|
@@ -78,11 +97,17 @@ export type ReconcileOutcome<T> = {
|
|
|
78
97
|
* - `no-op` — the user's root equals the base: nothing to replay, and no
|
|
79
98
|
* refetch; the caller's document is behind the server's.
|
|
80
99
|
* - `unavailable` — the refetch produced nothing; caller falls back.
|
|
81
|
-
* - `
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
100
|
+
* - `already-applied` — the refetch already holds the user's value at every
|
|
101
|
+
* path the user changed, and the user's whole array for a path through
|
|
102
|
+
* one; nothing is replayed.
|
|
103
|
+
* - `merged` — the foreign writer touched only paths the user did not, or
|
|
104
|
+
* made the user's change itself at a path through no array; the merged
|
|
105
|
+
* root carries both intents.
|
|
106
|
+
* - `conflict` — a same-path divergence tripped a `test` op, including the
|
|
107
|
+
* user's own value inside an array the refetch holds otherwise than the
|
|
108
|
+
* user; or an `add` collides with one the foreign writer made; or the
|
|
109
|
+
* foreign writer removed or replaced what the user edited inside. The
|
|
110
|
+
* caller drops the write and surfaces `theirs`.
|
|
86
111
|
*
|
|
87
112
|
* I/O is the caller's: `refetch` supplies the current root, and applying the
|
|
88
113
|
* `merged` result (update vs save, new base, UI refresh) stays at the call
|
|
@@ -100,8 +125,8 @@ export interface ConflictResolver {
|
|
|
100
125
|
/**
|
|
101
126
|
* Reconcile the user's `base → ours` intent against the current
|
|
102
127
|
* server state (theirs, from `refetch`), returning a {@link ReconcileOutcome} the caller
|
|
103
|
-
* acts on (persist `merged`,
|
|
104
|
-
* back on `unavailable`).
|
|
128
|
+
* acts on (persist `merged`, write nothing on `already-applied`, drop on
|
|
129
|
+
* `conflict`, catch up on `no-op`, fall back on `unavailable`).
|
|
105
130
|
*/
|
|
106
131
|
resolve<T extends object>(base: T, ours: T, refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>>;
|
|
107
132
|
}
|
package/lib/patch-merge.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"patch-merge.d.ts","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAMJ,KAAK,SAAS,IAAI,kBAAkB,EAEtC,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,kBAAkB,CAAC,GAAG,kBAAkB,EAAE,CASnH;AAED
|
|
1
|
+
{"version":3,"file":"patch-merge.d.ts","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAMJ,KAAK,SAAS,IAAI,kBAAkB,EAEtC,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,kBAAkB,CAAC,GAAG,kBAAkB,EAAE,CASnH;AAED;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IACzB;IACG;;;OAGG;IACH,MAAM,EAAE,QAAQ,CAAC;IACjB;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC;CACZ,GACD;IACG;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;CAClB,GACD;IACG,2GAA2G;IAC3G,MAAM,EAAE,iBAAiB,CAAC;IAC1B;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC;CACZ,GACD;IACG;;;;;;OAMG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC;CACZ,GACD;IACG;;;;OAIG;IACH,MAAM,EAAE,aAAa,CAAC;CACxB,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,SAAS,MAAM,EAC1D,IAAI,EAAE,CAAC,EACP,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GACrC,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAuB9B;AA8ID;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC9B;;;;;OAKG;IACH,OAAO,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC;CACnH;AAED;;;;;GAKG;AACH,qBAAa,2BAA4B,YAAW,gBAAgB;IACjE,OAAO,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAE/G;CACH;AAED;;;;;GAKG;AACH,qBAAa,qBAAsB,YAAW,gBAAgB;IACrD,OAAO,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAEvH;CACH"}
|
package/lib/patch-merge.js
CHANGED
|
@@ -52,11 +52,17 @@ function augmentWithTestOps(base, userPatch) {
|
|
|
52
52
|
* - `no-op` — the user's root equals the base: nothing to replay, and no
|
|
53
53
|
* refetch; the caller's document is behind the server's.
|
|
54
54
|
* - `unavailable` — the refetch produced nothing; caller falls back.
|
|
55
|
-
* - `
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* - `already-applied` — the refetch already holds the user's value at every
|
|
56
|
+
* path the user changed, and the user's whole array for a path through
|
|
57
|
+
* one; nothing is replayed.
|
|
58
|
+
* - `merged` — the foreign writer touched only paths the user did not, or
|
|
59
|
+
* made the user's change itself at a path through no array; the merged
|
|
60
|
+
* root carries both intents.
|
|
61
|
+
* - `conflict` — a same-path divergence tripped a `test` op, including the
|
|
62
|
+
* user's own value inside an array the refetch holds otherwise than the
|
|
63
|
+
* user; or an `add` collides with one the foreign writer made; or the
|
|
64
|
+
* foreign writer removed or replaced what the user edited inside. The
|
|
65
|
+
* caller drops the write and surfaces `theirs`.
|
|
60
66
|
*
|
|
61
67
|
* I/O is the caller's: `refetch` supplies the current root, and applying the
|
|
62
68
|
* `merged` result (update vs save, new base, UI refresh) stays at the call
|
|
@@ -71,46 +77,142 @@ async function reconcileByPatchReplay(base, ours, refetch) {
|
|
|
71
77
|
if (!theirs) {
|
|
72
78
|
return { status: 'unavailable' };
|
|
73
79
|
}
|
|
74
|
-
|
|
80
|
+
const comparable = toJson(theirs);
|
|
81
|
+
const pending = withoutAgreed(base, ours, comparable, userPatch);
|
|
82
|
+
if (pending.length === 0) {
|
|
83
|
+
return { status: 'already-applied', theirs };
|
|
84
|
+
}
|
|
85
|
+
if (addsCollide(base, comparable, pending)) {
|
|
75
86
|
return { status: 'conflict', theirs };
|
|
76
87
|
}
|
|
77
88
|
try {
|
|
78
|
-
const merged = (0, fast_json_patch_1.applyPatch)((0, fast_json_patch_1.deepClone)(theirs), augmentWithTestOps(base,
|
|
89
|
+
const merged = (0, fast_json_patch_1.applyPatch)((0, fast_json_patch_1.deepClone)(theirs), augmentWithTestOps(base, pending), true).newDocument;
|
|
79
90
|
return { status: 'merged', merged };
|
|
80
91
|
}
|
|
81
92
|
catch {
|
|
82
93
|
return { status: 'conflict', theirs };
|
|
83
94
|
}
|
|
84
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* `userPatch` without the ops `theirs` already agrees with: a `replace` or
|
|
98
|
+
* object-key `add` whose key the object at its parent path holds at the
|
|
99
|
+
* user's value, a `remove` whose key that object lacks. A parent that is
|
|
100
|
+
* absent or no object agrees with nothing: a foreign writer deleted or
|
|
101
|
+
* replaced what the user edited inside, or a refetch omitted a document it
|
|
102
|
+
* could not read.
|
|
103
|
+
*
|
|
104
|
+
* Ops address array elements by index, so an op through an array agrees only
|
|
105
|
+
* when that array, the outermost on its path, equals the user's: matching one
|
|
106
|
+
* index of an array that shifted would drop the user's edit to another
|
|
107
|
+
* element. An insert never agrees, as {@link addsCollide} keeps two equal
|
|
108
|
+
* additions apart, and neither does any op through its array, or replaying
|
|
109
|
+
* the insert alone would add its element a second time. Each array is
|
|
110
|
+
* compared once, however many ops run through it and however many arrays the
|
|
111
|
+
* patch inserts into, so a bulk edit stays linear.
|
|
112
|
+
*
|
|
113
|
+
* `theirs` is the refetch through {@link toJson}.
|
|
114
|
+
*/
|
|
115
|
+
function withoutAgreed(base, ours, theirs, userPatch) {
|
|
116
|
+
const jsonOurs = toJson(ours);
|
|
117
|
+
// An insert is keyed by the outermost array on its path, as every op is looked up, so one set answers for every array.
|
|
118
|
+
const insertedArrays = new Set(userPatch
|
|
119
|
+
.filter(op => op.op === 'add' && Array.isArray(valueAt(base, op.path.slice(0, op.path.lastIndexOf('/')))))
|
|
120
|
+
.map(op => outermostArrayOn(jsonOurs, op.path)));
|
|
121
|
+
const arrayAgrees = new Map();
|
|
122
|
+
return userPatch.filter(op => {
|
|
123
|
+
const array = outermostArrayOn(jsonOurs, op.path);
|
|
124
|
+
if (array !== undefined) {
|
|
125
|
+
let agrees = arrayAgrees.get(array);
|
|
126
|
+
if (agrees === undefined) {
|
|
127
|
+
agrees = !insertedArrays.has(array) && (0, fast_json_patch_1._areEquals)(valueAt(theirs, array), valueAt(jsonOurs, array));
|
|
128
|
+
arrayAgrees.set(array, agrees);
|
|
129
|
+
}
|
|
130
|
+
return !agrees;
|
|
131
|
+
}
|
|
132
|
+
const cut = op.path.lastIndexOf('/');
|
|
133
|
+
const parent = valueAt(theirs, op.path.slice(0, cut));
|
|
134
|
+
if (typeof parent !== 'object' || parent === null || Array.isArray(parent)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
const key = (0, fast_json_patch_1.unescapePathComponent)(op.path.slice(cut + 1));
|
|
138
|
+
if (op.op === 'remove') {
|
|
139
|
+
return Object.hasOwn(parent, key);
|
|
140
|
+
}
|
|
141
|
+
if (op.op !== 'replace' && op.op !== 'add') {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return !Object.hasOwn(parent, key) || !(0, fast_json_patch_1._areEquals)(Reflect.get(parent, key), op.value);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** The outermost array a step of `pointer` before its last runs through in `document`, as a pointer; `undefined` for none. */
|
|
148
|
+
function outermostArrayOn(document, pointer) {
|
|
149
|
+
const steps = pointer.split('/');
|
|
150
|
+
for (let i = 1; i < steps.length; i++) {
|
|
151
|
+
const prefix = steps.slice(0, i).join('/');
|
|
152
|
+
if (Array.isArray(valueAt(document, prefix))) {
|
|
153
|
+
return prefix;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* `value` through JSON, as `compare` clones the values it diffs: an
|
|
160
|
+
* undefined-valued key would otherwise make two equal values differ.
|
|
161
|
+
*/
|
|
162
|
+
function toJson(value) {
|
|
163
|
+
return JSON.parse(JSON.stringify(value));
|
|
164
|
+
}
|
|
85
165
|
/**
|
|
86
166
|
* Whether an `add` of `userPatch` collides with one the foreign writer made:
|
|
87
167
|
* it inserts a value into an array that `theirs` holds more often than
|
|
88
|
-
* `base` did, or it adds an object key that `theirs` already holds
|
|
168
|
+
* `base` did, or it adds an object key that `theirs` already holds, at
|
|
169
|
+
* another value once `withoutAgreed` has dropped the equal ones.
|
|
89
170
|
*
|
|
90
171
|
* Replayed, the first inserts the value a second time and the second replaces
|
|
91
|
-
* the foreign value. Identical
|
|
92
|
-
* skipping drops one of two
|
|
172
|
+
* the foreign value. Identical array elements conflict rather than being
|
|
173
|
+
* skipped: skipping drops one of two insertions that merely happen to be
|
|
174
|
+
* equal. `theirs` is the refetch through {@link toJson}.
|
|
93
175
|
*/
|
|
94
176
|
function addsCollide(base, theirs, userPatch) {
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
const comparable = JSON.parse(JSON.stringify(theirs));
|
|
177
|
+
// Each array's elements are counted once, however many ops insert into it, so a bulk insert stays linear.
|
|
178
|
+
const tallies = new Map();
|
|
98
179
|
return userPatch.some(op => {
|
|
99
180
|
if (op.op !== 'add') {
|
|
100
181
|
return false;
|
|
101
182
|
}
|
|
102
183
|
const cut = op.path.lastIndexOf('/');
|
|
103
|
-
const
|
|
104
|
-
const
|
|
184
|
+
const parent = op.path.slice(0, cut);
|
|
185
|
+
const before = valueAt(base, parent);
|
|
186
|
+
const now = valueAt(theirs, parent);
|
|
105
187
|
if (Array.isArray(before)) {
|
|
106
|
-
|
|
188
|
+
if (!Array.isArray(now)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
let tally = tallies.get(parent);
|
|
192
|
+
if (!tally) {
|
|
193
|
+
tally = { before: tallyElements(before), now: tallyElements(now) };
|
|
194
|
+
tallies.set(parent, tally);
|
|
195
|
+
}
|
|
196
|
+
const key = canonicalJson(op.value);
|
|
197
|
+
return (tally.now.get(key) ?? 0) > (tally.before.get(key) ?? 0);
|
|
107
198
|
}
|
|
108
199
|
return typeof now === 'object' && now !== null && Object.hasOwn(now, (0, fast_json_patch_1.unescapePathComponent)(op.path.slice(cut + 1)));
|
|
109
200
|
});
|
|
110
201
|
}
|
|
111
|
-
/** How many elements of `array`
|
|
112
|
-
function
|
|
113
|
-
|
|
202
|
+
/** How many elements of `array` there are of each value, keyed by {@link canonicalJson}. */
|
|
203
|
+
function tallyElements(array) {
|
|
204
|
+
const tally = new Map();
|
|
205
|
+
for (const element of array) {
|
|
206
|
+
const key = canonicalJson(element);
|
|
207
|
+
tally.set(key, (tally.get(key) ?? 0) + 1);
|
|
208
|
+
}
|
|
209
|
+
return tally;
|
|
210
|
+
}
|
|
211
|
+
/** `value` as JSON with every object's keys sorted, so two deeply equal values give one string whatever their key order. */
|
|
212
|
+
function canonicalJson(value) {
|
|
213
|
+
return JSON.stringify(value, (_key, nested) => typeof nested === 'object' && nested !== null && !Array.isArray(nested)
|
|
214
|
+
? Object.fromEntries(Object.entries(nested).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
|
|
215
|
+
: nested);
|
|
114
216
|
}
|
|
115
217
|
/** The value at `pointer`, or `undefined` when a step of it is missing. */
|
|
116
218
|
function valueAt(document, pointer) {
|
package/lib/patch-merge.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"patch-merge.js","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":";AAAA;;;;;;;kFAOkF;;;;;AAElF,qDAQyB;AAEzB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,4BAAmC,IAAY,EAAE,SAA4C;IAC1F,MAAM,SAAS,GAAyB,EAAE,CAAC;IAC3C,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;QAC1B,IAAI,EAAE,CAAC,EAAE,KAAK,SAAS,IAAI,EAAE,CAAC,EAAE,KAAK,QAAQ,EAAE,CAAC;YAC7C,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,IAAA,mCAAiB,EAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC1F,CAAC;QACD,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,SAAS,CAAC;AACpB,CAAC;
|
|
1
|
+
{"version":3,"file":"patch-merge.js","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":"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"}
|
package/lib/reconcile-write.d.ts
CHANGED
|
@@ -46,12 +46,14 @@ export type ReconcileWriteOutcome =
|
|
|
46
46
|
readonly status: 'persisted';
|
|
47
47
|
} | {
|
|
48
48
|
/**
|
|
49
|
-
* `merged`: a merged write landed. `
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* `merged`: a merged write landed. `already-applied`: the refetch
|
|
50
|
+
* already held the edit, so nothing more was written. `unchanged`: the
|
|
51
|
+
* edit was empty, so the caller is behind the server. `conflict`: the
|
|
52
|
+
* resolver found a same-path collision. `dropped`: the writes ran out.
|
|
53
|
+
* `unavailable`: the refetch produced nothing and
|
|
54
|
+
* {@link ReconcileWriteHooks.onUnavailable} ran.
|
|
53
55
|
*/
|
|
54
|
-
readonly status: 'merged' | 'unchanged' | 'conflict' | 'dropped' | 'unavailable';
|
|
56
|
+
readonly status: 'merged' | 'already-applied' | 'unchanged' | 'conflict' | 'dropped' | 'unavailable';
|
|
55
57
|
readonly conflict: ConflictError;
|
|
56
58
|
readonly writes: number;
|
|
57
59
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reconcile-write.d.ts","sourceRoot":"","sources":["../src/reconcile-write.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAAE,KAAK,aAAa,EAAmB,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACnF,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAItD,6FAA6F;AAC7F,MAAM,WAAW,cAAc,CAAC,MAAM;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;CACrC;AAED,8DAA8D;AAC9D,MAAM,WAAW,mBAAmB,CAAC,MAAM;IACxC;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE;;;;OAIG;IACH,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IACvD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,gBAAgB,EAAE,gBAAgB,CAAC;IAC5C,kGAAkG;IAClG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,aAAa,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxE;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB;AAC9B,8BAA8B;AAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAChC;IACG
|
|
1
|
+
{"version":3,"file":"reconcile-write.d.ts","sourceRoot":"","sources":["../src/reconcile-write.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAAE,KAAK,aAAa,EAAmB,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACnF,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAItD,6FAA6F;AAC7F,MAAM,WAAW,cAAc,CAAC,MAAM;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;CACrC;AAED,8DAA8D;AAC9D,MAAM,WAAW,mBAAmB,CAAC,MAAM;IACxC;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE;;;;OAIG;IACH,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IACvD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,gBAAgB,EAAE,gBAAgB,CAAC;IAC5C,kGAAkG;IAClG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,aAAa,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxE;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB;AAC9B,8BAA8B;AAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAChC;IACG;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,iBAAiB,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,GAAG,aAAa,CAAC;IACrG,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEP;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,MAAM,SAAS,MAAM,EACvD,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,mBAAmB,CAAC,MAAM,CAAC,GAClC,OAAO,CAAC,qBAAqB,CAAC,CAmChC"}
|
package/lib/reconcile-write.js
CHANGED
|
@@ -41,6 +41,8 @@ async function reconcileWrite(model, baseVersion, hooks) {
|
|
|
41
41
|
candidate = outcome.merged;
|
|
42
42
|
candidateBaseVersion = refetched?.baseVersion ?? 'any';
|
|
43
43
|
continue;
|
|
44
|
+
case 'already-applied':
|
|
45
|
+
return { status: 'already-applied', conflict, writes };
|
|
44
46
|
case 'no-op':
|
|
45
47
|
return { status: 'unchanged', conflict, writes };
|
|
46
48
|
case 'conflict':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reconcile-write.js","sourceRoot":"","sources":["../src/reconcile-write.ts"],"names":[],"mappings":";AAAA;;;;;;;kFAOkF;;;AAElF,qCAA+D;AAI/D,MAAM,kBAAkB,GAAG,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"reconcile-write.js","sourceRoot":"","sources":["../src/reconcile-write.ts"],"names":[],"mappings":";AAAA;;;;;;;kFAOkF;;;AAElF,qCAA+D;AAI/D,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAoD7B;;;;;GAKG;AACI,KAAK,yBACT,KAAa,EACb,WAAwB,EACxB,KAAkC;IAElC,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,IAAI,oBAAoB,GAAG,WAAW,CAAC;IACvC,IAAI,QAAmC,CAAC;IACxC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,kBAAkB,CAAC;IACxD,KAAK,IAAI,MAAM,GAAG,CAAC,GAAI,MAAM,EAAE,EAAE,CAAC;QAC/B,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;QACjF,IAAI,CAAC,QAAQ,EAAE,CAAC;YACb,OAAO,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;QAChG,CAAC;QACD,QAAQ,GAAG,QAAQ,CAAC;QACpB,IAAI,MAAM,IAAI,SAAS,EAAE,CAAC;YACvB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QAClD,CAAC;QACD,IAAI,SAA6C,CAAC;QAClD,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE;YAChF,SAAS,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC;YAClC,OAAO,SAAS,EAAE,KAAK,CAAC;QAC3B,CAAC,CAAC,CAAC;QACH,QAAQ,OAAO,CAAC,MAAM,EAAE,CAAC;YACtB,KAAK,QAAQ;gBACV,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;gBAC3B,oBAAoB,GAAG,SAAS,EAAE,WAAW,IAAI,KAAK,CAAC;gBACvD,SAAS;YACZ,KAAK,iBAAiB;gBACnB,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;YAC1D,KAAK,OAAO;gBACT,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;YACpD,KAAK,UAAU;gBACZ,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;YACnD,KAAK,aAAa;gBACf,MAAM,KAAK,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;gBAC7C,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QACzD,CAAC;IACJ,CAAC;AACJ,CAAC;AAED,2EAA2E;AAC3E,KAAK,UAAU,iBAAiB,CAC7B,KAAkC,EAClC,KAAa,EACb,WAAwB;IAExB,IAAI,CAAC;QACF,MAAM,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;QACxC,OAAO,SAAS,CAAC;IACpB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACrB,IAAI,CAAC,IAAA,wBAAe,EAAC,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,GAAG,CAAC;QACb,CAAC;QACD,OAAO,GAAG,CAAC;IACd,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
package/src/patch-merge.ts
CHANGED
|
@@ -52,13 +52,17 @@ export function augmentWithTestOps(base: object, userPatch: ReadonlyArray<JsonPa
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Outcome of {@link reconcileByPatchReplay}. The caller persists `merged` and
|
|
55
|
-
* takes it as the new base,
|
|
55
|
+
* takes it as the new base, takes `theirs` as the new base on
|
|
56
|
+
* `already-applied`, drops + surfaces the `theirs` root on `conflict`,
|
|
56
57
|
* catches up with the server on `no-op`, and decides its own fallback (e.g.
|
|
57
58
|
* force-retry) on `unavailable`.
|
|
58
59
|
*/
|
|
59
60
|
export type ReconcileOutcome<T> =
|
|
60
61
|
| {
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* The replay succeeded: the foreign writer touched no path the user
|
|
64
|
+
* did, or wrote the user's value at one that runs through no array.
|
|
65
|
+
*/
|
|
62
66
|
status: 'merged';
|
|
63
67
|
/**
|
|
64
68
|
* A new root built on theirs, carrying both intents. It is NOT the
|
|
@@ -77,7 +81,23 @@ export type ReconcileOutcome<T> =
|
|
|
77
81
|
status: 'no-op';
|
|
78
82
|
}
|
|
79
83
|
| {
|
|
80
|
-
/**
|
|
84
|
+
/** The refetch already holds every path the user changed at the user's value: nothing is left to write. */
|
|
85
|
+
status: 'already-applied';
|
|
86
|
+
/**
|
|
87
|
+
* The server's current root, refetched and unmodified. Take it as the
|
|
88
|
+
* new base, not `ours`: it can also hold another writer's edit to a
|
|
89
|
+
* path the user did not change.
|
|
90
|
+
*/
|
|
91
|
+
theirs: T;
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
/**
|
|
95
|
+
* User and foreign writer changed one path to different values, or
|
|
96
|
+
* both changed one inside an array the refetch holds otherwise than
|
|
97
|
+
* the user, even to the same value; both inserted the same array
|
|
98
|
+
* element; or the foreign writer removed or replaced what the user
|
|
99
|
+
* edited inside.
|
|
100
|
+
*/
|
|
81
101
|
status: 'conflict';
|
|
82
102
|
/**
|
|
83
103
|
* The server's current root, refetched and unmodified — the user's
|
|
@@ -103,11 +123,17 @@ export type ReconcileOutcome<T> =
|
|
|
103
123
|
* - `no-op` — the user's root equals the base: nothing to replay, and no
|
|
104
124
|
* refetch; the caller's document is behind the server's.
|
|
105
125
|
* - `unavailable` — the refetch produced nothing; caller falls back.
|
|
106
|
-
* - `
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
126
|
+
* - `already-applied` — the refetch already holds the user's value at every
|
|
127
|
+
* path the user changed, and the user's whole array for a path through
|
|
128
|
+
* one; nothing is replayed.
|
|
129
|
+
* - `merged` — the foreign writer touched only paths the user did not, or
|
|
130
|
+
* made the user's change itself at a path through no array; the merged
|
|
131
|
+
* root carries both intents.
|
|
132
|
+
* - `conflict` — a same-path divergence tripped a `test` op, including the
|
|
133
|
+
* user's own value inside an array the refetch holds otherwise than the
|
|
134
|
+
* user; or an `add` collides with one the foreign writer made; or the
|
|
135
|
+
* foreign writer removed or replaced what the user edited inside. The
|
|
136
|
+
* caller drops the write and surfaces `theirs`.
|
|
111
137
|
*
|
|
112
138
|
* I/O is the caller's: `refetch` supplies the current root, and applying the
|
|
113
139
|
* `merged` result (update vs save, new base, UI refresh) stays at the call
|
|
@@ -126,47 +152,151 @@ export async function reconcileByPatchReplay<T extends object>(
|
|
|
126
152
|
if (!theirs) {
|
|
127
153
|
return { status: 'unavailable' };
|
|
128
154
|
}
|
|
129
|
-
|
|
155
|
+
const comparable = toJson(theirs);
|
|
156
|
+
const pending = withoutAgreed(base, ours, comparable, userPatch);
|
|
157
|
+
if (pending.length === 0) {
|
|
158
|
+
return { status: 'already-applied', theirs };
|
|
159
|
+
}
|
|
160
|
+
if (addsCollide(base, comparable, pending)) {
|
|
130
161
|
return { status: 'conflict', theirs };
|
|
131
162
|
}
|
|
132
163
|
try {
|
|
133
|
-
const merged = applyPatch(deepClone(theirs), augmentWithTestOps(base,
|
|
164
|
+
const merged = applyPatch(deepClone(theirs), augmentWithTestOps(base, pending), true).newDocument;
|
|
134
165
|
return { status: 'merged', merged };
|
|
135
166
|
} catch {
|
|
136
167
|
return { status: 'conflict', theirs };
|
|
137
168
|
}
|
|
138
169
|
}
|
|
139
170
|
|
|
171
|
+
/**
|
|
172
|
+
* `userPatch` without the ops `theirs` already agrees with: a `replace` or
|
|
173
|
+
* object-key `add` whose key the object at its parent path holds at the
|
|
174
|
+
* user's value, a `remove` whose key that object lacks. A parent that is
|
|
175
|
+
* absent or no object agrees with nothing: a foreign writer deleted or
|
|
176
|
+
* replaced what the user edited inside, or a refetch omitted a document it
|
|
177
|
+
* could not read.
|
|
178
|
+
*
|
|
179
|
+
* Ops address array elements by index, so an op through an array agrees only
|
|
180
|
+
* when that array, the outermost on its path, equals the user's: matching one
|
|
181
|
+
* index of an array that shifted would drop the user's edit to another
|
|
182
|
+
* element. An insert never agrees, as {@link addsCollide} keeps two equal
|
|
183
|
+
* additions apart, and neither does any op through its array, or replaying
|
|
184
|
+
* the insert alone would add its element a second time. Each array is
|
|
185
|
+
* compared once, however many ops run through it and however many arrays the
|
|
186
|
+
* patch inserts into, so a bulk edit stays linear.
|
|
187
|
+
*
|
|
188
|
+
* `theirs` is the refetch through {@link toJson}.
|
|
189
|
+
*/
|
|
190
|
+
function withoutAgreed(base: object, ours: object, theirs: object, userPatch: readonly JsonPatchOperation[]): JsonPatchOperation[] {
|
|
191
|
+
const jsonOurs = toJson(ours);
|
|
192
|
+
// An insert is keyed by the outermost array on its path, as every op is looked up, so one set answers for every array.
|
|
193
|
+
const insertedArrays = new Set(
|
|
194
|
+
userPatch
|
|
195
|
+
.filter(op => op.op === 'add' && Array.isArray(valueAt(base, op.path.slice(0, op.path.lastIndexOf('/')))))
|
|
196
|
+
.map(op => outermostArrayOn(jsonOurs, op.path))
|
|
197
|
+
);
|
|
198
|
+
const arrayAgrees = new Map<string, boolean>();
|
|
199
|
+
return userPatch.filter(op => {
|
|
200
|
+
const array = outermostArrayOn(jsonOurs, op.path);
|
|
201
|
+
if (array !== undefined) {
|
|
202
|
+
let agrees = arrayAgrees.get(array);
|
|
203
|
+
if (agrees === undefined) {
|
|
204
|
+
agrees = !insertedArrays.has(array) && _areEquals(valueAt(theirs, array), valueAt(jsonOurs, array));
|
|
205
|
+
arrayAgrees.set(array, agrees);
|
|
206
|
+
}
|
|
207
|
+
return !agrees;
|
|
208
|
+
}
|
|
209
|
+
const cut = op.path.lastIndexOf('/');
|
|
210
|
+
const parent = valueAt(theirs, op.path.slice(0, cut));
|
|
211
|
+
if (typeof parent !== 'object' || parent === null || Array.isArray(parent)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const key = unescapePathComponent(op.path.slice(cut + 1));
|
|
215
|
+
if (op.op === 'remove') {
|
|
216
|
+
return Object.hasOwn(parent, key);
|
|
217
|
+
}
|
|
218
|
+
if (op.op !== 'replace' && op.op !== 'add') {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return !Object.hasOwn(parent, key) || !_areEquals(Reflect.get(parent, key), op.value);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The outermost array a step of `pointer` before its last runs through in `document`, as a pointer; `undefined` for none. */
|
|
226
|
+
function outermostArrayOn(document: object, pointer: string): string | undefined {
|
|
227
|
+
const steps = pointer.split('/');
|
|
228
|
+
for (let i = 1; i < steps.length; i++) {
|
|
229
|
+
const prefix = steps.slice(0, i).join('/');
|
|
230
|
+
if (Array.isArray(valueAt(document, prefix))) {
|
|
231
|
+
return prefix;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `value` through JSON, as `compare` clones the values it diffs: an
|
|
239
|
+
* undefined-valued key would otherwise make two equal values differ.
|
|
240
|
+
*/
|
|
241
|
+
function toJson<T>(value: T): T {
|
|
242
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
243
|
+
}
|
|
244
|
+
|
|
140
245
|
/**
|
|
141
246
|
* Whether an `add` of `userPatch` collides with one the foreign writer made:
|
|
142
247
|
* it inserts a value into an array that `theirs` holds more often than
|
|
143
|
-
* `base` did, or it adds an object key that `theirs` already holds
|
|
248
|
+
* `base` did, or it adds an object key that `theirs` already holds, at
|
|
249
|
+
* another value once `withoutAgreed` has dropped the equal ones.
|
|
144
250
|
*
|
|
145
251
|
* Replayed, the first inserts the value a second time and the second replaces
|
|
146
|
-
* the foreign value. Identical
|
|
147
|
-
* skipping drops one of two
|
|
252
|
+
* the foreign value. Identical array elements conflict rather than being
|
|
253
|
+
* skipped: skipping drops one of two insertions that merely happen to be
|
|
254
|
+
* equal. `theirs` is the refetch through {@link toJson}.
|
|
148
255
|
*/
|
|
149
256
|
function addsCollide(base: object, theirs: object, userPatch: readonly JsonPatchOperation[]): boolean {
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
const comparable = JSON.parse(JSON.stringify(theirs)) as object;
|
|
257
|
+
// Each array's elements are counted once, however many ops insert into it, so a bulk insert stays linear.
|
|
258
|
+
const tallies = new Map<string, { readonly before: ReadonlyMap<string, number>; readonly now: ReadonlyMap<string, number> }>();
|
|
153
259
|
return userPatch.some(op => {
|
|
154
260
|
if (op.op !== 'add') {
|
|
155
261
|
return false;
|
|
156
262
|
}
|
|
157
263
|
const cut = op.path.lastIndexOf('/');
|
|
158
|
-
const
|
|
159
|
-
const
|
|
264
|
+
const parent = op.path.slice(0, cut);
|
|
265
|
+
const before = valueAt(base, parent);
|
|
266
|
+
const now = valueAt(theirs, parent);
|
|
160
267
|
if (Array.isArray(before)) {
|
|
161
|
-
|
|
268
|
+
if (!Array.isArray(now)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
let tally = tallies.get(parent);
|
|
272
|
+
if (!tally) {
|
|
273
|
+
tally = { before: tallyElements(before), now: tallyElements(now) };
|
|
274
|
+
tallies.set(parent, tally);
|
|
275
|
+
}
|
|
276
|
+
const key = canonicalJson(op.value);
|
|
277
|
+
return (tally.now.get(key) ?? 0) > (tally.before.get(key) ?? 0);
|
|
162
278
|
}
|
|
163
279
|
return typeof now === 'object' && now !== null && Object.hasOwn(now, unescapePathComponent(op.path.slice(cut + 1)));
|
|
164
280
|
});
|
|
165
281
|
}
|
|
166
282
|
|
|
167
|
-
/** How many elements of `array`
|
|
168
|
-
function
|
|
169
|
-
|
|
283
|
+
/** How many elements of `array` there are of each value, keyed by {@link canonicalJson}. */
|
|
284
|
+
function tallyElements(array: readonly unknown[]): ReadonlyMap<string, number> {
|
|
285
|
+
const tally = new Map<string, number>();
|
|
286
|
+
for (const element of array) {
|
|
287
|
+
const key = canonicalJson(element);
|
|
288
|
+
tally.set(key, (tally.get(key) ?? 0) + 1);
|
|
289
|
+
}
|
|
290
|
+
return tally;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `value` as JSON with every object's keys sorted, so two deeply equal values give one string whatever their key order. */
|
|
294
|
+
function canonicalJson(value: unknown): string {
|
|
295
|
+
return JSON.stringify(value, (_key, nested: unknown) =>
|
|
296
|
+
typeof nested === 'object' && nested !== null && !Array.isArray(nested)
|
|
297
|
+
? Object.fromEntries(Object.entries(nested).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
|
|
298
|
+
: nested
|
|
299
|
+
);
|
|
170
300
|
}
|
|
171
301
|
|
|
172
302
|
/** The value at `pointer`, or `undefined` when a step of it is missing. */
|
|
@@ -189,8 +319,8 @@ export interface ConflictResolver {
|
|
|
189
319
|
/**
|
|
190
320
|
* Reconcile the user's `base → ours` intent against the current
|
|
191
321
|
* server state (theirs, from `refetch`), returning a {@link ReconcileOutcome} the caller
|
|
192
|
-
* acts on (persist `merged`,
|
|
193
|
-
* back on `unavailable`).
|
|
322
|
+
* acts on (persist `merged`, write nothing on `already-applied`, drop on
|
|
323
|
+
* `conflict`, catch up on `no-op`, fall back on `unavailable`).
|
|
194
324
|
*/
|
|
195
325
|
resolve<T extends object>(base: T, ours: T, refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>>;
|
|
196
326
|
}
|
package/src/reconcile-write.ts
CHANGED
|
@@ -51,12 +51,14 @@ export type ReconcileWriteOutcome =
|
|
|
51
51
|
| { readonly status: 'persisted' }
|
|
52
52
|
| {
|
|
53
53
|
/**
|
|
54
|
-
* `merged`: a merged write landed. `
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* `merged`: a merged write landed. `already-applied`: the refetch
|
|
55
|
+
* already held the edit, so nothing more was written. `unchanged`: the
|
|
56
|
+
* edit was empty, so the caller is behind the server. `conflict`: the
|
|
57
|
+
* resolver found a same-path collision. `dropped`: the writes ran out.
|
|
58
|
+
* `unavailable`: the refetch produced nothing and
|
|
59
|
+
* {@link ReconcileWriteHooks.onUnavailable} ran.
|
|
58
60
|
*/
|
|
59
|
-
readonly status: 'merged' | 'unchanged' | 'conflict' | 'dropped' | 'unavailable';
|
|
61
|
+
readonly status: 'merged' | 'already-applied' | 'unchanged' | 'conflict' | 'dropped' | 'unavailable';
|
|
60
62
|
readonly conflict: ConflictError;
|
|
61
63
|
readonly writes: number;
|
|
62
64
|
};
|
|
@@ -95,6 +97,8 @@ export async function reconcileWrite<TModel extends object>(
|
|
|
95
97
|
candidate = outcome.merged;
|
|
96
98
|
candidateBaseVersion = refetched?.baseVersion ?? 'any';
|
|
97
99
|
continue;
|
|
100
|
+
case 'already-applied':
|
|
101
|
+
return { status: 'already-applied', conflict, writes };
|
|
98
102
|
case 'no-op':
|
|
99
103
|
return { status: 'unchanged', conflict, writes };
|
|
100
104
|
case 'conflict':
|