@dabble/patches 0.2.10 → 0.2.11

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 (87) hide show
  1. package/dist/json-patch/patchProxy.d.ts +41 -0
  2. package/dist/json-patch/patchProxy.js +125 -0
  3. package/dist/json-patch/state.d.ts +2 -0
  4. package/dist/json-patch/state.js +8 -0
  5. package/dist/json-patch/transformPatch.d.ts +19 -0
  6. package/dist/json-patch/transformPatch.js +37 -0
  7. package/dist/json-patch/types.d.ts +52 -0
  8. package/dist/json-patch/types.js +1 -0
  9. package/dist/json-patch/utils/deepEqual.d.ts +1 -0
  10. package/dist/json-patch/utils/deepEqual.js +33 -0
  11. package/dist/json-patch/utils/exit.d.ts +2 -0
  12. package/dist/json-patch/utils/exit.js +4 -0
  13. package/dist/json-patch/utils/get.d.ts +2 -0
  14. package/dist/json-patch/utils/get.js +6 -0
  15. package/dist/json-patch/utils/getOpData.d.ts +2 -0
  16. package/dist/json-patch/utils/getOpData.js +10 -0
  17. package/dist/json-patch/utils/getType.d.ts +3 -0
  18. package/dist/json-patch/utils/getType.js +6 -0
  19. package/dist/json-patch/utils/index.d.ts +14 -0
  20. package/dist/json-patch/utils/index.js +14 -0
  21. package/dist/json-patch/utils/log.d.ts +2 -0
  22. package/dist/json-patch/utils/log.js +7 -0
  23. package/dist/json-patch/utils/ops.d.ts +14 -0
  24. package/dist/json-patch/utils/ops.js +103 -0
  25. package/dist/json-patch/utils/paths.d.ts +9 -0
  26. package/dist/json-patch/utils/paths.js +53 -0
  27. package/dist/json-patch/utils/pluck.d.ts +5 -0
  28. package/dist/json-patch/utils/pluck.js +30 -0
  29. package/dist/json-patch/utils/shallowCopy.d.ts +1 -0
  30. package/dist/json-patch/utils/shallowCopy.js +20 -0
  31. package/dist/json-patch/utils/softWrites.d.ts +7 -0
  32. package/dist/json-patch/utils/softWrites.js +18 -0
  33. package/dist/json-patch/utils/toArrayIndex.d.ts +1 -0
  34. package/dist/json-patch/utils/toArrayIndex.js +12 -0
  35. package/dist/json-patch/utils/toKeys.d.ts +1 -0
  36. package/dist/json-patch/utils/toKeys.js +15 -0
  37. package/dist/json-patch/utils/updateArrayIndexes.d.ts +5 -0
  38. package/dist/json-patch/utils/updateArrayIndexes.js +38 -0
  39. package/dist/json-patch/utils/updateArrayPath.d.ts +5 -0
  40. package/dist/json-patch/utils/updateArrayPath.js +45 -0
  41. package/dist/net/AbstractTransport.d.ts +47 -0
  42. package/dist/net/AbstractTransport.js +39 -0
  43. package/dist/net/PatchesSync.d.ts +47 -0
  44. package/dist/net/PatchesSync.js +289 -0
  45. package/dist/net/index.d.ts +9 -0
  46. package/dist/net/index.js +7 -0
  47. package/dist/net/protocol/JSONRPCClient.d.ts +55 -0
  48. package/dist/net/protocol/JSONRPCClient.js +106 -0
  49. package/dist/net/protocol/types.d.ts +142 -0
  50. package/dist/net/protocol/types.js +1 -0
  51. package/dist/net/types.d.ts +6 -0
  52. package/dist/net/types.js +1 -0
  53. package/dist/net/webrtc/WebRTCAwareness.d.ts +81 -0
  54. package/dist/net/webrtc/WebRTCAwareness.js +119 -0
  55. package/dist/net/webrtc/WebRTCTransport.d.ts +80 -0
  56. package/dist/net/webrtc/WebRTCTransport.js +157 -0
  57. package/dist/net/websocket/PatchesWebSocket.d.ts +107 -0
  58. package/dist/net/websocket/PatchesWebSocket.js +144 -0
  59. package/dist/net/websocket/SignalingService.d.ts +91 -0
  60. package/dist/net/websocket/SignalingService.js +140 -0
  61. package/dist/net/websocket/WebSocketTransport.d.ts +58 -0
  62. package/dist/net/websocket/WebSocketTransport.js +190 -0
  63. package/dist/net/websocket/onlineState.d.ts +9 -0
  64. package/dist/net/websocket/onlineState.js +18 -0
  65. package/dist/persist/InMemoryStore.d.ts +23 -0
  66. package/dist/persist/InMemoryStore.js +103 -0
  67. package/dist/persist/IndexedDBStore.d.ts +81 -0
  68. package/dist/persist/IndexedDBStore.js +377 -0
  69. package/dist/persist/PatchesStore.d.ts +38 -0
  70. package/dist/persist/PatchesStore.js +1 -0
  71. package/dist/persist/index.d.ts +3 -0
  72. package/dist/persist/index.js +3 -0
  73. package/dist/server/PatchesBranchManager.d.ts +40 -0
  74. package/dist/server/PatchesBranchManager.js +138 -0
  75. package/dist/server/PatchesHistoryManager.d.ts +43 -0
  76. package/dist/server/PatchesHistoryManager.js +59 -0
  77. package/dist/server/PatchesServer.d.ts +129 -0
  78. package/dist/server/PatchesServer.js +358 -0
  79. package/dist/server/index.d.ts +3 -0
  80. package/dist/server/index.js +3 -0
  81. package/dist/types.d.ts +164 -0
  82. package/dist/types.js +1 -0
  83. package/dist/utils/batching.d.ts +5 -0
  84. package/dist/utils/batching.js +38 -0
  85. package/dist/utils.d.ts +36 -0
  86. package/dist/utils.js +103 -0
  87. package/package.json +1 -1
