@dabble/patches 0.2.31 → 0.3.0

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.
Files changed (81) hide show
  1. package/dist/algorithms/client/applyCommittedChanges.d.ts +8 -0
  2. package/dist/algorithms/client/applyCommittedChanges.js +40 -0
  3. package/dist/{utils → algorithms/client}/batching.d.ts +1 -1
  4. package/dist/{utils → algorithms/client}/batching.js +2 -2
  5. package/dist/{utils → algorithms/client}/breakChange.d.ts +2 -3
  6. package/dist/algorithms/client/breakChange.js +258 -0
  7. package/dist/algorithms/client/createStateFromSnapshot.d.ts +7 -0
  8. package/dist/algorithms/client/createStateFromSnapshot.js +9 -0
  9. package/dist/algorithms/client/getJSONByteSize.js +12 -0
  10. package/dist/algorithms/client/makeChange.d.ts +3 -0
  11. package/dist/algorithms/client/makeChange.js +37 -0
  12. package/dist/algorithms/server/commitChanges.d.ts +12 -0
  13. package/dist/algorithms/server/commitChanges.js +80 -0
  14. package/dist/algorithms/server/createVersion.d.ts +12 -0
  15. package/dist/algorithms/server/createVersion.js +28 -0
  16. package/dist/algorithms/server/getSnapshotAtRevision.d.ts +10 -0
  17. package/dist/algorithms/server/getSnapshotAtRevision.js +29 -0
  18. package/dist/algorithms/server/getStateAtRevision.d.ts +9 -0
  19. package/dist/algorithms/server/getStateAtRevision.js +18 -0
  20. package/dist/algorithms/server/handleOfflineSessionsAndBatches.d.ts +12 -0
  21. package/dist/algorithms/server/handleOfflineSessionsAndBatches.js +80 -0
  22. package/dist/algorithms/server/transformIncomingChanges.d.ts +11 -0
  23. package/dist/algorithms/server/transformIncomingChanges.js +40 -0
  24. package/dist/algorithms/shared/applyChanges.d.ts +10 -0
  25. package/dist/algorithms/shared/applyChanges.js +17 -0
  26. package/dist/{utils.d.ts → algorithms/shared/rebaseChanges.d.ts} +1 -11
  27. package/dist/{utils.js → algorithms/shared/rebaseChanges.js} +3 -43
  28. package/dist/client/InMemoryStore.d.ts +4 -2
  29. package/dist/client/InMemoryStore.js +12 -3
  30. package/dist/client/IndexedDBStore.d.ts +36 -3
  31. package/dist/client/IndexedDBStore.js +399 -271
  32. package/dist/client/Patches.d.ts +11 -41
  33. package/dist/client/Patches.js +197 -208
  34. package/dist/client/PatchesDoc.d.ts +24 -41
  35. package/dist/client/PatchesDoc.js +57 -214
  36. package/dist/client/PatchesHistoryClient.js +1 -1
  37. package/dist/client/PatchesStore.d.ts +188 -10
  38. package/dist/data/change.d.ts +3 -0
  39. package/dist/data/change.js +20 -0
  40. package/dist/data/version.d.ts +12 -0
  41. package/dist/data/version.js +17 -0
  42. package/dist/event-signal.js +5 -13
  43. package/dist/json-patch/ops/add.js +1 -1
  44. package/dist/json-patch/ops/move.js +1 -1
  45. package/dist/json-patch/ops/remove.js +1 -1
  46. package/dist/json-patch/ops/replace.js +1 -1
  47. package/dist/json-patch/utils/get.js +0 -1
  48. package/dist/json-patch/utils/log.d.ts +4 -1
  49. package/dist/json-patch/utils/log.js +2 -5
  50. package/dist/json-patch/utils/ops.d.ts +1 -1
  51. package/dist/json-patch/utils/ops.js +4 -1
  52. package/dist/json-patch/utils/paths.js +2 -2
  53. package/dist/json-patch/utils/toArrayIndex.js +1 -1
  54. package/dist/net/PatchesSync.d.ts +55 -24
  55. package/dist/net/PatchesSync.js +338 -254
  56. package/dist/net/protocol/types.d.ts +2 -2
  57. package/dist/net/websocket/AuthorizationProvider.d.ts +9 -2
  58. package/dist/net/websocket/AuthorizationProvider.js +14 -2
  59. package/dist/net/websocket/PatchesWebSocket.d.ts +4 -4
  60. package/dist/net/websocket/PatchesWebSocket.js +5 -4
  61. package/dist/net/websocket/RPCServer.d.ts +2 -2
  62. package/dist/net/websocket/RPCServer.js +3 -3
  63. package/dist/net/websocket/SignalingService.js +1 -1
  64. package/dist/net/websocket/WebSocketServer.d.ts +1 -1
  65. package/dist/net/websocket/WebSocketServer.js +2 -2
  66. package/dist/net/websocket/WebSocketTransport.js +1 -1
  67. package/dist/net/websocket/onlineState.d.ts +1 -1
  68. package/dist/net/websocket/onlineState.js +8 -2
  69. package/dist/server/PatchesBranchManager.js +9 -16
  70. package/dist/server/PatchesHistoryManager.js +1 -1
  71. package/dist/server/PatchesServer.d.ts +11 -38
  72. package/dist/server/PatchesServer.js +32 -255
  73. package/dist/types.d.ts +8 -6
  74. package/dist/utils/concurrency.d.ts +26 -0
  75. package/dist/utils/concurrency.js +60 -0
  76. package/dist/utils/deferred.d.ts +7 -0
  77. package/dist/utils/deferred.js +23 -0
  78. package/package.json +11 -5
  79. package/dist/utils/breakChange.js +0 -302
  80. package/dist/utils/getJSONByteSize.js +0 -12
  81. /package/dist/{utils → algorithms/client}/getJSONByteSize.d.ts +0 -0
