@dabble/patches 0.1.1

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 (120) hide show
  1. package/README.md +632 -0
  2. package/dist/client/PatchDoc.d.ts +85 -0
  3. package/dist/client/PatchDoc.js +299 -0
  4. package/dist/client/index.d.ts +2 -0
  5. package/dist/client/index.js +1 -0
  6. package/dist/event-signal.d.ts +31 -0
  7. package/dist/event-signal.js +40 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +1 -0
  10. package/dist/json-patch/JSONPatch.d.ts +126 -0
  11. package/dist/json-patch/JSONPatch.js +221 -0
  12. package/dist/json-patch/applyPatch.d.ts +11 -0
  13. package/dist/json-patch/applyPatch.js +37 -0
  14. package/dist/json-patch/composePatch.d.ts +2 -0
  15. package/dist/json-patch/composePatch.js +38 -0
  16. package/dist/json-patch/createJSONPatch.d.ts +35 -0
  17. package/dist/json-patch/createJSONPatch.js +41 -0
  18. package/dist/json-patch/index.d.ts +9 -0
  19. package/dist/json-patch/index.js +8 -0
  20. package/dist/json-patch/invertPatch.d.ts +2 -0
  21. package/dist/json-patch/invertPatch.js +31 -0
  22. package/dist/json-patch/ops/add.d.ts +2 -0
  23. package/dist/json-patch/ops/add.js +52 -0
  24. package/dist/json-patch/ops/bitmask.d.ts +14 -0
  25. package/dist/json-patch/ops/bitmask.js +48 -0
  26. package/dist/json-patch/ops/copy.d.ts +2 -0
  27. package/dist/json-patch/ops/copy.js +34 -0
  28. package/dist/json-patch/ops/increment.d.ts +5 -0
  29. package/dist/json-patch/ops/increment.js +21 -0
  30. package/dist/json-patch/ops/index.d.ts +22 -0
  31. package/dist/json-patch/ops/index.js +25 -0
  32. package/dist/json-patch/ops/move.d.ts +2 -0
  33. package/dist/json-patch/ops/move.js +211 -0
  34. package/dist/json-patch/ops/remove.d.ts +2 -0
  35. package/dist/json-patch/ops/remove.js +31 -0
  36. package/dist/json-patch/ops/replace.d.ts +2 -0
  37. package/dist/json-patch/ops/replace.js +44 -0
  38. package/dist/json-patch/ops/test.d.ts +2 -0
  39. package/dist/json-patch/ops/test.js +22 -0
  40. package/dist/json-patch/ops/text.d.ts +2 -0
  41. package/dist/json-patch/ops/text.js +57 -0
  42. package/dist/json-patch/patchProxy.d.ts +41 -0
  43. package/dist/json-patch/patchProxy.js +125 -0
  44. package/dist/json-patch/state.d.ts +2 -0
  45. package/dist/json-patch/state.js +8 -0
  46. package/dist/json-patch/transformPatch.d.ts +19 -0
  47. package/dist/json-patch/transformPatch.js +37 -0
  48. package/dist/json-patch/types.d.ts +52 -0
  49. package/dist/json-patch/types.js +1 -0
  50. package/dist/json-patch/utils/deepEqual.d.ts +1 -0
  51. package/dist/json-patch/utils/deepEqual.js +33 -0
  52. package/dist/json-patch/utils/exit.d.ts +2 -0
  53. package/dist/json-patch/utils/exit.js +4 -0
  54. package/dist/json-patch/utils/get.d.ts +2 -0
  55. package/dist/json-patch/utils/get.js +6 -0
  56. package/dist/json-patch/utils/getOpData.d.ts +2 -0
  57. package/dist/json-patch/utils/getOpData.js +10 -0
  58. package/dist/json-patch/utils/getType.d.ts +3 -0
  59. package/dist/json-patch/utils/getType.js +6 -0
  60. package/dist/json-patch/utils/index.d.ts +14 -0
  61. package/dist/json-patch/utils/index.js +14 -0
  62. package/dist/json-patch/utils/log.d.ts +2 -0
  63. package/dist/json-patch/utils/log.js +7 -0
  64. package/dist/json-patch/utils/ops.d.ts +14 -0
  65. package/dist/json-patch/utils/ops.js +103 -0
  66. package/dist/json-patch/utils/paths.d.ts +9 -0
  67. package/dist/json-patch/utils/paths.js +53 -0
  68. package/dist/json-patch/utils/pluck.d.ts +5 -0
  69. package/dist/json-patch/utils/pluck.js +30 -0
  70. package/dist/json-patch/utils/shallowCopy.d.ts +1 -0
  71. package/dist/json-patch/utils/shallowCopy.js +20 -0
  72. package/dist/json-patch/utils/softWrites.d.ts +7 -0
  73. package/dist/json-patch/utils/softWrites.js +18 -0
  74. package/dist/json-patch/utils/toArrayIndex.d.ts +1 -0
  75. package/dist/json-patch/utils/toArrayIndex.js +12 -0
  76. package/dist/json-patch/utils/toKeys.d.ts +1 -0
  77. package/dist/json-patch/utils/toKeys.js +15 -0
  78. package/dist/json-patch/utils/updateArrayIndexes.d.ts +5 -0
  79. package/dist/json-patch/utils/updateArrayIndexes.js +38 -0
  80. package/dist/json-patch/utils/updateArrayPath.d.ts +5 -0
  81. package/dist/json-patch/utils/updateArrayPath.js +45 -0
  82. package/dist/net/AbstractTransport.d.ts +47 -0
  83. package/dist/net/AbstractTransport.js +37 -0
  84. package/dist/net/PatchesOfflineFirst.d.ts +3 -0
  85. package/dist/net/PatchesOfflineFirst.js +3 -0
  86. package/dist/net/PatchesRealtime.d.ts +90 -0
  87. package/dist/net/PatchesRealtime.js +257 -0
  88. package/dist/net/index.d.ts +9 -0
  89. package/dist/net/index.js +8 -0
  90. package/dist/net/protocol/JSONRPCClient.d.ts +55 -0
  91. package/dist/net/protocol/JSONRPCClient.js +106 -0
  92. package/dist/net/protocol/types.d.ts +142 -0
  93. package/dist/net/protocol/types.js +1 -0
  94. package/dist/net/webrtc/WebRTCAwareness.d.ts +81 -0
  95. package/dist/net/webrtc/WebRTCAwareness.js +119 -0
  96. package/dist/net/webrtc/WebRTCTransport.d.ts +80 -0
  97. package/dist/net/webrtc/WebRTCTransport.js +157 -0
  98. package/dist/net/websocket/PatchesWebSocket.d.ts +107 -0
  99. package/dist/net/websocket/PatchesWebSocket.js +144 -0
  100. package/dist/net/websocket/SignalingService.d.ts +91 -0
  101. package/dist/net/websocket/SignalingService.js +140 -0
  102. package/dist/net/websocket/WebSocketTransport.d.ts +47 -0
  103. package/dist/net/websocket/WebSocketTransport.js +138 -0
  104. package/dist/persist/IndexedDBStore.d.ts +72 -0
  105. package/dist/persist/IndexedDBStore.js +283 -0
  106. package/dist/persist/index.d.ts +2 -0
  107. package/dist/persist/index.js +1 -0
  108. package/dist/server/BranchManager.d.ts +40 -0
  109. package/dist/server/BranchManager.js +138 -0
  110. package/dist/server/HistoryManager.d.ts +63 -0
  111. package/dist/server/HistoryManager.js +92 -0
  112. package/dist/server/PatchServer.d.ts +129 -0
  113. package/dist/server/PatchServer.js +358 -0
  114. package/dist/server/index.d.ts +4 -0
  115. package/dist/server/index.js +3 -0
  116. package/dist/types.d.ts +158 -0
  117. package/dist/types.js +1 -0
  118. package/dist/utils.d.ts +36 -0
  119. package/dist/utils.js +83 -0
  120. package/package.json +78 -0
