@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.
Files changed (43) hide show
  1. package/dist/algorithms/lww/consolidateOps.js +2 -0
  2. package/dist/algorithms/ot/client/applyCommittedChanges.js +1 -7
  3. package/dist/algorithms/ot/server/buildVersionState.d.ts +51 -0
  4. package/dist/algorithms/ot/server/buildVersionState.js +67 -0
  5. package/dist/algorithms/ot/server/commitChanges.d.ts +16 -14
  6. package/dist/algorithms/ot/server/commitChanges.js +58 -75
  7. package/dist/algorithms/ot/server/createVersion.d.ts +36 -7
  8. package/dist/algorithms/ot/server/createVersion.js +24 -5
  9. package/dist/algorithms/ot/server/getSnapshotAtRevision.d.ts +13 -1
  10. package/dist/algorithms/ot/server/getSnapshotAtRevision.js +20 -9
  11. package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.d.ts +11 -11
  12. package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.js +14 -52
  13. package/dist/algorithms/ot/server/transformIncomingChanges.d.ts +4 -3
  14. package/dist/algorithms/ot/server/transformIncomingChanges.js +2 -16
  15. package/dist/algorithms/ot/shared/applyChanges.js +5 -1
  16. package/dist/client/LWWInMemoryStore.js +4 -0
  17. package/dist/client/LWWIndexedDBStore.js +7 -0
  18. package/dist/client/Patches.js +5 -2
  19. package/dist/net/PatchesClient.d.ts +4 -1
  20. package/dist/net/PatchesSync.js +14 -3
  21. package/dist/net/protocol/JSONRPCServer.js +15 -0
  22. package/dist/net/protocol/types.d.ts +5 -2
  23. package/dist/server/CompressedStoreBackend.d.ts +4 -3
  24. package/dist/server/CompressedStoreBackend.js +8 -13
  25. package/dist/server/LWWBranchManager.js +12 -3
  26. package/dist/server/LWWMemoryStoreBackend.d.ts +5 -8
  27. package/dist/server/LWWMemoryStoreBackend.js +11 -6
  28. package/dist/server/LWWServer.d.ts +16 -15
  29. package/dist/server/LWWServer.js +28 -27
  30. package/dist/server/OTBranchManager.js +9 -6
  31. package/dist/server/OTServer.d.ts +23 -19
  32. package/dist/server/OTServer.js +31 -27
  33. package/dist/server/PatchesHistoryManager.d.ts +23 -4
  34. package/dist/server/PatchesHistoryManager.js +40 -4
  35. package/dist/server/PatchesServer.d.ts +25 -9
  36. package/dist/server/RevConflictError.d.ts +13 -0
  37. package/dist/server/RevConflictError.js +10 -0
  38. package/dist/server/index.d.ts +5 -1
  39. package/dist/server/index.js +15 -0
  40. package/dist/server/jsonReadable.d.ts +22 -0
  41. package/dist/server/jsonReadable.js +68 -0
  42. package/dist/server/types.d.ts +30 -16
  43. package/package.json +1 -1
@@ -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, options = {}) {
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
- * Reconstructs state from snapshot + ops changed since snapshot.
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 The document state and revision, or `{ state: {}, rev: 0 }` if not found.
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
- if (ops.length === 0) {
46
- return { state: baseState, rev: baseRev };
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 state = new JSONPatch(ops).apply(baseState);
49
- const rev = Math.max(baseRev, ...ops.map((op) => op.rev ?? 0));
50
- return { state, rev };
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 containing 0 or 1 synthesized changes.
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 Array containing 0-1 changes with catchup ops and new rev.
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 { state, rev } = await this.getDoc(docId);
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 { state, rev } = await this.getDoc(docId);
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: Date.now(),
189
- endedAt: Date.now(),
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, state);
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, stateAtRev, []);
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, state, changes);
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, PatchesState, ChangeInput, ChangeMutator, EditableVersionMetadata } from '../types.js';
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 The current state of the document.
54
+ * @returns A ReadableStream of JSON string chunks.
59
55
  */
60
- getDoc(docId: string): Promise<PatchesState>;
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 The changes that occurred after the specified revision.
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: both catchup changes (from other
72
- * clients) and the client's own transformed changes. Only the new changes are
73
- * broadcast to other clients.
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 Combined array of catchup changes followed by the client's committed changes.
76
+ * @returns An object with the committed changes and an optional reload flag.
79
77
  */
80
- commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<Change[]>;
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
- * @param mutator
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
  }
@@ -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 The current state of the document.
38
+ * @returns A ReadableStream of JSON string chunks.
41
39
  */
42
40
  async getDoc(docId) {
43
- return getStateAtRevision(this.store, docId);
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 The changes that occurred after the specified revision.
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: both catchup changes (from other
58
- * clients) and the client's own transformed changes. Only the new changes are
59
- * broadcast to other clients.
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 Combined array of catchup changes followed by the client's committed changes.
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
- return [...catchupChanges, ...newChanges];
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
- * @param mutator
87
- * @returns
89
+ * Stateless — uses getCurrentRev instead of loading document state.
88
90
  */
89
91
  async change(docId, mutator, metadata) {
90
- const { state, rev } = await this.getDoc(docId);
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 { rev } = await this.getDoc(docId);
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 { state: initialState, changes } = await getSnapshotAtRevision(this.store, docId);
130
- const state = applyChanges(initialState, changes);
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 The document state at that version.
50
- * @throws Error if the version ID is not found or state loading fails.
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<any>;
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<any>;
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 The document state at that version.
53
- * @throws Error if the version ID is not found or state loading fails.
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
- return await this.store.loadVersionState(docId, versionId);
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 { PatchesState, Change, ChangeInput, CommitChangesOptions, DeleteDocOptions, EditableVersionMetadata, ChangeMutator } from '../types.js';
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 The document state and revision, or `{ state: {}, rev: 0 }` if not found.
42
+ * @returns A ReadableStream of JSON string chunks.
34
43
  */
35
- getDoc(docId: string): Promise<PatchesState>;
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 specified revision, in revision order.
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
- * both catchup changes (from other clients) and the client's own transformed changes.
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 Combined array of catchup changes followed by the client's committed changes.
66
+ * @returns An object with the committed changes and an optional reload flag.
54
67
  */
55
- commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<Change[]>;
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 };