@dabble/patches 0.2.32 → 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.
- package/dist/algorithms/client/applyCommittedChanges.d.ts +8 -0
- package/dist/algorithms/client/applyCommittedChanges.js +40 -0
- package/dist/{utils → algorithms/client}/batching.d.ts +1 -1
- package/dist/{utils → algorithms/client}/batching.js +2 -2
- package/dist/{utils → algorithms/client}/breakChange.d.ts +2 -3
- package/dist/algorithms/client/breakChange.js +258 -0
- package/dist/algorithms/client/createStateFromSnapshot.d.ts +7 -0
- package/dist/algorithms/client/createStateFromSnapshot.js +9 -0
- package/dist/algorithms/client/getJSONByteSize.js +12 -0
- package/dist/algorithms/client/makeChange.d.ts +3 -0
- package/dist/algorithms/client/makeChange.js +37 -0
- package/dist/algorithms/server/commitChanges.d.ts +12 -0
- package/dist/algorithms/server/commitChanges.js +80 -0
- package/dist/algorithms/server/createVersion.d.ts +12 -0
- package/dist/algorithms/server/createVersion.js +28 -0
- package/dist/algorithms/server/getSnapshotAtRevision.d.ts +10 -0
- package/dist/algorithms/server/getSnapshotAtRevision.js +29 -0
- package/dist/algorithms/server/getStateAtRevision.d.ts +9 -0
- package/dist/algorithms/server/getStateAtRevision.js +18 -0
- package/dist/algorithms/server/handleOfflineSessionsAndBatches.d.ts +12 -0
- package/dist/algorithms/server/handleOfflineSessionsAndBatches.js +80 -0
- package/dist/algorithms/server/transformIncomingChanges.d.ts +11 -0
- package/dist/algorithms/server/transformIncomingChanges.js +40 -0
- package/dist/algorithms/shared/applyChanges.d.ts +10 -0
- package/dist/algorithms/shared/applyChanges.js +17 -0
- package/dist/{utils.d.ts → algorithms/shared/rebaseChanges.d.ts} +1 -11
- package/dist/{utils.js → algorithms/shared/rebaseChanges.js} +3 -43
- package/dist/client/InMemoryStore.d.ts +2 -1
- package/dist/client/InMemoryStore.js +9 -3
- package/dist/client/IndexedDBStore.d.ts +34 -2
- package/dist/client/IndexedDBStore.js +399 -282
- package/dist/client/Patches.d.ts +11 -41
- package/dist/client/Patches.js +197 -208
- package/dist/client/PatchesDoc.d.ts +24 -41
- package/dist/client/PatchesDoc.js +57 -214
- package/dist/client/PatchesHistoryClient.js +1 -1
- package/dist/client/PatchesStore.d.ts +186 -9
- package/dist/data/change.d.ts +3 -0
- package/dist/data/change.js +20 -0
- package/dist/data/version.d.ts +12 -0
- package/dist/data/version.js +17 -0
- package/dist/json-patch/ops/add.js +1 -1
- package/dist/json-patch/ops/move.js +1 -1
- package/dist/json-patch/ops/remove.js +1 -1
- package/dist/json-patch/ops/replace.js +1 -1
- package/dist/json-patch/utils/get.js +0 -1
- package/dist/json-patch/utils/log.d.ts +4 -1
- package/dist/json-patch/utils/log.js +2 -5
- package/dist/json-patch/utils/ops.d.ts +1 -1
- package/dist/json-patch/utils/ops.js +4 -1
- package/dist/json-patch/utils/paths.js +2 -2
- package/dist/json-patch/utils/toArrayIndex.js +1 -1
- package/dist/net/PatchesSync.d.ts +55 -24
- package/dist/net/PatchesSync.js +336 -258
- package/dist/net/websocket/AuthorizationProvider.d.ts +9 -2
- package/dist/net/websocket/AuthorizationProvider.js +14 -2
- package/dist/net/websocket/PatchesWebSocket.d.ts +2 -2
- package/dist/net/websocket/PatchesWebSocket.js +3 -2
- package/dist/net/websocket/RPCServer.d.ts +2 -2
- package/dist/net/websocket/RPCServer.js +3 -3
- package/dist/net/websocket/SignalingService.js +1 -1
- package/dist/net/websocket/WebSocketServer.d.ts +1 -1
- package/dist/net/websocket/WebSocketServer.js +2 -2
- package/dist/net/websocket/WebSocketTransport.js +1 -1
- package/dist/net/websocket/onlineState.d.ts +1 -1
- package/dist/net/websocket/onlineState.js +8 -2
- package/dist/server/PatchesBranchManager.js +9 -16
- package/dist/server/PatchesHistoryManager.js +1 -1
- package/dist/server/PatchesServer.d.ts +11 -38
- package/dist/server/PatchesServer.js +32 -255
- package/dist/types.d.ts +8 -6
- package/dist/utils/concurrency.d.ts +26 -0
- package/dist/utils/concurrency.js +60 -0
- package/dist/utils/deferred.d.ts +7 -0
- package/dist/utils/deferred.js +23 -0
- package/package.json +11 -5
- package/dist/utils/breakChange.js +0 -302
- package/dist/utils/getJSONByteSize.js +0 -12
- /package/dist/{utils → algorithms/client}/getJSONByteSize.d.ts +0 -0
package/dist/net/PatchesSync.js
CHANGED
|
@@ -1,300 +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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
107
|
+
throw err;
|
|
56
108
|
}
|
|
57
109
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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 });
|
|
161
|
+
}
|
|
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);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
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
|
+
}
|
|
179
|
+
try {
|
|
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
|
+
}
|
|
67
202
|
}
|
|
68
203
|
catch (err) {
|
|
69
|
-
console.
|
|
70
|
-
|
|
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
|
+
}
|
|
71
209
|
}
|
|
72
210
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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) {
|
|
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
|
+
}
|
|
155
222
|
try {
|
|
156
|
-
|
|
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
|
+
}
|
|
157
240
|
}
|
|
158
241
|
catch (err) {
|
|
159
|
-
console.
|
|
160
|
-
this.onError.emit(err);
|
|
242
|
+
console.error(`Flush failed for doc ${docId}:`, err);
|
|
243
|
+
this.onError.emit(err, { docId });
|
|
244
|
+
throw err; // Re-throw so caller (like syncAll) knows it failed
|
|
161
245
|
}
|
|
162
246
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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) {
|
|
167
254
|
try {
|
|
168
|
-
|
|
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}`);
|
|
255
|
+
await this._applyServerChangesToDoc(docId, serverChanges);
|
|
173
256
|
}
|
|
174
257
|
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);
|
|
177
258
|
this.onError.emit(err, { docId });
|
|
178
259
|
}
|
|
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
260
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
|
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
|
+
]);
|
|
298
|
+
}
|
|
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;
|
|
208
314
|
}
|
|
209
315
|
}
|
|
210
316
|
else {
|
|
211
|
-
|
|
212
|
-
await this.store.saveDoc(docId, snapshot);
|
|
317
|
+
console.warn(`Offline: Server delete for doc ${docId} deferred.`);
|
|
213
318
|
}
|
|
214
319
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!this.state.connected) {
|
|
230
|
-
throw new Error('Not connected to server');
|
|
231
|
-
}
|
|
232
|
-
this.isFlushing.add(docId);
|
|
233
|
-
if (this.state.syncing !== 'updating') {
|
|
234
|
-
this.setState({ syncing: 'updating' });
|
|
235
|
-
}
|
|
236
|
-
try {
|
|
237
|
-
// Get changes from Patches for this doc
|
|
238
|
-
let pending = await this.store.getPendingChanges(docId);
|
|
239
|
-
if (!pending.length) {
|
|
240
|
-
// Try to get from memory if available
|
|
241
|
-
pending = this.patches.getDocChanges(docId);
|
|
242
|
-
if (!pending.length) {
|
|
243
|
-
this.isFlushing.delete(docId);
|
|
244
|
-
return; // Nothing to flush
|
|
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();
|
|
245
334
|
}
|
|
246
335
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!
|
|
250
|
-
|
|
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
|
+
}
|
|
251
351
|
}
|
|
252
|
-
const range = [batch[0].rev, batch[batch.length - 1].rev];
|
|
253
|
-
const committed = await this.ws.commitChanges(docId, batch);
|
|
254
|
-
// Persist committed + remove pending in store
|
|
255
|
-
await this.store.saveCommittedChanges(docId, committed, range);
|
|
256
|
-
// Notify Patches to update PatchesDoc
|
|
257
|
-
this.patches.applyServerChanges(docId, committed);
|
|
258
|
-
// Fetch remaining pending for next batch or check completion
|
|
259
|
-
pending = await this.store.getPendingChanges(docId);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
catch (err) {
|
|
263
|
-
console.error(`Flush failed for doc ${docId}:`, err);
|
|
264
|
-
this.onError.emit(err, { docId });
|
|
265
|
-
// Let Patches know about the failure
|
|
266
|
-
this.patches.handleSendFailure(docId);
|
|
267
|
-
// Don't clear flushing flag, let next sync attempt retry
|
|
268
|
-
throw err; // Re-throw so caller (like syncAll) knows it failed
|
|
269
|
-
}
|
|
270
|
-
finally {
|
|
271
|
-
this.isFlushing.delete(docId);
|
|
272
|
-
// Update global sync state if nothing else is flushing
|
|
273
|
-
if (this.isFlushing.size === 0 && this.state.syncing === 'updating') {
|
|
274
|
-
this.setState({ syncing: null });
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// --- Server Operations ---
|
|
279
|
-
/**
|
|
280
|
-
* Initiates the deletion process for a document both locally and on the server.
|
|
281
|
-
* This now delegates the local tombstone marking to Patches.
|
|
282
|
-
*/
|
|
283
|
-
async _handleDocDeleted(docId) {
|
|
284
|
-
// Attempt server delete if online
|
|
285
|
-
if (this.state.connected) {
|
|
286
|
-
try {
|
|
287
|
-
await this.ws.deleteDoc(docId);
|
|
288
|
-
await this.store.confirmDeleteDoc(docId);
|
|
289
352
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|
|
294
366
|
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
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 };
|
|
@@ -40,7 +40,14 @@ export interface AuthorizationProvider<T extends AuthContext = AuthContext> {
|
|
|
40
40
|
canAccess(ctx: T | undefined, docId: string, kind: Access, method: string, params?: Record<string, any>): boolean | Promise<boolean>;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* A permissive provider that authorises every action.
|
|
44
|
-
*
|
|
43
|
+
* A permissive provider that authorises every action.
|
|
44
|
+
* WARNING: This should only be used for development/testing purposes.
|
|
45
|
+
* Never use this in production as it allows unrestricted access.
|
|
45
46
|
*/
|
|
46
47
|
export declare const allowAll: AuthorizationProvider;
|
|
48
|
+
/**
|
|
49
|
+
* A secure default provider that denies all access.
|
|
50
|
+
* This forces developers to explicitly implement proper authorization.
|
|
51
|
+
* Use this as the default to ensure security by default.
|
|
52
|
+
*/
|
|
53
|
+
export declare const denyAll: AuthorizationProvider;
|