@dabble/patches 0.8.5 → 0.8.7

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.
@@ -31,7 +31,7 @@ declare class LWWInMemoryStore implements LWWClientStore {
31
31
  listDocs(includeDeleted?: boolean): Promise<TrackedDoc[]>;
32
32
  /**
33
33
  * Saves the current document state to storage.
34
- * Clears all committed fields, pending ops, and sending change.
34
+ * Clears committed fields (subsumed by the snapshot) but preserves pending ops.
35
35
  */
36
36
  saveDoc(docId: string, docState: PatchesState): Promise<void>;
37
37
  /**
@@ -71,7 +71,13 @@ declare class LWWInMemoryStore implements LWWClientStore {
71
71
  */
72
72
  saveSendingChange(docId: string, change: Change): Promise<void>;
73
73
  /**
74
- * Clear sendingChange after server ack, move ops to committed.
74
+ * Move sending ops to committed, then clear the sending slot.
75
+ * committedRev is NOT updated here — applyServerChanges owns that using the
76
+ * server's actual rev. Updating it here would bump the rev above the server's
77
+ * real value for noop changes (where the server doesn't create a new rev).
78
+ *
79
+ * Call this BEFORE applyServerChanges so that server corrections (which run
80
+ * after) overwrite any stale ops for fields the server won via LWW.
75
81
  */
76
82
  confirmSendingChange(docId: string): Promise<void>;
77
83
  /**
@@ -51,14 +51,15 @@ class LWWInMemoryStore {
51
51
  }
52
52
  /**
53
53
  * Saves the current document state to storage.
54
- * Clears all committed fields, pending ops, and sending change.
54
+ * Clears committed fields (subsumed by the snapshot) but preserves pending ops.
55
55
  */
56
56
  async saveDoc(docId, docState) {
57
+ const existing = this.docs.get(docId);
57
58
  this.docs.set(docId, {
58
59
  snapshot: { state: docState.state, rev: docState.rev },
59
60
  committedFields: /* @__PURE__ */ new Map(),
60
- pendingOps: /* @__PURE__ */ new Map(),
61
- sendingChange: null,
61
+ pendingOps: existing?.pendingOps ?? /* @__PURE__ */ new Map(),
62
+ sendingChange: existing?.sendingChange ?? null,
62
63
  committedRev: docState.rev
63
64
  });
64
65
  }
@@ -150,7 +151,13 @@ class LWWInMemoryStore {
150
151
  buf.pendingOps.clear();
151
152
  }
152
153
  /**
153
- * Clear sendingChange after server ack, move ops to committed.
154
+ * Move sending ops to committed, then clear the sending slot.
155
+ * committedRev is NOT updated here — applyServerChanges owns that using the
156
+ * server's actual rev. Updating it here would bump the rev above the server's
157
+ * real value for noop changes (where the server doesn't create a new rev).
158
+ *
159
+ * Call this BEFORE applyServerChanges so that server corrections (which run
160
+ * after) overwrite any stale ops for fields the server won via LWW.
154
161
  */
155
162
  async confirmSendingChange(docId) {
156
163
  const buf = this.docs.get(docId);
@@ -158,10 +165,6 @@ class LWWInMemoryStore {
158
165
  for (const op of buf.sendingChange.ops) {
159
166
  buf.committedFields.set(op.path, op.value);
160
167
  }
161
- const changeRev = buf.sendingChange.rev;
162
- if (changeRev !== void 0 && changeRev > buf.committedRev) {
163
- buf.committedRev = changeRev;
164
- }
165
168
  buf.sendingChange = null;
166
169
  }
167
170
  /**
@@ -73,7 +73,7 @@ declare class LWWIndexedDBStore implements LWWClientStore {
73
73
  getDoc(docId: string): Promise<PatchesSnapshot | undefined>;
74
74
  /**
75
75
  * Saves the current document state to storage.
76
- * Clears all committed fields and pending ops.
76
+ * Clears committed fields (subsumed by the snapshot) but preserves pending ops.
77
77
  */
78
78
  saveDoc(docId: string, docState: PatchesState): Promise<void>;
79
79
  /**
@@ -101,7 +101,13 @@ declare class LWWIndexedDBStore implements LWWClientStore {
101
101
  */
102
102
  saveSendingChange(docId: string, change: Change): Promise<void>;
103
103
  /**
104
- * Clear sendingChange after server ack, move ops to committed.
104
+ * Move sending ops to committed, then clear the sending slot.
105
+ * committedRev is NOT updated here — applyServerChanges owns that using the
106
+ * server's actual rev. Updating it here would bump the rev above the server's
107
+ * real value for noop changes (where the server doesn't create a new rev).
108
+ *
109
+ * Call this BEFORE applyServerChanges so that server corrections (which run
110
+ * after) overwrite any stale ops for fields the server won via LWW.
105
111
  */
106
112
  confirmSendingChange(docId: string): Promise<void>;
107
113
  /**
@@ -122,17 +122,15 @@ class LWWIndexedDBStore {
122
122
  };
123
123
  }
124
124
  async saveDoc(docId, docState) {
125
- const [tx, snapshots, committedOps, pendingOps, sendingChanges, docsStore] = await this.db.transaction(
126
- ["snapshots", "committedOps", "pendingOps", "sendingChanges", "docs"],
125
+ const [tx, snapshots, committedOps, docsStore] = await this.db.transaction(
126
+ ["snapshots", "committedOps", "docs"],
127
127
  "readwrite"
128
128
  );
129
129
  const { rev, state } = docState;
130
130
  await Promise.all([
131
131
  docsStore.put({ docId, committedRev: rev, algorithm: "lww" }),
132
132
  snapshots.put({ docId, state, rev }),
133
- this.deleteFieldsForDoc(committedOps, docId),
134
- this.deleteFieldsForDoc(pendingOps, docId),
135
- sendingChanges.delete(docId)
133
+ this.deleteFieldsForDoc(committedOps, docId)
136
134
  ]);
137
135
  await tx.complete();
138
136
  }
@@ -233,8 +231,8 @@ class LWWIndexedDBStore {
233
231
  await tx.complete();
234
232
  }
235
233
  async confirmSendingChange(docId) {
236
- const [tx, sendingChanges, committedOps, docsStore] = await this.db.transaction(
237
- ["sendingChanges", "committedOps", "docs"],
234
+ const [tx, sendingChanges, committedOps] = await this.db.transaction(
235
+ ["sendingChanges", "committedOps"],
238
236
  "readwrite"
239
237
  );
240
238
  const sending = await sendingChanges.get(docId);
@@ -243,13 +241,6 @@ class LWWIndexedDBStore {
243
241
  return;
244
242
  }
245
243
  await Promise.all(sending.change.ops.map((op) => committedOps.put({ ...op, docId })));
246
- const changeRev = sending.change.rev;
247
- if (changeRev !== void 0) {
248
- const docMeta = await docsStore.get(docId) ?? { docId, committedRev: 0, algorithm: "lww" };
249
- if (changeRev > docMeta.committedRev) {
250
- await docsStore.put({ ...docMeta, committedRev: changeRev });
251
- }
252
- }
253
244
  await sendingChanges.delete(docId);
254
245
  await tx.complete();
255
246
  }
@@ -31,10 +31,11 @@ class OTInMemoryStore {
31
31
  }
32
32
  // ─── Writes ────────────────────────────────────────────────────────────
33
33
  async saveDoc(docId, snapshot) {
34
+ const existing = this.docs.get(docId);
34
35
  this.docs.set(docId, {
35
36
  snapshot,
36
37
  committed: [],
37
- pending: []
38
+ pending: existing?.pending ?? []
38
39
  });
39
40
  }
40
41
  async savePendingChanges(docId, changes) {
@@ -119,8 +119,7 @@ class OTIndexedDBStore {
119
119
  await Promise.all([
120
120
  docsStore.put({ docId, committedRev: rev, algorithm: "ot" }),
121
121
  snapshots.put({ docId, state, rev }),
122
- committedChanges.delete([docId, 0], [docId, Infinity]),
123
- pendingChanges.delete([docId, 0], [docId, Infinity])
122
+ committedChanges.delete([docId, 0], [docId, Infinity])
124
123
  ]);
125
124
  await tx.complete();
126
125
  }
@@ -144,7 +144,7 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
144
144
  protected _handleConnectionChange(connectionState: ConnectionState): void;
145
145
  protected _handleDocsTracked(docIds: string[], algorithmName?: AlgorithmName): Promise<void>;
146
146
  protected _handleDocsUntracked(docIds: string[]): Promise<void>;
147
- protected _handleDocChange(docId: string): Promise<void>;
147
+ protected _handleDocChange(docId: string): void;
148
148
  /**
149
149
  * Unified handler for remote document deletion (both real-time notifications and offline discovery).
150
150
  * Cleans up local state and notifies the application with any pending changes that were lost.
@@ -12,7 +12,7 @@ import { breakChangesIntoBatches } from "../algorithms/ot/shared/changeBatching.
12
12
  import { BaseDoc } from "../client/BaseDoc.js";
13
13
  import { Patches } from "../client/Patches.js";
14
14
  import { isDocLoaded } from "../shared/utils.js";
15
- import { blockable } from "../utils/concurrency.js";
15
+ import { blockable, serialGate } from "../utils/concurrency.js";
16
16
  import { ErrorCodes, StatusError } from "./error.js";
17
17
  import { PatchesWebSocket } from "./websocket/PatchesWebSocket.js";
18
18
  import { onlineState } from "./websocket/onlineState.js";
@@ -22,7 +22,7 @@ const EMPTY_DOC_STATE = {
22
22
  syncStatus: "unsynced",
23
23
  isLoaded: false
24
24
  };
25
- class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable], __receiveCommittedChanges_dec = [blockable], _a) {
25
+ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate], __receiveCommittedChanges_dec = [blockable], _a) {
26
26
  constructor(patches, urlOrConnection, options) {
27
27
  super({
28
28
  online: onlineState.isOnline,
@@ -232,7 +232,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
232
232
  }
233
233
  }
234
234
  async syncDoc(docId) {
235
- if (!this.state.connected) return;
235
+ if (!this.state.connected || !this.trackedDocs.has(docId)) return;
236
236
  this._updateDocSyncState(docId, { syncStatus: "syncing" });
237
237
  const doc = this.patches.getOpenDoc(docId);
238
238
  const algorithm = this._getAlgorithm(docId);
@@ -318,8 +318,8 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
318
318
  openDoc.import({ ...snapshot, changes: [] });
319
319
  }
320
320
  } else {
321
- await this._applyServerChangesToDoc(docId, committed);
322
321
  await algorithm.confirmSent(docId, changeBatch);
322
+ await this._applyServerChangesToDoc(docId, committed);
323
323
  }
324
324
  pending = await algorithm.getPendingToSend(docId) ?? [];
325
325
  }
@@ -460,11 +460,11 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
460
460
  }
461
461
  }
462
462
  }
463
- async _handleDocChange(docId) {
463
+ _handleDocChange(docId) {
464
464
  if (!this.trackedDocs.has(docId)) return;
465
465
  this._updateDocSyncState(docId, { hasPending: true });
466
466
  if (!this.state.connected) return;
467
- await this.syncDoc(docId);
467
+ this.syncDoc(docId);
468
468
  }
469
469
  /**
470
470
  * Unified handler for remote document deletion (both real-time notifications and offline discovery).
@@ -5,10 +5,10 @@ import { LWWServer } from './LWWServer.js';
5
5
  import { LWWStoreBackend, BranchingStoreBackend } from './types.js';
6
6
  import 'easy-signal';
7
7
  import '../net/websocket/AuthorizationProvider.js';
8
- import '../net/protocol/types.js';
8
+ import '../json-patch/types.js';
9
9
  import '../json-patch/JSONPatch.js';
10
10
  import '@dabble/delta';
11
- import '../json-patch/types.js';
11
+ import '../net/protocol/types.js';
12
12
  import './PatchesServer.js';
13
13
 
14
14
  /**
@@ -4,10 +4,10 @@ import { Change, CommitChangesOptions, DeleteDocOptions, ChangeInput, ChangeMuta
4
4
  import { PatchesServer } from './PatchesServer.js';
5
5
  import { LWWStoreBackend } from './types.js';
6
6
  import '../net/websocket/AuthorizationProvider.js';
7
- import '../net/protocol/types.js';
7
+ import '../json-patch/types.js';
8
8
  import '../json-patch/JSONPatch.js';
9
9
  import '@dabble/delta';
10
- import '../json-patch/types.js';
10
+ import '../net/protocol/types.js';
11
11
 
12
12
  /**
13
13
  * Configuration options for LWWServer.
@@ -1,5 +1,5 @@
1
1
  import { JSONPatchOp } from '../json-patch/types.js';
2
- import { VersionMetadata, Change, ListVersionsOptions, EditableVersionMetadata, ListChangesOptions, DocumentTombstone, Branch } from '../types.js';
2
+ import { DocumentTombstone, VersionMetadata, Change, ListVersionsOptions, EditableVersionMetadata, ListChangesOptions, Branch } from '../types.js';
3
3
  import '../json-patch/JSONPatch.js';
4
4
  import '@dabble/delta';
5
5
 
@@ -18,6 +18,33 @@ declare function blocking<T extends (docId: string, ...args: any[]) => Promise<a
18
18
  * Also, a Typescript decorator for functions whose response should be blocked when needed.
19
19
  */
20
20
  declare function blockableResponse<T extends (docId: string, ...args: any[]) => Promise<any>>(target: T): T;
21
+ /**
22
+ * Wraps a function so that only one invocation per key runs at a time.
23
+ * While in-flight, any additional calls for the same key are collapsed into
24
+ * exactly one queued follow-up. When the in-flight call finishes, the follow-up
25
+ * runs once (picking up all work that accumulated in the window). Further calls
26
+ * while the follow-up is itself in-flight queue another single follow-up, and
27
+ * so on — naturally serialising all work without ever dropping it.
28
+ *
29
+ * Contrast with `singleInvocation(true)`, which collapses concurrent calls but
30
+ * does not schedule a follow-up, so work that arrives mid-flight is lost until
31
+ * the next external trigger.
32
+ *
33
+ * ### Example
34
+ * ```ts
35
+ * const syncDoc = serialGate(async (docId: string) => {
36
+ * const pending = await store.getPending(docId);
37
+ * await server.commit(docId, pending);
38
+ * });
39
+ *
40
+ * // Three rapid calls: only one commitChanges in-flight at a time,
41
+ * // one follow-up picks up everything that arrived during the window.
42
+ * syncDoc('doc1');
43
+ * syncDoc('doc1');
44
+ * syncDoc('doc1');
45
+ * ```
46
+ */
47
+ declare function serialGate<T extends (key: string, ...args: any[]) => Promise<void>>(target: T): T;
21
48
  /**
22
49
  * Wrap a function to only return the result of the first call.
23
50
  *
@@ -30,4 +57,4 @@ declare function blockableResponse<T extends (docId: string, ...args: any[]) =>
30
57
  declare function singleInvocation<T extends (...args: any[]) => Promise<any>>(target: T): T;
31
58
  declare function singleInvocation<T extends (...args: any[]) => Promise<any>>(matchOnFirstArg: boolean): (target: T) => T;
32
59
 
33
- export { blockable, blockableResponse, blocking, releaseConcurrency, singleInvocation };
60
+ export { blockable, blockableResponse, blocking, releaseConcurrency, serialGate, singleInvocation };
@@ -27,6 +27,39 @@ function blockableResponse(target) {
27
27
  return concurrency(args[0]).blockResponse(target.apply(this, args));
28
28
  };
29
29
  }
30
+ function serialGate(target) {
31
+ const instances = /* @__PURE__ */ new WeakMap();
32
+ function getState(thisArg) {
33
+ let state = instances.get(thisArg);
34
+ if (!state) {
35
+ state = { inFlight: /* @__PURE__ */ new Map(), queued: /* @__PURE__ */ new Set() };
36
+ instances.set(thisArg, state);
37
+ }
38
+ return state;
39
+ }
40
+ function run(thisArg, key, args) {
41
+ const { inFlight, queued } = getState(thisArg);
42
+ const promise = target.apply(thisArg, [key, ...args]).finally(() => {
43
+ if (inFlight.get(key) === promise) {
44
+ inFlight.delete(key);
45
+ if (queued.has(key)) {
46
+ queued.delete(key);
47
+ run(thisArg, key, args);
48
+ }
49
+ }
50
+ });
51
+ inFlight.set(key, promise);
52
+ return promise;
53
+ }
54
+ return function(key, ...args) {
55
+ const { inFlight, queued } = getState(this);
56
+ if (inFlight.has(key)) {
57
+ queued.add(key);
58
+ return inFlight.get(key);
59
+ }
60
+ return run(this, key, args);
61
+ };
62
+ }
30
63
  function singleInvocation(matchOnFirstArgOrTarget) {
31
64
  if (typeof matchOnFirstArgOrTarget === "function") {
32
65
  return singleInvocation(false)(matchOnFirstArgOrTarget);
@@ -50,5 +83,6 @@ export {
50
83
  blockableResponse,
51
84
  blocking,
52
85
  releaseConcurrency,
86
+ serialGate,
53
87
  singleInvocation
54
88
  };
@@ -100,13 +100,21 @@ function _usePatchesDocEager(docId, options) {
100
100
  });
101
101
  let unsubscribe = null;
102
102
  if (autoClose) {
103
+ let unmounted = false;
103
104
  manager.openDoc(patches, docId, openDocOpts).then((patchesDoc) => {
105
+ if (unmounted) {
106
+ manager.closeDoc(patches, docId, shouldUntrack);
107
+ return;
108
+ }
104
109
  unsubscribe = setupDoc(patchesDoc);
105
110
  }).catch((err) => {
106
- baseReturn.error.value = err;
107
- baseReturn.loading.value = false;
111
+ if (!unmounted) {
112
+ baseReturn.error.value = err;
113
+ baseReturn.loading.value = false;
114
+ }
108
115
  });
109
116
  onBeforeUnmount(() => {
117
+ unmounted = true;
110
118
  unsubscribe?.();
111
119
  manager.closeDoc(patches, docId, shouldUntrack);
112
120
  });
@@ -219,6 +227,7 @@ function providePatchesDoc(name, docId, options = {}) {
219
227
  });
220
228
  const currentDocId = ref(unref(docId));
221
229
  let unsubscribe = null;
230
+ let providerUnmounted = false;
222
231
  async function initDoc(id) {
223
232
  currentDocId.value = id;
224
233
  unsubscribe?.();
@@ -226,10 +235,16 @@ function providePatchesDoc(name, docId, options = {}) {
226
235
  if (autoClose) {
227
236
  try {
228
237
  const patchesDoc = await manager.openDoc(patches, id, openDocOpts);
238
+ if (providerUnmounted || currentDocId.value !== id) {
239
+ manager.closeDoc(patches, id, shouldUntrack);
240
+ return;
241
+ }
229
242
  unsubscribe = setupDoc(patchesDoc);
230
243
  } catch (err) {
231
- baseReturn.error.value = err;
232
- baseReturn.loading.value = false;
244
+ if (!providerUnmounted && currentDocId.value === id) {
245
+ baseReturn.error.value = err;
246
+ baseReturn.loading.value = false;
247
+ }
233
248
  }
234
249
  } else {
235
250
  try {
@@ -264,6 +279,7 @@ function providePatchesDoc(name, docId, options = {}) {
264
279
  });
265
280
  }
266
281
  onBeforeUnmount(async () => {
282
+ providerUnmounted = true;
267
283
  unsubscribe?.();
268
284
  if (autoClose) {
269
285
  await manager.closeDoc(patches, currentDocId.value, shouldUntrack);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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": {