@@ -0,0 +1,377 @@
1
+ import { signal } from '../event-signal.js';
2
+ import { transformPatch } from '../json-patch/transformPatch.js';
3
+ import { applyChanges, deferred } from '../utils.js';
4
+ const DB_VERSION = 1;
5
+ const SNAPSHOT_INTERVAL = 200;
6
+ /**
7
+ * Creates a new IndexedDB database with stores:
8
+ * - snapshots<{ docId: string; rev: number; state: any }> (primary key: docId)
9
+ * - committedChanges<Change & { docId: string; }> (primary key: [docId, rev])
10
+ * - pendingChanges<Change & { docId: string; }> (primary key: [docId, rev])
11
+ * - docs<{ docId: string; committedRev: number; deleted?: boolean }> (primary key: docId)
12
+ *
13
+ * Under the hood, this class will store snapshots of the document only for committed state. It will not update the
14
+ * committed state on *every* received committed change as this can cause issues with IndexedDB with many large updates.
15
+ * After every 200 committed changes, the class will save the current state to the snapshot store and delete the committed changes that went into it.
16
+ * A snapshot will not be created if there are pending changes based on revisions older than the 200th committed change until those pending changes are committed.
17
+ */
18
+ export class IndexedDBStore {
19
+ constructor(dbName) {
20
+ this.db = null;
21
+ /** Subscribe to be notified after local state changes are saved to the database. */
22
+ this.onPendingChanges = signal();
23
+ this.dbName = dbName;
24
+ this.dbPromise = deferred();
25
+ if (this.dbName) {
26
+ this.initDB();
27
+ }
28
+ }
29
+ async initDB() {
30
+ if (!this.dbName)
31
+ return;
32
+ const request = indexedDB.open(this.dbName, DB_VERSION);
33
+ request.onerror = () => this.dbPromise.reject(request.error);
34
+ request.onsuccess = () => {
35
+ this.db = request.result;
36
+ this.dbPromise.resolve(this.db);
37
+ };
38
+ request.onupgradeneeded = event => {
39
+ const db = event.target.result;
40
+ // Create stores
41
+ if (!db.objectStoreNames.contains('snapshots')) {
42
+ db.createObjectStore('snapshots', { keyPath: 'docId' });
43
+ }
44
+ if (!db.objectStoreNames.contains('committedChanges')) {
45
+ db.createObjectStore('committedChanges', { keyPath: ['docId', 'rev'] });
46
+ }
47
+ if (!db.objectStoreNames.contains('pendingChanges')) {
48
+ db.createObjectStore('pendingChanges', { keyPath: ['docId', 'rev'] });
49
+ }
50
+ if (!db.objectStoreNames.contains('docs')) {
51
+ db.createObjectStore('docs', { keyPath: 'docId' });
52
+ }
53
+ };
54
+ }
55
+ getDB() {
56
+ return this.dbPromise.promise;
57
+ }
58
+ /**
59
+ * Set the name of the database, loads a new database connection.
60
+ * @param dbName - The new name of the database.
61
+ */
62
+ setName(dbName) {
63
+ this.dbName = dbName;
64
+ if (this.db) {
65
+ this.db.close();
66
+ this.db = null;
67
+ this.dbPromise = deferred();
68
+ }
69
+ this.initDB();
70
+ }
71
+ /**
72
+ * Closes the database connection. After calling this method, the store
73
+ * will no longer be usable. A new instance must be created to reopen
74
+ * the database.
75
+ */
76
+ async close() {
77
+ await this.dbPromise.promise;
78
+ if (this.db) {
79
+ this.db.close();
80
+ this.db = null;
81
+ this.dbPromise = deferred();
82
+ this.dbPromise.resolve(null);
83
+ }
84
+ }
85
+ async deleteDB() {
86
+ if (!this.dbName)
87
+ return;
88
+ await this.close();
89
+ await new Promise((resolve, reject) => {
90
+ const request = indexedDB.deleteDatabase(this.dbName);
91
+ request.onsuccess = () => resolve();
92
+ request.onerror = () => reject(request.error);
93
+ request.onblocked = () => reject(request.error);
94
+ });
95
+ }
96
+ async transaction(storeNames, mode) {
97
+ const db = await this.getDB();
98
+ const tx = new IDBTransactionWrapper(db.transaction(storeNames, mode));
99
+ const stores = storeNames.map(name => tx.getStore(name));
100
+ return [tx, ...stores];
101
+ }
102
+ // ─── Snapshots + Reconstruction ────────────────────────────────────────────
103
+ /**
104
+ * Rebuilds a document snapshot + pending queue *without* loading
105
+ * the full PatchesDoc into memory.
106
+ *
107
+ * 1. load the last snapshot (state + rev)
108
+ * 2. load committedChanges[rev > snapshot.rev]
109
+ * 3. load pendingChanges
110
+ * 4. apply committed changes, rebase pending
111
+ * 5. return { state, rev, changes: pending }
112
+ */
113
+ async getDoc(docId) {
114
+ const [tx, docsStore, snapshots, committedChanges, pendingChanges] = await this.transaction(['docs', 'snapshots', 'committedChanges', 'pendingChanges'], 'readonly');
115
+ const docMeta = await docsStore.get(docId);
116
+ if (docMeta?.deleted) {
117
+ await tx.complete();
118
+ return undefined;
119
+ }
120
+ const snapshot = await snapshots.get(docId);
121
+ const committed = await committedChanges.getAll([docId, snapshot?.rev ?? 0], [docId, Infinity]);
122
+ const pending = await pendingChanges.getAll([docId, 0], [docId, Infinity]);
123
+ if (!snapshot && !committed.length && !pending.length)
124
+ return undefined;
125
+ // Apply any committed changes to the snapshot state
126
+ const state = applyChanges(snapshot?.state, committed);
127
+ // Rebase pending changes if there are any committed changes received since their baseRev
128
+ const lastCommitted = committed[committed.length - 1];
129
+ const baseRev = pending[0]?.baseRev;
130
+ if (lastCommitted && baseRev && baseRev < lastCommitted.rev) {
131
+ const patch = committed
132
+ .filter(change => change.rev > baseRev)
133
+ .map(change => change.ops)
134
+ .flat();
135
+ const offset = lastCommitted.rev - baseRev;
136
+ pending.forEach(change => {
137
+ change.rev += offset;
138
+ change.ops = transformPatch(state, patch, change.ops);
139
+ });
140
+ }
141
+ await tx.complete();
142
+ return {
143
+ state,
144
+ rev: committed[committed.length - 1]?.rev ?? snapshot?.rev ?? 0,
145
+ changes: pending,
146
+ };
147
+ }
148
+ /**
149
+ * Completely remove all data for this docId and mark it as deleted (tombstone).
150
+ */
151
+ async deleteDoc(docId) {
152
+ const [tx, snapshots, committedChanges, pendingChanges, docsStore] = await this.transaction(['snapshots', 'committedChanges', 'pendingChanges', 'docs'], 'readwrite');
153
+ const docMeta = (await docsStore.get(docId)) ?? { docId, committedRev: 0 };
154
+ await docsStore.put({ ...docMeta, deleted: true });
155
+ await Promise.all([
156
+ snapshots.delete(docId),
157
+ committedChanges.delete([docId, 0], [docId, Infinity]),
158
+ pendingChanges.delete([docId, 0], [docId, Infinity]),
159
+ ]);
160
+ await tx.complete();
161
+ }
162
+ async confirmDeleteDoc(docId) {
163
+ const [tx, docsStore] = await this.transaction(['docs'], 'readwrite');
164
+ await docsStore.delete(docId);
165
+ await tx.complete();
166
+ }
167
+ // ─── Pending Changes ────────────────────────────────────────────────────────
168
+ /**
169
+ * Append an array of local changes to the pending queue.
170
+ * Called *before* you attempt to send them to the server.
171
+ */
172
+ async savePendingChanges(docId, changes) {
173
+ const [tx, pendingChanges, docsStore] = await this.transaction(['pendingChanges', 'docs'], 'readwrite');
174
+ let docMeta = await docsStore.get(docId);
175
+ if (!docMeta) {
176
+ docMeta = { docId, committedRev: 0 };
177
+ await docsStore.put(docMeta);
178
+ }
179
+ else if (docMeta.deleted) {
180
+ delete docMeta.deleted;
181
+ await docsStore.put(docMeta);
182
+ console.warn(`Revived document ${docId} by saving pending changes.`);
183
+ }
184
+ await Promise.all(changes.map(change => pendingChanges.put({ ...change, docId })));
185
+ this.onPendingChanges.emit(docId, changes);
186
+ await tx.complete();
187
+ }
188
+ /** Read back all pending changes for this docId (in order). */
189
+ async getPendingChanges(docId) {
190
+ const [tx, pendingChanges] = await this.transaction(['pendingChanges'], 'readonly');
191
+ const result = await pendingChanges.getAll([docId, 0], [docId, Infinity]);
192
+ await tx.complete();
193
+ return result;
194
+ }
195
+ // ─── Committed Changes ─────────────────────────────────────────────────────
196
+ /**
197
+ * Store server‐confirmed changes. Will:
198
+ * - persist them in the committedChanges store
199
+ * - remove any pending changes whose rev falls within `sentPendingRange`
200
+ * - optionally compact a new snapshot after N changes (hidden internally)
201
+ * @param docId - The ID of the document to save the changes for
202
+ * @param changes - The changes to save
203
+ * @param sentPendingRange - The range of pending changes to remove, *must* be provided after receiving the changes
204
+ * from the server in response to a patchesDoc request.
205
+ */
206
+ async saveCommittedChanges(docId, changes, sentPendingRange) {
207
+ const [tx, committedChanges, pendingChanges, snapshots, docsStore] = await this.transaction(['committedChanges', 'pendingChanges', 'snapshots', 'docs'], 'readwrite');
208
+ // Save committed changes
209
+ await Promise.all(changes.map(change => committedChanges.put({ ...change, docId })));
210
+ // Remove pending changes if range provided
211
+ if (sentPendingRange) {
212
+ await pendingChanges.delete([docId, sentPendingRange[0]], [docId, sentPendingRange[1]]);
213
+ }
214
+ // Check if we should create a snapshot
215
+ const count = await committedChanges.count([docId, 0], [docId, Infinity]);
216
+ if (count >= SNAPSHOT_INTERVAL) {
217
+ // Update the snapshot. A snapshot will not be updated if there are pending changes based on revisions older than
218
+ // the latest committed change until those pending changes are committed.
219
+ const [snapshot, committed, firstPending] = await Promise.all([
220
+ snapshots.get(docId),
221
+ committedChanges.getAll([docId, 0], [docId, Infinity], SNAPSHOT_INTERVAL),
222
+ pendingChanges.getFirstFromCursor([docId, 0], [docId, Infinity]),
223
+ ]);
224
+ // Update the snapshot
225
+ const lastRev = committed[committed.length - 1]?.rev;
226
+ if (!firstPending?.baseRev || firstPending?.baseRev >= lastRev) {
227
+ const state = applyChanges(snapshot?.state, committed);
228
+ await Promise.all([
229
+ snapshots.put({
230
+ docId,
231
+ rev: lastRev,
232
+ state,
233
+ }),
234
+ committedChanges.delete([docId, 0], [docId, lastRev]),
235
+ ]);
236
+ }
237
+ }
238
+ // Update committedRev in the docs store if changes were saved
239
+ const lastCommittedRev = changes.at(-1)?.rev;
240
+ if (lastCommittedRev !== undefined) {
241
+ const docMeta = (await docsStore.get(docId)) ?? { docId, committedRev: 0 };
242
+ if (lastCommittedRev > docMeta.committedRev) {
243
+ await docsStore.put({ ...docMeta, committedRev: lastCommittedRev, deleted: undefined });
244
+ }
245
+ }
246
+ await tx.complete();
247
+ }
248
+ // --- New method for OfflineStore interface ---
249
+ async listDocs(includeDeleted = false) {
250
+ const [tx, docsStore] = await this.transaction(['docs'], 'readonly');
251
+ const allDocs = await docsStore.getAll();
252
+ await tx.complete();
253
+ return includeDeleted ? allDocs : allDocs.filter(doc => !doc.deleted);
254
+ }
255
+ // ─── Metadata / Tracking ───────────────────────────────────────────
256
+ async trackDocs(docIds) {
257
+ const [tx, docsStore] = await this.transaction(['docs'], 'readwrite');
258
+ await Promise.all(docIds.map(async (docId) => {
259
+ const existing = await docsStore.get(docId);
260
+ if (existing) {
261
+ // If exists but deleted, undelete it
262
+ if (existing.deleted) {
263
+ await docsStore.put({ ...existing, deleted: undefined });
264
+ }
265
+ // Otherwise, it's already tracked and not deleted, do nothing
266
+ }
267
+ else {
268
+ // If doesn't exist, add it
269
+ await docsStore.put({ docId, committedRev: 0 });
270
+ }
271
+ }));
272
+ await tx.complete();
273
+ }
274
+ async untrackDocs(docIds) {
275
+ const [tx, docsStore, snapshots, committedChanges, pendingChanges] = await this.transaction(['docs', 'snapshots', 'committedChanges', 'pendingChanges'], 'readwrite');
276
+ await Promise.all(docIds.map(docId => {
277
+ return Promise.all([
278
+ docsStore.delete(docId),
279
+ snapshots.delete(docId),
280
+ committedChanges.delete([docId, 0], [docId, Infinity]),
281
+ pendingChanges.delete([docId, 0], [docId, Infinity]),
282
+ ]);
283
+ }));
284
+ await tx.complete();
285
+ }
286
+ // ─── Revision Tracking ─────────────────────────────────────────────────────
287
+ /**
288
+ * Tell me the last committed revision you have *and* the highest
289
+ * rev of any change. Use these to drive:
290
+ * - fetch changes: api.getChangesSince(docId, committedRev)
291
+ * - build new patch: newChange.rev = pendingRev; baseRev = committedRev
292
+ */
293
+ async getLastRevs(docId) {
294
+ const [tx, committedChanges, pendingChanges] = await this.transaction(['committedChanges', 'pendingChanges'], 'readonly');
295
+ const [lastCommitted, lastPending] = await Promise.all([
296
+ committedChanges.getLastFromCursor([docId, 0], [docId, Infinity]),
297
+ pendingChanges.getLastFromCursor([docId, 0], [docId, Infinity]),
298
+ ]);
299
+ await tx.complete();
300
+ return [lastCommitted?.rev ?? 0, lastPending?.rev ?? lastCommitted?.rev ?? 0];
301
+ }
302
+ }
303
+ class IDBTransactionWrapper {
304
+ constructor(tx) {
305
+ this.tx = tx;
306
+ this.promise = new Promise((resolve, reject) => {
307
+ tx.oncomplete = () => resolve();
308
+ tx.onerror = () => reject(tx.error);
309
+ });
310
+ }
311
+ getStore(name) {
312
+ return new IDBStoreWrapper(this.tx.objectStore(name));
313
+ }
314
+ async complete() {
315
+ return this.promise;
316
+ }
317
+ }
318
+ class IDBStoreWrapper {
319
+ constructor(store) {
320
+ this.store = store;
321
+ }
322
+ createRange(lower, upper) {
323
+ if (lower === undefined && upper === undefined)
324
+ return undefined;
325
+ return IDBKeyRange.bound(lower, upper);
326
+ }
327
+ async getAll(lower, upper, count) {
328
+ return new Promise((resolve, reject) => {
329
+ const request = this.store.getAll(this.createRange(lower, upper), count);
330
+ request.onsuccess = () => resolve(request.result);
331
+ request.onerror = () => reject(request.error);
332
+ });
333
+ }
334
+ async get(key) {
335
+ return new Promise((resolve, reject) => {
336
+ const request = this.store.get(key);
337
+ request.onsuccess = () => resolve(request.result);
338
+ request.onerror = () => reject(request.error);
339
+ });
340
+ }
341
+ async put(value) {
342
+ return new Promise((resolve, reject) => {
343
+ const request = this.store.put(value);
344
+ request.onsuccess = () => resolve(request.result);
345
+ request.onerror = () => reject(request.error);
346
+ });
347
+ }
348
+ async delete(keyOrLower, upper) {
349
+ return new Promise((resolve, reject) => {
350
+ const key = upper === undefined ? keyOrLower : this.createRange(keyOrLower, upper);
351
+ const request = this.store.delete(key);
352
+ request.onsuccess = () => resolve();
353
+ request.onerror = () => reject(request.error);
354
+ });
355
+ }
356
+ async count(lower, upper) {
357
+ return new Promise((resolve, reject) => {
358
+ const request = this.store.count(this.createRange(lower, upper));
359
+ request.onsuccess = () => resolve(request.result);
360
+ request.onerror = () => reject(request.error);
361
+ });
362
+ }
363
+ async getFirstFromCursor(lower, upper) {
364
+ return new Promise((resolve, reject) => {
365
+ const request = this.store.openCursor(this.createRange(lower, upper));
366
+ request.onsuccess = () => resolve(request.result?.value);
367
+ request.onerror = () => reject(request.error);
368
+ });
369
+ }
370
+ async getLastFromCursor(lower, upper) {
371
+ return new Promise((resolve, reject) => {
372
+ const request = this.store.openCursor(this.createRange(lower, upper), 'prev');
373
+ request.onsuccess = () => resolve(request.result?.value);
374
+ request.onerror = () => reject(request.error);
375
+ });
376
+ }
377
+ }
@@ -0,0 +1,38 @@
1
+ import type { Change, PatchesSnapshot } from '../types.js';
2
+ /** Represents metadata for a document tracked by the store. */
3
+ export interface TrackedDoc {
4
+ docId: string;
5
+ /** The last revision number confirmed by the server. */
6
+ committedRev: number;
7
+ /** Optional flag indicating the document has been locally deleted. */
8
+ deleted?: true;
9
+ }
10
+ /**
11
+ * Pluggable persistence layer contract used by Patches + PatchesSync.
12
+ * It is *not* strictly offline; an in‑memory implementation fulfils the same contract.
13
+ */
14
+ export interface PatchesStore {
15
+ /**
16
+ * Ensure these docs exist in the local index (or undelete them).
17
+ * Called by `trackDocs` before syncing begins.
18
+ */
19
+ trackDocs(docIds: string[]): Promise<void>;
20
+ /**
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.
23
+ */
24
+ untrackDocs(docIds: string[]): Promise<void>;
25
+ /** List currently tracked docs (optionally including deleted). */
26
+ listDocs(includeDeleted?: boolean): Promise<TrackedDoc[]>;
27
+ getDoc(docId: string): Promise<PatchesSnapshot | undefined>;
28
+ getPendingChanges(docId: string): Promise<Change[]>;
29
+ getLastRevs(docId: string): Promise<[committedRev: number, pendingRev: number]>;
30
+ savePendingChanges(docId: string, changes: Change[]): Promise<void>;
31
+ saveCommittedChanges(docId: string, changes: Change[], sentPendingRange?: [number, number]): Promise<void>;
32
+ /** Permanently delete document (writes tombstone so server delete happens later). */
33
+ deleteDoc(docId: string): Promise<void>;
34
+ /** Confirm that a doc has been deleted (e.g., after a tombstone has been written). */
35
+ confirmDeleteDoc(docId: string): Promise<void>;
36
+ /** Close the store */
37
+ close(): Promise<void>;
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './IndexedDBStore.js';
2
+ export * from './InMemoryStore.js';
3
+ export * from './PatchesStore.js';
@@ -0,0 +1,3 @@
1
+ export * from './IndexedDBStore.js';
2
+ export * from './InMemoryStore.js';
3
+ export * from './PatchesStore.js';
@@ -0,0 +1,40 @@
1
+ import type { Branch, BranchingStoreBackend, BranchStatus, Change } from '../types.js';
2
+ import type { PatchesServer } from './PatchesServer.js';
3
+ /**
4
+ * Helps manage branches for a document. A branch is a document that is branched from another document. Its first
5
+ * version will be the point-in-time of the original document at the time of the branch. Branches allow for parallel
6
+ * development of a document with the ability to merge changes back into the original document later.
7
+ */
8
+ export declare class PatchesBranchManager {
9
+ private readonly store;
10
+ private readonly patchesServer;
11
+ constructor(store: BranchingStoreBackend, patchesServer: PatchesServer);
12
+ /**
13
+ * Lists all open branches for a document.
14
+ * @param docId - The ID of the document.
15
+ * @returns The branches.
16
+ */
17
+ listBranches(docId: string): Promise<Branch[]>;
18
+ /**
19
+ * Creates a new branch for a document.
20
+ * @param docId - The ID of the document to branch from.
21
+ * @param rev - The revision of the document to branch from.
22
+ * @param branchName - Optional name for the branch.
23
+ * @param metadata - Additional optional metadata to store with the branch.
24
+ * @returns The ID of the new branch document.
25
+ */
26
+ createBranch(docId: string, rev: number, branchName?: string, metadata?: Record<string, any>): Promise<string>;
27
+ /**
28
+ * Closes a branch, marking it as merged or deleted.
29
+ * @param branchId - The ID of the branch to close.
30
+ * @param status - The status to set for the branch.
31
+ */
32
+ closeBranch(branchId: string, status?: Exclude<BranchStatus, 'open'>): Promise<void>;
33
+ /**
34
+ * Merges changes from a branch back into its source document.
35
+ * @param branchId - The ID of the branch document to merge.
36
+ * @returns The server commit change(s) applied to the source document.
37
+ * @throws Error if branch not found, already closed/merged, or merge fails.
38
+ */
39
+ mergeBranch(branchId: string): Promise<Change[]>;
40
+ }
@@ -0,0 +1,138 @@
1
+ import { createId } from 'crypto-id';
2
+ /**
3
+ * Helps manage branches for a document. A branch is a document that is branched from another document. Its first
4
+ * version will be the point-in-time of the original document at the time of the branch. Branches allow for parallel
5
+ * development of a document with the ability to merge changes back into the original document later.
6
+ */
7
+ export class PatchesBranchManager {
8
+ constructor(store, patchesServer) {
9
+ this.store = store;
10
+ this.patchesServer = patchesServer;
11
+ }
12
+ /**
13
+ * Lists all open branches for a document.
14
+ * @param docId - The ID of the document.
15
+ * @returns The branches.
16
+ */
17
+ async listBranches(docId) {
18
+ return await this.store.listBranches(docId);
19
+ }
20
+ /**
21
+ * Creates a new branch for a document.
22
+ * @param docId - The ID of the document to branch from.
23
+ * @param rev - The revision of the document to branch from.
24
+ * @param branchName - Optional name for the branch.
25
+ * @param metadata - Additional optional metadata to store with the branch.
26
+ * @returns The ID of the new branch document.
27
+ */
28
+ async createBranch(docId, rev, branchName, metadata) {
29
+ // Prevent branching off a branch
30
+ const maybeBranch = await this.store.loadBranch(docId);
31
+ if (maybeBranch) {
32
+ throw new Error('Cannot create a branch from another branch.');
33
+ }
34
+ // 1. Get the state at the branch point
35
+ const stateAtRev = (await this.patchesServer._getStateAtRevision(docId, rev)).state;
36
+ const branchDocId = createId();
37
+ const now = Date.now();
38
+ // Create an initial version at the branch point rev (for snapshotting/large docs)
39
+ const initialVersionMetadata = {
40
+ id: createId(),
41
+ origin: 'main', // Branch doc versions are 'main' until merged
42
+ startDate: now,
43
+ endDate: now,
44
+ rev,
45
+ baseRev: rev,
46
+ name: branchName,
47
+ groupId: branchDocId,
48
+ branchName,
49
+ };
50
+ await this.store.createVersion(branchDocId, initialVersionMetadata, stateAtRev, []);
51
+ // 2. Create the branch metadata record
52
+ const branch = {
53
+ id: branchDocId,
54
+ branchedFromId: docId,
55
+ branchedRev: rev,
56
+ created: now,
57
+ name: branchName,
58
+ status: 'open',
59
+ ...(metadata && { metadata }),
60
+ };
61
+ await this.store.createBranch(branch);
62
+ return branchDocId;
63
+ }
64
+ /**
65
+ * Closes a branch, marking it as merged or deleted.
66
+ * @param branchId - The ID of the branch to close.
67
+ * @param status - The status to set for the branch.
68
+ */
69
+ async closeBranch(branchId, status = 'closed') {
70
+ await this.store.updateBranch(branchId, { status });
71
+ }
72
+ /**
73
+ * Merges changes from a branch back into its source document.
74
+ * @param branchId - The ID of the branch document to merge.
75
+ * @returns The server commit change(s) applied to the source document.
76
+ * @throws Error if branch not found, already closed/merged, or merge fails.
77
+ */
78
+ async mergeBranch(branchId) {
79
+ // 1. Load branch metadata
80
+ const branch = await this.store.loadBranch(branchId);
81
+ if (!branch) {
82
+ throw new Error(`Branch with ID ${branchId} not found.`);
83
+ }
84
+ if (branch.status !== 'open') {
85
+ throw new Error(`Branch ${branchId} is not open (status: ${branch.status}). Cannot merge.`);
86
+ }
87
+ const sourceDocId = branch.branchedFromId;
88
+ const branchStartRevOnSource = branch.branchedRev;
89
+ // 2. Get all committed server changes made on the branch document since it was created.
90
+ const branchChanges = await this.store.listChanges(branchId, {});
91
+ if (branchChanges.length === 0) {
92
+ console.log(`Branch ${branchId} has no changes to merge.`);
93
+ await this.closeBranch(branchId, 'merged');
94
+ return [];
95
+ }
96
+ // 3. Get all versions from the branch doc (skip offline versions)
97
+ const branchVersions = await this.store.listVersions(branchId, { origin: 'main' });
98
+ // 4. For each version, create a corresponding version in the main doc with updated fields
99
+ let lastVersionId;
100
+ for (const v of branchVersions) {
101
+ const newVersionId = createId();
102
+ const newVersionMetadata = {
103
+ ...v,
104
+ id: newVersionId,
105
+ origin: 'branch',
106
+ baseRev: branchStartRevOnSource,
107
+ groupId: branchId,
108
+ branchName: branch.name,
109
+ parentId: lastVersionId,
110
+ };
111
+ const state = await this.store.loadVersionState(branchId, v.id);
112
+ const changes = await this.store.loadVersionChanges(branchId, v.id);
113
+ await this.store.createVersion(sourceDocId, newVersionMetadata, state, changes);
114
+ lastVersionId = newVersionId;
115
+ }
116
+ // 5. Flatten all branch changes into a single change for the main doc
117
+ const now = Date.now();
118
+ const flattenedChange = {
119
+ id: createId(12),
120
+ ops: branchChanges.flatMap(c => c.ops),
121
+ rev: branchStartRevOnSource + branchChanges.length,
122
+ baseRev: branchStartRevOnSource,
123
+ created: now,
124
+ };
125
+ // 6. Commit the flattened change to the main doc
126
+ let committedMergeChanges = [];
127
+ try {
128
+ [, committedMergeChanges] = await this.patchesServer.commitChanges(sourceDocId, [flattenedChange]);
129
+ }
130
+ catch (error) {
131
+ console.error(`Failed to merge branch ${branchId} into ${sourceDocId}:`, error);
132
+ throw new Error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ // 7. Merge succeeded. Update the branch status.
135
+ await this.closeBranch(branchId, 'merged');
136
+ return committedMergeChanges;
137
+ }
138
+ }
@@ -0,0 +1,43 @@
1
+ import type { Change, ListVersionsOptions, PatchesStoreBackend, VersionMetadata } from '../types.js';
2
+ /**
3
+ * Helps retrieve historical information (versions, changes) for a document
4
+ * using the new versioning model based on IDs and metadata.
5
+ */
6
+ export declare class PatchesHistoryManager {
7
+ private readonly docId;
8
+ private readonly store;
9
+ constructor(docId: string, store: PatchesStoreBackend);
10
+ /**
11
+ * Lists version metadata for the document, supporting various filters.
12
+ * @param options Filtering and sorting options (e.g., limit, reverse, origin, groupId, date range).
13
+ * @returns A list of version metadata objects.
14
+ */
15
+ listVersions(options?: ListVersionsOptions): Promise<VersionMetadata[]>;
16
+ /**
17
+ * Loads the full document state snapshot for a specific version by its ID.
18
+ * @param versionId - The unique ID of the version.
19
+ * @returns The document state at that version.
20
+ * @throws Error if the version ID is not found or state loading fails.
21
+ */
22
+ getStateAtVersion(versionId: string): Promise<any>;
23
+ /**
24
+ * Loads the list of original client changes that were included in a specific version.
25
+ * Useful for replaying/scrubbing through the operations within an offline or online session.
26
+ * @param versionId - The unique ID of the version.
27
+ * @returns An array of Change objects.
28
+ * @throws Error if the version ID is not found or change loading fails.
29
+ */
30
+ getChangesForVersion(versionId: string): Promise<Change[]>;
31
+ /**
32
+ * Lists committed server changes for the document, typically used for server-side processing
33
+ * or deep history analysis based on raw revisions.
34
+ * @param options - Options like start/end revision, limit.
35
+ * @returns The list of committed Change objects.
36
+ */
37
+ listServerChanges(options?: {
38
+ limit?: number;
39
+ startAfterRev?: number;
40
+ endBeforeRev?: number;
41
+ reverse?: boolean;
42
+ }): Promise<Change[]>;
43
+ }