@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.
@@ -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, drops + surfaces the `theirs` root on `conflict`,
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
- /** The replay succeeded: the foreign writer touched no path the user did. */
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
- /** User and foreign writer changed one path, or both added the same element or key. */
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
- * - `merged` — the foreign writer touched only paths the user did not; the
82
- * merged root carries both intents.
83
- * - `conflict` — a same-path divergence tripped a `test` op, or an `add`
84
- * collides with one the foreign writer made; caller drops the write and
85
- * surfaces `theirs`.
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`, drop on `conflict`, catch up on `no-op`, fall
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
  }
@@ -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;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IACzB;IACG,6EAA6E;IAC7E,MAAM,EAAE,QAAQ,CAAC;IACjB;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC;CACZ,GACD;IACG;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;CAClB,GACD;IACG,uFAAuF;IACvF,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC;CACZ,GACD;IACG;;;;OAIG;IACH,MAAM,EAAE,aAAa,CAAC;CACxB,CAAC;AAEP;;;;;;;;;;;;;;;;;GAiBG;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,CAkB9B;AA2CD;;;;;;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"}
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"}
@@ -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
- * - `merged` — the foreign writer touched only paths the user did not; the
56
- * merged root carries both intents.
57
- * - `conflict` — a same-path divergence tripped a `test` op, or an `add`
58
- * collides with one the foreign writer made; caller drops the write and
59
- * surfaces `theirs`.
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
- if (addsCollide(base, theirs, userPatch)) {
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, userPatch), true).newDocument;
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 values conflict rather than being skipped:
92
- * skipping drops one of two additions that merely happen to be equal.
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
- // Through JSON, as `compare` clones the add values: an undefined-valued key
96
- // in `theirs` would otherwise make an equal element count as another.
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 before = valueAt(base, op.path.slice(0, cut));
104
- const now = valueAt(comparable, op.path.slice(0, cut));
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
- return Array.isArray(now) && occurrences(now, op.value) > occurrences(before, op.value);
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` deeply equal `value`, whatever their key order. */
112
- function occurrences(array, value) {
113
- return array.filter(element => (0, fast_json_patch_1._areEquals)(element, value)).length;
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) {
@@ -1 +1 @@
1
- {"version":3,"file":"patch-merge.js","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"patch-merge.js","sourceRoot":"","sources":["../src/patch-merge.ts"],"names":[],"mappings":"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"}
@@ -46,12 +46,14 @@ export type ReconcileWriteOutcome =
46
46
  readonly status: 'persisted';
47
47
  } | {
48
48
  /**
49
- * `merged`: a merged write landed. `unchanged`: the edit was empty, so the
50
- * caller is behind the server. `conflict`: the resolver found a same-path
51
- * collision. `dropped`: the writes ran out. `unavailable`: the refetch
52
- * produced nothing and {@link ReconcileWriteHooks.onUnavailable} ran.
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;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,GAAG,aAAa,CAAC;IACjF,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,CAiChC"}
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"}
@@ -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;AAkD7B;;;;;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,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/protocol",
3
- "version": "1.0.0-next.280",
3
+ "version": "1.0.0-next.281",
4
4
  "description": "Generic, language-agnostic types, constants, and pure utilities for the hydranium framework.",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -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, drops + surfaces the `theirs` root on `conflict`,
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
- /** The replay succeeded: the foreign writer touched no path the user did. */
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
- /** User and foreign writer changed one path, or both added the same element or key. */
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
- * - `merged` — the foreign writer touched only paths the user did not; the
107
- * merged root carries both intents.
108
- * - `conflict` — a same-path divergence tripped a `test` op, or an `add`
109
- * collides with one the foreign writer made; caller drops the write and
110
- * surfaces `theirs`.
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
- if (addsCollide(base, theirs, userPatch)) {
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, userPatch), true).newDocument;
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 values conflict rather than being skipped:
147
- * skipping drops one of two additions that merely happen to be equal.
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
- // Through JSON, as `compare` clones the add values: an undefined-valued key
151
- // in `theirs` would otherwise make an equal element count as another.
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 before = valueAt(base, op.path.slice(0, cut));
159
- const now = valueAt(comparable, op.path.slice(0, cut));
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
- return Array.isArray(now) && occurrences(now, op.value) > occurrences(before, op.value);
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` deeply equal `value`, whatever their key order. */
168
- function occurrences(array: readonly unknown[], value: unknown): number {
169
- return array.filter(element => _areEquals(element, value)).length;
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`, drop on `conflict`, catch up on `no-op`, fall
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
  }
@@ -51,12 +51,14 @@ export type ReconcileWriteOutcome =
51
51
  | { readonly status: 'persisted' }
52
52
  | {
53
53
  /**
54
- * `merged`: a merged write landed. `unchanged`: the edit was empty, so the
55
- * caller is behind the server. `conflict`: the resolver found a same-path
56
- * collision. `dropped`: the writes ran out. `unavailable`: the refetch
57
- * produced nothing and {@link ReconcileWriteHooks.onUnavailable} ran.
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':