@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,129 @@
|
|
|
1
|
+
import type { Change, ListVersionsOptions, PatchSnapshot, PatchState, PatchStoreBackend, VersionMetadata } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration options for the PatchServer.
|
|
4
|
+
*/
|
|
5
|
+
export interface PatchServerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The maximum time difference in minutes between consecutive changes
|
|
8
|
+
* to be considered part of the same editing session for versioning.
|
|
9
|
+
* Defaults to 30 minutes.
|
|
10
|
+
*/
|
|
11
|
+
sessionTimeoutMinutes?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Handles the server-side Operational Transformation (OT) logic,
|
|
15
|
+
* coordinating batches of changes, managing versioning based on sessions (including offline),
|
|
16
|
+
* and persisting data using a backend store.
|
|
17
|
+
*/
|
|
18
|
+
export declare class PatchServer {
|
|
19
|
+
private readonly store;
|
|
20
|
+
private readonly sessionTimeoutMillis;
|
|
21
|
+
constructor(store: PatchStoreBackend, options?: PatchServerOptions);
|
|
22
|
+
/**
|
|
23
|
+
* Subscribes the connected client to one or more documents.
|
|
24
|
+
* @param ids Document ID(s) to subscribe to.
|
|
25
|
+
* @returns A list of document IDs the client is now successfully subscribed to.
|
|
26
|
+
*/
|
|
27
|
+
subscribe(clientId: string, ids: string | string[]): Promise<string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Unsubscribes the connected client from one or more documents.
|
|
30
|
+
* @param ids Document ID(s) to unsubscribe from.
|
|
31
|
+
*/
|
|
32
|
+
unsubscribe(clientId: string, ids: string | string[]): Promise<string[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Get the latest version of a document and changes since the last version.
|
|
35
|
+
* @param docId - The ID of the document.
|
|
36
|
+
* @returns The latest version of the document and changes since the last version.
|
|
37
|
+
*/
|
|
38
|
+
getDoc(docId: string, atRev?: number): Promise<PatchSnapshot>;
|
|
39
|
+
/**
|
|
40
|
+
* Get changes that occurred after a specific revision.
|
|
41
|
+
* @param docId - The ID of the document.
|
|
42
|
+
* @param rev - The revision number.
|
|
43
|
+
* @returns The changes that occurred after the specified revision.
|
|
44
|
+
*/
|
|
45
|
+
getChangesSince(docId: string, rev: number): Promise<Change[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Commits a set of changes to a document, applying operational transformation as needed.
|
|
48
|
+
* @param docId - The ID of the document.
|
|
49
|
+
* @param changes - The changes to commit.
|
|
50
|
+
* @returns A tuple of [committedChanges, transformedChanges] where:
|
|
51
|
+
* - committedChanges: Changes that were already committed to the server after the client's base revision
|
|
52
|
+
* - transformedChanges: The client's changes after being transformed against concurrent changes
|
|
53
|
+
*/
|
|
54
|
+
commitChanges(docId: string, changes: Change[]): Promise<[Change[], Change[]]>;
|
|
55
|
+
/**
|
|
56
|
+
* Handles offline/large batch versioning logic for multi-batch uploads.
|
|
57
|
+
* Groups changes into sessions, merges with previous batch if needed, and creates/extends versions.
|
|
58
|
+
* @param docId Document ID
|
|
59
|
+
* @param changes The incoming changes (all with the same batchId)
|
|
60
|
+
* @param baseRev The base revision for the batch
|
|
61
|
+
* @param batchId The batch identifier
|
|
62
|
+
* @returns The collapsed changes for transformation
|
|
63
|
+
*/
|
|
64
|
+
private handleOfflineBatches;
|
|
65
|
+
/**
|
|
66
|
+
* Deletes a document.
|
|
67
|
+
* @param docId The document ID.
|
|
68
|
+
*/
|
|
69
|
+
deleteDoc(docId: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Create a new named version snapshot of a document's current state.
|
|
72
|
+
* @param docId The document ID.
|
|
73
|
+
* @param name The name of the version.
|
|
74
|
+
* @returns The ID of the created version.
|
|
75
|
+
*/
|
|
76
|
+
createVersion(docId: string, name: string): Promise<string>;
|
|
77
|
+
/**
|
|
78
|
+
* Lists version metadata for a document, supporting various filters.
|
|
79
|
+
* @param docId The document ID.
|
|
80
|
+
* @param options Filtering and sorting options.
|
|
81
|
+
* @returns A list of version metadata objects.
|
|
82
|
+
*/
|
|
83
|
+
listVersions(docId: string, options: ListVersionsOptions): Promise<VersionMetadata[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Get the state snapshot for a specific version ID.
|
|
86
|
+
* @param docId The document ID.
|
|
87
|
+
* @param versionId The ID of the version.
|
|
88
|
+
* @returns The state snapshot for the specified version.
|
|
89
|
+
*/
|
|
90
|
+
getVersionState(docId: string, versionId: string): Promise<PatchState>;
|
|
91
|
+
/**
|
|
92
|
+
* Get the original Change objects associated with a specific version ID.
|
|
93
|
+
* @param docId The document ID.
|
|
94
|
+
* @param versionId The ID of the version.
|
|
95
|
+
* @returns The original Change objects for the specified version.
|
|
96
|
+
*/
|
|
97
|
+
getVersionChanges(docId: string, versionId: string): Promise<Change[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Update the name of a specific version.
|
|
100
|
+
* @param docId The document ID.
|
|
101
|
+
* @param versionId The ID of the version.
|
|
102
|
+
* @param name The new name for the version.
|
|
103
|
+
*/
|
|
104
|
+
updateVersion(docId: string, versionId: string, name: string): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Retrieves the document state of the version before the given revision and changes after up to that revision or all
|
|
107
|
+
* changes since that version.
|
|
108
|
+
* @param docId The document ID.
|
|
109
|
+
* @param rev The revision number. If not provided, the latest state, its revision, and all changes since are returned.
|
|
110
|
+
* @returns The document state at the last version before the revision, its revision number, and all changes up to the specified revision (or all changes if no revision is provided).
|
|
111
|
+
*/
|
|
112
|
+
_getSnapshotAtRevision(docId: string, rev?: number): Promise<PatchSnapshot>;
|
|
113
|
+
/**
|
|
114
|
+
* Gets the state at a specific revision.
|
|
115
|
+
* @param docId The document ID.
|
|
116
|
+
* @param rev The revision number. If not provided, the latest state and its revision is returned.
|
|
117
|
+
* @returns The state at the specified revision *and* its revision number.
|
|
118
|
+
*/
|
|
119
|
+
_getStateAtRevision(docId: string, rev?: number): Promise<PatchState>;
|
|
120
|
+
/**
|
|
121
|
+
* Creates a new version snapshot of a document's current state.
|
|
122
|
+
* @param docId The document ID.
|
|
123
|
+
* @param state The document state at the time of the version.
|
|
124
|
+
* @param changes The changes since the last version that created the state (the last change's rev is the state's rev and will be the version's rev).
|
|
125
|
+
* @param name The name of the version.
|
|
126
|
+
* @returns The ID of the created version.
|
|
127
|
+
*/
|
|
128
|
+
_createVersion(docId: string, state: any, changes: Change[], name?: string): Promise<VersionMetadata | undefined>;
|
|
129
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { createId } from 'crypto-id';
|
|
2
|
+
import { applyPatch } from '../json-patch/applyPatch.js';
|
|
3
|
+
import { transformPatch } from '../json-patch/transformPatch.js';
|
|
4
|
+
import { applyChanges } from '../utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Handles the server-side Operational Transformation (OT) logic,
|
|
7
|
+
* coordinating batches of changes, managing versioning based on sessions (including offline),
|
|
8
|
+
* and persisting data using a backend store.
|
|
9
|
+
*/
|
|
10
|
+
export class PatchServer {
|
|
11
|
+
constructor(store, options = {}) {
|
|
12
|
+
this.store = store;
|
|
13
|
+
this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1000;
|
|
14
|
+
}
|
|
15
|
+
// --- Patches API Methods ---
|
|
16
|
+
// === Subscription Operations ===
|
|
17
|
+
/**
|
|
18
|
+
* Subscribes the connected client to one or more documents.
|
|
19
|
+
* @param ids Document ID(s) to subscribe to.
|
|
20
|
+
* @returns A list of document IDs the client is now successfully subscribed to.
|
|
21
|
+
*/
|
|
22
|
+
subscribe(clientId, ids) {
|
|
23
|
+
return this.store.addSubscription(clientId, Array.isArray(ids) ? ids : [ids]);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Unsubscribes the connected client from one or more documents.
|
|
27
|
+
* @param ids Document ID(s) to unsubscribe from.
|
|
28
|
+
*/
|
|
29
|
+
unsubscribe(clientId, ids) {
|
|
30
|
+
return this.store.removeSubscription(clientId, Array.isArray(ids) ? ids : [ids]);
|
|
31
|
+
}
|
|
32
|
+
// === Document Operations ===
|
|
33
|
+
/**
|
|
34
|
+
* Get the latest version of a document and changes since the last version.
|
|
35
|
+
* @param docId - The ID of the document.
|
|
36
|
+
* @returns The latest version of the document and changes since the last version.
|
|
37
|
+
*/
|
|
38
|
+
async getDoc(docId, atRev) {
|
|
39
|
+
return this._getSnapshotAtRevision(docId, atRev);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get changes that occurred after a specific revision.
|
|
43
|
+
* @param docId - The ID of the document.
|
|
44
|
+
* @param rev - The revision number.
|
|
45
|
+
* @returns The changes that occurred after the specified revision.
|
|
46
|
+
*/
|
|
47
|
+
getChangesSince(docId, rev) {
|
|
48
|
+
return this.store.listChanges(docId, { startAfter: rev });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Commits a set of changes to a document, applying operational transformation as needed.
|
|
52
|
+
* @param docId - The ID of the document.
|
|
53
|
+
* @param changes - The changes to commit.
|
|
54
|
+
* @returns A tuple of [committedChanges, transformedChanges] where:
|
|
55
|
+
* - committedChanges: Changes that were already committed to the server after the client's base revision
|
|
56
|
+
* - transformedChanges: The client's changes after being transformed against concurrent changes
|
|
57
|
+
*/
|
|
58
|
+
async commitChanges(docId, changes) {
|
|
59
|
+
if (changes.length === 0) {
|
|
60
|
+
return [[], []];
|
|
61
|
+
}
|
|
62
|
+
// Assume all changes share the same baseRev. Client ensures this.
|
|
63
|
+
const batchId = changes[0].batchId;
|
|
64
|
+
const baseRev = changes[0].baseRev;
|
|
65
|
+
if (baseRev === undefined) {
|
|
66
|
+
throw new Error(`Client changes must include baseRev for doc ${docId}.`);
|
|
67
|
+
}
|
|
68
|
+
// Add check for inconsistent baseRev within the batch if needed
|
|
69
|
+
if (changes.some(c => c.baseRev !== baseRev)) {
|
|
70
|
+
throw new Error(`Client changes must have consistent baseRev for doc ${docId}.`);
|
|
71
|
+
}
|
|
72
|
+
// 1. Load server state details (assuming store methods exist)
|
|
73
|
+
let { state: currentState, rev: currentRev, changes: currentChanges } = await this._getSnapshotAtRevision(docId);
|
|
74
|
+
currentState = applyChanges(currentState, currentChanges);
|
|
75
|
+
currentRev = currentChanges.at(-1)?.rev ?? currentRev;
|
|
76
|
+
// Basic validation
|
|
77
|
+
if (baseRev > currentRev) {
|
|
78
|
+
throw new Error(`Client baseRev (${baseRev}) is ahead of server revision (${currentRev}) for doc ${docId}. Client needs to reload the document.`);
|
|
79
|
+
}
|
|
80
|
+
const partOfInitialBatch = batchId && changes[0].rev > 1;
|
|
81
|
+
if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch) {
|
|
82
|
+
throw new Error(`Client baseRev is 0 but server has already been created for doc ${docId}. Client needs to load the existing document.`);
|
|
83
|
+
}
|
|
84
|
+
// Ensure all new changes' `created` field is in the past, that each `rev` is correct, and that `baseRev` is set
|
|
85
|
+
let rev = baseRev + 1;
|
|
86
|
+
changes.forEach(c => {
|
|
87
|
+
c.created = Math.min(c.created, Date.now());
|
|
88
|
+
c.rev = rev++;
|
|
89
|
+
c.baseRev = baseRev;
|
|
90
|
+
});
|
|
91
|
+
// 2. Check if we need to create a new version - if the last change was created more than a session ago
|
|
92
|
+
const lastChange = currentChanges[currentChanges.length - 1];
|
|
93
|
+
if (lastChange && lastChange.created < Date.now() - this.sessionTimeoutMillis) {
|
|
94
|
+
await this._createVersion(docId, currentState, currentChanges);
|
|
95
|
+
}
|
|
96
|
+
// 3. Load committed changes *after* the client's baseRev for transformation and idempotency checks
|
|
97
|
+
let committedChanges = await this.store.listChanges(docId, {
|
|
98
|
+
startAfter: baseRev,
|
|
99
|
+
withoutBatchId: batchId,
|
|
100
|
+
});
|
|
101
|
+
const commitedIds = new Set(committedChanges.map(c => c.id));
|
|
102
|
+
changes = changes.filter(c => !commitedIds.has(c.id));
|
|
103
|
+
// If all incoming changes were already committed, return the committed changes found
|
|
104
|
+
if (changes.length === 0) {
|
|
105
|
+
return [committedChanges, []];
|
|
106
|
+
}
|
|
107
|
+
// 4. Handle offline-session versioning:
|
|
108
|
+
// - batchId present (multi-batch uploads)
|
|
109
|
+
// - or the first change is older than the session timeout (single-batch offline)
|
|
110
|
+
const isOfflineTimestamp = changes[0].created < Date.now() - this.sessionTimeoutMillis;
|
|
111
|
+
if (isOfflineTimestamp || batchId) {
|
|
112
|
+
changes = await this.handleOfflineBatches(docId, changes, baseRev, batchId);
|
|
113
|
+
}
|
|
114
|
+
// 5. Transform the *entire batch* of incoming (and potentially collapsed offline) changes
|
|
115
|
+
// against committed changes that happened *after* the client's baseRev.
|
|
116
|
+
// The state used for transformation should be the server state *at the client's baseRev*.
|
|
117
|
+
let stateAtBaseRev = (await this._getStateAtRevision(docId, baseRev)).state;
|
|
118
|
+
const committedOps = committedChanges.flatMap(c => c.ops);
|
|
119
|
+
// Apply transformation based on state at baseRev
|
|
120
|
+
const transformedChanges = changes
|
|
121
|
+
.map(change => {
|
|
122
|
+
// Transform the incoming change's ops against the ops committed since baseRev
|
|
123
|
+
const transformedOps = transformPatch(stateAtBaseRev, committedOps, change.ops);
|
|
124
|
+
if (transformedOps.length === 0) {
|
|
125
|
+
return null; // Change is obsolete after transformation
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
stateAtBaseRev = applyPatch(stateAtBaseRev, change.ops, { strict: true });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
console.error(`Error applying change ${change.id} to state:`, error);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Return a new change object with transformed ops and original metadata
|
|
135
|
+
return { ...change, ops: transformedOps };
|
|
136
|
+
})
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
// Persist the newly transformed changes
|
|
139
|
+
if (transformedChanges.length > 0) {
|
|
140
|
+
await this.store.saveChanges(docId, transformedChanges);
|
|
141
|
+
}
|
|
142
|
+
// Return committed changes and newly transformed changes separately
|
|
143
|
+
return [committedChanges, transformedChanges];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Handles offline/large batch versioning logic for multi-batch uploads.
|
|
147
|
+
* Groups changes into sessions, merges with previous batch if needed, and creates/extends versions.
|
|
148
|
+
* @param docId Document ID
|
|
149
|
+
* @param changes The incoming changes (all with the same batchId)
|
|
150
|
+
* @param baseRev The base revision for the batch
|
|
151
|
+
* @param batchId The batch identifier
|
|
152
|
+
* @returns The collapsed changes for transformation
|
|
153
|
+
*/
|
|
154
|
+
async handleOfflineBatches(docId, changes, baseRev, batchId) {
|
|
155
|
+
// Use batchId as groupId for multi-batch uploads; default offline sessions have no groupId
|
|
156
|
+
const groupId = batchId ?? createId();
|
|
157
|
+
// Find the last version for this groupId (if any)
|
|
158
|
+
const [lastVersion] = await this.store.listVersions(docId, {
|
|
159
|
+
groupId,
|
|
160
|
+
reverse: true,
|
|
161
|
+
limit: 1,
|
|
162
|
+
});
|
|
163
|
+
let offlineBaseState;
|
|
164
|
+
let parentId;
|
|
165
|
+
if (lastVersion) {
|
|
166
|
+
// Continue from the last version's state
|
|
167
|
+
// loadVersionState returns a PatchState ({state, rev}); extract the .state
|
|
168
|
+
const vs = await this.store.loadVersionState(docId, lastVersion.id);
|
|
169
|
+
offlineBaseState = vs.state ?? vs;
|
|
170
|
+
parentId = lastVersion.id;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// First batch for this batchId: start at baseRev
|
|
174
|
+
offlineBaseState = (await this._getStateAtRevision(docId, baseRev)).state;
|
|
175
|
+
}
|
|
176
|
+
let sessionStartIndex = 0;
|
|
177
|
+
for (let i = 1; i <= changes.length; i++) {
|
|
178
|
+
const isLastChange = i === changes.length;
|
|
179
|
+
const timeDiff = isLastChange ? Infinity : changes[i].created - changes[i - 1].created;
|
|
180
|
+
// Session ends if timeout exceeded OR it's the last change in the batch
|
|
181
|
+
if (timeDiff > this.sessionTimeoutMillis || isLastChange) {
|
|
182
|
+
const sessionChanges = changes.slice(sessionStartIndex, i);
|
|
183
|
+
if (sessionChanges.length > 0) {
|
|
184
|
+
// Check if this is a continuation of the previous session (merge/extend)
|
|
185
|
+
const isContinuation = !!lastVersion && sessionChanges[0].created - lastVersion.endDate <= this.sessionTimeoutMillis;
|
|
186
|
+
if (isContinuation) {
|
|
187
|
+
// Merge/extend the existing version
|
|
188
|
+
const mergedState = applyChanges(offlineBaseState, sessionChanges);
|
|
189
|
+
await this.store.saveChanges(docId, sessionChanges);
|
|
190
|
+
await this.store.updateVersion(docId, lastVersion.id, {}); // metadata already updated above
|
|
191
|
+
offlineBaseState = mergedState;
|
|
192
|
+
parentId = lastVersion.parentId;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Create a new version for this session
|
|
196
|
+
offlineBaseState = applyChanges(offlineBaseState, sessionChanges);
|
|
197
|
+
const versionId = createId();
|
|
198
|
+
const sessionMetadata = {
|
|
199
|
+
id: versionId,
|
|
200
|
+
parentId,
|
|
201
|
+
groupId,
|
|
202
|
+
origin: 'offline',
|
|
203
|
+
startDate: sessionChanges[0].created,
|
|
204
|
+
endDate: sessionChanges[sessionChanges.length - 1].created,
|
|
205
|
+
rev: sessionChanges[sessionChanges.length - 1].rev,
|
|
206
|
+
baseRev,
|
|
207
|
+
};
|
|
208
|
+
await this.store.createVersion(docId, sessionMetadata, offlineBaseState, sessionChanges);
|
|
209
|
+
parentId = versionId;
|
|
210
|
+
}
|
|
211
|
+
sessionStartIndex = i;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Collapse all changes into one for transformation
|
|
216
|
+
return [
|
|
217
|
+
changes.reduce((firstChange, nextChange) => {
|
|
218
|
+
firstChange.ops = [...firstChange.ops, ...nextChange.ops];
|
|
219
|
+
return firstChange;
|
|
220
|
+
}),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Deletes a document.
|
|
225
|
+
* @param docId The document ID.
|
|
226
|
+
*/
|
|
227
|
+
deleteDoc(docId) {
|
|
228
|
+
return this.store.deleteDoc(docId);
|
|
229
|
+
}
|
|
230
|
+
// === Version Operations ===
|
|
231
|
+
/**
|
|
232
|
+
* Create a new named version snapshot of a document's current state.
|
|
233
|
+
* @param docId The document ID.
|
|
234
|
+
* @param name The name of the version.
|
|
235
|
+
* @returns The ID of the created version.
|
|
236
|
+
*/
|
|
237
|
+
async createVersion(docId, name) {
|
|
238
|
+
let { state, changes } = await this._getSnapshotAtRevision(docId);
|
|
239
|
+
state = applyChanges(state, changes);
|
|
240
|
+
const version = await this._createVersion(docId, state, changes, name);
|
|
241
|
+
if (!version) {
|
|
242
|
+
throw new Error(`No changes to create a version for doc ${docId}.`);
|
|
243
|
+
}
|
|
244
|
+
return version.id;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Lists version metadata for a document, supporting various filters.
|
|
248
|
+
* @param docId The document ID.
|
|
249
|
+
* @param options Filtering and sorting options.
|
|
250
|
+
* @returns A list of version metadata objects.
|
|
251
|
+
*/
|
|
252
|
+
listVersions(docId, options) {
|
|
253
|
+
if (!options.orderBy) {
|
|
254
|
+
options.orderBy = 'startDate';
|
|
255
|
+
}
|
|
256
|
+
return this.store.listVersions(docId, options);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Get the state snapshot for a specific version ID.
|
|
260
|
+
* @param docId The document ID.
|
|
261
|
+
* @param versionId The ID of the version.
|
|
262
|
+
* @returns The state snapshot for the specified version.
|
|
263
|
+
*/
|
|
264
|
+
getVersionState(docId, versionId) {
|
|
265
|
+
return this.store.loadVersionState(docId, versionId);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get the original Change objects associated with a specific version ID.
|
|
269
|
+
* @param docId The document ID.
|
|
270
|
+
* @param versionId The ID of the version.
|
|
271
|
+
* @returns The original Change objects for the specified version.
|
|
272
|
+
*/
|
|
273
|
+
getVersionChanges(docId, versionId) {
|
|
274
|
+
return this.store.loadVersionChanges(docId, versionId);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Update the name of a specific version.
|
|
278
|
+
* @param docId The document ID.
|
|
279
|
+
* @param versionId The ID of the version.
|
|
280
|
+
* @param name The new name for the version.
|
|
281
|
+
*/
|
|
282
|
+
updateVersion(docId, versionId, name) {
|
|
283
|
+
return this.store.updateVersion(docId, versionId, { name });
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Retrieves the document state of the version before the given revision and changes after up to that revision or all
|
|
287
|
+
* changes since that version.
|
|
288
|
+
* @param docId The document ID.
|
|
289
|
+
* @param rev The revision number. If not provided, the latest state, its revision, and all changes since are returned.
|
|
290
|
+
* @returns The document state at the last version before the revision, its revision number, and all changes up to the specified revision (or all changes if no revision is provided).
|
|
291
|
+
*/
|
|
292
|
+
async _getSnapshotAtRevision(docId, rev) {
|
|
293
|
+
const versions = await this.store.listVersions(docId, {
|
|
294
|
+
limit: 1,
|
|
295
|
+
reverse: true,
|
|
296
|
+
startAfter: rev ? rev + 1 : undefined,
|
|
297
|
+
origin: 'main',
|
|
298
|
+
orderBy: 'rev',
|
|
299
|
+
});
|
|
300
|
+
const latestMainVersion = versions[0];
|
|
301
|
+
const versionState = (latestMainVersion && (await this.store.loadVersionState(docId, latestMainVersion.id))) || null;
|
|
302
|
+
const versionRev = latestMainVersion?.rev ?? 0;
|
|
303
|
+
// Get *all* changes since that version up to the target revision (if specified)
|
|
304
|
+
const changesSinceVersion = await this.store.listChanges(docId, {
|
|
305
|
+
startAfter: versionRev,
|
|
306
|
+
endBefore: rev ? rev + 1 : undefined,
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
state: versionState, // State from the base version
|
|
310
|
+
rev: versionRev, // Revision of the base version's state
|
|
311
|
+
changes: changesSinceVersion, // Changes that occurred *after* the base version state
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Gets the state at a specific revision.
|
|
316
|
+
* @param docId The document ID.
|
|
317
|
+
* @param rev The revision number. If not provided, the latest state and its revision is returned.
|
|
318
|
+
* @returns The state at the specified revision *and* its revision number.
|
|
319
|
+
*/
|
|
320
|
+
async _getStateAtRevision(docId, rev) {
|
|
321
|
+
// Note: _getSnapshotAtRevision now returns the state *of the version* and changes *since* it.
|
|
322
|
+
// We need to apply the changes to get the state *at* the target revision.
|
|
323
|
+
const { state: versionState, rev: snapshotRev, changes } = await this._getSnapshotAtRevision(docId, rev);
|
|
324
|
+
return {
|
|
325
|
+
// Ensure null is passed if versionState or versionState.state is null/undefined
|
|
326
|
+
state: applyChanges(versionState?.state ?? null, changes),
|
|
327
|
+
rev: changes.at(-1)?.rev ?? snapshotRev,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Creates a new version snapshot of a document's current state.
|
|
332
|
+
* @param docId The document ID.
|
|
333
|
+
* @param state The document state at the time of the version.
|
|
334
|
+
* @param changes The changes since the last version that created the state (the last change's rev is the state's rev and will be the version's rev).
|
|
335
|
+
* @param name The name of the version.
|
|
336
|
+
* @returns The ID of the created version.
|
|
337
|
+
*/
|
|
338
|
+
async _createVersion(docId, state, changes, name) {
|
|
339
|
+
if (changes.length === 0)
|
|
340
|
+
return;
|
|
341
|
+
const baseRev = changes[0].baseRev;
|
|
342
|
+
if (baseRev === undefined) {
|
|
343
|
+
throw new Error(`Client changes must include baseRev for doc ${docId}.`);
|
|
344
|
+
}
|
|
345
|
+
const versionId = createId();
|
|
346
|
+
const sessionMetadata = {
|
|
347
|
+
id: versionId,
|
|
348
|
+
name,
|
|
349
|
+
origin: 'main',
|
|
350
|
+
startDate: changes[0].created,
|
|
351
|
+
endDate: changes[changes.length - 1].created,
|
|
352
|
+
rev: changes[changes.length - 1].rev,
|
|
353
|
+
baseRev,
|
|
354
|
+
};
|
|
355
|
+
await this.store.createVersion(docId, sessionMetadata, state, changes);
|
|
356
|
+
return sessionMetadata;
|
|
357
|
+
}
|
|
358
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { JSONPatchOp } from './json-patch/types';
|
|
2
|
+
export interface Change {
|
|
3
|
+
/** Unique identifier for the change, generated client-side. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** The patch operations. */
|
|
6
|
+
ops: JSONPatchOp[];
|
|
7
|
+
/** The revision number assigned on the client to the optimistic revision and updated by the server after commit. */
|
|
8
|
+
rev: number;
|
|
9
|
+
/** The server revision this change was based on. Required for client->server changes. */
|
|
10
|
+
baseRev?: number;
|
|
11
|
+
/** Client-side timestamp when the change was created. */
|
|
12
|
+
created: number;
|
|
13
|
+
/** Optional arbitrary metadata associated with the change. */
|
|
14
|
+
metadata?: Record<string, any>;
|
|
15
|
+
/** Optional batch identifier for grouping changes that belong to the same client batch (for multi-batch offline/large edits). */
|
|
16
|
+
batchId?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Represents the state of a document in the OT protocol.
|
|
20
|
+
* @property state - The state of the document.
|
|
21
|
+
* @property rev - The revision number of the state.
|
|
22
|
+
*/
|
|
23
|
+
export interface PatchState<T = any> {
|
|
24
|
+
state: T;
|
|
25
|
+
rev: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Represents a snapshot of a document in the OT protocol.
|
|
29
|
+
* @property state - The state of the document.
|
|
30
|
+
* @property rev - The revision number of the state.
|
|
31
|
+
* @property changes - Any unapplied changes since `rev` that may be applied to the `state` to get the latest state.
|
|
32
|
+
*/
|
|
33
|
+
export interface PatchSnapshot<T = any> extends PatchState<T> {
|
|
34
|
+
changes: Change[];
|
|
35
|
+
}
|
|
36
|
+
/** Status options for a branch */
|
|
37
|
+
export type BranchStatus = 'open' | 'closed' | 'merged' | 'archived' | 'abandoned';
|
|
38
|
+
export interface Branch {
|
|
39
|
+
/** The ID of the branch document. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** The ID of the document this document was branched from. */
|
|
42
|
+
branchedFromId: string;
|
|
43
|
+
/** The revision number on the source document where the branch occurred. */
|
|
44
|
+
branchedRev: number;
|
|
45
|
+
/** Server-side timestamp when the branch record was created. */
|
|
46
|
+
created: number;
|
|
47
|
+
/** Optional user-friendly name for the branch. */
|
|
48
|
+
name?: string;
|
|
49
|
+
/** Current status of the branch. */
|
|
50
|
+
status: BranchStatus;
|
|
51
|
+
/** Optional arbitrary metadata associated with the branch record. */
|
|
52
|
+
metadata?: Record<string, any>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Metadata, state snapshot, and included changes for a specific version.
|
|
56
|
+
*/
|
|
57
|
+
export interface VersionMetadata {
|
|
58
|
+
/** Unique identifier (UUID) for this version record. */
|
|
59
|
+
id: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
/** ID of the parent version in the history DAG. Undefined for root versions and for the first branched version. */
|
|
62
|
+
parentId?: string;
|
|
63
|
+
/** Identifier linking versions from the same offline batch or branch. */
|
|
64
|
+
groupId?: string;
|
|
65
|
+
/** Indicates how the version was created ('main', 'offline', 'branch'). */
|
|
66
|
+
origin: 'main' | 'offline' | 'branch';
|
|
67
|
+
/** User-defined name if origin is 'branch'. */
|
|
68
|
+
branchName?: string;
|
|
69
|
+
/** Timestamp marking the beginning of the changes included in this version (e.g., first change in session). */
|
|
70
|
+
startDate: number;
|
|
71
|
+
/** Timestamp marking the end of the changes included in this version (e.g., last change in session). */
|
|
72
|
+
endDate: number;
|
|
73
|
+
/** The revision number this version was created at. */
|
|
74
|
+
rev: number;
|
|
75
|
+
/** The revision number on the main timeline before the changes that created this version. If this is an offline/branch version, this is the revision number of the source document where the branch was created and not . */
|
|
76
|
+
baseRev: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Options for listing committed server changes. *Always* ordered by revision number.
|
|
80
|
+
*/
|
|
81
|
+
export interface ListChangesOptions {
|
|
82
|
+
/** List changes committed strictly *after* this revision number. */
|
|
83
|
+
startAfter?: number;
|
|
84
|
+
/** List changes committed strictly *before* this revision number. */
|
|
85
|
+
endBefore?: number;
|
|
86
|
+
/** Maximum number of changes to return. */
|
|
87
|
+
limit?: number;
|
|
88
|
+
/** Return changes in descending revision order (latest first). Defaults to false (ascending). */
|
|
89
|
+
reverse?: boolean;
|
|
90
|
+
/** Filter out changes that have the given batch ID. */
|
|
91
|
+
withoutBatchId?: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Options for listing version metadata.
|
|
95
|
+
*/
|
|
96
|
+
export interface ListVersionsOptions {
|
|
97
|
+
/** List versions whose orderBy field is *after* this value. */
|
|
98
|
+
startAfter?: number;
|
|
99
|
+
/** List versions whose orderBy field is strictly *before* this value. */
|
|
100
|
+
endBefore?: number;
|
|
101
|
+
/** Maximum number of versions to return. */
|
|
102
|
+
limit?: number;
|
|
103
|
+
/** Sort by start date, rev, or baseRev. Defaults to 'rev'. */
|
|
104
|
+
orderBy?: 'startDate' | 'rev' | 'baseRev';
|
|
105
|
+
/** Return versions in descending order. Defaults to false (ascending). When reversed, startAfter and endBefore apply to the *reversed* list. */
|
|
106
|
+
reverse?: boolean;
|
|
107
|
+
/** Filter by the origin type. */
|
|
108
|
+
origin?: 'main' | 'offline' | 'branch';
|
|
109
|
+
/** Filter by the group ID (branch ID or offline batch ID). */
|
|
110
|
+
groupId?: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Interface for a backend storage system for patch synchronization.
|
|
114
|
+
* Defines methods needed by PatchServer, HistoryManager, etc.
|
|
115
|
+
*/
|
|
116
|
+
export interface PatchStoreBackend {
|
|
117
|
+
/** Adds a subscription for a client to one or more documents. */
|
|
118
|
+
addSubscription(clientId: string, docIds: string[]): Promise<string[]>;
|
|
119
|
+
/** Removes a subscription for a client from one or more documents. */
|
|
120
|
+
removeSubscription(clientId: string, docIds: string[]): Promise<string[]>;
|
|
121
|
+
/** Saves a batch of committed server changes. */
|
|
122
|
+
saveChanges(docId: string, changes: Change[]): Promise<void>;
|
|
123
|
+
/** Lists committed server changes based on revision numbers. */
|
|
124
|
+
listChanges(docId: string, options: ListChangesOptions): Promise<Change[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Saves version metadata, its state snapshot, and the original changes that constitute it.
|
|
127
|
+
* State and changes are stored separately from the core metadata.
|
|
128
|
+
*/
|
|
129
|
+
createVersion(docId: string, metadata: VersionMetadata, state: any, changes: Change[]): Promise<void>;
|
|
130
|
+
/** Update a version's metadata. */
|
|
131
|
+
updateVersion(docId: string, versionId: string, metadata: Partial<VersionMetadata>): Promise<void>;
|
|
132
|
+
/** Lists version metadata based on filtering/sorting options. */
|
|
133
|
+
listVersions(docId: string, options: ListVersionsOptions): Promise<VersionMetadata[]>;
|
|
134
|
+
/** Loads the state snapshot for a specific version ID. */
|
|
135
|
+
loadVersionState(docId: string, versionId: string): Promise<any | undefined>;
|
|
136
|
+
/** Loads the original Change objects associated with a specific version ID. */
|
|
137
|
+
loadVersionChanges(docId: string, versionId: string): Promise<Change[]>;
|
|
138
|
+
/** Deletes a document. */
|
|
139
|
+
deleteDoc(docId: string): Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Extends PatchStoreBackend with methods specifically for managing branches.
|
|
143
|
+
*/
|
|
144
|
+
export interface BranchingStoreBackend extends PatchStoreBackend {
|
|
145
|
+
/** Lists metadata records for branches originating from a document. */
|
|
146
|
+
listBranches(docId: string): Promise<Branch[]>;
|
|
147
|
+
/** Loads the metadata record for a specific branch ID. */
|
|
148
|
+
loadBranch(branchId: string): Promise<Branch | null>;
|
|
149
|
+
/** Creates or updates the metadata record for a branch. */
|
|
150
|
+
createBranch(branch: Branch): Promise<void>;
|
|
151
|
+
/** Updates specific fields (status, name, metadata) of an existing branch record. */
|
|
152
|
+
updateBranch(branchId: string, updates: Partial<Pick<Branch, 'status' | 'name' | 'metadata'>>): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* @deprecated Use updateBranch with status instead.
|
|
155
|
+
* Marks a branch as closed. Implementations might handle this via updateBranch.
|
|
156
|
+
*/
|
|
157
|
+
closeBranch(branchId: string): Promise<void>;
|
|
158
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|