@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.
- package/README.md +632 -0
- package/dist/client/PatchDoc.d.ts +85 -0
- package/dist/client/PatchDoc.js +299 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/event-signal.d.ts +31 -0
- package/dist/event-signal.js +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/json-patch/JSONPatch.d.ts +126 -0
- package/dist/json-patch/JSONPatch.js +221 -0
- package/dist/json-patch/applyPatch.d.ts +11 -0
- package/dist/json-patch/applyPatch.js +37 -0
- package/dist/json-patch/composePatch.d.ts +2 -0
- package/dist/json-patch/composePatch.js +38 -0
- package/dist/json-patch/createJSONPatch.d.ts +35 -0
- package/dist/json-patch/createJSONPatch.js +41 -0
- package/dist/json-patch/index.d.ts +9 -0
- package/dist/json-patch/index.js +8 -0
- package/dist/json-patch/invertPatch.d.ts +2 -0
- package/dist/json-patch/invertPatch.js +31 -0
- package/dist/json-patch/ops/add.d.ts +2 -0
- package/dist/json-patch/ops/add.js +52 -0
- package/dist/json-patch/ops/bitmask.d.ts +14 -0
- package/dist/json-patch/ops/bitmask.js +48 -0
- package/dist/json-patch/ops/copy.d.ts +2 -0
- package/dist/json-patch/ops/copy.js +34 -0
- package/dist/json-patch/ops/increment.d.ts +5 -0
- package/dist/json-patch/ops/increment.js +21 -0
- package/dist/json-patch/ops/index.d.ts +22 -0
- package/dist/json-patch/ops/index.js +25 -0
- package/dist/json-patch/ops/move.d.ts +2 -0
- package/dist/json-patch/ops/move.js +211 -0
- package/dist/json-patch/ops/remove.d.ts +2 -0
- package/dist/json-patch/ops/remove.js +31 -0
- package/dist/json-patch/ops/replace.d.ts +2 -0
- package/dist/json-patch/ops/replace.js +44 -0
- package/dist/json-patch/ops/test.d.ts +2 -0
- package/dist/json-patch/ops/test.js +22 -0
- package/dist/json-patch/ops/text.d.ts +2 -0
- package/dist/json-patch/ops/text.js +57 -0
- package/dist/json-patch/patchProxy.d.ts +41 -0
- package/dist/json-patch/patchProxy.js +125 -0
- package/dist/json-patch/state.d.ts +2 -0
- package/dist/json-patch/state.js +8 -0
- package/dist/json-patch/transformPatch.d.ts +19 -0
- package/dist/json-patch/transformPatch.js +37 -0
- package/dist/json-patch/types.d.ts +52 -0
- package/dist/json-patch/types.js +1 -0
- package/dist/json-patch/utils/deepEqual.d.ts +1 -0
- package/dist/json-patch/utils/deepEqual.js +33 -0
- package/dist/json-patch/utils/exit.d.ts +2 -0
- package/dist/json-patch/utils/exit.js +4 -0
- package/dist/json-patch/utils/get.d.ts +2 -0
- package/dist/json-patch/utils/get.js +6 -0
- package/dist/json-patch/utils/getOpData.d.ts +2 -0
- package/dist/json-patch/utils/getOpData.js +10 -0
- package/dist/json-patch/utils/getType.d.ts +3 -0
- package/dist/json-patch/utils/getType.js +6 -0
- package/dist/json-patch/utils/index.d.ts +14 -0
- package/dist/json-patch/utils/index.js +14 -0
- package/dist/json-patch/utils/log.d.ts +2 -0
- package/dist/json-patch/utils/log.js +7 -0
- package/dist/json-patch/utils/ops.d.ts +14 -0
- package/dist/json-patch/utils/ops.js +103 -0
- package/dist/json-patch/utils/paths.d.ts +9 -0
- package/dist/json-patch/utils/paths.js +53 -0
- package/dist/json-patch/utils/pluck.d.ts +5 -0
- package/dist/json-patch/utils/pluck.js +30 -0
- package/dist/json-patch/utils/shallowCopy.d.ts +1 -0
- package/dist/json-patch/utils/shallowCopy.js +20 -0
- package/dist/json-patch/utils/softWrites.d.ts +7 -0
- package/dist/json-patch/utils/softWrites.js +18 -0
- package/dist/json-patch/utils/toArrayIndex.d.ts +1 -0
- package/dist/json-patch/utils/toArrayIndex.js +12 -0
- package/dist/json-patch/utils/toKeys.d.ts +1 -0
- package/dist/json-patch/utils/toKeys.js +15 -0
- package/dist/json-patch/utils/updateArrayIndexes.d.ts +5 -0
- package/dist/json-patch/utils/updateArrayIndexes.js +38 -0
- package/dist/json-patch/utils/updateArrayPath.d.ts +5 -0
- package/dist/json-patch/utils/updateArrayPath.js +45 -0
- package/dist/net/AbstractTransport.d.ts +47 -0
- package/dist/net/AbstractTransport.js +37 -0
- package/dist/net/PatchesOfflineFirst.d.ts +3 -0
- package/dist/net/PatchesOfflineFirst.js +3 -0
- package/dist/net/PatchesRealtime.d.ts +90 -0
- package/dist/net/PatchesRealtime.js +257 -0
- package/dist/net/index.d.ts +9 -0
- package/dist/net/index.js +8 -0
- package/dist/net/protocol/JSONRPCClient.d.ts +55 -0
- package/dist/net/protocol/JSONRPCClient.js +106 -0
- package/dist/net/protocol/types.d.ts +142 -0
- package/dist/net/protocol/types.js +1 -0
- package/dist/net/webrtc/WebRTCAwareness.d.ts +81 -0
- package/dist/net/webrtc/WebRTCAwareness.js +119 -0
- package/dist/net/webrtc/WebRTCTransport.d.ts +80 -0
- package/dist/net/webrtc/WebRTCTransport.js +157 -0
- package/dist/net/websocket/PatchesWebSocket.d.ts +107 -0
- package/dist/net/websocket/PatchesWebSocket.js +144 -0
- package/dist/net/websocket/SignalingService.d.ts +91 -0
- package/dist/net/websocket/SignalingService.js +140 -0
- package/dist/net/websocket/WebSocketTransport.d.ts +47 -0
- package/dist/net/websocket/WebSocketTransport.js +138 -0
- package/dist/persist/IndexedDBStore.d.ts +72 -0
- package/dist/persist/IndexedDBStore.js +283 -0
- package/dist/persist/index.d.ts +2 -0
- package/dist/persist/index.js +1 -0
- package/dist/server/BranchManager.d.ts +40 -0
- package/dist/server/BranchManager.js +138 -0
- package/dist/server/HistoryManager.d.ts +63 -0
- package/dist/server/HistoryManager.js +92 -0
- package/dist/server/PatchServer.d.ts +129 -0
- package/dist/server/PatchServer.js +358 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +3 -0
- package/dist/types.d.ts +158 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +36 -0
- package/dist/utils.js +83 -0
- 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 @@
|
|
|
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
|
+
}
|