@@ -0,0 +1,283 @@
1
+ import { signal } from '../event-signal.js';
2
+ import { transformPatch } from '../json-patch/transformPatch.js';
3
+ import { applyChanges } 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
+ * - deleted<{ docId: string; }> (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 = this.initDB();
25
+ }
26
+ async initDB() {
27
+ return new Promise((resolve, reject) => {
28
+ const request = indexedDB.open(this.dbName, DB_VERSION);
29
+ request.onerror = () => reject(request.error);
30
+ request.onsuccess = () => {
31
+ this.db = request.result;
32
+ resolve(this.db);
33
+ };
34
+ request.onupgradeneeded = event => {
35
+ const db = event.target.result;
36
+ // Create stores
37
+ if (!db.objectStoreNames.contains('snapshots')) {
38
+ db.createObjectStore('snapshots', { keyPath: 'docId' });
39
+ }
40
+ if (!db.objectStoreNames.contains('committedChanges')) {
41
+ db.createObjectStore('committedChanges', { keyPath: ['docId', 'rev'] });
42
+ }
43
+ if (!db.objectStoreNames.contains('pendingChanges')) {
44
+ db.createObjectStore('pendingChanges', { keyPath: ['docId', 'rev'] });
45
+ }
46
+ if (!db.objectStoreNames.contains('deleted')) {
47
+ db.createObjectStore('deleted', { keyPath: 'docId' });
48
+ }
49
+ };
50
+ });
51
+ }
52
+ getDB() {
53
+ return this.dbPromise;
54
+ }
55
+ /**
56
+ * Closes the database connection. After calling this method, the store
57
+ * will no longer be usable. A new instance must be created to reopen
58
+ * the database.
59
+ */
60
+ async close() {
61
+ await this.dbPromise;
62
+ if (this.db) {
63
+ this.db.close();
64
+ this.db = null;
65
+ }
66
+ }
67
+ async transaction(storeNames, mode) {
68
+ const db = await this.getDB();
69
+ const tx = new IDBTransactionWrapper(db.transaction(storeNames, mode));
70
+ const stores = storeNames.map(name => tx.getStore(name));
71
+ return [tx, ...stores];
72
+ }
73
+ // ─── Snapshots + Reconstruction ────────────────────────────────────────────
74
+ /**
75
+ * Rebuilds a document snapshot + pending queue *without* loading
76
+ * the full PatchDoc into memory.
77
+ *
78
+ * 1. load the last snapshot (state + rev)
79
+ * 2. load committedChanges[rev > snapshot.rev]
80
+ * 3. load pendingChanges
81
+ * 4. apply committed changes, rebase pending
82
+ * 5. return { state, rev, changes: pending }
83
+ */
84
+ async getDoc(docId) {
85
+ const [tx, snapshots, committedChanges, pendingChanges] = await this.transaction(['snapshots', 'committedChanges', 'pendingChanges'], 'readonly');
86
+ const snapshot = await snapshots.get(docId);
87
+ const committed = await committedChanges.getAll([docId, snapshot?.rev ?? 0 + 1], [docId, Infinity]);
88
+ const pending = await pendingChanges.getAll([docId, 0], [docId, Infinity]);
89
+ if (!snapshot && !committed.length && !pending.length)
90
+ return undefined;
91
+ // Apply any committed changes to the snapshot state
92
+ const state = applyChanges(snapshot?.state, committed);
93
+ // Rebase pending changes if there are any committed changes received since their baseRev
94
+ const lastCommitted = committed[committed.length - 1];
95
+ const baseRev = pending[0]?.baseRev;
96
+ if (lastCommitted && baseRev && baseRev < lastCommitted.rev) {
97
+ const patch = committed
98
+ .filter(change => change.rev > baseRev)
99
+ .map(change => change.ops)
100
+ .flat();
101
+ const offset = lastCommitted.rev - baseRev;
102
+ pending.forEach(change => {
103
+ change.rev += offset;
104
+ change.ops = transformPatch(state, patch, change.ops);
105
+ });
106
+ }
107
+ await tx.complete();
108
+ return {
109
+ state,
110
+ rev: committed[committed.length - 1]?.rev ?? snapshot?.rev ?? 0,
111
+ changes: pending,
112
+ };
113
+ }
114
+ /**
115
+ * Completely remove all data for this docId and mark it
116
+ * as deleted (tombstone). Provider will call `patchAPI.deleteDoc`
117
+ * on reconnect.
118
+ */
119
+ async deleteDoc(docId) {
120
+ const [tx, snapshots, committedChanges, pendingChanges, deleted] = await this.transaction(['snapshots', 'committedChanges', 'pendingChanges', 'deleted'], 'readwrite');
121
+ await Promise.all([
122
+ snapshots.delete(docId),
123
+ committedChanges.delete([docId, 0], [docId, Infinity]),
124
+ pendingChanges.delete([docId, 0], [docId, Infinity]),
125
+ deleted.add({ docId }),
126
+ ]);
127
+ await tx.complete();
128
+ }
129
+ // ─── Pending Changes ────────────────────────────────────────────────────────
130
+ /**
131
+ * Append an array of local changes to the pending queue.
132
+ * Called *before* you attempt to send them to the server.
133
+ */
134
+ async savePendingChanges(docId, changes) {
135
+ const [tx, pendingChanges] = await this.transaction(['pendingChanges'], 'readwrite');
136
+ await Promise.all(changes.map(change => pendingChanges.add({ ...change, docId })));
137
+ this.onPendingChanges.emit(docId, changes);
138
+ await tx.complete();
139
+ }
140
+ /** Read back all pending changes for this docId (in order). */
141
+ async getPendingChanges(docId) {
142
+ const [tx, pendingChanges] = await this.transaction(['pendingChanges'], 'readonly');
143
+ const result = await pendingChanges.getAll([docId, 0], [docId, Infinity]);
144
+ await tx.complete();
145
+ return result;
146
+ }
147
+ // ─── Committed Changes ─────────────────────────────────────────────────────
148
+ /**
149
+ * Store server‐confirmed changes. Will:
150
+ * - persist them in the committedChanges store
151
+ * - remove any pending changes whose rev falls within `sentPendingRange`
152
+ * - optionally compact a new snapshot after N changes (hidden internally)
153
+ * @param docId - The ID of the document to save the changes for
154
+ * @param changes - The changes to save
155
+ * @param sentPendingRange - The range of pending changes to remove, *must* be provided after receiving the changes
156
+ * from the server in response to a patchDoc request.
157
+ */
158
+ async saveCommittedChanges(docId, changes, sentPendingRange) {
159
+ const [tx, committedChanges, pendingChanges, snapshots] = await this.transaction(['committedChanges', 'pendingChanges', 'snapshots'], 'readwrite');
160
+ // Save committed changes
161
+ await Promise.all(changes.map(change => committedChanges.add({ ...change, docId })));
162
+ // Remove pending changes if range provided
163
+ if (sentPendingRange) {
164
+ await pendingChanges.delete([docId, sentPendingRange[0]], [docId, sentPendingRange[1]]);
165
+ }
166
+ // Check if we should create a snapshot
167
+ const count = await committedChanges.count([docId, 0], [docId, Infinity]);
168
+ if (count >= SNAPSHOT_INTERVAL) {
169
+ // Update the snapshot. A snapshot will not be updated if there are pending changes based on revisions older than
170
+ // the latest committed change until those pending changes are committed.
171
+ const [snapshot, committed, firstPending] = await Promise.all([
172
+ snapshots.get(docId),
173
+ committedChanges.getAll([docId, 0], [docId, Infinity], SNAPSHOT_INTERVAL),
174
+ pendingChanges.getFirstFromCursor([docId, 0], [docId, Infinity]),
175
+ ]);
176
+ // Update the snapshot
177
+ const lastRev = committed[committed.length - 1]?.rev;
178
+ if (!firstPending?.baseRev || firstPending?.baseRev >= lastRev) {
179
+ const state = applyChanges(snapshot?.state, committed);
180
+ await Promise.all([
181
+ snapshots.add({
182
+ docId,
183
+ rev: lastRev,
184
+ state,
185
+ }),
186
+ committedChanges.delete([docId, 0], [docId, lastRev]),
187
+ ]);
188
+ }
189
+ }
190
+ await tx.complete();
191
+ }
192
+ // ─── Revision Tracking ─────────────────────────────────────────────────────
193
+ /**
194
+ * Tell me the last committed revision you have *and* the highest
195
+ * rev of any change. Use these to drive:
196
+ * - fetch changes: api.getChangesSince(docId, committedRev)
197
+ * - build new patch: newChange.rev = pendingRev; baseRev = committedRev
198
+ */
199
+ async getLastRevs(docId) {
200
+ const [tx, committedChanges, pendingChanges] = await this.transaction(['committedChanges', 'pendingChanges'], 'readonly');
201
+ const [lastCommitted, lastPending] = await Promise.all([
202
+ committedChanges.getLastFromCursor([docId, 0], [docId, Infinity]),
203
+ pendingChanges.getLastFromCursor([docId, 0], [docId, Infinity]),
204
+ ]);
205
+ await tx.complete();
206
+ return [lastCommitted?.rev ?? 0, lastPending?.rev ?? lastCommitted?.rev ?? 0];
207
+ }
208
+ }
209
+ class IDBTransactionWrapper {
210
+ constructor(tx) {
211
+ this.tx = tx;
212
+ this.promise = new Promise((resolve, reject) => {
213
+ tx.oncomplete = () => resolve();
214
+ tx.onerror = () => reject(tx.error);
215
+ });
216
+ }
217
+ getStore(name) {
218
+ return new IDBStoreWrapper(this.tx.objectStore(name));
219
+ }
220
+ async complete() {
221
+ return this.promise;
222
+ }
223
+ }
224
+ class IDBStoreWrapper {
225
+ constructor(store) {
226
+ this.store = store;
227
+ }
228
+ createRange(lower, upper) {
229
+ if (lower === undefined && upper === undefined)
230
+ return undefined;
231
+ return IDBKeyRange.bound(lower, upper);
232
+ }
233
+ async getAll(lower, upper, count) {
234
+ return new Promise((resolve, reject) => {
235
+ const request = this.store.getAll(this.createRange(lower, upper), count);
236
+ request.onsuccess = () => resolve(request.result);
237
+ request.onerror = () => reject(request.error);
238
+ });
239
+ }
240
+ async get(key) {
241
+ return new Promise((resolve, reject) => {
242
+ const request = this.store.get(key);
243
+ request.onsuccess = () => resolve(request.result);
244
+ request.onerror = () => reject(request.error);
245
+ });
246
+ }
247
+ async add(value) {
248
+ return new Promise((resolve, reject) => {
249
+ const request = this.store.add(value);
250
+ request.onsuccess = () => resolve(request.result);
251
+ request.onerror = () => reject(request.error);
252
+ });
253
+ }
254
+ async delete(keyOrLower, upper) {
255
+ return new Promise((resolve, reject) => {
256
+ const key = upper === undefined ? keyOrLower : this.createRange(keyOrLower, upper);
257
+ const request = this.store.delete(key);
258
+ request.onsuccess = () => resolve();
259
+ request.onerror = () => reject(request.error);
260
+ });
261
+ }
262
+ async count(lower, upper) {
263
+ return new Promise((resolve, reject) => {
264
+ const request = this.store.count(this.createRange(lower, upper));
265
+ request.onsuccess = () => resolve(request.result);
266
+ request.onerror = () => reject(request.error);
267
+ });
268
+ }
269
+ async getFirstFromCursor(lower, upper) {
270
+ return new Promise((resolve, reject) => {
271
+ const request = this.store.openCursor(this.createRange(lower, upper));
272
+ request.onsuccess = () => resolve(request.result?.value);
273
+ request.onerror = () => reject(request.error);
274
+ });
275
+ }
276
+ async getLastFromCursor(lower, upper) {
277
+ return new Promise((resolve, reject) => {
278
+ const request = this.store.openCursor(this.createRange(lower, upper), 'prev');
279
+ request.onsuccess = () => resolve(request.result?.value);
280
+ request.onerror = () => reject(request.error);
281
+ });
282
+ }
283
+ }
@@ -0,0 +1,2 @@
1
+ export { IndexedDBStore } from './IndexedDBStore';
2
+ export type { Change, PatchSnapshot, VersionMetadata } from '../types';
@@ -0,0 +1 @@
1
+ export { IndexedDBStore } from './IndexedDBStore';
@@ -0,0 +1,40 @@
1
+ import type { Branch, BranchingStoreBackend, BranchStatus, Change } from '../types.js';
2
+ import type { PatchServer } from './PatchServer.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 BranchManager {
9
+ private readonly store;
10
+ private readonly patchServer;
11
+ constructor(store: BranchingStoreBackend, patchServer: PatchServer);
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 BranchManager {
8
+ constructor(store, patchServer) {
9
+ this.store = store;
10
+ this.patchServer = patchServer;
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.patchServer._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.patchServer.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,63 @@
1
+ import type { Change, PatchStoreBackend, 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 HistoryManager {
7
+ private readonly docId;
8
+ private readonly store;
9
+ constructor(docId: string, store: PatchStoreBackend);
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?: {
16
+ limit?: number;
17
+ reverse?: boolean;
18
+ origin?: 'online' | 'offline' | 'branch';
19
+ groupId?: string;
20
+ startDateAfter?: number;
21
+ endDateBefore?: number;
22
+ }): Promise<VersionMetadata[]>;
23
+ /**
24
+ * Loads the metadata for a specific version by its ID.
25
+ * @param versionId The unique ID of the version.
26
+ * @returns The VersionMetadata object or null if not found.
27
+ */
28
+ getVersionMetadata(versionId: string): Promise<VersionMetadata | null>;
29
+ /**
30
+ * Loads the full document state snapshot for a specific version by its ID.
31
+ * @param versionId - The unique ID of the version.
32
+ * @returns The document state at that version.
33
+ * @throws Error if the version ID is not found or state loading fails.
34
+ */
35
+ getStateAtVersion(versionId: string): Promise<any>;
36
+ /**
37
+ * Loads the list of original client changes that were included in a specific version.
38
+ * Useful for replaying/scrubbing through the operations within an offline or online session.
39
+ * @param versionId - The unique ID of the version.
40
+ * @returns An array of Change objects.
41
+ * @throws Error if the version ID is not found or change loading fails.
42
+ */
43
+ getChangesForVersion(versionId: string): Promise<Change[]>;
44
+ /**
45
+ * Convenience method to get the state of the parent version.
46
+ * Useful for client-side scrubbing, providing the state *before* a version's changes were applied.
47
+ * @param versionId - The ID of the version whose parent state is needed.
48
+ * @returns The state of the parent version, or undefined if it's the root version or parent not found.
49
+ */
50
+ getParentState(versionId: string): Promise<any | undefined>;
51
+ /**
52
+ * Lists committed server changes for the document, typically used for server-side processing
53
+ * or deep history analysis based on raw revisions.
54
+ * @param options - Options like start/end revision, limit.
55
+ * @returns The list of committed Change objects.
56
+ */
57
+ listServerChanges(options?: {
58
+ limit?: number;
59
+ startAfterRev?: number;
60
+ endBeforeRev?: number;
61
+ reverse?: boolean;
62
+ }): Promise<Change[]>;
63
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Helps retrieve historical information (versions, changes) for a document
3
+ * using the new versioning model based on IDs and metadata.
4
+ */
5
+ export class HistoryManager {
6
+ constructor(docId, store) {
7
+ this.docId = docId;
8
+ this.store = store;
9
+ }
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
+ async listVersions(options = {}) {
16
+ return await this.store.listVersions(this.docId, options);
17
+ }
18
+ /**
19
+ * Loads the metadata for a specific version by its ID.
20
+ * @param versionId The unique ID of the version.
21
+ * @returns The VersionMetadata object or null if not found.
22
+ */
23
+ async getVersionMetadata(versionId) {
24
+ try {
25
+ return await this.store.loadVersionMetadata(this.docId, versionId);
26
+ }
27
+ catch (error) {
28
+ console.warn(`Metadata for version ${versionId} not found for doc ${this.docId}.`, error);
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * Loads the full document state snapshot for a specific version by its ID.
34
+ * @param versionId - The unique ID of the version.
35
+ * @returns The document state at that version.
36
+ * @throws Error if the version ID is not found or state loading fails.
37
+ */
38
+ async getStateAtVersion(versionId) {
39
+ try {
40
+ return await this.store.loadVersionState(this.docId, versionId);
41
+ }
42
+ catch (error) {
43
+ console.error(`Failed to load state for version ${versionId} of doc ${this.docId}.`, error);
44
+ throw new Error(`Could not load state for version ${versionId}.`);
45
+ }
46
+ }
47
+ /**
48
+ * Loads the list of original client changes that were included in a specific version.
49
+ * Useful for replaying/scrubbing through the operations within an offline or online session.
50
+ * @param versionId - The unique ID of the version.
51
+ * @returns An array of Change objects.
52
+ * @throws Error if the version ID is not found or change loading fails.
53
+ */
54
+ async getChangesForVersion(versionId) {
55
+ try {
56
+ return await this.store.loadVersionChanges(this.docId, versionId);
57
+ }
58
+ catch (error) {
59
+ console.error(`Failed to load changes for version ${versionId} of doc ${this.docId}.`, error);
60
+ throw new Error(`Could not load changes for version ${versionId}.`);
61
+ }
62
+ }
63
+ /**
64
+ * Convenience method to get the state of the parent version.
65
+ * Useful for client-side scrubbing, providing the state *before* a version's changes were applied.
66
+ * @param versionId - The ID of the version whose parent state is needed.
67
+ * @returns The state of the parent version, or undefined if it's the root version or parent not found.
68
+ */
69
+ async getParentState(versionId) {
70
+ const metadata = await this.getVersionMetadata(versionId);
71
+ if (!metadata?.parentId) {
72
+ return undefined; // Root version or metadata fetch failed
73
+ }
74
+ try {
75
+ return await this.getStateAtVersion(metadata.parentId);
76
+ }
77
+ catch (error) {
78
+ console.warn(`Could not load parent state for version ${versionId} (parent ID: ${metadata.parentId}).`, error);
79
+ return undefined; // Parent exists but state load failed
80
+ }
81
+ }
82
+ /**
83
+ * Lists committed server changes for the document, typically used for server-side processing
84
+ * or deep history analysis based on raw revisions.
85
+ * @param options - Options like start/end revision, limit.
86
+ * @returns The list of committed Change objects.
87
+ */
88
+ async listServerChanges(options = {}) {
89
+ // Added return type
90
+ return await this.store.listChanges(this.docId, options);
91
+ }
92
+ }