@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
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createStateFromSnapshot } from '../algorithms/client/createStateFromSnapshot.js';
|
|
2
|
+
import { makeChange } from '../algorithms/client/makeChange.js';
|
|
3
|
+
import { applyChanges } from '../algorithms/shared/applyChanges.js';
|
|
2
4
|
import { signal } from '../event-signal.js';
|
|
3
|
-
import { createJSONPatch } from '../json-patch/createJSONPatch.js';
|
|
4
|
-
import { applyChanges, rebaseChanges } from '../utils.js';
|
|
5
|
-
import { breakChange } from '../utils/breakChange.js';
|
|
6
5
|
/**
|
|
7
6
|
* Represents a document synchronized using JSON patches.
|
|
8
7
|
* Manages committed state, pending (local-only) changes, and
|
|
@@ -17,18 +16,18 @@ export class PatchesDoc {
|
|
|
17
16
|
*/
|
|
18
17
|
constructor(initialState = {}, initialMetadata = {}, options = {}) {
|
|
19
18
|
this._id = null;
|
|
20
|
-
this._pendingChanges = [];
|
|
21
|
-
this._sendingChanges = [];
|
|
22
19
|
this._changeMetadata = {};
|
|
20
|
+
this._syncing = null;
|
|
23
21
|
/** Subscribe to be notified before local state changes. */
|
|
24
22
|
this.onBeforeChange = signal();
|
|
25
23
|
/** Subscribe to be notified after local state changes are applied. */
|
|
26
24
|
this.onChange = signal();
|
|
27
25
|
/** Subscribe to be notified whenever state changes from any source. */
|
|
28
26
|
this.onUpdate = signal();
|
|
29
|
-
|
|
27
|
+
/** Subscribe to be notified when syncing state changes. */
|
|
28
|
+
this.onSyncing = signal();
|
|
30
29
|
this._state = structuredClone(initialState);
|
|
31
|
-
this.
|
|
30
|
+
this._snapshot = { state: this._state, rev: 0, changes: [] };
|
|
32
31
|
this._changeMetadata = initialMetadata;
|
|
33
32
|
this._maxPayloadBytes = options.maxPayloadBytes;
|
|
34
33
|
}
|
|
@@ -36,23 +35,23 @@ export class PatchesDoc {
|
|
|
36
35
|
get id() {
|
|
37
36
|
return this._id;
|
|
38
37
|
}
|
|
39
|
-
/** Current local state (committed +
|
|
38
|
+
/** Current local state (committed + pending). */
|
|
40
39
|
get state() {
|
|
41
40
|
return this._state;
|
|
42
41
|
}
|
|
42
|
+
/** Are we currently syncing this document? */
|
|
43
|
+
get syncing() {
|
|
44
|
+
return this._syncing;
|
|
45
|
+
}
|
|
43
46
|
/** Last committed revision number from the server. */
|
|
44
47
|
get committedRev() {
|
|
45
|
-
return this.
|
|
46
|
-
}
|
|
47
|
-
/** Are there changes currently awaiting server confirmation? */
|
|
48
|
-
get isSending() {
|
|
49
|
-
return this._sendingChanges.length > 0;
|
|
48
|
+
return this._snapshot.rev;
|
|
50
49
|
}
|
|
51
50
|
/** Are there local changes that haven't been sent yet? */
|
|
52
51
|
get hasPending() {
|
|
53
|
-
return this.
|
|
52
|
+
return this._snapshot.changes.length > 0;
|
|
54
53
|
}
|
|
55
|
-
/** Subscribe to be notified whenever
|
|
54
|
+
/** Subscribe to be notified whenever the state changes. */
|
|
56
55
|
subscribe(onUpdate) {
|
|
57
56
|
const unsub = this.onUpdate(onUpdate);
|
|
58
57
|
onUpdate(this._state);
|
|
@@ -65,23 +64,15 @@ export class PatchesDoc {
|
|
|
65
64
|
* are treated as pending.
|
|
66
65
|
*/
|
|
67
66
|
export() {
|
|
68
|
-
return
|
|
69
|
-
state: this._committedState,
|
|
70
|
-
rev: this._committedRev,
|
|
71
|
-
// Includes sending and pending changes. On import, all become pending.
|
|
72
|
-
changes: [...this._sendingChanges, ...this._pendingChanges],
|
|
73
|
-
};
|
|
67
|
+
return structuredClone(this._snapshot);
|
|
74
68
|
}
|
|
75
69
|
/**
|
|
76
70
|
* Imports previously exported document state.
|
|
77
71
|
* Resets sending state and treats all imported changes as pending.
|
|
78
72
|
*/
|
|
79
73
|
import(snapshot) {
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
82
|
-
this._pendingChanges = snapshot.changes ?? []; // All imported changes become pending
|
|
83
|
-
this._sendingChanges = []; // Reset sending state on import
|
|
84
|
-
this._recalculateLocalState();
|
|
74
|
+
this._snapshot = structuredClone(snapshot);
|
|
75
|
+
this._state = createStateFromSnapshot(snapshot);
|
|
85
76
|
this.onUpdate.emit(this._state);
|
|
86
77
|
}
|
|
87
78
|
/**
|
|
@@ -96,203 +87,44 @@ export class PatchesDoc {
|
|
|
96
87
|
* @returns The generated Change object or null if no changes occurred.
|
|
97
88
|
*/
|
|
98
89
|
change(mutator) {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
// Determine the client-side rev for local ordering before server assigns final rev.
|
|
104
|
-
const lastPendingRev = this._pendingChanges[this._pendingChanges.length - 1]?.rev;
|
|
105
|
-
const lastSendingRev = this._sendingChanges[this._sendingChanges.length - 1]?.rev;
|
|
106
|
-
const latestLocalRev = Math.max(this._committedRev, lastPendingRev ?? 0, lastSendingRev ?? 0);
|
|
107
|
-
// It's the baseRev that matters for sending.
|
|
108
|
-
const change = {
|
|
109
|
-
rev: latestLocalRev + 1, // Tentative rev for local sorting
|
|
110
|
-
id: createId(),
|
|
111
|
-
ops: patch.ops,
|
|
112
|
-
baseRev: this._committedRev,
|
|
113
|
-
created: Date.now(),
|
|
114
|
-
...(Object.keys(this._changeMetadata).length > 0 && { metadata: { ...this._changeMetadata } }),
|
|
115
|
-
};
|
|
116
|
-
this.onBeforeChange.emit(change);
|
|
117
|
-
// Apply to local state immediately
|
|
118
|
-
this._state = patch.apply(this._state);
|
|
119
|
-
if (this._maxPayloadBytes) {
|
|
120
|
-
// Check if the change needs to be split due to size
|
|
121
|
-
const changes = breakChange(change, this._maxPayloadBytes);
|
|
122
|
-
// Emit events for each change piece
|
|
123
|
-
for (const piece of changes) {
|
|
124
|
-
this._pendingChanges.push(piece);
|
|
125
|
-
this.onChange.emit(piece);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
this._pendingChanges.push(change);
|
|
130
|
-
this.onChange.emit(change);
|
|
90
|
+
const changes = makeChange(this._snapshot, mutator, this._changeMetadata, this._maxPayloadBytes);
|
|
91
|
+
if (changes.length === 0) {
|
|
92
|
+
return changes;
|
|
131
93
|
}
|
|
94
|
+
this._state = applyChanges(this._state, changes);
|
|
95
|
+
this._snapshot.changes.push(...changes);
|
|
96
|
+
this.onChange.emit(changes);
|
|
132
97
|
this.onUpdate.emit(this._state);
|
|
133
|
-
return
|
|
98
|
+
return changes;
|
|
134
99
|
}
|
|
135
100
|
/**
|
|
136
|
-
*
|
|
137
|
-
* @returns
|
|
138
|
-
* @throws Error if changes are already being sent.
|
|
101
|
+
* Returns the pending changes for this document.
|
|
102
|
+
* @returns The pending changes.
|
|
139
103
|
*/
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// It's generally simpler if the client waits for confirmation before sending more.
|
|
143
|
-
// If overlapping requests are needed, state management becomes much more complex.
|
|
144
|
-
throw new Error('Cannot get updates while previous batch is awaiting confirmation.');
|
|
145
|
-
}
|
|
146
|
-
if (!this.hasPending) {
|
|
147
|
-
return [];
|
|
148
|
-
}
|
|
149
|
-
this._sendingChanges = this._pendingChanges;
|
|
150
|
-
this._pendingChanges = [];
|
|
151
|
-
return this._sendingChanges;
|
|
104
|
+
getPendingChanges() {
|
|
105
|
+
return this._snapshot.changes;
|
|
152
106
|
}
|
|
153
107
|
/**
|
|
154
|
-
*
|
|
155
|
-
* @param
|
|
156
|
-
*
|
|
157
|
-
* or contain **one or more** `Change` objects consisting of:
|
|
158
|
-
* • any missing history since the client's `baseRev`, followed by
|
|
159
|
-
* • the server‑side result of the client's batch (typically the
|
|
160
|
-
* transformed versions of the changes the client sent).
|
|
161
|
-
* @throws Error if the input format is unexpected or application fails.
|
|
108
|
+
* Applies committed changes to the document. Should only be called from a sync provider.
|
|
109
|
+
* @param serverChanges The changes to apply.
|
|
110
|
+
* @param rebasedPendingChanges The rebased pending changes to apply.
|
|
162
111
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
console.warn('Applying unexpected server commit cautiously.');
|
|
178
|
-
// Proceed as if confirmation was expected
|
|
179
|
-
}
|
|
180
|
-
else {
|
|
181
|
-
throw new Error('Received unexpected server commit with mismatching revision.');
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
if (serverCommit.length === 0) {
|
|
185
|
-
// Server confirmed no change; discard the sending changes.
|
|
186
|
-
this._sendingChanges = [];
|
|
187
|
-
}
|
|
188
|
-
else {
|
|
189
|
-
// Server responded with one *or more* changes:
|
|
190
|
-
// 1. possibly earlier missing revisions produced by other clients
|
|
191
|
-
// 2. followed by the server‑side commit(s) that correspond to the batch we sent.
|
|
192
|
-
// Basic sanity check – final revision in the array should advance committedRev.
|
|
193
|
-
const lastChange = serverCommit[serverCommit.length - 1];
|
|
194
|
-
if (!lastChange.rev || lastChange.rev <= this._committedRev) {
|
|
195
|
-
throw new Error(`Server commit invalid final revision: ${lastChange.rev}, expected > ${this._committedRev}`);
|
|
196
|
-
}
|
|
197
|
-
// 1. Discard the confirmed _sendingChanges first so that the delegated
|
|
198
|
-
// external‑update path does not attempt to rebase them.
|
|
199
|
-
this._sendingChanges = [];
|
|
200
|
-
// 2. Apply everything through the common external‑update handler which
|
|
201
|
-
// will update committed state, revision, rebase pending changes, etc.
|
|
202
|
-
this.applyExternalServerUpdate(serverCommit);
|
|
203
|
-
return; // done – external handler emitted updates
|
|
204
|
-
}
|
|
205
|
-
// For the zero‑length confirmation path we still need to recalc state and
|
|
206
|
-
// notify listeners (the 1‑change path is handled by applyExternalServerUpdate).
|
|
207
|
-
this._recalculateLocalState();
|
|
112
|
+
applyCommittedChanges(serverChanges, rebasedPendingChanges) {
|
|
113
|
+
// Ensure server changes are sequential to the current committed revision
|
|
114
|
+
if (this._snapshot.rev !== serverChanges[0].rev - 1) {
|
|
115
|
+
throw new Error('Cannot apply committed changes to a doc that is not at the correct revision');
|
|
116
|
+
}
|
|
117
|
+
// Track IDs of pending changes for debugging
|
|
118
|
+
// const pendingIds = new Set(rebasedPendingChanges.map(c => c.id));
|
|
119
|
+
// Apply server changes to the base state of the snapshot
|
|
120
|
+
this._snapshot.state = applyChanges(this._snapshot.state, serverChanges);
|
|
121
|
+
this._snapshot.rev = serverChanges[serverChanges.length - 1].rev;
|
|
122
|
+
// The rebasedPendingChanges are the new complete set of pending changes
|
|
123
|
+
this._snapshot.changes = rebasedPendingChanges;
|
|
124
|
+
// Recalculate the live state from the updated snapshot
|
|
125
|
+
this._state = createStateFromSnapshot(this._snapshot);
|
|
208
126
|
this.onUpdate.emit(this._state);
|
|
209
127
|
}
|
|
210
|
-
/**
|
|
211
|
-
* Applies incoming changes from the server that were *not* initiated by this client.
|
|
212
|
-
* @param externalServerChanges An array of sequential changes from the server.
|
|
213
|
-
*/
|
|
214
|
-
applyExternalServerUpdate(externalServerChanges) {
|
|
215
|
-
if (externalServerChanges.length === 0) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const firstChange = externalServerChanges[0];
|
|
219
|
-
// Allow for gaps if server sends updates out of order, but warn.
|
|
220
|
-
if (firstChange.rev && firstChange.rev <= this._committedRev) {
|
|
221
|
-
console.warn(`Ignoring external server update starting at revision ${firstChange.rev} which is <= current committed ${this._committedRev}`);
|
|
222
|
-
return; // Ignore already processed or irrelevant changes
|
|
223
|
-
}
|
|
224
|
-
// if (firstChange.rev && firstChange.rev !== this._committedRev + 1) {
|
|
225
|
-
// console.warn(`External server update starting at ${firstChange.rev} does not directly follow committed ${this._committedRev}`);
|
|
226
|
-
// // Handle potential gaps - request resync? Apply cautiously?
|
|
227
|
-
// }
|
|
228
|
-
const lastChange = externalServerChanges[externalServerChanges.length - 1];
|
|
229
|
-
// 1. Apply to committed state
|
|
230
|
-
try {
|
|
231
|
-
this._committedState = applyChanges(this._committedState, externalServerChanges);
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
console.error('Failed to apply external server update to committed state:', error);
|
|
235
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
236
|
-
throw new Error(`Critical sync error applying external server update: ${errorMessage}`);
|
|
237
|
-
}
|
|
238
|
-
// 2. Update committed revision
|
|
239
|
-
if (lastChange.rev) {
|
|
240
|
-
this._committedRev = lastChange.rev;
|
|
241
|
-
}
|
|
242
|
-
else {
|
|
243
|
-
console.error('External server update missing revision on last change.');
|
|
244
|
-
// Cannot reliably update revision - potential state divergence
|
|
245
|
-
}
|
|
246
|
-
// 3. Rebase *both* sending and pending changes against the external changes
|
|
247
|
-
if (this.isSending) {
|
|
248
|
-
this._sendingChanges = rebaseChanges(externalServerChanges, this._sendingChanges);
|
|
249
|
-
}
|
|
250
|
-
if (this.hasPending) {
|
|
251
|
-
this._pendingChanges = rebaseChanges(externalServerChanges, this._pendingChanges);
|
|
252
|
-
}
|
|
253
|
-
// 4. Recalculate local state
|
|
254
|
-
this._recalculateLocalState();
|
|
255
|
-
// 5. Notify listeners
|
|
256
|
-
this.onUpdate.emit(this._state);
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Handles the scenario where sending changes to the server failed.
|
|
260
|
-
* Moves the changes that were in the process of being sent back to the
|
|
261
|
-
* beginning of the pending queue to be retried later.
|
|
262
|
-
*/
|
|
263
|
-
handleSendFailure() {
|
|
264
|
-
if (this.isSending) {
|
|
265
|
-
console.warn(`Handling send failure: Moving ${this._sendingChanges.length} changes back to pending queue.`);
|
|
266
|
-
// Prepend sending changes back to pending queue to maintain order and prioritize retry
|
|
267
|
-
this._pendingChanges.unshift(...this._sendingChanges);
|
|
268
|
-
this._sendingChanges = [];
|
|
269
|
-
// Do NOT recalculate state here, as the state didn't actually advance
|
|
270
|
-
// due to the failed send. The state should still reflect the last known
|
|
271
|
-
// good state + pending changes (which now includes the failed ones).
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
console.warn('handleSendFailure called but no changes were marked as sending.');
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
/** Recalculates _state from _committedState + _sendingChanges + _pendingChanges */
|
|
278
|
-
_recalculateLocalState() {
|
|
279
|
-
try {
|
|
280
|
-
this._state = applyChanges(this._committedState, [...this._sendingChanges, ...this._pendingChanges]);
|
|
281
|
-
}
|
|
282
|
-
catch (error) {
|
|
283
|
-
console.error('CRITICAL: Error recalculating local state after update:', error);
|
|
284
|
-
// This indicates a potentially serious issue with patch application or rebasing logic.
|
|
285
|
-
// Re-throw the error to allow higher-level handling (e.g., trigger resync)
|
|
286
|
-
throw error;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* @deprecated Use export() - kept for backward compatibility if needed.
|
|
291
|
-
*/
|
|
292
|
-
toJSON() {
|
|
293
|
-
console.warn('PatchesDoc.toJSON() is deprecated. Use export() instead.');
|
|
294
|
-
return this.export();
|
|
295
|
-
}
|
|
296
128
|
/**
|
|
297
129
|
* Assigns an identifier to this document. Can only be set once.
|
|
298
130
|
* @param id The unique identifier for the document.
|
|
@@ -306,4 +138,15 @@ export class PatchesDoc {
|
|
|
306
138
|
this._id = id;
|
|
307
139
|
}
|
|
308
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Updates the syncing state of the document.
|
|
143
|
+
* @param newSyncing The new syncing state.
|
|
144
|
+
*/
|
|
145
|
+
updateSyncing(newSyncing) {
|
|
146
|
+
this._syncing = newSyncing;
|
|
147
|
+
this.onSyncing.emit(newSyncing);
|
|
148
|
+
}
|
|
149
|
+
toJSON() {
|
|
150
|
+
return this.export();
|
|
151
|
+
}
|
|
309
152
|
}
|
|
@@ -13,27 +13,204 @@ export interface TrackedDoc {
|
|
|
13
13
|
*/
|
|
14
14
|
export interface PatchesStore {
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Registers documents for local tracking and synchronization.
|
|
17
|
+
*
|
|
18
|
+
* Creates local records for new documents or reactivates previously deleted ones.
|
|
19
|
+
* Must handle duplicate calls gracefully - tracking an already-tracked document is a no-op.
|
|
20
|
+
* Sets initial committedRev to 0 for new documents.
|
|
21
|
+
*
|
|
22
|
+
* @param docIds Array of document IDs to start tracking
|
|
23
|
+
* @example
|
|
24
|
+
* // Start tracking two documents
|
|
25
|
+
* await store.trackDocs(['doc1', 'doc2']);
|
|
26
|
+
*
|
|
27
|
+
* // Reactivate a previously deleted document
|
|
28
|
+
* await store.trackDocs(['previously-deleted-doc']);
|
|
18
29
|
*/
|
|
19
30
|
trackDocs(docIds: string[]): Promise<void>;
|
|
20
31
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
32
|
+
* Permanently removes documents from local tracking and storage.
|
|
33
|
+
*
|
|
34
|
+
* Deletes all local data (state, pending changes, metadata) without notifying the server.
|
|
35
|
+
* Use this when the user no longer wants a document synchronized locally, not for collaborative deletion.
|
|
36
|
+
* Cannot be undone - use deleteDoc() for collaborative deletion that syncs to other clients.
|
|
37
|
+
*
|
|
38
|
+
* @param docIds Array of document IDs to stop tracking
|
|
39
|
+
* @example
|
|
40
|
+
* // Stop tracking documents the user no longer needs
|
|
41
|
+
* await store.untrackDocs(['old-draft', 'cancelled-project']);
|
|
23
42
|
*/
|
|
24
43
|
untrackDocs(docIds: string[]): Promise<void>;
|
|
25
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Returns metadata for all locally tracked documents.
|
|
46
|
+
*
|
|
47
|
+
* By default excludes documents marked as deleted. Set includeDeleted=true to include tombstoned
|
|
48
|
+
* documents that are pending server deletion confirmation.
|
|
49
|
+
*
|
|
50
|
+
* @param includeDeleted Whether to include documents marked for deletion
|
|
51
|
+
* @returns Array of document metadata including docId, committedRev, and deletion status
|
|
52
|
+
* @example
|
|
53
|
+
* // Get active documents
|
|
54
|
+
* const activeDocs = await store.listDocs();
|
|
55
|
+
*
|
|
56
|
+
* // Get all documents including deleted ones
|
|
57
|
+
* const allDocs = await store.listDocs(true);
|
|
58
|
+
*/
|
|
26
59
|
listDocs(includeDeleted?: boolean): Promise<TrackedDoc[]>;
|
|
60
|
+
/**
|
|
61
|
+
* Retrieves the current document snapshot from storage.
|
|
62
|
+
*
|
|
63
|
+
* Returns the complete document state as last saved, including revision metadata.
|
|
64
|
+
* Returns undefined if the document doesn't exist or isn't tracked.
|
|
65
|
+
* This is the primary method for loading document state on startup.
|
|
66
|
+
*
|
|
67
|
+
* @param docId Document identifier
|
|
68
|
+
* @returns Document snapshot with state and metadata, or undefined if not found
|
|
69
|
+
* @example
|
|
70
|
+
* const snapshot = await store.getDoc('my-document');
|
|
71
|
+
* if (snapshot) {
|
|
72
|
+
* console.log('Current state:', snapshot.state);
|
|
73
|
+
* console.log('At revision:', snapshot.rev);
|
|
74
|
+
* }
|
|
75
|
+
*/
|
|
27
76
|
getDoc(docId: string): Promise<PatchesSnapshot | undefined>;
|
|
77
|
+
/**
|
|
78
|
+
* Retrieves all pending (unconfirmed) changes for a document.
|
|
79
|
+
*
|
|
80
|
+
* Pending changes are local edits that haven't been confirmed by the server yet.
|
|
81
|
+
* Returns changes in chronological order as they were created locally.
|
|
82
|
+
* Used during sync to resend unconfirmed operations.
|
|
83
|
+
*
|
|
84
|
+
* @param docId Document identifier
|
|
85
|
+
* @returns Array of pending changes in chronological order
|
|
86
|
+
* @example
|
|
87
|
+
* const pendingChanges = await store.getPendingChanges('my-document');
|
|
88
|
+
* console.log(`${pendingChanges.length} changes waiting for server confirmation`);
|
|
89
|
+
*/
|
|
28
90
|
getPendingChanges(docId: string): Promise<Change[]>;
|
|
91
|
+
/**
|
|
92
|
+
* Returns revision counters for tracking document sync state.
|
|
93
|
+
*
|
|
94
|
+
* committedRev: Last revision confirmed by the server
|
|
95
|
+
* pendingRev: Next revision number for new local changes
|
|
96
|
+
* The gap between these indicates how many changes are pending server confirmation.
|
|
97
|
+
*
|
|
98
|
+
* @param docId Document identifier
|
|
99
|
+
* @returns Tuple of [committedRev, pendingRev]
|
|
100
|
+
* @example
|
|
101
|
+
* const [committed, pending] = await store.getLastRevs('my-document');
|
|
102
|
+
* console.log(`Server confirmed through rev ${committed}, local changes at rev ${pending}`);
|
|
103
|
+
* if (pending > committed) {
|
|
104
|
+
* console.log(`${pending - committed} changes pending server confirmation`);
|
|
105
|
+
* }
|
|
106
|
+
*/
|
|
29
107
|
getLastRevs(docId: string): Promise<[committedRev: number, pendingRev: number]>;
|
|
108
|
+
/**
|
|
109
|
+
* Saves the current document state to persistent storage.
|
|
110
|
+
*
|
|
111
|
+
* Overwrites the existing document snapshot with new state and revision metadata.
|
|
112
|
+
* Called after applying committed changes from the server or creating document snapshots.
|
|
113
|
+
* The state should include the revision number it represents.
|
|
114
|
+
*
|
|
115
|
+
* @param docId Document identifier
|
|
116
|
+
* @param docState Complete document state with metadata
|
|
117
|
+
* @example
|
|
118
|
+
* // Save state after applying server changes
|
|
119
|
+
* await store.saveDoc('my-document', {
|
|
120
|
+
* state: { title: 'Updated Title', content: '...' },
|
|
121
|
+
* rev: 42,
|
|
122
|
+
* createdAt: Date.now()
|
|
123
|
+
* });
|
|
124
|
+
*/
|
|
30
125
|
saveDoc(docId: string, docState: PatchesState): Promise<void>;
|
|
31
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Appends new pending changes to the document's local change queue.
|
|
128
|
+
*
|
|
129
|
+
* Adds changes to the end of the pending changes list without replacing existing ones.
|
|
130
|
+
* Called when the user makes local edits that haven't been sent to the server yet.
|
|
131
|
+
* Changes should have sequential revision numbers starting after the last pending change.
|
|
132
|
+
*
|
|
133
|
+
* @param docId Document identifier
|
|
134
|
+
* @param changes Array of new changes to append
|
|
135
|
+
* @example
|
|
136
|
+
* // User made a local edit
|
|
137
|
+
* const newChange = { rev: 15, patches: [...], clientId: 'client-123' };
|
|
138
|
+
* await store.savePendingChanges('my-document', [newChange]);
|
|
139
|
+
*/
|
|
140
|
+
savePendingChanges(docId: string, changes: Change[]): Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Records changes confirmed by the server and optionally removes sent pending changes.
|
|
143
|
+
*
|
|
144
|
+
* Adds server-confirmed changes to the document's history and updates the committed revision.
|
|
145
|
+
* If sentPendingRange is provided, removes the specified range of pending changes that
|
|
146
|
+
* were confirmed by the server (they're no longer pending).
|
|
147
|
+
*
|
|
148
|
+
* @param docId Document identifier
|
|
149
|
+
* @param changes Server-confirmed changes to record
|
|
150
|
+
* @param sentPendingRange Optional range [startRev, endRev] of pending changes to remove
|
|
151
|
+
* @example
|
|
152
|
+
* // Server confirmed our changes
|
|
153
|
+
* await store.saveCommittedChanges('my-document', serverChanges, [10, 12]);
|
|
154
|
+
*
|
|
155
|
+
* // Server sent changes from other clients
|
|
156
|
+
* await store.saveCommittedChanges('my-document', serverChanges);
|
|
157
|
+
*/
|
|
32
158
|
saveCommittedChanges(docId: string, changes: Change[], sentPendingRange?: [number, number]): Promise<void>;
|
|
33
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Completely replaces the document's pending changes with a new set.
|
|
161
|
+
*
|
|
162
|
+
* Discards all existing pending changes and replaces them with the provided array.
|
|
163
|
+
* Used when operational transformation rebases pending changes after receiving server updates.
|
|
164
|
+
* The new changes should have sequential revision numbers.
|
|
165
|
+
*
|
|
166
|
+
* @param docId Document identifier
|
|
167
|
+
* @param changes New complete set of pending changes
|
|
168
|
+
* @example
|
|
169
|
+
* // After rebasing pending changes due to server conflicts
|
|
170
|
+
* const rebasedChanges = transformPendingChanges(serverChanges, currentPending);
|
|
171
|
+
* await store.replacePendingChanges('my-document', rebasedChanges);
|
|
172
|
+
*/
|
|
173
|
+
replacePendingChanges(docId: string, changes: Change[]): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* Marks a document for collaborative deletion.
|
|
176
|
+
*
|
|
177
|
+
* Sets the document's deleted flag to create a tombstone that will be synchronized
|
|
178
|
+
* to the server and other clients. The document remains locally accessible until
|
|
179
|
+
* confirmDeleteDoc() is called after server confirmation.
|
|
180
|
+
* Different from untrackDocs() which only affects local storage.
|
|
181
|
+
*
|
|
182
|
+
* @param docId Document identifier to mark for deletion
|
|
183
|
+
* @example
|
|
184
|
+
* // User deletes a document
|
|
185
|
+
* await store.deleteDoc('my-document');
|
|
186
|
+
* // Document is marked deleted but still accessible until server confirms
|
|
187
|
+
*/
|
|
34
188
|
deleteDoc(docId: string): Promise<void>;
|
|
35
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Confirms server-side deletion and removes the document locally.
|
|
191
|
+
*
|
|
192
|
+
* Called after the server confirms a document deletion. Removes all local data
|
|
193
|
+
* for the document including the tombstone. The document will no longer appear
|
|
194
|
+
* in listDocs() results even with includeDeleted=true.
|
|
195
|
+
*
|
|
196
|
+
* @param docId Document identifier to confirm deletion
|
|
197
|
+
* @example
|
|
198
|
+
* // Server confirmed the deletion
|
|
199
|
+
* await store.confirmDeleteDoc('my-document');
|
|
200
|
+
* // Document is now completely removed from local storage
|
|
201
|
+
*/
|
|
36
202
|
confirmDeleteDoc(docId: string): Promise<void>;
|
|
37
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Shuts down the store and releases resources.
|
|
205
|
+
*
|
|
206
|
+
* Closes database connections, clears caches, and performs cleanup.
|
|
207
|
+
* Should be called when the application is shutting down or switching stores.
|
|
208
|
+
* The store cannot be used after calling close().
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* // Application shutdown
|
|
212
|
+
* await store.close();
|
|
213
|
+
* // Store is no longer usable
|
|
214
|
+
*/
|
|
38
215
|
close(): Promise<void>;
|
|
39
216
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { inc } from 'alphacounter';
|
|
2
|
+
import { createId } from 'crypto-id';
|
|
3
|
+
/**
|
|
4
|
+
* Create a change id for a given revision. Uses a random 4 character id, prefixed with a revision number string.
|
|
5
|
+
* @param rev - The revision number.
|
|
6
|
+
* @returns The change id.
|
|
7
|
+
*/
|
|
8
|
+
function createChangeId(rev) {
|
|
9
|
+
return inc.from(rev) + createId(4);
|
|
10
|
+
}
|
|
11
|
+
export function createChange(baseRev, rev, ops, metadata) {
|
|
12
|
+
return {
|
|
13
|
+
id: createChangeId(rev),
|
|
14
|
+
baseRev,
|
|
15
|
+
rev,
|
|
16
|
+
ops,
|
|
17
|
+
created: Date.now(),
|
|
18
|
+
...metadata,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { VersionMetadata } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Create a version id for a given document. Uses a sortable 16 character id.
|
|
4
|
+
* @returns The version id.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createVersionId(): string;
|
|
7
|
+
/**
|
|
8
|
+
* Create a version.
|
|
9
|
+
* @param data - The version data.
|
|
10
|
+
* @returns The version.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createVersionMetadata(data: Omit<VersionMetadata, 'id'>): VersionMetadata;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createSortableId } from 'crypto-id';
|
|
2
|
+
/**
|
|
3
|
+
* Create a version id for a given document. Uses a sortable 16 character id.
|
|
4
|
+
* @returns The version id.
|
|
5
|
+
*/
|
|
6
|
+
export function createVersionId() {
|
|
7
|
+
return createSortableId();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Create a version.
|
|
11
|
+
* @param data - The version data.
|
|
12
|
+
* @returns The version.
|
|
13
|
+
*/
|
|
14
|
+
export function createVersionMetadata(data) {
|
|
15
|
+
data.id = createVersionId();
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
@@ -15,7 +15,7 @@ export const add = {
|
|
|
15
15
|
}
|
|
16
16
|
if (Array.isArray(target)) {
|
|
17
17
|
const index = toArrayIndex(target, lastKey);
|
|
18
|
-
if (target.length < index) {
|
|
18
|
+
if (index < 0 || target.length < index) {
|
|
19
19
|
return `[op:add] invalid array index: ${path}`;
|
|
20
20
|
}
|
|
21
21
|
pluckWithShallowCopy(state, keys, true).splice(index, 0, value);
|
|
@@ -19,7 +19,7 @@ export const move = {
|
|
|
19
19
|
}
|
|
20
20
|
if (Array.isArray(target)) {
|
|
21
21
|
const index = toArrayIndex(target, lastKey);
|
|
22
|
-
if (target.length <= index) {
|
|
22
|
+
if (index < 0 || target.length <= index) {
|
|
23
23
|
return `[op:move] invalid array index: ${path}`;
|
|
24
24
|
}
|
|
25
25
|
value = target[index];
|
|
@@ -12,7 +12,7 @@ export const remove = {
|
|
|
12
12
|
}
|
|
13
13
|
if (Array.isArray(target)) {
|
|
14
14
|
const index = toArrayIndex(target, lastKey);
|
|
15
|
-
if (target.length <= index) {
|
|
15
|
+
if (index < 0 || target.length <= index) {
|
|
16
16
|
return '[op:remove] invalid array index: ' + path;
|
|
17
17
|
}
|
|
18
18
|
pluckWithShallowCopy(state, keys).splice(index, 1);
|
|
@@ -16,7 +16,7 @@ export const replace = {
|
|
|
16
16
|
}
|
|
17
17
|
if (Array.isArray(target)) {
|
|
18
18
|
const index = toArrayIndex(target, lastKey);
|
|
19
|
-
if (target.length <= index) {
|
|
19
|
+
if (index < 0 || target.length <= index) {
|
|
20
20
|
return `[op:replace] invalid array index: ${path}`;
|
|
21
21
|
}
|
|
22
22
|
if (!deepEqual(target[index], value)) {
|