@dabble/patches 0.7.21 → 0.7.23
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/dist/algorithms/lww/consolidateOps.js +2 -0
- package/dist/algorithms/ot/client/applyCommittedChanges.js +1 -7
- package/dist/algorithms/ot/server/buildVersionState.d.ts +51 -0
- package/dist/algorithms/ot/server/buildVersionState.js +67 -0
- package/dist/algorithms/ot/server/commitChanges.d.ts +16 -14
- package/dist/algorithms/ot/server/commitChanges.js +58 -75
- package/dist/algorithms/ot/server/createVersion.d.ts +36 -7
- package/dist/algorithms/ot/server/createVersion.js +24 -5
- package/dist/algorithms/ot/server/getSnapshotAtRevision.d.ts +13 -1
- package/dist/algorithms/ot/server/getSnapshotAtRevision.js +20 -9
- package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.d.ts +11 -11
- package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.js +14 -52
- package/dist/algorithms/ot/server/transformIncomingChanges.d.ts +4 -3
- package/dist/algorithms/ot/server/transformIncomingChanges.js +2 -16
- package/dist/algorithms/ot/shared/applyChanges.js +5 -1
- package/dist/client/LWWInMemoryStore.js +4 -0
- package/dist/client/LWWIndexedDBStore.js +7 -0
- package/dist/client/Patches.js +5 -2
- package/dist/net/PatchesClient.d.ts +4 -1
- package/dist/net/PatchesSync.js +14 -3
- package/dist/net/protocol/JSONRPCServer.js +15 -0
- package/dist/net/protocol/types.d.ts +5 -2
- package/dist/server/CompressedStoreBackend.d.ts +4 -3
- package/dist/server/CompressedStoreBackend.js +8 -13
- package/dist/server/LWWBranchManager.js +12 -3
- package/dist/server/LWWMemoryStoreBackend.d.ts +5 -8
- package/dist/server/LWWMemoryStoreBackend.js +11 -6
- package/dist/server/LWWServer.d.ts +16 -15
- package/dist/server/LWWServer.js +28 -27
- package/dist/server/OTBranchManager.js +9 -6
- package/dist/server/OTServer.d.ts +23 -19
- package/dist/server/OTServer.js +31 -27
- package/dist/server/PatchesHistoryManager.d.ts +23 -4
- package/dist/server/PatchesHistoryManager.js +40 -4
- package/dist/server/PatchesServer.d.ts +25 -9
- package/dist/server/RevConflictError.d.ts +13 -0
- package/dist/server/RevConflictError.js +10 -0
- package/dist/server/index.d.ts +5 -1
- package/dist/server/index.js +15 -0
- package/dist/server/jsonReadable.d.ts +22 -0
- package/dist/server/jsonReadable.js +68 -0
- package/dist/server/types.d.ts +30 -16
- package/package.json +1 -1
package/dist/server/LWWServer.js
CHANGED
|
@@ -4,8 +4,8 @@ import { consolidateOps, convertDeltaOps } from "../algorithms/lww/consolidateOp
|
|
|
4
4
|
import { createChange } from "../data/change.js";
|
|
5
5
|
import { signal } from "easy-signal";
|
|
6
6
|
import { createJSONPatch } from "../json-patch/createJSONPatch.js";
|
|
7
|
-
import { JSONPatch } from "../json-patch/JSONPatch.js";
|
|
8
7
|
import { getClientId } from "../net/serverContext.js";
|
|
8
|
+
import { concatStreams } from "./jsonReadable.js";
|
|
9
9
|
import { createTombstoneIfSupported, removeTombstoneIfExists } from "./tombstone.js";
|
|
10
10
|
import { assertVersionMetadata } from "./utils.js";
|
|
11
11
|
class LWWServer {
|
|
@@ -21,33 +21,35 @@ class LWWServer {
|
|
|
21
21
|
undeleteDoc: "write"
|
|
22
22
|
};
|
|
23
23
|
store;
|
|
24
|
-
snapshotInterval;
|
|
25
24
|
/** Notifies listeners whenever a batch of changes is successfully committed. */
|
|
26
25
|
onChangesCommitted = signal();
|
|
27
26
|
/** Notifies listeners when a document is deleted. */
|
|
28
27
|
onDocDeleted = signal();
|
|
29
|
-
constructor(store,
|
|
28
|
+
constructor(store, _options = {}) {
|
|
30
29
|
this.store = store;
|
|
31
|
-
this.snapshotInterval = options.snapshotInterval ?? 200;
|
|
32
30
|
}
|
|
33
31
|
/**
|
|
34
|
-
* Get the current state of a document.
|
|
35
|
-
*
|
|
32
|
+
* Get the current state of a document as a ReadableStream of JSON.
|
|
33
|
+
* Streams `{"state":...,"rev":N,"changes":[...]}` with the snapshot state
|
|
34
|
+
* flowing through without parsing.
|
|
36
35
|
*
|
|
37
36
|
* @param docId - The document ID.
|
|
38
|
-
* @returns
|
|
37
|
+
* @returns A ReadableStream of JSON string chunks.
|
|
39
38
|
*/
|
|
40
39
|
async getDoc(docId) {
|
|
41
40
|
const snapshot = await this.store.getSnapshot(docId);
|
|
42
|
-
const baseState = snapshot?.state ?? {};
|
|
43
41
|
const baseRev = snapshot?.rev ?? 0;
|
|
44
42
|
const ops = await this.store.listOps(docId, { sinceRev: baseRev });
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
let changes = [];
|
|
44
|
+
if (ops.length > 0) {
|
|
45
|
+
const sortedOps = [...ops].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
|
|
46
|
+
const maxRev = Math.max(baseRev, ...ops.map((op) => op.rev ?? 0));
|
|
47
|
+
const maxTs = Math.max(...ops.map((op) => op.ts ?? 0));
|
|
48
|
+
changes = [createChange(baseRev, maxRev, sortedOps, { committedAt: maxTs || Date.now() })];
|
|
47
49
|
}
|
|
48
|
-
const
|
|
49
|
-
const rev =
|
|
50
|
-
return {
|
|
50
|
+
const statePayload = snapshot?.state ?? "{}";
|
|
51
|
+
const rev = changes[changes.length - 1]?.rev || baseRev;
|
|
52
|
+
return concatStreams(`{"state":`, statePayload, `,"rev":${rev},"changes":`, JSON.stringify(changes), "}");
|
|
51
53
|
}
|
|
52
54
|
/**
|
|
53
55
|
* Get changes that occurred after a specific revision.
|
|
@@ -55,7 +57,7 @@ class LWWServer {
|
|
|
55
57
|
*
|
|
56
58
|
* @param docId - The document ID.
|
|
57
59
|
* @param rev - The revision number to get changes after.
|
|
58
|
-
* @returns Array
|
|
60
|
+
* @returns Array of synthesized changes (0 or 1 elements).
|
|
59
61
|
*/
|
|
60
62
|
async getChangesSince(docId, rev) {
|
|
61
63
|
const ops = await this.store.listOps(docId, { sinceRev: rev });
|
|
@@ -78,11 +80,11 @@ class LWWServer {
|
|
|
78
80
|
* @param docId - The document ID.
|
|
79
81
|
* @param changes - The changes to commit (always 1 for LWW).
|
|
80
82
|
* @param options - Optional commit options (ignored for LWW).
|
|
81
|
-
* @returns
|
|
83
|
+
* @returns An object with the committed changes (0-1 elements).
|
|
82
84
|
*/
|
|
83
85
|
async commitChanges(docId, changes, options) {
|
|
84
86
|
if (changes.length === 0) {
|
|
85
|
-
return [];
|
|
87
|
+
return { changes: [] };
|
|
86
88
|
}
|
|
87
89
|
const change = changes[0];
|
|
88
90
|
const serverNow = Date.now();
|
|
@@ -96,10 +98,6 @@ class LWWServer {
|
|
|
96
98
|
if (opsToStore.length > 0 || pathsToDelete.length > 0) {
|
|
97
99
|
newRev = await this.store.saveOps(docId, opsToStore, pathsToDelete);
|
|
98
100
|
}
|
|
99
|
-
if (newRev > 0 && newRev % this.snapshotInterval === 0) {
|
|
100
|
-
const { state } = await this.getDoc(docId);
|
|
101
|
-
await this.store.saveSnapshot(docId, state, newRev);
|
|
102
|
-
}
|
|
103
101
|
const responseOps = [...opsToReturn];
|
|
104
102
|
if (clientRev !== void 0) {
|
|
105
103
|
const opsSince = await this.store.listOps(docId, { sinceRev: clientRev });
|
|
@@ -122,7 +120,7 @@ class LWWServer {
|
|
|
122
120
|
console.error(`Failed to notify clients about committed changes for doc ${docId}:`, error);
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
|
-
return [responseChange];
|
|
123
|
+
return { changes: [responseChange] };
|
|
126
124
|
}
|
|
127
125
|
/**
|
|
128
126
|
* Delete a document and emit deletion signal.
|
|
@@ -148,13 +146,14 @@ class LWWServer {
|
|
|
148
146
|
}
|
|
149
147
|
/**
|
|
150
148
|
* Make a server-side change to a document.
|
|
149
|
+
* Stateless — uses getCurrentRev instead of loading document state.
|
|
151
150
|
* @param docId - The document ID.
|
|
152
151
|
* @param mutator - A function that receives a JSONPatch and PathProxy to define the changes.
|
|
153
152
|
* @param metadata - Optional metadata for the change.
|
|
154
153
|
* @returns The created change, or null if no operations were generated.
|
|
155
154
|
*/
|
|
156
155
|
async change(docId, mutator, metadata) {
|
|
157
|
-
const
|
|
156
|
+
const rev = await this.store.getCurrentRev(docId);
|
|
158
157
|
const patch = createJSONPatch(mutator);
|
|
159
158
|
if (patch.ops.length === 0) {
|
|
160
159
|
return null;
|
|
@@ -162,13 +161,14 @@ class LWWServer {
|
|
|
162
161
|
const serverNow = Date.now();
|
|
163
162
|
const opsWithTs = patch.ops.map((op) => ({ ...op, ts: serverNow }));
|
|
164
163
|
const change = createChange(rev, rev + 1, opsWithTs, metadata);
|
|
165
|
-
patch.apply(state);
|
|
166
164
|
await this.commitChanges(docId, [change]);
|
|
167
165
|
return change;
|
|
168
166
|
}
|
|
169
167
|
/**
|
|
170
168
|
* Captures the current state of a document as a new version.
|
|
171
169
|
* Only works if store implements VersioningStoreBackend.
|
|
170
|
+
* Does NOT build state — creates version metadata, then emits `onVersionCreated`
|
|
171
|
+
* so subscribers can build and persist state out of band.
|
|
172
172
|
*
|
|
173
173
|
* @param docId - The document ID.
|
|
174
174
|
* @param metadata - Optional metadata for the version.
|
|
@@ -179,19 +179,20 @@ class LWWServer {
|
|
|
179
179
|
if (!this.isVersioningStore(this.store)) {
|
|
180
180
|
throw new Error("LWW versioning requires a store that implements VersioningStoreBackend");
|
|
181
181
|
}
|
|
182
|
-
const
|
|
182
|
+
const rev = await this.store.getCurrentRev(docId);
|
|
183
183
|
if (rev === 0) {
|
|
184
184
|
return null;
|
|
185
185
|
}
|
|
186
|
+
const now = Date.now();
|
|
186
187
|
const versionMetadata = createVersionMetadata({
|
|
187
188
|
origin: "main",
|
|
188
|
-
startedAt:
|
|
189
|
-
endedAt:
|
|
189
|
+
startedAt: now,
|
|
190
|
+
endedAt: now,
|
|
190
191
|
startRev: rev,
|
|
191
192
|
endRev: rev,
|
|
192
193
|
...metadata
|
|
193
194
|
});
|
|
194
|
-
await this.store.createVersion(docId, versionMetadata
|
|
195
|
+
await this.store.createVersion(docId, versionMetadata);
|
|
195
196
|
return versionMetadata.id;
|
|
196
197
|
}
|
|
197
198
|
/**
|
|
@@ -40,9 +40,13 @@ class OTBranchManager {
|
|
|
40
40
|
const { state: stateAtRev } = await getStateAtRevision(this.store, docId, rev);
|
|
41
41
|
const branchDocId = await generateBranchId(this.store, docId);
|
|
42
42
|
const now = Date.now();
|
|
43
|
+
const initialChange = createChange(0, 1, [{ op: "replace", path: "", value: stateAtRev }], {
|
|
44
|
+
createdAt: now,
|
|
45
|
+
committedAt: now
|
|
46
|
+
});
|
|
47
|
+
await this.store.saveChanges(branchDocId, [initialChange]);
|
|
43
48
|
const initialVersionMetadata = createVersionMetadata({
|
|
44
49
|
origin: "main",
|
|
45
|
-
// Branch doc versions are 'main' until merged
|
|
46
50
|
startedAt: now,
|
|
47
51
|
endedAt: now,
|
|
48
52
|
endRev: rev,
|
|
@@ -51,7 +55,7 @@ class OTBranchManager {
|
|
|
51
55
|
groupId: branchDocId,
|
|
52
56
|
branchName: metadata?.name
|
|
53
57
|
});
|
|
54
|
-
await this.store.createVersion(branchDocId, initialVersionMetadata,
|
|
58
|
+
await this.store.createVersion(branchDocId, initialVersionMetadata, [initialChange]);
|
|
55
59
|
const branch = createBranchRecord(branchDocId, docId, rev, metadata);
|
|
56
60
|
await this.store.createBranch(branch);
|
|
57
61
|
return branchDocId;
|
|
@@ -107,9 +111,8 @@ class OTBranchManager {
|
|
|
107
111
|
// Keep branchName for traceability
|
|
108
112
|
parentId: lastVersionId
|
|
109
113
|
});
|
|
110
|
-
const state = await this.store.loadVersionState(branchId, v.id);
|
|
111
114
|
const changes = await this.store.loadVersionChanges?.(branchId, v.id);
|
|
112
|
-
await this.store.createVersion(sourceDocId, newVersionMetadata,
|
|
115
|
+
await this.store.createVersion(sourceDocId, newVersionMetadata, changes);
|
|
113
116
|
lastVersionId = newVersionMetadata.id;
|
|
114
117
|
}
|
|
115
118
|
const committedMergeChanges = await wrapMergeCommit(branchId, sourceDocId, async () => {
|
|
@@ -120,7 +123,7 @@ class OTBranchManager {
|
|
|
120
123
|
rev: void 0
|
|
121
124
|
// Let commitChanges assign sequential revs
|
|
122
125
|
}));
|
|
123
|
-
return this.patchesServer.commitChanges(sourceDocId, adjustedChanges);
|
|
126
|
+
return (await this.patchesServer.commitChanges(sourceDocId, adjustedChanges)).changes;
|
|
124
127
|
} else {
|
|
125
128
|
const rev = branchStartRevOnSource + branchChanges.length;
|
|
126
129
|
const flattenedChange = createChange(
|
|
@@ -132,7 +135,7 @@ class OTBranchManager {
|
|
|
132
135
|
if (this.maxPayloadBytes) {
|
|
133
136
|
changesToCommit = breakChanges(changesToCommit, this.maxPayloadBytes);
|
|
134
137
|
}
|
|
135
|
-
return this.patchesServer.commitChanges(sourceDocId, changesToCommit);
|
|
138
|
+
return (await this.patchesServer.commitChanges(sourceDocId, changesToCommit)).changes;
|
|
136
139
|
}
|
|
137
140
|
});
|
|
138
141
|
await this.closeBranch(branchId, "merged");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as easy_signal from 'easy-signal';
|
|
2
2
|
import { PatchesServer } from './PatchesServer.js';
|
|
3
3
|
import { OTStoreBackend } from './types.js';
|
|
4
|
-
import { Change, CommitChangesOptions, DeleteDocOptions,
|
|
4
|
+
import { Change, CommitChangesOptions, DeleteDocOptions, ChangeInput, ChangeMutator, EditableVersionMetadata } from '../types.js';
|
|
5
5
|
import { ApiDefinition } from '../net/protocol/JSONRPCServer.js';
|
|
6
6
|
import '../json-patch/JSONPatch.js';
|
|
7
7
|
import '@dabble/delta';
|
|
@@ -19,11 +19,6 @@ interface OTServerOptions {
|
|
|
19
19
|
* Defaults to 30 minutes.
|
|
20
20
|
*/
|
|
21
21
|
sessionTimeoutMinutes?: number;
|
|
22
|
-
/**
|
|
23
|
-
* Maximum size in bytes for a single change's storage representation.
|
|
24
|
-
* Useful for databases with row size limits.
|
|
25
|
-
*/
|
|
26
|
-
maxStorageBytes?: number;
|
|
27
22
|
}
|
|
28
23
|
/**
|
|
29
24
|
* Handles the server-side Operational Transformation (OT) logic,
|
|
@@ -45,7 +40,6 @@ declare class OTServer implements PatchesServer {
|
|
|
45
40
|
*/
|
|
46
41
|
static api: ApiDefinition;
|
|
47
42
|
private readonly sessionTimeoutMillis;
|
|
48
|
-
private readonly maxStorageBytes?;
|
|
49
43
|
readonly store: OTStoreBackend;
|
|
50
44
|
/** Notifies listeners whenever a batch of changes is *successfully* committed. */
|
|
51
45
|
readonly onChangesCommitted: easy_signal.Signal<(docId: string, changes: Change[], options?: CommitChangesOptions, originClientId?: string) => void>;
|
|
@@ -53,39 +47,46 @@ declare class OTServer implements PatchesServer {
|
|
|
53
47
|
readonly onDocDeleted: easy_signal.Signal<(docId: string, options?: DeleteDocOptions, originClientId?: string) => void>;
|
|
54
48
|
constructor(store: OTStoreBackend, options?: OTServerOptions);
|
|
55
49
|
/**
|
|
56
|
-
* Get the current state of a document.
|
|
50
|
+
* Get the current state of a document as a ReadableStream of JSON.
|
|
51
|
+
* Streams `{"state":...,"rev":N,"changes":[...]}` with the version state
|
|
52
|
+
* flowing through without parsing.
|
|
57
53
|
* @param docId - The ID of the document.
|
|
58
|
-
* @returns
|
|
54
|
+
* @returns A ReadableStream of JSON string chunks.
|
|
59
55
|
*/
|
|
60
|
-
getDoc(docId: string): Promise<
|
|
56
|
+
getDoc(docId: string): Promise<ReadableStream<string>>;
|
|
61
57
|
/**
|
|
62
58
|
* Get changes that occurred after a specific revision.
|
|
63
59
|
* @param docId - The ID of the document.
|
|
64
60
|
* @param rev - The revision number.
|
|
65
|
-
* @returns
|
|
61
|
+
* @returns Array of changes after the given revision.
|
|
66
62
|
*/
|
|
67
63
|
getChangesSince(docId: string, rev: number): Promise<Change[]>;
|
|
68
64
|
/**
|
|
69
65
|
* Commits a set of changes to a document, applying operational transformation as needed.
|
|
70
66
|
*
|
|
71
|
-
* Returns all changes the client needs to apply
|
|
72
|
-
*
|
|
73
|
-
*
|
|
67
|
+
* Returns all changes the client needs to apply (catchup changes from others, followed
|
|
68
|
+
* by the client's own transformed changes), plus an optional `docReloadRequired` flag.
|
|
69
|
+
*
|
|
70
|
+
* When `docReloadRequired` is true, the client must call `getDoc` to reload the full
|
|
71
|
+
* current state before continuing — its local state is stale.
|
|
74
72
|
*
|
|
75
73
|
* @param docId - The ID of the document.
|
|
76
74
|
* @param changes - The changes to commit.
|
|
77
75
|
* @param options - Optional commit settings (e.g., forceCommit for migrations).
|
|
78
|
-
* @returns
|
|
76
|
+
* @returns An object with the committed changes and an optional reload flag.
|
|
79
77
|
*/
|
|
80
|
-
commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<
|
|
78
|
+
commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<{
|
|
79
|
+
changes: Change[];
|
|
80
|
+
docReloadRequired?: true;
|
|
81
|
+
}>;
|
|
81
82
|
/**
|
|
82
83
|
* Make a server-side change to a document.
|
|
83
|
-
*
|
|
84
|
-
* @returns
|
|
84
|
+
* Stateless — uses getCurrentRev instead of loading document state.
|
|
85
85
|
*/
|
|
86
86
|
change<T = Record<string, any>>(docId: string, mutator: ChangeMutator<T>, metadata?: Record<string, any>): Promise<Change | null>;
|
|
87
87
|
/**
|
|
88
88
|
* Deletes a document.
|
|
89
|
+
* Stateless — uses getCurrentRev instead of loading document state.
|
|
89
90
|
* @param docId The document ID.
|
|
90
91
|
* @param options - Optional deletion settings (e.g., skipTombstone for testing).
|
|
91
92
|
*/
|
|
@@ -98,9 +99,12 @@ declare class OTServer implements PatchesServer {
|
|
|
98
99
|
undeleteDoc(docId: string): Promise<boolean>;
|
|
99
100
|
/**
|
|
100
101
|
* Captures the current state of a document as a new version.
|
|
102
|
+
* Does NOT build state — creates version metadata + changes, then emits
|
|
103
|
+
* `onVersionCreated` so subscribers can build and persist state out of band.
|
|
104
|
+
*
|
|
101
105
|
* @param docId The document ID.
|
|
102
106
|
* @param metadata Optional metadata for the version.
|
|
103
|
-
* @returns The ID of the created version.
|
|
107
|
+
* @returns The ID of the created version, or null if no changes to capture.
|
|
104
108
|
*/
|
|
105
109
|
captureCurrentVersion(docId: string, metadata?: EditableVersionMetadata): Promise<string | null>;
|
|
106
110
|
}
|
package/dist/server/OTServer.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import "../chunk-IZ2YBCUP.js";
|
|
2
2
|
import { commitChanges } from "../algorithms/ot/server/commitChanges.js";
|
|
3
3
|
import { createVersion } from "../algorithms/ot/server/createVersion.js";
|
|
4
|
-
import { getSnapshotAtRevision } from "../algorithms/ot/server/getSnapshotAtRevision.js";
|
|
5
|
-
import { getStateAtRevision } from "../algorithms/ot/server/getStateAtRevision.js";
|
|
6
|
-
import { applyChanges } from "../algorithms/ot/shared/applyChanges.js";
|
|
4
|
+
import { getSnapshotAtRevision, getSnapshotStream } from "../algorithms/ot/server/getSnapshotAtRevision.js";
|
|
7
5
|
import { createChange } from "../data/change.js";
|
|
8
6
|
import { signal } from "easy-signal";
|
|
9
7
|
import { createJSONPatch } from "../json-patch/createJSONPatch.js";
|
|
@@ -23,7 +21,6 @@ class OTServer {
|
|
|
23
21
|
undeleteDoc: "write"
|
|
24
22
|
};
|
|
25
23
|
sessionTimeoutMillis;
|
|
26
|
-
maxStorageBytes;
|
|
27
24
|
store;
|
|
28
25
|
/** Notifies listeners whenever a batch of changes is *successfully* committed. */
|
|
29
26
|
onChangesCommitted = signal();
|
|
@@ -31,46 +28,48 @@ class OTServer {
|
|
|
31
28
|
onDocDeleted = signal();
|
|
32
29
|
constructor(store, options = {}) {
|
|
33
30
|
this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1e3;
|
|
34
|
-
this.maxStorageBytes = options.maxStorageBytes;
|
|
35
31
|
this.store = store;
|
|
36
32
|
}
|
|
37
33
|
/**
|
|
38
|
-
* Get the current state of a document.
|
|
34
|
+
* Get the current state of a document as a ReadableStream of JSON.
|
|
35
|
+
* Streams `{"state":...,"rev":N,"changes":[...]}` with the version state
|
|
36
|
+
* flowing through without parsing.
|
|
39
37
|
* @param docId - The ID of the document.
|
|
40
|
-
* @returns
|
|
38
|
+
* @returns A ReadableStream of JSON string chunks.
|
|
41
39
|
*/
|
|
42
40
|
async getDoc(docId) {
|
|
43
|
-
return
|
|
41
|
+
return getSnapshotStream(this.store, docId);
|
|
44
42
|
}
|
|
45
43
|
/**
|
|
46
44
|
* Get changes that occurred after a specific revision.
|
|
47
45
|
* @param docId - The ID of the document.
|
|
48
46
|
* @param rev - The revision number.
|
|
49
|
-
* @returns
|
|
47
|
+
* @returns Array of changes after the given revision.
|
|
50
48
|
*/
|
|
51
|
-
getChangesSince(docId, rev) {
|
|
49
|
+
async getChangesSince(docId, rev) {
|
|
52
50
|
return this.store.listChanges(docId, { startAfter: rev });
|
|
53
51
|
}
|
|
54
52
|
/**
|
|
55
53
|
* Commits a set of changes to a document, applying operational transformation as needed.
|
|
56
54
|
*
|
|
57
|
-
* Returns all changes the client needs to apply
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* Returns all changes the client needs to apply (catchup changes from others, followed
|
|
56
|
+
* by the client's own transformed changes), plus an optional `docReloadRequired` flag.
|
|
57
|
+
*
|
|
58
|
+
* When `docReloadRequired` is true, the client must call `getDoc` to reload the full
|
|
59
|
+
* current state before continuing — its local state is stale.
|
|
60
60
|
*
|
|
61
61
|
* @param docId - The ID of the document.
|
|
62
62
|
* @param changes - The changes to commit.
|
|
63
63
|
* @param options - Optional commit settings (e.g., forceCommit for migrations).
|
|
64
|
-
* @returns
|
|
64
|
+
* @returns An object with the committed changes and an optional reload flag.
|
|
65
65
|
*/
|
|
66
66
|
async commitChanges(docId, changes, options) {
|
|
67
|
-
const { catchupChanges, newChanges } = await commitChanges(
|
|
67
|
+
const { catchupChanges, newChanges, docReloadRequired } = await commitChanges(
|
|
68
68
|
this.store,
|
|
69
69
|
docId,
|
|
70
70
|
changes,
|
|
71
71
|
this.sessionTimeoutMillis,
|
|
72
|
-
options
|
|
73
|
-
this.maxStorageBytes
|
|
72
|
+
options
|
|
74
73
|
);
|
|
75
74
|
if (newChanges.length > 0) {
|
|
76
75
|
try {
|
|
@@ -79,32 +78,35 @@ class OTServer {
|
|
|
79
78
|
console.error(`Failed to notify clients about committed changes for doc ${docId}:`, error);
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
|
-
|
|
81
|
+
const result = {
|
|
82
|
+
changes: [...catchupChanges, ...newChanges]
|
|
83
|
+
};
|
|
84
|
+
if (docReloadRequired) result.docReloadRequired = true;
|
|
85
|
+
return result;
|
|
83
86
|
}
|
|
84
87
|
/**
|
|
85
88
|
* Make a server-side change to a document.
|
|
86
|
-
*
|
|
87
|
-
* @returns
|
|
89
|
+
* Stateless — uses getCurrentRev instead of loading document state.
|
|
88
90
|
*/
|
|
89
91
|
async change(docId, mutator, metadata) {
|
|
90
|
-
const
|
|
92
|
+
const rev = await this.store.getCurrentRev(docId);
|
|
91
93
|
const patch = createJSONPatch(mutator);
|
|
92
94
|
if (patch.ops.length === 0) {
|
|
93
95
|
return null;
|
|
94
96
|
}
|
|
95
97
|
const change = createChange(rev, rev + 1, patch.ops, metadata);
|
|
96
|
-
patch.apply(state);
|
|
97
98
|
await this.commitChanges(docId, [change]);
|
|
98
99
|
return change;
|
|
99
100
|
}
|
|
100
101
|
/**
|
|
101
102
|
* Deletes a document.
|
|
103
|
+
* Stateless — uses getCurrentRev instead of loading document state.
|
|
102
104
|
* @param docId The document ID.
|
|
103
105
|
* @param options - Optional deletion settings (e.g., skipTombstone for testing).
|
|
104
106
|
*/
|
|
105
107
|
async deleteDoc(docId, options) {
|
|
106
108
|
const clientId = getClientId();
|
|
107
|
-
const
|
|
109
|
+
const rev = await this.store.getCurrentRev(docId);
|
|
108
110
|
await createTombstoneIfSupported(this.store, docId, rev, clientId, options?.skipTombstone);
|
|
109
111
|
await this.store.deleteDoc(docId);
|
|
110
112
|
await this.onDocDeleted.emit(docId, options, clientId);
|
|
@@ -120,15 +122,17 @@ class OTServer {
|
|
|
120
122
|
// === Version Operations ===
|
|
121
123
|
/**
|
|
122
124
|
* Captures the current state of a document as a new version.
|
|
125
|
+
* Does NOT build state — creates version metadata + changes, then emits
|
|
126
|
+
* `onVersionCreated` so subscribers can build and persist state out of band.
|
|
127
|
+
*
|
|
123
128
|
* @param docId The document ID.
|
|
124
129
|
* @param metadata Optional metadata for the version.
|
|
125
|
-
* @returns The ID of the created version.
|
|
130
|
+
* @returns The ID of the created version, or null if no changes to capture.
|
|
126
131
|
*/
|
|
127
132
|
async captureCurrentVersion(docId, metadata) {
|
|
128
133
|
assertVersionMetadata(metadata);
|
|
129
|
-
const {
|
|
130
|
-
const
|
|
131
|
-
const version = await createVersion(this.store, docId, state, changes, metadata);
|
|
134
|
+
const { changes } = await getSnapshotAtRevision(this.store, docId);
|
|
135
|
+
const version = await createVersion(this.store, docId, changes, { metadata });
|
|
132
136
|
if (!version) {
|
|
133
137
|
return null;
|
|
134
138
|
}
|
|
@@ -44,12 +44,30 @@ declare class PatchesHistoryManager {
|
|
|
44
44
|
updateVersion(docId: string, versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
45
45
|
/**
|
|
46
46
|
* Loads the full document state snapshot for a specific version by its ID.
|
|
47
|
+
* Returns a ReadableStream so the state can be streamed to clients via RPC.
|
|
47
48
|
* @param docId - The ID of the document.
|
|
48
49
|
* @param versionId - The unique ID of the version.
|
|
49
|
-
* @returns
|
|
50
|
-
* @throws Error if
|
|
50
|
+
* @returns A ReadableStream of the JSON state, or a stream of 'null' if not found.
|
|
51
|
+
* @throws Error if state loading fails.
|
|
51
52
|
*/
|
|
52
|
-
getStateAtVersion(docId: string, versionId: string): Promise<
|
|
53
|
+
getStateAtVersion(docId: string, versionId: string): Promise<ReadableStream<string>>;
|
|
54
|
+
/**
|
|
55
|
+
* Returns the document state immediately before a version's changes begin.
|
|
56
|
+
*
|
|
57
|
+
* Uses the version's `parentId` chain to find the correct baseline — so
|
|
58
|
+
* offline-branch session 2 returns the state after session 1, not after the
|
|
59
|
+
* last main-timeline version. Bridges any gap between the parent's `endRev`
|
|
60
|
+
* and the version's `startRev` by applying intermediate changes.
|
|
61
|
+
*
|
|
62
|
+
* Use this as the baseline when scrubbing through a version's individual changes.
|
|
63
|
+
*
|
|
64
|
+
* Only available for OT stores.
|
|
65
|
+
*
|
|
66
|
+
* @param docId - The document ID.
|
|
67
|
+
* @param versionId - The version whose pre-state to compute.
|
|
68
|
+
* @returns A ReadableStream of the JSON state at `version.startRev - 1`.
|
|
69
|
+
*/
|
|
70
|
+
getStateBeforeVersion(docId: string, versionId: string): Promise<ReadableStream<string>>;
|
|
53
71
|
/**
|
|
54
72
|
* Loads the list of original client changes that were included in a specific version.
|
|
55
73
|
* Useful for replaying/scrubbing through the operations within an offline or online session.
|
|
@@ -61,8 +79,9 @@ declare class PatchesHistoryManager {
|
|
|
61
79
|
getChangesForVersion(docId: string, versionId: string): Promise<Change[]>;
|
|
62
80
|
/**
|
|
63
81
|
* Alias for getStateAtVersion for RPC API compatibility.
|
|
82
|
+
* Returns a ReadableStream for efficient JSON-RPC streaming.
|
|
64
83
|
*/
|
|
65
|
-
getVersionState(docId: string, versionId: string): Promise<
|
|
84
|
+
getVersionState(docId: string, versionId: string): Promise<ReadableStream<string>>;
|
|
66
85
|
/**
|
|
67
86
|
* Alias for getChangesForVersion for RPC API compatibility.
|
|
68
87
|
*/
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import "../chunk-IZ2YBCUP.js";
|
|
2
|
+
import { getStateBeforeVersionAsStream } from "../algorithms/ot/server/buildVersionState.js";
|
|
3
|
+
import { jsonReadable } from "./jsonReadable.js";
|
|
2
4
|
import { assertVersionMetadata } from "./utils.js";
|
|
3
5
|
class PatchesHistoryManager {
|
|
4
6
|
constructor(patches, store) {
|
|
@@ -10,7 +12,8 @@ class PatchesHistoryManager {
|
|
|
10
12
|
createVersion: "write",
|
|
11
13
|
updateVersion: "write",
|
|
12
14
|
getVersionState: "read",
|
|
13
|
-
getVersionChanges: "read"
|
|
15
|
+
getVersionChanges: "read",
|
|
16
|
+
getStateBeforeVersion: "read"
|
|
14
17
|
};
|
|
15
18
|
store;
|
|
16
19
|
/**
|
|
@@ -47,19 +50,51 @@ class PatchesHistoryManager {
|
|
|
47
50
|
}
|
|
48
51
|
/**
|
|
49
52
|
* Loads the full document state snapshot for a specific version by its ID.
|
|
53
|
+
* Returns a ReadableStream so the state can be streamed to clients via RPC.
|
|
50
54
|
* @param docId - The ID of the document.
|
|
51
55
|
* @param versionId - The unique ID of the version.
|
|
52
|
-
* @returns
|
|
53
|
-
* @throws Error if
|
|
56
|
+
* @returns A ReadableStream of the JSON state, or a stream of 'null' if not found.
|
|
57
|
+
* @throws Error if state loading fails.
|
|
54
58
|
*/
|
|
55
59
|
async getStateAtVersion(docId, versionId) {
|
|
56
60
|
try {
|
|
57
|
-
|
|
61
|
+
const rawState = await this.store.loadVersionState(docId, versionId);
|
|
62
|
+
if (rawState === void 0) {
|
|
63
|
+
return jsonReadable("null");
|
|
64
|
+
}
|
|
65
|
+
return typeof rawState === "string" ? jsonReadable(rawState) : rawState;
|
|
58
66
|
} catch (error) {
|
|
59
67
|
console.error(`Failed to load state for version ${versionId} of doc ${docId}.`, error);
|
|
60
68
|
throw new Error(`Could not load state for version ${versionId}.`);
|
|
61
69
|
}
|
|
62
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the document state immediately before a version's changes begin.
|
|
73
|
+
*
|
|
74
|
+
* Uses the version's `parentId` chain to find the correct baseline — so
|
|
75
|
+
* offline-branch session 2 returns the state after session 1, not after the
|
|
76
|
+
* last main-timeline version. Bridges any gap between the parent's `endRev`
|
|
77
|
+
* and the version's `startRev` by applying intermediate changes.
|
|
78
|
+
*
|
|
79
|
+
* Use this as the baseline when scrubbing through a version's individual changes.
|
|
80
|
+
*
|
|
81
|
+
* Only available for OT stores.
|
|
82
|
+
*
|
|
83
|
+
* @param docId - The document ID.
|
|
84
|
+
* @param versionId - The version whose pre-state to compute.
|
|
85
|
+
* @returns A ReadableStream of the JSON state at `version.startRev - 1`.
|
|
86
|
+
*/
|
|
87
|
+
async getStateBeforeVersion(docId, versionId) {
|
|
88
|
+
if (!("listChanges" in this.store)) {
|
|
89
|
+
throw new Error("getStateBeforeVersion is only supported for OT stores.");
|
|
90
|
+
}
|
|
91
|
+
const otStore = this.store;
|
|
92
|
+
const version = await otStore.loadVersion(docId, versionId);
|
|
93
|
+
if (!version) {
|
|
94
|
+
throw new Error(`Version ${versionId} not found for doc ${docId}.`);
|
|
95
|
+
}
|
|
96
|
+
return getStateBeforeVersionAsStream(otStore, docId, version);
|
|
97
|
+
}
|
|
63
98
|
/**
|
|
64
99
|
* Loads the list of original client changes that were included in a specific version.
|
|
65
100
|
* Useful for replaying/scrubbing through the operations within an offline or online session.
|
|
@@ -81,6 +116,7 @@ class PatchesHistoryManager {
|
|
|
81
116
|
// ---------------------------------------------------------------------------
|
|
82
117
|
/**
|
|
83
118
|
* Alias for getStateAtVersion for RPC API compatibility.
|
|
119
|
+
* Returns a ReadableStream for efficient JSON-RPC streaming.
|
|
84
120
|
*/
|
|
85
121
|
async getVersionState(docId, versionId) {
|
|
86
122
|
return this.getStateAtVersion(docId, versionId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Change, ChangeInput, CommitChangesOptions, DeleteDocOptions, EditableVersionMetadata, ChangeMutator } from '../types.js';
|
|
2
2
|
import { ApiDefinition } from '../net/protocol/JSONRPCServer.js';
|
|
3
3
|
import '../json-patch/JSONPatch.js';
|
|
4
4
|
import '@dabble/delta';
|
|
@@ -18,6 +18,14 @@ interface CommitResult {
|
|
|
18
18
|
catchupChanges: Change[];
|
|
19
19
|
/** The client's changes after transformation (will be broadcast to others). */
|
|
20
20
|
newChanges: Change[];
|
|
21
|
+
/**
|
|
22
|
+
* When true, the client's local state is stale and it must call `getDoc` to
|
|
23
|
+
* reload the full current document before continuing. This happens when an
|
|
24
|
+
* offline-first client (baseRev: 0) commits changes to a document that already
|
|
25
|
+
* has server-side history — the server commits the changes but cannot send
|
|
26
|
+
* all the missed history inline.
|
|
27
|
+
*/
|
|
28
|
+
docReloadRequired?: true;
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Interface that document servers must implement to work with the RPC layer.
|
|
@@ -28,31 +36,39 @@ interface CommitResult {
|
|
|
28
36
|
*/
|
|
29
37
|
interface PatchesServer {
|
|
30
38
|
/**
|
|
31
|
-
* Get the current state of a document.
|
|
39
|
+
* Get the current state of a document as a ReadableStream of JSON.
|
|
40
|
+
* The stream contains the full JSON envelope: `{"state":...,"rev":N,"changes":[...]}`.
|
|
32
41
|
* @param docId - The document ID.
|
|
33
|
-
* @returns
|
|
42
|
+
* @returns A ReadableStream of JSON string chunks.
|
|
34
43
|
*/
|
|
35
|
-
getDoc(docId: string): Promise<
|
|
44
|
+
getDoc(docId: string): Promise<ReadableStream<string>>;
|
|
36
45
|
/**
|
|
37
46
|
* Get changes that occurred after a specific revision.
|
|
38
47
|
* @param docId - The document ID.
|
|
39
48
|
* @param rev - The revision number to get changes after.
|
|
40
|
-
* @returns Array of changes after the
|
|
49
|
+
* @returns Array of changes after the given revision.
|
|
41
50
|
*/
|
|
42
51
|
getChangesSince(docId: string, rev: number): Promise<Change[]>;
|
|
43
52
|
/**
|
|
44
53
|
* Commit changes to a document.
|
|
45
54
|
*
|
|
46
55
|
* Applies operational transformation as needed, assigns revision numbers,
|
|
47
|
-
* and persists the changes. Returns all changes the client needs to apply
|
|
48
|
-
*
|
|
56
|
+
* and persists the changes. Returns all changes the client needs to apply
|
|
57
|
+
* (catchup changes from others, followed by the client's own transformed changes),
|
|
58
|
+
* plus an optional `docReloadRequired` flag.
|
|
59
|
+
*
|
|
60
|
+
* When `docReloadRequired` is true, the client's changes were committed but its
|
|
61
|
+
* local state is stale. The client must call `getDoc` to reload before continuing.
|
|
49
62
|
*
|
|
50
63
|
* @param docId - The document ID.
|
|
51
64
|
* @param changes - The changes to commit.
|
|
52
65
|
* @param options - Optional commit options.
|
|
53
|
-
* @returns
|
|
66
|
+
* @returns An object with the committed changes and an optional reload flag.
|
|
54
67
|
*/
|
|
55
|
-
commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<
|
|
68
|
+
commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<{
|
|
69
|
+
changes: Change[];
|
|
70
|
+
docReloadRequired?: true;
|
|
71
|
+
}>;
|
|
56
72
|
/**
|
|
57
73
|
* Delete a document.
|
|
58
74
|
* @param docId - The document ID.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by store implementations when `saveChanges` detects that a revision
|
|
3
|
+
* already exists. This signals a concurrent-write conflict so `commitChanges`
|
|
4
|
+
* can retry with fresh state.
|
|
5
|
+
*
|
|
6
|
+
* Store implementations should catch their native conflict errors (e.g.
|
|
7
|
+
* Firestore ALREADY_EXISTS) and re-throw as `RevConflictError`.
|
|
8
|
+
*/
|
|
9
|
+
declare class RevConflictError extends Error {
|
|
10
|
+
constructor(message?: string);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { RevConflictError };
|