@dabble/patches 0.7.18 → 0.7.20

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.
@@ -8,14 +8,16 @@ import { Change, PatchesSnapshot, DocSyncStatus, ChangeMutator } from './types.j
8
8
  * Uses a snapshot-based approach with revision tracking and rebasing
9
9
  * for handling concurrent edits.
10
10
  *
11
- * The `change()` method (inherited from BaseDoc) captures ops and emits them
12
- * via `onChange` - it does NOT apply locally. The OTAlgorithm handles packaging
13
- * ops into Changes, persisting them, and calling `applyChanges()` to update state.
11
+ * The `change()` method (inherited from BaseDoc) applies ops optimistically
12
+ * to `state` and emits them via `onChange`. The OTAlgorithm packages ops into
13
+ * Changes, persists them, and calls `applyChanges()` to confirm the optimistic
14
+ * update (shifting from the FIFO queue and skipping the state setter).
14
15
  *
15
16
  * ## State Model
16
17
  * - `_committedState`: Base state from server (at `_committedRev`)
17
18
  * - `_pendingChanges`: Local changes not yet committed by server
18
- * - `state` (getter from BaseDoc): Live state = committedState + pendingChanges applied
19
+ * - `_optimisticOps` (from BaseDoc): Ops applied by change() but not yet confirmed
20
+ * - `state`: Live state = committedState + pendingChanges + optimistic ops applied
19
21
  *
20
22
  * ## Wire Efficiency
21
23
  * For Worker-Tab communication, only changes are sent over the wire (not full state).
