@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.
Files changed (79) 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 +2 -1
  29. package/dist/client/InMemoryStore.js +9 -3
  30. package/dist/client/IndexedDBStore.d.ts +34 -2
  31. package/dist/client/IndexedDBStore.js +399 -282
  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 +186 -9
  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/json-patch/ops/add.js +1 -1
  43. package/dist/json-patch/ops/move.js +1 -1
  44. package/dist/json-patch/ops/remove.js +1 -1
  45. package/dist/json-patch/ops/replace.js +1 -1
  46. package/dist/json-patch/utils/get.js +0 -1
  47. package/dist/json-patch/utils/log.d.ts +4 -1
  48. package/dist/json-patch/utils/log.js +2 -5
  49. package/dist/json-patch/utils/ops.d.ts +1 -1
  50. package/dist/json-patch/utils/ops.js +4 -1
  51. package/dist/json-patch/utils/paths.js +2 -2
  52. package/dist/json-patch/utils/toArrayIndex.js +1 -1
  53. package/dist/net/PatchesSync.d.ts +55 -24
  54. package/dist/net/PatchesSync.js +336 -258
  55. package/dist/net/websocket/AuthorizationProvider.d.ts +9 -2
  56. package/dist/net/websocket/AuthorizationProvider.js +14 -2
  57. package/dist/net/websocket/PatchesWebSocket.d.ts +2 -2
  58. package/dist/net/websocket/PatchesWebSocket.js +3 -2
  59. package/dist/net/websocket/RPCServer.d.ts +2 -2
  60. package/dist/net/websocket/RPCServer.js +3 -3
  61. package/dist/net/websocket/SignalingService.js +1 -1
  62. package/dist/net/websocket/WebSocketServer.d.ts +1 -1
  63. package/dist/net/websocket/WebSocketServer.js +2 -2
  64. package/dist/net/websocket/WebSocketTransport.js +1 -1
  65. package/dist/net/websocket/onlineState.d.ts +1 -1
  66. package/dist/net/websocket/onlineState.js +8 -2
  67. package/dist/server/PatchesBranchManager.js +9 -16
  68. package/dist/server/PatchesHistoryManager.js +1 -1
  69. package/dist/server/PatchesServer.d.ts +11 -38
  70. package/dist/server/PatchesServer.js +32 -255
  71. package/dist/types.d.ts +8 -6
  72. package/dist/utils/concurrency.d.ts +26 -0
  73. package/dist/utils/concurrency.js +60 -0
  74. package/dist/utils/deferred.d.ts +7 -0
  75. package/dist/utils/deferred.js +23 -0
  76. package/package.json +11 -5
  77. package/dist/utils/breakChange.js +0 -302
  78. package/dist/utils/getJSONByteSize.js +0 -12
  79. /package/dist/{utils → algorithms/client}/getJSONByteSize.d.ts +0 -0
@@ -1,8 +1,7 @@
1
- import { createId } from 'crypto-id';
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
- this._committedState = structuredClone(initialState);
27
+ /** Subscribe to be notified when syncing state changes. */
28
+ this.onSyncing = signal();
30
29
  this._state = structuredClone(initialState);
31
- this._committedRev = 0;
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 + sending + pending). */
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._committedRev;
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._pendingChanges.length > 0;
52
+ return this._snapshot.changes.length > 0;
54
53
  }
55
- /** Subscribe to be notified whenever value changes. */
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._committedState = structuredClone(snapshot.state); // Use structuredClone
81
- this._committedRev = snapshot.rev;
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 patch = createJSONPatch(this._state, mutator);
100
- if (patch.ops.length === 0) {
101
- return null;
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 change;
98
+ return changes;
134
99
  }
135
100
  /**
136
- * Retrieves pending changes and marks them as sending.
137
- * @returns Array of changes ready to be sent to the server.
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
- getUpdatesForServer() {
141
- if (this.isSending) {
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
- * Processes the server's response to a batch of changes sent via `getUpdatesForServer`.
155
- * @param serverCommit The array of committed changes from the server.
156
- * Expected to be empty (`[]`) if the sent batch was a no‑op,
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
- applyServerConfirmation(serverCommit) {
164
- if (!Array.isArray(serverCommit)) {
165
- throw new Error('Invalid server confirmation format: Expected an array.');
166
- }
167
- if (!this.isSending) {
168
- console.warn('Received server confirmation but no changes were marked as sending.');
169
- // Decide how to handle this - ignore? Apply if possible?
170
- // For now, let's ignore if the server sent something unexpected.
171
- if (serverCommit.length === 0)
172
- return; // Ignore empty confirmations if not sending
173
- // If server sent a commit unexpectedly, it implies a state mismatch. Hard to recover.
174
- // Maybe apply cautiously if rev matches?
175
- const commit = serverCommit[0];
176
- if (commit && commit.rev === this._committedRev + 1) {
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
  }
@@ -1,5 +1,5 @@
1
+ import { applyChanges } from '../algorithms/shared/applyChanges.js';
1
2
  import { signal } from '../event-signal.js';
2
- import { applyChanges } from '../utils.js';
3
3
  class LRUCache {
4
4
  constructor(maxSize) {
5
5
  this.maxSize = maxSize;
@@ -13,27 +13,204 @@ export interface TrackedDoc {
13
13
  */
14
14
  export interface PatchesStore {
15
15
  /**
16
- * Ensure these docs exist in the local index (or undelete them).
17
- * Called by `trackDocs` before syncing begins.
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
- * Drop all local data for these docs without creating a delete tombstone.
22
- * Called by `untrackDocs` when the user no longer cares about a doc locally.
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
- /** List currently tracked docs (optionally including deleted). */
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
- savePendingChange(docId: string, change: Change): Promise<void>;
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
- /** Permanently delete document (writes tombstone so server delete happens later). */
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
- /** Confirm that a doc has been deleted (e.g., after a tombstone has been written). */
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
- /** Close the store */
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,3 @@
1
+ import type { JSONPatchOp } from '../json-patch/types';
2
+ import type { Change } from '../types';
3
+ export declare function createChange(baseRev: number, rev: number, ops: JSONPatchOp[], metadata?: Record<string, any>): Change;
@@ -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)) {