@@ -1,294 +1,378 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ import { isEqual } from '@dabble/delta';
36
+ import { applyCommittedChanges } from '../algorithms/client/applyCommittedChanges.js';
37
+ import { breakIntoBatches } from '../algorithms/client/batching.js';
1
38
  import { Patches } from '../client/Patches.js';
2
39
  import { signal } from '../event-signal.js';
3
- import { breakIntoBatches } from '../utils/batching.js';
40
+ import { blockable } from '../utils/concurrency.js';
4
41
  import { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
5
42
  import { onlineState } from './websocket/onlineState.js';
6
43
  /**
7
44
  * Handles WebSocket connection, document subscriptions, and syncing logic between
8
45
  * the Patches instance and the server.
9
46
  */
10
- export class PatchesSync {
11
- constructor(url, patches, options = {}) {
12
- this.isFlushing = new Set();
13
- this.globalSyncTimeout = null;
14
- this._state = { online: false, connected: false, syncing: null };
15
- // Signals
16
- this.onStateChange = signal();
17
- this.onError = signal();
18
- // --- Event Handlers ---
19
- this._handleOnlineChange = (isOnline) => {
20
- this.setState({ online: isOnline });
21
- if (isOnline && this.state.connected) {
22
- this.scheduleGlobalSync();
47
+ let PatchesSync = (() => {
48
+ var _a;
49
+ let _instanceExtraInitializers = [];
50
+ let _syncDoc_decorators;
51
+ let __receiveCommittedChanges_decorators;
52
+ return _a = class PatchesSync {
53
+ constructor(patches, url, wsOptions) {
54
+ this.ws = __runInitializers(this, _instanceExtraInitializers);
55
+ this._state = { online: false, connected: false, syncing: null };
56
+ /**
57
+ * Signal emitted when the sync state changes.
58
+ */
59
+ this.onStateChange = signal();
60
+ /**
61
+ * Signal emitted when an error occurs.
62
+ */
63
+ this.onError = signal();
64
+ this.patches = patches;
65
+ this.store = patches.store;
66
+ this.maxPayloadBytes = patches.docOptions?.maxPayloadBytes;
67
+ this.ws = new PatchesWebSocket(url, wsOptions);
68
+ this._state.online = onlineState.isOnline;
69
+ this.trackedDocs = new Set(patches.trackedDocs);
70
+ // --- Event Listeners ---
71
+ onlineState.onOnlineChange(online => this.updateState({ online }));
72
+ this.ws.onStateChange(this._handleConnectionChange.bind(this));
73
+ this.ws.onChangesCommitted(this._receiveCommittedChanges.bind(this));
74
+ // Listen to Patches for tracking changes
75
+ patches.onTrackDocs(this._handleDocsTracked.bind(this));
76
+ patches.onUntrackDocs(this._handleDocsUntracked.bind(this));
77
+ patches.onDeleteDoc(this._handleDocDeleted.bind(this));
23
78
  }
24
- };
25
- this._handleConnectionChange = (connectionState) => {
26
- const isConnected = connectionState === 'connected';
27
- const isConnecting = connectionState === 'connecting';
28
- // Preserve syncing state if moving from connecting -> connected
29
- // Reset syncing if disconnected or errored
30
- const newSyncingState = isConnected
31
- ? this._state.syncing // Preserve
32
- : isConnecting
33
- ? this._state.syncing // Preserve during connecting phase too
34
- : null; // Reset
35
- this.setState({ connected: isConnected, syncing: newSyncingState });
36
- if (isConnected) {
37
- // Sync everything on connect/reconnect
38
- void this.syncAllKnownDocs();
79
+ /**
80
+ * Gets the current sync state.
81
+ */
82
+ get state() {
83
+ return this._state;
39
84
  }
40
- };
41
- this._handleDocsTracked = async (docIds) => {
42
- const newIds = docIds.filter(id => !this.trackedDocs.has(id));
43
- if (!newIds.length)
44
- return;
45
- newIds.forEach(id => this.trackedDocs.add(id));
46
- if (this.state.connected) {
85
+ /**
86
+ * Updates the sync state.
87
+ * @param update - The partial state to update.
88
+ */
89
+ updateState(update) {
90
+ const newState = { ...this._state, ...update };
91
+ if (!isEqual(this._state, newState)) {
92
+ this._state = newState;
93
+ this.onStateChange.emit(this._state);
94
+ }
95
+ }
96
+ /**
97
+ * Connects to the WebSocket server and starts syncing if online. If not online, it will wait for online state.
98
+ */
99
+ async connect() {
47
100
  try {
48
- await this.ws.subscribe(newIds);
49
- // Trigger sync for newly tracked docs immediately
50
- await Promise.all(newIds.map(id => this.syncDoc(id)));
101
+ await this.ws.connect();
51
102
  }
52
103
  catch (err) {
53
- console.warn(`Failed to subscribe/sync newly tracked docs: ${newIds.join(', ')}`, err);
104
+ console.error('PatchesSync connection failed:', err);
105
+ this.updateState({ connected: false, syncing: err instanceof Error ? err : new Error(String(err)) });
54
106
  this.onError.emit(err);
55
- // State remains tracked locally, will retry on next sync
107
+ throw err;
56
108
  }
57
109
  }
58
- };
59
- this._handleDocsUntracked = async (docIds) => {
60
- const existingIds = docIds.filter(id => this.trackedDocs.has(id));
61
- if (!existingIds.length)
62
- return;
63
- existingIds.forEach(id => this.trackedDocs.delete(id));
64
- if (this.state.connected) {
110
+ /**
111
+ * Disconnects from the WebSocket server and stops syncing.
112
+ */
113
+ disconnect() {
114
+ this.ws.disconnect();
115
+ this.updateState({ connected: false, syncing: null });
116
+ }
117
+ /**
118
+ * Syncs all known docs when initially connected.
119
+ */
120
+ async syncAllKnownDocs() {
121
+ if (!this.state.connected)
122
+ return;
123
+ this.updateState({ syncing: 'updating' });
65
124
  try {
66
- await this.ws.unsubscribe(existingIds);
125
+ const tracked = await this.store.listDocs(true); // Include deleted docs
126
+ const activeDocs = tracked.filter(t => !t.deleted);
127
+ const deletedDocs = tracked.filter(t => t.deleted);
128
+ const activeDocIds = activeDocs.map((t) => t.docId);
129
+ // Ensure tracked set reflects only active docs for subscription purposes
130
+ this.trackedDocs = new Set(activeDocIds);
131
+ // Subscribe to active docs
132
+ if (activeDocIds.length > 0) {
133
+ try {
134
+ await this.ws.subscribe(activeDocIds);
135
+ }
136
+ catch (err) {
137
+ console.warn('Error subscribing to active docs during sync:', err);
138
+ this.onError.emit(err);
139
+ }
140
+ }
141
+ // Sync each active doc
142
+ const activeSyncPromises = activeDocIds.map((id) => this.syncDoc(id));
143
+ // Attempt to delete docs marked with tombstones
144
+ const deletePromises = deletedDocs.map(async ({ docId }) => {
145
+ try {
146
+ console.info(`Attempting server delete for tombstoned doc: ${docId}`);
147
+ await this.ws.deleteDoc(docId);
148
+ // If server delete succeeds, remove tombstone and all data locally
149
+ await this.store.confirmDeleteDoc(docId);
150
+ console.info(`Successfully deleted and untracked doc: ${docId}`);
151
+ }
152
+ catch (err) {
153
+ // If server delete fails (e.g., offline, already deleted), keep tombstone for retry
154
+ console.warn(`Server delete failed for ${docId}, keeping tombstone:`, err);
155
+ this.onError.emit(err, { docId });
156
+ }
157
+ });
158
+ // Wait for all sync and delete operations
159
+ await Promise.all([...activeSyncPromises, ...deletePromises]);
160
+ this.updateState({ syncing: null });
67
161
  }
68
- catch (err) {
69
- console.warn(`Failed to unsubscribe docs: ${existingIds.join(', ')}`, err);
70
- // Continue with local untrack
162
+ catch (error) {
163
+ console.error('Error during global sync:', error);
164
+ this.updateState({ syncing: error instanceof Error ? error : new Error(String(error)) });
165
+ this.onError.emit(error);
71
166
  }
72
167
  }
73
- };
74
- this.patches = patches;
75
- this.store = patches.store;
76
- this.options = options;
77
- this.ws = new PatchesWebSocket(url, options.wsOptions);
78
- this._state.online = onlineState.isOnline;
79
- this.trackedDocs = new Set(patches.trackedDocs);
80
- // Set maxPayloadBytes on Patches docOptions if provided
81
- if (options.maxPayloadBytes) {
82
- patches.updateDocOptions({ maxPayloadBytes: options.maxPayloadBytes });
83
- }
84
- // --- Event Listeners ---
85
- onlineState.onOnlineChange(this._handleOnlineChange);
86
- this.ws.onStateChange(this._handleConnectionChange);
87
- this.ws.onChangesCommitted(({ docId, changes }) => {
88
- // Persist first, then notify Patches instance to update PatchesDoc
89
- this.store
90
- .saveCommittedChanges(docId, changes)
91
- .then(() => this.patches.applyServerChanges(docId, changes))
92
- .catch((err) => this.onError.emit(err, { docId }));
93
- });
94
- // Forward errors to Patches error signal
95
- this.onError((err, context) => {
96
- patches.onError.emit(err, context);
97
- });
98
- // Listen to Patches for tracking changes
99
- patches.onTrackDocs(this._handleDocsTracked);
100
- patches.onUntrackDocs(this._handleDocsUntracked);
101
- patches.onDeleteDoc(this._handleDocDeleted);
102
- }
103
- get state() {
104
- return this._state;
105
- }
106
- setState(update) {
107
- const newState = { ...this._state, ...update };
108
- if (JSON.stringify(this._state) !== JSON.stringify(newState)) {
109
- this._state = newState;
110
- this.onStateChange.emit(this._state);
111
- }
112
- }
113
- // --- Connection & Lifecycle ---
114
- async connect() {
115
- try {
116
- await this.ws.connect();
117
- // _handleConnectionChange handles state update and sync trigger
118
- }
119
- catch (err) {
120
- console.error('PatchesSync connection failed:', err);
121
- this.setState({ connected: false, syncing: err instanceof Error ? err : new Error(String(err)) });
122
- this.onError.emit(err);
123
- throw err;
124
- }
125
- }
126
- disconnect() {
127
- if (this.globalSyncTimeout)
128
- clearTimeout(this.globalSyncTimeout);
129
- this.ws.disconnect();
130
- this.setState({ connected: false, syncing: null });
131
- }
132
- // --- Doc Tracking & Subscription (Now handled via signals) ---
133
- // --- Syncing Logic ---
134
- scheduleGlobalSync() {
135
- if (this.globalSyncTimeout)
136
- clearTimeout(this.globalSyncTimeout);
137
- this.globalSyncTimeout = setTimeout(() => {
138
- this.globalSyncTimeout = null;
139
- void this.syncAllKnownDocs();
140
- }, 300);
141
- }
142
- async syncAllKnownDocs() {
143
- if (!this.state.connected)
144
- return;
145
- this.setState({ syncing: 'updating' });
146
- try {
147
- const tracked = await this.store.listDocs(true); // Include deleted docs
148
- const activeDocs = tracked.filter(t => !t.deleted);
149
- const deletedDocs = tracked.filter(t => t.deleted);
150
- const activeDocIds = activeDocs.map((t) => t.docId);
151
- // Ensure tracked set reflects only active docs for subscription purposes
152
- this.trackedDocs = new Set(activeDocIds);
153
- // Subscribe to active docs
154
- if (activeDocIds.length > 0) {
168
+ /**
169
+ * Syncs a single document.
170
+ * @param docId The ID of the document to sync.
171
+ */
172
+ async syncDoc(docId) {
173
+ if (!this.state.connected)
174
+ return;
175
+ const doc = this.patches.getOpenDoc(docId);
176
+ if (doc) {
177
+ doc.updateSyncing('updating');
178
+ }
155
179
  try {
156
- await this.ws.subscribe(activeDocIds);
180
+ const pending = await this.store.getPendingChanges(docId);
181
+ if (pending.length > 0) {
182
+ await this.flushDoc(docId); // flushDoc handles setting flushing state
183
+ }
184
+ else {
185
+ // No pending, just check for server changes
186
+ const [committedRev] = await this.store.getLastRevs(docId);
187
+ if (committedRev) {
188
+ const serverChanges = await this.ws.getChangesSince(docId, committedRev);
189
+ if (serverChanges.length > 0) {
190
+ // Apply server changes with proper rebasing
191
+ await this._applyServerChangesToDoc(docId, serverChanges);
192
+ }
193
+ }
194
+ else {
195
+ const snapshot = await this.ws.getDoc(docId);
196
+ await this.store.saveDoc(docId, snapshot);
197
+ }
198
+ }
199
+ if (doc) {
200
+ doc.updateSyncing(null);
201
+ }
157
202
  }
158
203
  catch (err) {
159
- console.warn('Error subscribing to active docs during sync:', err);
160
- this.onError.emit(err);
204
+ console.error(`Error syncing doc ${docId}:`, err);
205
+ this.onError.emit(err, { docId });
206
+ if (doc) {
207
+ doc.updateSyncing(err instanceof Error ? err : new Error(String(err)));
208
+ }
161
209
  }
162
210
  }
163
- // Sync each active doc
164
- const activeSyncPromises = activeDocIds.map((id) => this.syncDoc(id));
165
- // Attempt to delete docs marked with tombstones
166
- const deletePromises = deletedDocs.map(async ({ docId }) => {
211
+ /**
212
+ * Flushes a document to the server.
213
+ * @param docId The ID of the document to flush.
214
+ */
215
+ async flushDoc(docId) {
216
+ if (!this.trackedDocs.has(docId)) {
217
+ throw new Error(`Document ${docId} is not tracked`);
218
+ }
219
+ if (!this.state.connected) {
220
+ throw new Error('Not connected to server');
221
+ }
167
222
  try {
168
- console.info(`Attempting server delete for tombstoned doc: ${docId}`);
169
- await this.ws.deleteDoc(docId);
170
- // If server delete succeeds, remove tombstone and all data locally
171
- await this.store.confirmDeleteDoc(docId);
172
- console.info(`Successfully deleted and untracked doc: ${docId}`);
223
+ // Get changes from store or memory
224
+ let pending = await this.store.getPendingChanges(docId);
225
+ if (!pending.length) {
226
+ return; // Nothing to flush
227
+ }
228
+ const batches = breakIntoBatches(pending, this.maxPayloadBytes);
229
+ for (const batch of batches) {
230
+ if (!this.state.connected) {
231
+ throw new Error('Disconnected during flush');
232
+ }
233
+ const range = [batch[0].rev, batch[batch.length - 1].rev];
234
+ const committed = await this.ws.commitChanges(docId, batch);
235
+ // Apply the committed changes using the sync algorithm (already saved to store)
236
+ await this._applyServerChangesToDoc(docId, committed, range);
237
+ // Fetch remaining pending for next batch or check completion
238
+ pending = await this.store.getPendingChanges(docId);
239
+ }
173
240
  }
174
241
  catch (err) {
175
- // If server delete fails (e.g., offline, already deleted), keep tombstone for retry
176
- console.warn(`Server delete failed for ${docId}, keeping tombstone:`, err);
242
+ console.error(`Flush failed for doc ${docId}:`, err);
177
243
  this.onError.emit(err, { docId });
244
+ throw err; // Re-throw so caller (like syncAll) knows it failed
178
245
  }
179
- });
180
- // Wait for all sync and delete operations
181
- await Promise.all([...activeSyncPromises, ...deletePromises]);
182
- this.setState({ syncing: null });
183
- }
184
- catch (error) {
185
- console.error('Error during global sync:', error);
186
- this.setState({ syncing: error instanceof Error ? error : new Error(String(error)) });
187
- this.onError.emit(error);
188
- }
189
- }
190
- async syncDoc(docId) {
191
- if (this.isFlushing.has(docId))
192
- return; // Already flushing
193
- if (!this.state.connected)
194
- return;
195
- try {
196
- const pending = await this.store.getPendingChanges(docId);
197
- if (pending.length > 0) {
198
- await this.flushDoc(docId); // flushDoc handles setting flushing state
199
246
  }
200
- else {
201
- // No pending, just check for server changes
202
- const [committedRev] = await this.store.getLastRevs(docId);
203
- const serverChanges = await this.ws.getChangesSince(docId, committedRev);
204
- if (serverChanges.length > 0) {
205
- await this.store.saveCommittedChanges(docId, serverChanges);
206
- this.patches.applyServerChanges(docId, serverChanges);
247
+ /**
248
+ * Receives committed changes from the server and applies them to the document. This is a blockable function, so it
249
+ * is separate from applyServerChangesToDoc, which is called by other blockable functions. Ensuring this is blockable
250
+ * ensures that while a doc is sending changes to the server, it isn't receiving changes from the server which could
251
+ * cause a race condition.
252
+ */
253
+ async _receiveCommittedChanges(docId, serverChanges) {
254
+ try {
255
+ await this._applyServerChangesToDoc(docId, serverChanges);
256
+ }
257
+ catch (err) {
258
+ this.onError.emit(err, { docId });
207
259
  }
208
260
  }
209
- }
210
- catch (err) {
211
- console.error(`Error syncing doc ${docId}:`, err);
212
- this.onError.emit(err, { docId });
213
- // Don't let one doc failure stop others in global sync
214
- }
215
- }
216
- async flushDoc(docId) {
217
- if (!this.trackedDocs.has(docId)) {
218
- throw new Error(`Document ${docId} is not tracked`);
219
- }
220
- if (this.isFlushing.has(docId)) {
221
- throw new Error(`Document ${docId} is already being flushed`);
222
- }
223
- if (!this.state.connected) {
224
- throw new Error('Not connected to server');
225
- }
226
- this.isFlushing.add(docId);
227
- if (this.state.syncing !== 'updating') {
228
- this.setState({ syncing: 'updating' });
229
- }
230
- try {
231
- // Get changes from Patches for this doc
232
- let pending = await this.store.getPendingChanges(docId);
233
- if (!pending.length) {
234
- // Try to get from memory if available
235
- pending = this.patches.getDocChanges(docId);
236
- if (!pending.length) {
237
- this.isFlushing.delete(docId);
238
- return; // Nothing to flush
261
+ /**
262
+ * Applies server changes to a document using the centralized sync algorithm.
263
+ * This ensures consistent OT behavior regardless of whether the doc is open in memory.
264
+ */
265
+ async _applyServerChangesToDoc(docId, serverChanges, sentPendingRange) {
266
+ // 1. Get current document snapshot from store
267
+ const currentSnapshot = await this.store.getDoc(docId);
268
+ if (!currentSnapshot) {
269
+ console.warn(`Cannot apply server changes to non-existent doc: ${docId}`);
270
+ return;
271
+ }
272
+ const doc = this.patches.getOpenDoc(docId);
273
+ if (doc) {
274
+ // Ensure we have all the changes, stored and in-memory (newly created but not yet persisted)
275
+ const inMemoryPendingChanges = doc?.getPendingChanges();
276
+ const latestRev = currentSnapshot.changes[currentSnapshot.changes.length - 1]?.rev || currentSnapshot.rev;
277
+ const newChanges = inMemoryPendingChanges.filter(change => change.rev > latestRev);
278
+ currentSnapshot.changes.push(...newChanges);
239
279
  }
280
+ // 2. Use the pure algorithm to calculate the new state
281
+ const { state, rev, changes: rebasedPendingChanges } = applyCommittedChanges(currentSnapshot, serverChanges);
282
+ // 3. If the doc is open in memory, update it with the new state
283
+ if (doc) {
284
+ if (doc.committedRev === serverChanges[0].rev - 1) {
285
+ // We can update the doc's snapshot
286
+ doc.applyCommittedChanges(serverChanges, rebasedPendingChanges);
287
+ }
288
+ else {
289
+ // We have to do a full state update
290
+ doc.import({ state, rev, changes: rebasedPendingChanges });
291
+ }
292
+ }
293
+ // 4. Save changes to store (if not already saved)
294
+ await Promise.all([
295
+ this.store.saveCommittedChanges(docId, serverChanges, sentPendingRange),
296
+ this.store.replacePendingChanges(docId, rebasedPendingChanges),
297
+ ]);
240
298
  }
241
- const batches = breakIntoBatches(pending, this.options.maxPayloadBytes);
242
- for (const batch of batches) {
243
- if (!this.state.connected) {
244
- throw new Error('Disconnected during flush');
299
+ /**
300
+ * Initiates the deletion process for a document both locally and on the server.
301
+ * This now delegates the local tombstone marking to Patches.
302
+ */
303
+ async _handleDocDeleted(docId) {
304
+ // Attempt server delete if online
305
+ if (this.state.connected) {
306
+ try {
307
+ await this.ws.deleteDoc(docId);
308
+ await this.store.confirmDeleteDoc(docId);
309
+ }
310
+ catch (err) {
311
+ console.error(`Server delete failed for doc ${docId}, will retry on reconnect/resync.`, err);
312
+ this.onError.emit(err, { docId });
313
+ throw err;
314
+ }
315
+ }
316
+ else {
317
+ console.warn(`Offline: Server delete for doc ${docId} deferred.`);
245
318
  }
246
- const range = [batch[0].rev, batch[batch.length - 1].rev];
247
- const committed = await this.ws.commitChanges(docId, batch);
248
- // Persist committed + remove pending in store
249
- await this.store.saveCommittedChanges(docId, committed, range);
250
- // Notify Patches to update PatchesDoc
251
- this.patches.applyServerChanges(docId, committed);
252
- // Fetch remaining pending for next batch or check completion
253
- pending = await this.store.getPendingChanges(docId);
254
319
  }
255
- }
256
- catch (err) {
257
- console.error(`Flush failed for doc ${docId}:`, err);
258
- this.onError.emit(err, { docId });
259
- // Let Patches know about the failure
260
- this.patches.handleSendFailure(docId);
261
- // Don't clear flushing flag, let next sync attempt retry
262
- throw err; // Re-throw so caller (like syncAll) knows it failed
263
- }
264
- finally {
265
- this.isFlushing.delete(docId);
266
- // Update global sync state if nothing else is flushing
267
- if (this.isFlushing.size === 0 && this.state.syncing === 'updating') {
268
- this.setState({ syncing: null });
320
+ _handleConnectionChange(connectionState) {
321
+ const isConnected = connectionState === 'connected';
322
+ const isConnecting = connectionState === 'connecting';
323
+ // Preserve syncing state if moving from connecting -> connected
324
+ // Reset syncing if disconnected or errored
325
+ const newSyncingState = isConnected
326
+ ? this._state.syncing // Preserve
327
+ : isConnecting
328
+ ? this._state.syncing // Preserve during connecting phase too
329
+ : null; // Reset
330
+ this.updateState({ connected: isConnected, syncing: newSyncingState });
331
+ if (isConnected) {
332
+ // Sync everything on connect/reconnect
333
+ void this.syncAllKnownDocs();
334
+ }
269
335
  }
270
- }
271
- }
272
- // --- Server Operations ---
273
- /**
274
- * Initiates the deletion process for a document both locally and on the server.
275
- * This now delegates the local tombstone marking to Patches.
276
- */
277
- async _handleDocDeleted(docId) {
278
- // Attempt server delete if online
279
- if (this.state.connected) {
280
- try {
281
- await this.ws.deleteDoc(docId);
282
- await this.store.confirmDeleteDoc(docId);
336
+ async _handleDocsTracked(docIds) {
337
+ const newIds = docIds.filter(id => !this.trackedDocs.has(id));
338
+ if (!newIds.length)
339
+ return;
340
+ newIds.forEach(id => this.trackedDocs.add(id));
341
+ if (this.state.connected) {
342
+ try {
343
+ await this.ws.subscribe(newIds);
344
+ // Trigger sync for newly tracked docs immediately
345
+ await Promise.all(newIds.map(id => this.syncDoc(id)));
346
+ }
347
+ catch (err) {
348
+ console.warn(`Failed to subscribe/sync newly tracked docs: ${newIds.join(', ')}`, err);
349
+ this.onError.emit(err);
350
+ }
351
+ }
283
352
  }
284
- catch (err) {
285
- console.error(`Server delete failed for doc ${docId}, will retry on reconnect/resync.`, err);
286
- this.onError.emit(err, { docId });
287
- throw err;
353
+ async _handleDocsUntracked(docIds) {
354
+ const existingIds = docIds.filter(id => this.trackedDocs.has(id));
355
+ if (!existingIds.length)
356
+ return;
357
+ existingIds.forEach(id => this.trackedDocs.delete(id));
358
+ if (this.state.connected) {
359
+ try {
360
+ await this.ws.unsubscribe(existingIds);
361
+ }
362
+ catch (err) {
363
+ console.warn(`Failed to unsubscribe docs: ${existingIds.join(', ')}`, err);
364
+ }
365
+ }
288
366
  }
289
- }
290
- else {
291
- console.warn(`Offline: Server delete for doc ${docId} deferred.`);
292
- }
293
- }
294
- }
367
+ },
368
+ (() => {
369
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
370
+ _syncDoc_decorators = [blockable];
371
+ __receiveCommittedChanges_decorators = [blockable];
372
+ __esDecorate(_a, null, _syncDoc_decorators, { kind: "method", name: "syncDoc", static: false, private: false, access: { has: obj => "syncDoc" in obj, get: obj => obj.syncDoc }, metadata: _metadata }, null, _instanceExtraInitializers);
373
+ __esDecorate(_a, null, __receiveCommittedChanges_decorators, { kind: "method", name: "_receiveCommittedChanges", static: false, private: false, access: { has: obj => "_receiveCommittedChanges" in obj, get: obj => obj._receiveCommittedChanges }, metadata: _metadata }, null, _instanceExtraInitializers);
374
+ if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
375
+ })(),
376
+ _a;
377
+ })();
378
+ export { PatchesSync };