@@ -49,12 +51,17 @@ declare class OTDoc<T extends object = object> extends BaseDoc<T> {
49
51
  */
50
52
  import(snapshot: PatchesSnapshot<T>): void;
51
53
  /**
52
- * Unified entry point for applying changes.
53
- * Used for Worker→Tab communication where only changes are sent over the wire.
54
+ * Recomputes state from committed + pending + remaining optimistic ops.
55
+ */
56
+ protected _recomputeState(): void;
57
+ /**
58
+ * Confirms changes from the algorithm pipeline.
54
59
  *
55
- * The method distinguishes between committed and pending changes using `committedAt`:
56
- * - `committedAt > 0`: Server-committed change (apply to committed state)
57
- * - `committedAt === 0`: Pending local change (append to pending)
60
+ * Distinguishes between committed and pending changes using `committedAt`:
61
+ * - `committedAt > 0`: Server-committed change (updates committed state, recomputes
62
+ * with remaining optimistic ops preserved)
63
+ * - `committedAt === 0`: Local change confirmation (shifts from optimistic queue,
64
+ * skips state update since change() already applied the ops)
58
65
  *
59
66
  * For server changes, all committed changes come first, followed by rebased pending.
60
67
  *
@@ -119,8 +126,8 @@ interface PatchesDoc<T extends object = object> extends ReadonlyStore<T> {
119
126
  */
120
127
  readonly onChange: Signal<(ops: JSONPatchOp[]) => void>;
121
128
  /**
122
- * Captures an update to the document, emitting JSON Patch ops via onChange.
123
- * Does NOT apply locally - the algorithm handles state updates.
129
+ * Captures an update to the document, applies it optimistically to state,
130
+ * and emits the ops via onChange for async persistence.
124
131
  * @param mutator Function that uses JSONPatch methods with type-safe paths.
125
132
  */
126
133
  change(mutator: ChangeMutator<T>): void;
@@ -130,15 +137,29 @@ interface PatchesDoc<T extends object = object> extends ReadonlyStore<T> {
130
137
  * Abstract base class for document implementations.
131
138
  * Contains shared state and methods used by both OTDoc and LWWDoc.
132
139
  *
133
- * The `change()` method captures ops and emits them via `onChange` - it does NOT
134
- * apply locally. The algorithm handles packaging ops, persisting them, and updating
135
- * the doc's state via `applyChanges()`.
140
+ * The `change()` method captures ops, applies them optimistically to `state`,
141
+ * and emits them via `onChange`. The algorithm handles packaging ops into Changes,
142
+ * persisting them, and confirming the optimistic update via `applyChanges()`.
143
+ *
144
+ * The mapping is strictly 1:1: one `change()` call produces one `applyChanges()`
145
+ * confirmation. A FIFO queue tracks outstanding optimistic ops so that
146
+ * `_recomputeState()` can reconstruct the full state (committed + pending +
147
+ * optimistic) when server changes arrive or during error recovery.
136
148
  *
137
- * Internal methods (updateSyncStatus, applyChanges, import) are on this class but not
138
- * on the PatchesDoc interface, as they're only used by Algorithm and PatchesSync.
149
+ * Internal methods (updateSyncStatus, applyChanges, import, rollbackOptimistic)
150
+ * are on this class but not on the PatchesDoc interface, as they're only used
151
+ * by Algorithm and PatchesSync.
139
152
  */
140
153
  declare abstract class BaseDoc<T extends object = object> extends ReadonlyStoreClass<T> implements PatchesDoc<T> {
141
154
  protected _id: string;
155
+ /**
156
+ * FIFO queue of ops applied optimistically by change() but not yet confirmed
157
+ * by applyChanges(). The 1:1 mapping between change() and applyChanges()
158
+ * means confirmation simply shifts from the front. The ops are stored (not
159
+ * just counted) so _recomputeState() can reconstruct the full state when
160
+ * server changes arrive during the optimistic window.
161
+ */
162
+ protected _optimisticOps: JSONPatchOp[][];
142
163
  /** Current sync status of this document. */
143
164
  readonly syncStatus: Store<DocSyncStatus>;
144
165
  /** Whether the document has completed its initial load. Sticky: once true, never reverts to false. */
@@ -164,11 +185,30 @@ declare abstract class BaseDoc<T extends object = object> extends ReadonlyStoreC
164
185
  /** Are there local changes that haven't been committed yet? */
165
186
  abstract get hasPending(): boolean;
166
187
  /**
167
- * Captures an update to the document, emitting JSON Patch ops via onChange.
168
- * Does NOT apply locally - the algorithm handles state updates via applyChanges.
188
+ * Captures an update to the document, applies it optimistically to state,
189
+ * and emits the ops via onChange for async persistence.
190
+ *
191
+ * State is updated synchronously (easy-signal's store.set() drains
192
+ * subscribers synchronously) so the UI sees changes immediately.
193
+ * The algorithm later confirms via applyChanges(), which shifts from
194
+ * the FIFO queue and skips the state update (avoiding double notifications).
195
+ *
169
196
  * @param mutator Function that uses JSONPatch methods with type-safe paths.
170
197
  */
171
198
  change(mutator: ChangeMutator<T>): void;
199
+ /**
200
+ * Rolls back all outstanding optimistic applies and recomputes state from
201
+ * confirmed state only. Called by Patches._handleDocChange when the
202
+ * algorithm rejects ops. Any remaining in-flight changes will apply
203
+ * normally via the fallback path in applyChanges().
204
+ */
205
+ rollbackOptimistic(): void;
206
+ /**
207
+ * Recomputes state from confirmed state (committed + pending) plus any
208
+ * remaining optimistic ops. Subclass-specific because each algorithm
209
+ * tracks committed/pending state differently.
210
+ */
211
+ protected abstract _recomputeState(): void;
172
212
  /**
173
213
  * Updates the sync status of the document.
174
214
  * Called by PatchesSync - not part of the app-facing PatchesDoc interface.
@@ -179,11 +219,11 @@ declare abstract class BaseDoc<T extends object = object> extends ReadonlyStoreC
179
219
  /** Latches _isLoaded to true when the doc has data or sync has resolved. */
180
220
  protected _checkLoaded(): void;
181
221
  /**
182
- * Applies changes to the document state.
183
- * Called by Algorithm for local changes and broadcasts - not part of PatchesDoc interface.
184
- *
185
- * For OT: Distinguishes committed (committedAt > 0) vs pending (committedAt === 0) changes.
186
- * For LWW: Applies all ops from changes and updates metadata.
222
+ * Confirms changes from the algorithm pipeline.
223
+ * For local changes (committedAt === 0): shifts from the optimistic queue
224
+ * and skips the state update since change() already applied them.
225
+ * For server changes (committedAt > 0): updates committed state and
226
+ * recomputes with any remaining optimistic ops.
187
227
  *
188
228
  * @param changes Array of changes to apply.
189
229
  */
@@ -20,12 +20,13 @@ function rebaseChanges(serverChanges, localChanges) {
20
20
  );
21
21
  const baseRev = lastChange.rev;
22
22
  let rev = lastChange.rev;
23
- return filteredLocalChanges.map((change) => {
23
+ const result = filteredLocalChanges.map((change) => {
24
24
  rev++;
25
25
  const ops = transformPatch.transform(change.ops).ops;
26
26
  if (!ops.length) return null;
27
27
  return { ...change, baseRev, rev, ops };
28
28
  }).filter(Boolean);
29
+ return result;
29
30
  }
30
31
  export {
31
32
  rebaseChanges
@@ -1,6 +1,6 @@
1
1
  import 'easy-signal';
2
2
  import '../json-patch/types.js';
3
3
  import '../types.js';
4
- export { B as BaseDoc } from '../BaseDoc-BRIP2YZp.js';
4
+ export { B as BaseDoc } from '../BaseDoc-CD5wZQMm.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,9 +1,18 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
2
  import { ReadonlyStoreClass, signal, store } from "easy-signal";
3
+ import { applyPatch } from "../json-patch/applyPatch.js";
3
4
  import { createJSONPatch } from "../json-patch/createJSONPatch.js";
4
5
  import { isDocLoaded } from "../shared/utils.js";
5
6
  class BaseDoc extends ReadonlyStoreClass {
6
7
  _id;
8
+ /**
9
+ * FIFO queue of ops applied optimistically by change() but not yet confirmed
10
+ * by applyChanges(). The 1:1 mapping between change() and applyChanges()
11
+ * means confirmation simply shifts from the front. The ops are stored (not
12
+ * just counted) so _recomputeState() can reconstruct the full state when
13
+ * server changes arrive during the optimistic window.
14
+ */
15
+ _optimisticOps = [];
7
16
  /** Current sync status of this document. */
8
17
  syncStatus = store("unsynced");
9
18
  /** Whether the document has completed its initial load. Sticky: once true, never reverts to false. */
@@ -30,8 +39,14 @@ class BaseDoc extends ReadonlyStoreClass {
30
39
  return this._id;
31
40
  }
32
41
  /**
33
- * Captures an update to the document, emitting JSON Patch ops via onChange.
34
- * Does NOT apply locally - the algorithm handles state updates via applyChanges.
42
+ * Captures an update to the document, applies it optimistically to state,
43
+ * and emits the ops via onChange for async persistence.
44
+ *
45
+ * State is updated synchronously (easy-signal's store.set() drains
46
+ * subscribers synchronously) so the UI sees changes immediately.
47
+ * The algorithm later confirms via applyChanges(), which shifts from
48
+ * the FIFO queue and skips the state update (avoiding double notifications).
49
+ *
35
50
  * @param mutator Function that uses JSONPatch methods with type-safe paths.
36
51
  */
37
52
  change(mutator) {
@@ -39,8 +54,20 @@ class BaseDoc extends ReadonlyStoreClass {
39
54
  if (patch.ops.length === 0) {
40
55
  return;
41
56
  }
57
+ this.state = applyPatch(this.state, patch.ops, { strict: true });
58
+ this._optimisticOps.push(patch.ops);
42
59
  this.onChange.emit(patch.ops);
43
60
  }
61
+ /**
62
+ * Rolls back all outstanding optimistic applies and recomputes state from
63
+ * confirmed state only. Called by Patches._handleDocChange when the
64
+ * algorithm rejects ops. Any remaining in-flight changes will apply
65
+ * normally via the fallback path in applyChanges().
66
+ */
67
+ rollbackOptimistic() {
68
+ this._optimisticOps = [];
69
+ this._recomputeState();
70
+ }
44
71
  // --- Internal methods (not on PatchesDoc interface) ---
45
72
  /**
46
73
  * Updates the sync status of the document.
@@ -1,6 +1,6 @@
1
1
  import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
- import { a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
4
4
  import { PatchesStore, TrackedDoc } from './PatchesStore.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -2,7 +2,7 @@ import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
3
  import { ClientAlgorithm } from './ClientAlgorithm.js';
4
4
  import { LWWClientStore } from './LWWClientStore.js';
5
- import { a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
5
+ import { a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
6
6
  import { TrackedDoc } from './PatchesStore.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
@@ -1,5 +1,5 @@
1
1
  import { PatchesSnapshot, Change } from '../types.js';
2
- import { B as BaseDoc } from '../BaseDoc-BRIP2YZp.js';
2
+ import { B as BaseDoc } from '../BaseDoc-CD5wZQMm.js';
3
3
  import '../json-patch/JSONPatch.js';
4
4
  import '@dabble/delta';
5
5
  import '../json-patch/types.js';
@@ -8,11 +8,14 @@ import 'easy-signal';
8
8
  /**
9
9
  * LWW (Last-Write-Wins) document implementation.
10
10
  *
11
- * The `change()` method (inherited from BaseDoc) captures ops and emits them
12
- * via `onChange` - it does NOT apply locally. The LWWAlgorithm handles:
13
- * - Packaging ops with timestamps
14
- * - Merging with pending fields
15
- * - Updating the doc's state via `applyChanges()`
11
+ * The `change()` method (inherited from BaseDoc) applies ops optimistically
12
+ * to `state` and emits them via `onChange`. The LWWAlgorithm packages ops
13
+ * with timestamps, persists them, and calls `applyChanges()` to confirm
14
+ * the optimistic update (shifting from the FIFO queue and skipping the state setter).
15
+ *
16
+ * Note: LWWAlgorithm adds `ts` (timestamp) metadata to ops before persisting.
17
+ * `applyPatch` ignores unknown op properties, so optimistic apply (raw ops)
18
+ * and confirmed apply (timestamped ops) produce identical state.
16
19
  *
17
20
  * Unlike OTDoc, LWWDoc doesn't need to track committed vs pending state
18
21
  * separately - the algorithm handles all conflict resolution by timestamp.
@@ -24,6 +27,8 @@ import 'easy-signal';
24
27
  declare class LWWDoc<T extends object = object> extends BaseDoc<T> {
25
28
  protected _committedRev: number;
26
29
  protected _hasPending: boolean;
30
+ /** Confirmed state (from algorithm pipeline), used to recompute after server changes. */
31
+ protected _baseState: T;
27
32
  /**
28
33
  * Creates an instance of LWWDoc.
29
34
  * @param id The unique identifier for this document.
@@ -40,13 +45,18 @@ declare class LWWDoc<T extends object = object> extends BaseDoc<T> {
40
45
  */
41
46
  import(snapshot: PatchesSnapshot<T>): void;
42
47
  /**
43
- * Applies changes to the document state.
44
- * Used for Worker-Tab communication where only changes are sent over the wire.
48
+ * Recomputes state from the base state plus remaining optimistic ops.
49
+ */
50
+ protected _recomputeState(): void;
51
+ /**
52
+ * Confirms changes from the algorithm pipeline.
45
53
  *
46
54
  * For LWW, all ops are applied in order. The method distinguishes between
47
55
  * committed and pending changes using `committedAt`:
48
- * - `committedAt > 0`: Server-committed change (updates committedRev)
49
- * - `committedAt === 0`: Pending local change (marks hasPending = true)
56
+ * - `committedAt > 0`: Server-committed change (updates committedRev, recomputes
57
+ * state with remaining optimistic ops preserved)
58
+ * - `committedAt === 0`: Pending local change (shifts from optimistic queue,
59
+ * skips state update since change() already applied the ops)
50
60
  *
51
61
  * @param changes Array of changes to apply
52
62
  * @param hasPending If provided, overrides the inferred pending state.
@@ -4,6 +4,8 @@ import { BaseDoc } from "./BaseDoc.js";
4
4
  class LWWDoc extends BaseDoc {
5
5
  _committedRev;
6
6
  _hasPending;
7
+ /** Confirmed state (from algorithm pipeline), used to recompute after server changes. */
8
+ _baseState;
7
9
  /**
8
10
  * Creates an instance of LWWDoc.
9
11
  * @param id The unique identifier for this document.
@@ -23,6 +25,7 @@ class LWWDoc extends BaseDoc {
23
25
  }
24
26
  this.state = currentState;
25
27
  }
28
+ this._baseState = this.state;
26
29
  this._checkLoaded();
27
30
  }
28
31
  /** Last committed revision number from the server. */
@@ -40,6 +43,7 @@ class LWWDoc extends BaseDoc {
40
43
  import(snapshot) {
41
44
  this._committedRev = snapshot.rev;
42
45
  this._hasPending = (snapshot.changes?.length ?? 0) > 0;
46
+ this._optimisticOps = [];
43
47
  let currentState = snapshot.state;
44
48
  if (snapshot.changes && snapshot.changes.length > 0) {
45
49
  for (const change of snapshot.changes) {
@@ -48,17 +52,29 @@ class LWWDoc extends BaseDoc {
48
52
  }
49
53
  }
50
54
  }
55
+ this._baseState = currentState;
51
56
  this._checkLoaded();
52
57
  this.state = currentState;
53
58
  }
54
59
  /**
55
- * Applies changes to the document state.
56
- * Used for Worker-Tab communication where only changes are sent over the wire.
60
+ * Recomputes state from the base state plus remaining optimistic ops.
61
+ */
62
+ _recomputeState() {
63
+ let newState = this._baseState;
64
+ for (const ops of this._optimisticOps) {
65
+ newState = applyPatch(newState, ops, { strict: true });
66
+ }
67
+ this.state = newState;
68
+ }
69
+ /**
70
+ * Confirms changes from the algorithm pipeline.
57
71
  *
58
72
  * For LWW, all ops are applied in order. The method distinguishes between
59
73
  * committed and pending changes using `committedAt`:
60
- * - `committedAt > 0`: Server-committed change (updates committedRev)
61
- * - `committedAt === 0`: Pending local change (marks hasPending = true)
74
+ * - `committedAt > 0`: Server-committed change (updates committedRev, recomputes
75
+ * state with remaining optimistic ops preserved)
76
+ * - `committedAt === 0`: Pending local change (shifts from optimistic queue,
77
+ * skips state update since change() already applied the ops)
62
78
  *
63
79
  * @param changes Array of changes to apply
64
80
  * @param hasPending If provided, overrides the inferred pending state.
@@ -66,17 +82,13 @@ class LWWDoc extends BaseDoc {
66
82
  */
67
83
  applyChanges(changes, hasPending) {
68
84
  if (changes.length === 0) return;
69
- let currentState = this.state;
70
- for (const change of changes) {
71
- for (const op of change.ops) {
72
- currentState = applyPatch(currentState, [op], { partial: true });
73
- }
74
- }
75
85
  let lastCommittedRev = this._committedRev;
76
86
  let hasPendingChanges = false;
87
+ let hasServerChanges = false;
77
88
  for (const change of changes) {
78
89
  if (change.committedAt > 0) {
79
90
  lastCommittedRev = change.rev;
91
+ hasServerChanges = true;
80
92
  } else {
81
93
  hasPendingChanges = true;
82
94
  }
@@ -84,7 +96,27 @@ class LWWDoc extends BaseDoc {
84
96
  this._committedRev = lastCommittedRev;
85
97
  this._hasPending = hasPending ?? hasPendingChanges;
86
98
  this._checkLoaded();
87
- this.state = currentState;
99
+ if (hasServerChanges) {
100
+ let newBaseState = this._baseState;
101
+ for (const change of changes) {
102
+ for (const op of change.ops) {
103
+ newBaseState = applyPatch(newBaseState, [op], { partial: true });
104
+ }
105
+ }
106
+ this._baseState = newBaseState;
107
+ this._recomputeState();
108
+ } else {
109
+ for (const change of changes) {
110
+ for (const op of change.ops) {
111
+ this._baseState = applyPatch(this._baseState, [op], { partial: true });
112
+ }
113
+ }
114
+ if (this._optimisticOps.length > 0) {
115
+ this._optimisticOps.shift();
116
+ } else {
117
+ this._recomputeState();
118
+ }
119
+ }
88
120
  }
89
121
  }
90
122
  export {
@@ -158,9 +158,6 @@ class LWWInMemoryStore {
158
158
  for (const op of buf.sendingChange.ops) {
159
159
  buf.committedFields.set(op.path, op.value);
160
160
  }
161
- if (buf.sendingChange.rev > buf.committedRev) {
162
- buf.committedRev = buf.sendingChange.rev;
163
- }
164
161
  buf.sendingChange = null;
165
162
  }
166
163
  /**
@@ -243,10 +243,6 @@ class LWWIndexedDBStore {
243
243
  return;
244
244
  }
245
245
  await Promise.all(sending.change.ops.map((op) => committedOps.put({ ...op, docId })));
246
- const docMeta = await docsStore.get(docId) ?? { docId, committedRev: 0, algorithm: "lww" };
247
- if (sending.change.rev > docMeta.committedRev) {
248
- await docsStore.put({ ...docMeta, committedRev: sending.change.rev });
249
- }
250
246
  await sendingChanges.delete(docId);
251
247
  await tx.complete();
252
248
  }
@@ -2,7 +2,7 @@ import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
3
  import { ClientAlgorithm } from './ClientAlgorithm.js';
4
4
  import { OTClientStore } from './OTClientStore.js';
5
- import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
5
+ import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
6
6
  import { TrackedDoc } from './PatchesStore.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
@@ -1,5 +1,5 @@
1
1
  import '../types.js';
2
- export { O as OTDoc } from '../BaseDoc-BRIP2YZp.js';
2
+ export { O as OTDoc } from '../BaseDoc-CD5wZQMm.js';
3
3
  import '../json-patch/JSONPatch.js';
4
4
  import '@dabble/delta';
5
5
  import '../json-patch/types.js';
@@ -1,6 +1,7 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
2
  import { createStateFromSnapshot } from "../algorithms/ot/client/createStateFromSnapshot.js";
3
3
  import { applyChanges as applyChangesToState } from "../algorithms/ot/shared/applyChanges.js";
4
+ import { applyPatch } from "../json-patch/applyPatch.js";
4
5
  import { BaseDoc } from "./BaseDoc.js";
5
6
  class OTDoc extends BaseDoc {
6
7
  /** Base state from the server at the committed revision. */
@@ -21,7 +22,21 @@ class OTDoc extends BaseDoc {
21
22
  this._committedRev = snapshot?.rev ?? 0;
22
23
  this._pendingChanges = snapshot?.changes ?? [];
23
24
  if (this._pendingChanges.length > 0) {
24
- this.state = applyChangesToState(this._committedState, this._pendingChanges);
25
+ try {
26
+ this.state = applyChangesToState(this._committedState, this._pendingChanges);
27
+ } catch {
28
+ let state = this._committedState;
29
+ const valid = [];
30
+ for (const c of this._pendingChanges) {
31
+ try {
32
+ state = applyPatch(state, c.ops, { strict: true });
33
+ valid.push(c);
34
+ } catch {
35
+ }
36
+ }
37
+ this._pendingChanges = valid;
38
+ this.state = state;
39
+ }
25
40
  }
26
41
  this._checkLoaded();
27
42
  }
@@ -48,16 +63,28 @@ class OTDoc extends BaseDoc {
48
63
  this._committedState = snapshot.state;
49
64
  this._committedRev = snapshot.rev;
50
65
  this._pendingChanges = snapshot.changes;
66
+ this._optimisticOps = [];
51
67
  this._checkLoaded();
52
68
  this.state = createStateFromSnapshot(snapshot);
53
69
  }
54
70
  /**
55
- * Unified entry point for applying changes.
56
- * Used for Worker→Tab communication where only changes are sent over the wire.
71
+ * Recomputes state from committed + pending + remaining optimistic ops.
72
+ */
73
+ _recomputeState() {
74
+ let newState = applyChangesToState(this._committedState, this._pendingChanges);
75
+ for (const ops of this._optimisticOps) {
76
+ newState = applyPatch(newState, ops, { strict: true });
77
+ }
78
+ this.state = newState;
79
+ }
80
+ /**
81
+ * Confirms changes from the algorithm pipeline.
57
82
  *
58
- * The method distinguishes between committed and pending changes using `committedAt`:
59
- * - `committedAt > 0`: Server-committed change (apply to committed state)
60
- * - `committedAt === 0`: Pending local change (append to pending)
83
+ * Distinguishes between committed and pending changes using `committedAt`:
84
+ * - `committedAt > 0`: Server-committed change (updates committed state, recomputes
85
+ * with remaining optimistic ops preserved)
86
+ * - `committedAt === 0`: Local change confirmation (shifts from optimistic queue,
87
+ * skips state update since change() already applied the ops)
61
88
  *
62
89
  * For server changes, all committed changes come first, followed by rebased pending.
63
90
  *
@@ -76,11 +103,15 @@ class OTDoc extends BaseDoc {
76
103
  this._committedRev = serverChanges[serverChanges.length - 1].rev;
77
104
  this._pendingChanges = rebasedPending;
78
105
  this._checkLoaded();
79
- this.state = applyChangesToState(this._committedState, this._pendingChanges);
106
+ this._recomputeState();
80
107
  } else {
81
108
  this._pendingChanges.push(...changes);
82
109
  this._checkLoaded();
83
- this.state = applyChangesToState(this.state, changes);
110
+ if (this._optimisticOps.length > 0) {
111
+ this._optimisticOps.shift();
112
+ } else {
113
+ this.state = applyChangesToState(this.state, changes);
114
+ }
84
115
  }
85
116
  }
86
117
  /**
@@ -3,7 +3,7 @@ import { Unsubscriber } from 'easy-signal';
3
3
  import { JSONPatchOp } from '../json-patch/types.js';
4
4
  import { Change } from '../types.js';
5
5
  import { ClientAlgorithm } from './ClientAlgorithm.js';
6
- import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
6
+ import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
7
7
  import { AlgorithmName } from './PatchesStore.js';
8
8
  import '../json-patch/JSONPatch.js';
9
9
  import '@dabble/delta';
@@ -46,6 +46,7 @@ interface ManagedDoc<T extends object> {
46
46
  declare class Patches {
47
47
  protected options: PatchesOptions;
48
48
  protected docs: Map<string, ManagedDoc<any>>;
49
+ private _changeQueues;
49
50
  readonly docOptions: PatchesDocOptions;
50
51
  readonly algorithms: Partial<Record<AlgorithmName, ClientAlgorithm>>;
51
52
  readonly defaultAlgorithm: AlgorithmName;
@@ -124,9 +125,12 @@ declare class Patches {
124
125
  close(): Promise<void>;
125
126
  /**
126
127
  * Internal handler for doc changes. Called when doc.onChange emits ops.
127
- * Delegates to algorithm for packaging and persisting.
128
+ * Serializes calls per docId to prevent concurrent handleDocChange from
129
+ * creating changes with the same rev (which would overwrite each other
130
+ * in IndexedDB's [docId, rev] keyed pendingChanges store).
128
131
  */
129
132
  protected _handleDocChange<T extends object>(docId: string, ops: JSONPatchOp[], doc: PatchesDoc<T>, algorithm: ClientAlgorithm, metadata: Record<string, any>): Promise<void>;
133
+ private _processDocChange;
130
134
  }
131
135
 
132
136
  export { type OpenDocOptions, Patches, type PatchesOptions };
@@ -14,6 +14,7 @@ class Patches {
14
14
  __runInitializers(_init, 5, this);
15
15
  __publicField(this, "options");
16
16
  __publicField(this, "docs", /* @__PURE__ */ new Map());
17
+ __publicField(this, "_changeQueues", /* @__PURE__ */ new Map());
17
18
  __publicField(this, "docOptions");
18
19
  __publicField(this, "algorithms");
19
20
  __publicField(this, "defaultAlgorithm");
@@ -148,6 +149,7 @@ class Patches {
148
149
  if (managed) {
149
150
  managed.unsubscribe();
150
151
  this.docs.delete(docId);
152
+ this._changeQueues.delete(docId);
151
153
  if (untrack) {
152
154
  await this.untrackDocs([docId]);
153
155
  }
@@ -186,6 +188,7 @@ class Patches {
186
188
  async close() {
187
189
  this.docs.forEach((managed) => managed.unsubscribe());
188
190
  this.docs.clear();
191
+ this._changeQueues.clear();
189
192
  await Promise.all(Object.values(this.algorithms).map((s) => s?.close()));
190
193
  this.onChange.clear();
191
194
  this.onDeleteDoc.clear();
@@ -196,14 +199,27 @@ class Patches {
196
199
  }
197
200
  /**
198
201
  * Internal handler for doc changes. Called when doc.onChange emits ops.
199
- * Delegates to algorithm for packaging and persisting.
202
+ * Serializes calls per docId to prevent concurrent handleDocChange from
203
+ * creating changes with the same rev (which would overwrite each other
204
+ * in IndexedDB's [docId, rev] keyed pendingChanges store).
200
205
  */
201
- async _handleDocChange(docId, ops, doc, algorithm, metadata) {
206
+ _handleDocChange(docId, ops, doc, algorithm, metadata) {
207
+ const prev = this._changeQueues.get(docId) ?? Promise.resolve();
208
+ const current = prev.then(() => this._processDocChange(docId, ops, doc, algorithm, metadata));
209
+ this._changeQueues.set(docId, current.catch(() => {
210
+ }));
211
+ return current;
212
+ }
213
+ async _processDocChange(docId, ops, doc, algorithm, metadata) {
202
214
  try {
203
215
  await algorithm.handleDocChange(docId, ops, doc, metadata);
204
216
  this.onChange.emit(docId);
205
217
  } catch (err) {
206
218
  console.error(`Error handling doc change for ${docId}:`, err);
219
+ const baseDoc = doc;
220
+ if (typeof baseDoc.rollbackOptimistic === "function") {
221
+ baseDoc.rollbackOptimistic();
222
+ }
207
223
  this.onError.emit(err, { docId });
208
224
  }
209
225
  }
@@ -1,6 +1,6 @@
1
1
  import 'easy-signal';
2
2
  import '../json-patch/types.js';
3
3
  import '../types.js';
4
- export { O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-BRIP2YZp.js';
4
+ export { O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-CD5wZQMm.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,6 +1,6 @@
1
1
  import { AlgorithmName } from './PatchesStore.js';
2
2
  import { Patches } from './Patches.js';
3
- import { P as PatchesDocOptions } from '../BaseDoc-BRIP2YZp.js';
3
+ import { P as PatchesDocOptions } from '../BaseDoc-CD5wZQMm.js';
4
4
  import '../types.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,4 +1,4 @@
1
- export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-BRIP2YZp.js';
1
+ export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-CD5wZQMm.js';
2
2
  export { IndexedDBFactoryOptions, MultiAlgorithmFactoryOptions, MultiAlgorithmIndexedDBFactoryOptions, PatchesFactoryOptions, createLWWIndexedDBPatches, createLWWPatches, createMultiAlgorithmIndexedDBPatches, createMultiAlgorithmPatches, createOTIndexedDBPatches, createOTPatches } from './factories.js';
3
3
  export { IDBStoreWrapper, IDBTransactionWrapper, IndexedDBStore } from './IndexedDBStore.js';
4
4
  export { OTIndexedDBStore } from './OTIndexedDBStore.js';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Delta } from '@dabble/delta';
2
- export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from './BaseDoc-BRIP2YZp.js';
2
+ export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from './BaseDoc-CD5wZQMm.js';
3
3
  export { IndexedDBFactoryOptions, MultiAlgorithmFactoryOptions, MultiAlgorithmIndexedDBFactoryOptions, PatchesFactoryOptions, createLWWIndexedDBPatches, createLWWPatches, createMultiAlgorithmIndexedDBPatches, createMultiAlgorithmPatches, createOTIndexedDBPatches, createOTPatches } from './client/factories.js';
4
4
  export { IDBStoreWrapper, IDBTransactionWrapper, IndexedDBStore } from './client/IndexedDBStore.js';
5
5
  export { OTIndexedDBStore } from './client/OTIndexedDBStore.js';
@@ -14,7 +14,7 @@ import '@dabble/delta';
14
14
  import '../json-patch/types.js';
15
15
  import './PatchesClient.js';
16
16
  import '../utils/deferred.js';
17
- import '../BaseDoc-BRIP2YZp.js';
17
+ import '../BaseDoc-CD5wZQMm.js';
18
18
 
19
19
  interface PatchesSyncState {
20
20
  online: boolean;
@@ -17,7 +17,7 @@ import 'easy-signal';
17
17
  import '../algorithms/ot/shared/changeBatching.js';
18
18
  import '../client/ClientAlgorithm.js';
19
19
  import '../json-patch/types.js';
20
- import '../BaseDoc-BRIP2YZp.js';
20
+ import '../BaseDoc-CD5wZQMm.js';
21
21
  import '../client/PatchesStore.js';
22
22
  import '../client/Patches.js';
23
23
  import '../server/types.js';
@@ -1,5 +1,5 @@
1
1
  import { Patches, OpenDocOptions } from '../client/Patches.js';
2
- import { a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
2
+ import { a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
3
3
  import 'easy-signal';
4
4
  import '../json-patch/types.js';
5
5
  import '../types.js';
@@ -7,7 +7,7 @@ import '../types.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
9
9
  import '../client/ClientAlgorithm.js';
10
- import '../BaseDoc-BRIP2YZp.js';
10
+ import '../BaseDoc-CD5wZQMm.js';
11
11
  import '../client/PatchesStore.js';
12
12
  import '../net/protocol/types.js';
13
13
  import '../net/protocol/JSONRPCClient.js';
@@ -6,5 +6,5 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-BRIP2YZp.js';
9
+ import '../BaseDoc-CD5wZQMm.js';
10
10
  import '../client/PatchesStore.js';
@@ -11,7 +11,7 @@ import '../types.js';
11
11
  import '../json-patch/JSONPatch.js';
12
12
  import '@dabble/delta';
13
13
  import '../client/ClientAlgorithm.js';
14
- import '../BaseDoc-BRIP2YZp.js';
14
+ import '../BaseDoc-CD5wZQMm.js';
15
15
  import '../client/PatchesStore.js';
16
16
  import '../net/PatchesSync.js';
17
17
  import '../net/protocol/types.js';
@@ -1,6 +1,6 @@
1
1
  import { Accessor } from 'solid-js';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
- import { a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
4
4
  import { JSONPatch } from '../json-patch/JSONPatch.js';
5
5
  import { ChangeMutator } from '../types.js';
6
6
  import 'easy-signal';
@@ -1,6 +1,6 @@
1
1
  import { ShallowRef, Ref, MaybeRef } from 'vue';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
- import { a as PatchesDoc } from '../BaseDoc-BRIP2YZp.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-CD5wZQMm.js';
4
4
  import { JSONPatch } from '../json-patch/JSONPatch.js';
5
5
  import { ChangeMutator } from '../types.js';
6
6
  import 'easy-signal';
@@ -77,7 +77,7 @@ interface UsePatchesDocReturn<T extends object> {
77
77
  * The underlying PatchesDoc instance.
78
78
  * Useful for advanced operations.
79
79
  */
80
- doc: Ref<PatchesDoc<T> | undefined>;
80
+ doc: ShallowRef<PatchesDoc<T> | undefined>;
81
81
  }
82
82
  /**
83
83
  * Return type for usePatchesDoc composable (lazy mode).
@@ -13,7 +13,7 @@ import { usePatchesContext } from "./provider.js";
13
13
  import { getDocManager } from "./doc-manager.js";
14
14
  function createDocReactiveState(options) {
15
15
  const { initialLoading = true, hasSyncContext = false, transformState, changeBehavior } = options;
16
- const doc = ref(void 0);
16
+ const doc = shallowRef(void 0);
17
17
  const data = shallowRef(void 0);
18
18
  const loading = ref(initialLoading);
19
19
  const error = ref();
@@ -6,5 +6,5 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-BRIP2YZp.js';
9
+ import '../BaseDoc-CD5wZQMm.js';
10
10
  import '../client/PatchesStore.js';
@@ -11,7 +11,7 @@ import '../types.js';
11
11
  import '../json-patch/JSONPatch.js';
12
12
  import '@dabble/delta';
13
13
  import '../client/ClientAlgorithm.js';
14
- import '../BaseDoc-BRIP2YZp.js';
14
+ import '../BaseDoc-CD5wZQMm.js';
15
15
  import '../client/PatchesStore.js';
16
16
  import '../net/PatchesSync.js';
17
17
  import '../net/protocol/types.js';
@@ -6,7 +6,7 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-BRIP2YZp.js';
9
+ import '../BaseDoc-CD5wZQMm.js';
10
10
  import '../client/PatchesStore.js';
11
11
 
12
12
  /**
@@ -7,7 +7,7 @@ import '../types.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
9
9
  import '../client/ClientAlgorithm.js';
10
- import '../BaseDoc-BRIP2YZp.js';
10
+ import '../BaseDoc-CD5wZQMm.js';
11
11
  import '../client/PatchesStore.js';
12
12
  import '../net/protocol/types.js';
13
13
  import '../net/protocol/JSONRPCClient.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {