@dabble/patches 0.7.22 → 0.7.24

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 (71) hide show
  1. package/dist/{BaseDoc-CD5wZQMm.d.ts → BaseDoc-BT18xPxU.d.ts} +1 -1
  2. package/dist/algorithms/lww/consolidateOps.js +2 -0
  3. package/dist/algorithms/ot/client/applyCommittedChanges.js +1 -7
  4. package/dist/algorithms/ot/server/buildVersionState.d.ts +51 -0
  5. package/dist/algorithms/ot/server/buildVersionState.js +67 -0
  6. package/dist/algorithms/ot/server/commitChanges.d.ts +16 -14
  7. package/dist/algorithms/ot/server/commitChanges.js +58 -75
  8. package/dist/algorithms/ot/server/createVersion.d.ts +36 -7
  9. package/dist/algorithms/ot/server/createVersion.js +24 -5
  10. package/dist/algorithms/ot/server/getSnapshotAtRevision.d.ts +13 -1
  11. package/dist/algorithms/ot/server/getSnapshotAtRevision.js +20 -9
  12. package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.d.ts +11 -11
  13. package/dist/algorithms/ot/server/handleOfflineSessionsAndBatches.js +14 -52
  14. package/dist/algorithms/ot/server/transformIncomingChanges.d.ts +4 -3
  15. package/dist/algorithms/ot/server/transformIncomingChanges.js +2 -16
  16. package/dist/algorithms/ot/shared/applyChanges.js +5 -1
  17. package/dist/algorithms/ot/shared/changeBatching.js +3 -3
  18. package/dist/client/BaseDoc.d.ts +1 -1
  19. package/dist/client/ClientAlgorithm.d.ts +1 -1
  20. package/dist/client/LWWAlgorithm.d.ts +1 -1
  21. package/dist/client/LWWDoc.d.ts +1 -1
  22. package/dist/client/LWWInMemoryStore.js +4 -0
  23. package/dist/client/LWWIndexedDBStore.js +7 -0
  24. package/dist/client/OTAlgorithm.d.ts +1 -1
  25. package/dist/client/OTDoc.d.ts +1 -1
  26. package/dist/client/Patches.d.ts +1 -1
  27. package/dist/client/Patches.js +6 -2
  28. package/dist/client/PatchesDoc.d.ts +1 -1
  29. package/dist/client/factories.d.ts +1 -1
  30. package/dist/client/index.d.ts +1 -1
  31. package/dist/compression/lz.js +3 -4
  32. package/dist/index.d.ts +1 -1
  33. package/dist/json-patch/invertPatch.js +2 -1
  34. package/dist/net/PatchesClient.d.ts +4 -1
  35. package/dist/net/PatchesSync.d.ts +1 -1
  36. package/dist/net/PatchesSync.js +14 -3
  37. package/dist/net/index.d.ts +1 -1
  38. package/dist/net/protocol/JSONRPCServer.js +15 -0
  39. package/dist/net/protocol/types.d.ts +5 -2
  40. package/dist/server/CompressedStoreBackend.d.ts +4 -3
  41. package/dist/server/CompressedStoreBackend.js +8 -13
  42. package/dist/server/LWWBranchManager.js +12 -3
  43. package/dist/server/LWWMemoryStoreBackend.d.ts +5 -8
  44. package/dist/server/LWWMemoryStoreBackend.js +11 -6
  45. package/dist/server/LWWServer.d.ts +16 -15
  46. package/dist/server/LWWServer.js +28 -27
  47. package/dist/server/OTBranchManager.js +9 -6
  48. package/dist/server/OTServer.d.ts +23 -19
  49. package/dist/server/OTServer.js +31 -27
  50. package/dist/server/PatchesHistoryManager.d.ts +23 -4
  51. package/dist/server/PatchesHistoryManager.js +42 -6
  52. package/dist/server/PatchesServer.d.ts +25 -9
  53. package/dist/server/RevConflictError.d.ts +13 -0
  54. package/dist/server/RevConflictError.js +10 -0
  55. package/dist/server/branchUtils.js +1 -1
  56. package/dist/server/index.d.ts +5 -1
  57. package/dist/server/index.js +15 -0
  58. package/dist/server/jsonReadable.d.ts +22 -0
  59. package/dist/server/jsonReadable.js +68 -0
  60. package/dist/server/types.d.ts +30 -16
  61. package/dist/shared/doc-manager.d.ts +1 -1
  62. package/dist/solid/context.d.ts +1 -1
  63. package/dist/solid/doc-manager.d.ts +1 -1
  64. package/dist/solid/index.d.ts +1 -1
  65. package/dist/solid/primitives.d.ts +1 -1
  66. package/dist/vue/composables.d.ts +1 -1
  67. package/dist/vue/doc-manager.d.ts +1 -1
  68. package/dist/vue/index.d.ts +1 -1
  69. package/dist/vue/managed-docs.d.ts +2 -2
  70. package/dist/vue/provider.d.ts +1 -1
  71. package/package.json +13 -12
@@ -237,4 +237,4 @@ declare abstract class BaseDoc<T extends object = object> extends ReadonlyStoreC
237
237
  abstract import(snapshot: PatchesSnapshot<T>): void;
238
238
  }
239
239
 
240
- export { BaseDoc as B, OTDoc as O, type PatchesDocOptions as P, type PatchesDoc as a };
240
+ export { BaseDoc as B, OTDoc as O, type PatchesDoc as P, type PatchesDocOptions as a };
@@ -60,6 +60,8 @@ function consolidateOps(existingOps, newOps) {
60
60
  const consolidated = consolidateFieldOp(existing, newOp);
61
61
  if (consolidated !== null) {
62
62
  opsToSave.push(consolidated);
63
+ } else if (!isSoftOp(newOp) && !combinableOps[newOp.op]) {
64
+ if (!opsToReturnMap.has(existing.path)) opsToReturnMap.set(existing.path, existing);
63
65
  }
64
66
  } else {
65
67
  if (isSoftOp(newOp)) {
@@ -17,13 +17,7 @@ function applyCommittedChanges(snapshot, committedChangesFromServer) {
17
17
  );
18
18
  }
19
19
  }
20
- try {
21
- state = applyChanges(state, newServerChanges);
22
- } catch (error) {
23
- console.error("Failed to apply server changes to committed state:", error);
24
- const errorMessage = error instanceof Error ? error.message : String(error);
25
- throw new Error(`Critical sync error applying server changes: ${errorMessage}`);
26
- }
20
+ state = applyChanges(state, newServerChanges);
27
21
  rev = lastChange.rev;
28
22
  if (changes && changes.length > 0) {
29
23
  changes = rebaseChanges(newServerChanges, changes);
@@ -0,0 +1,51 @@
1
+ import { OTStoreBackend } from '../../../server/types.js';
2
+ import { VersionMetadata, Change, PatchesState } from '../../../types.js';
3
+ import '../../../json-patch/types.js';
4
+ import '../../../json-patch/JSONPatch.js';
5
+ import '@dabble/delta';
6
+
7
+ /**
8
+ * Computes the document state at `version.startRev - 1`: the state just before
9
+ * this version's changes begin.
10
+ *
11
+ * Uses `version.parentId` (via `store.loadVersion`) when available for
12
+ * an efficient cached lookup. After loading the parent state, bridges any gap
13
+ * between the parent's `endRev` and `version.startRev` by applying intermediate
14
+ * changes. When no parentId exists (first version ever), builds from scratch.
15
+ *
16
+ * @param store - The store backend.
17
+ * @param docId - The document ID.
18
+ * @param version - The version whose pre-state to compute.
19
+ * @returns `{ state, rev }` at `version.startRev - 1`.
20
+ */
21
+ declare function getBaseStateBeforeVersion(store: OTStoreBackend, docId: string, version: VersionMetadata): Promise<PatchesState>;
22
+ /**
23
+ * Returns the document state immediately before a version's changes begin,
24
+ * as a ReadableStream.
25
+ *
26
+ * Fast path: when the parent state needs no modification (no gap between the
27
+ * parent's `endRev` and `version.startRev`), the raw bytes are streamed
28
+ * directly from the store without parsing.
29
+ *
30
+ * Slow path: when there is a gap, the parent state is parsed, intermediate
31
+ * changes are applied, and the result is re-serialized.
32
+ *
33
+ * @param store - The store backend.
34
+ * @param docId - The document ID.
35
+ * @param version - The version whose pre-state to compute.
36
+ * @returns A ReadableStream of the JSON state at `version.startRev - 1`.
37
+ */
38
+ declare function getStateBeforeVersionAsStream(store: OTStoreBackend, docId: string, version: VersionMetadata): Promise<ReadableStream<string>>;
39
+ /**
40
+ * Builds the document state for a version by computing the base state
41
+ * (via `getBaseStateBeforeVersion`) and applying the version's changes on top.
42
+ *
43
+ * @param store - The store backend to load previous version state from.
44
+ * @param docId - The document ID.
45
+ * @param version - The version metadata.
46
+ * @param changes - The changes included in this version.
47
+ * @returns The built state for the version.
48
+ */
49
+ declare function buildVersionState(store: OTStoreBackend, docId: string, version: VersionMetadata, changes: Change[]): Promise<any>;
50
+
51
+ export { buildVersionState, getBaseStateBeforeVersion, getStateBeforeVersionAsStream };
@@ -0,0 +1,67 @@
1
+ import "../../../chunk-IZ2YBCUP.js";
2
+ import { jsonReadable, parseVersionState } from "../../../server/jsonReadable.js";
3
+ import { applyChanges } from "../shared/applyChanges.js";
4
+ async function getBaseStateBeforeVersion(store, docId, version) {
5
+ let baseState = null;
6
+ let baseRev = 0;
7
+ if (version.parentId) {
8
+ const [rawState, parentMeta] = await Promise.all([
9
+ store.loadVersionState(docId, version.parentId),
10
+ store.loadVersion(docId, version.parentId)
11
+ ]);
12
+ if (parentMeta && rawState !== void 0) {
13
+ baseState = await parseVersionState(rawState);
14
+ baseRev = parentMeta.endRev;
15
+ }
16
+ }
17
+ if (baseRev < version.startRev - 1) {
18
+ const gapChanges = await store.listChanges(docId, {
19
+ startAfter: baseRev,
20
+ endBefore: version.startRev
21
+ });
22
+ if (gapChanges.length > 0) {
23
+ baseState = applyChanges(baseState, gapChanges);
24
+ }
25
+ }
26
+ return { state: baseState, rev: version.startRev - 1 };
27
+ }
28
+ async function getStateBeforeVersionAsStream(store, docId, version) {
29
+ if (version.parentId) {
30
+ const [rawState, parentMeta] = await Promise.all([
31
+ store.loadVersionState(docId, version.parentId),
32
+ store.loadVersion(docId, version.parentId)
33
+ ]);
34
+ if (parentMeta && rawState !== void 0) {
35
+ const baseRev = parentMeta.endRev;
36
+ if (baseRev >= version.startRev - 1) {
37
+ return typeof rawState === "string" ? jsonReadable(rawState) : rawState;
38
+ }
39
+ const baseState = await parseVersionState(rawState);
40
+ const gapChanges = await store.listChanges(docId, {
41
+ startAfter: baseRev,
42
+ endBefore: version.startRev
43
+ });
44
+ const state = gapChanges.length > 0 ? applyChanges(baseState, gapChanges) : baseState;
45
+ return jsonReadable(JSON.stringify(state));
46
+ }
47
+ }
48
+ if (version.startRev > 1) {
49
+ const gapChanges = await store.listChanges(docId, {
50
+ startAfter: 0,
51
+ endBefore: version.startRev
52
+ });
53
+ if (gapChanges.length > 0) {
54
+ return jsonReadable(JSON.stringify(applyChanges(null, gapChanges)));
55
+ }
56
+ }
57
+ return jsonReadable("null");
58
+ }
59
+ async function buildVersionState(store, docId, version, changes) {
60
+ const { state: baseState } = await getBaseStateBeforeVersion(store, docId, version);
61
+ return applyChanges(baseState, changes);
62
+ }
63
+ export {
64
+ buildVersionState,
65
+ getBaseStateBeforeVersion,
66
+ getStateBeforeVersionAsStream
67
+ };
@@ -12,32 +12,34 @@ import '../../../net/protocol/types.js';
12
12
  /**
13
13
  * Commits a set of changes to a document, applying operational transformation as needed.
14
14
  *
15
- * ## Offline-First Catchup Optimization
15
+ * ## Stateless Design
16
16
  *
17
- * When a client that has never synced (baseRev: 0) commits changes to an existing document,
18
- * the server applies an optimization to avoid expensive transformation through potentially
19
- * thousands of historical changes. Instead of:
17
+ * This function never loads or builds document state. It uses `getCurrentRev` to get the
18
+ * current revision and transforms changes against committed changes only (no state parameter
19
+ * passed to transformPatch). Bad ops become noops during transformation.
20
20
  *
21
- * 1. Transforming the client's changes against all N existing changes
22
- * 2. Returning all N changes as catchup for the client to apply
21
+ * ## Version Creation
23
22
  *
24
- * The server:
25
- * 1. Rebases the client's baseRev to the current revision (treats changes as if made at head)
26
- * 2. Returns a synthetic catchup change with `{ op: 'replace', path: '', value: currentState }`
23
+ * Versions are created (metadata + changes saved to store) when session timeouts are
24
+ * detected. After saving, `onVersionCreated` is emitted so subscribers can build and
25
+ * persist state out of band.
27
26
  *
28
- * This single root-level replace gives the client the full current document state efficiently.
29
- * The client's `applyCommittedChanges` recognizes this pattern and allows the revision jump.
27
+ * ## Conflict Retry
28
+ *
29
+ * When a store's `saveChanges` throws `RevConflictError` (e.g. because another server
30
+ * instance committed the same revision), the function retries: re-reads `currentRev`,
31
+ * re-fetches committed changes, re-transforms, and re-saves. The retry naturally resolves
32
+ * because the fresh `currentRev` includes the conflicting commit.
30
33
  *
31
34
  * @param store - The backend store for persistence.
32
35
  * @param docId - The ID of the document.
33
36
  * @param changes - The changes to commit.
34
37
  * @param sessionTimeoutMillis - Timeout for session-based versioning.
35
38
  * @param options - Optional commit settings.
36
- * @param maxStorageBytes - Optional max bytes per change for storage limits.
37
39
  * @returns A CommitResult containing:
38
- * - catchupChanges: Changes the client missed (or a synthetic root-replace for offline-first clients)
40
+ * - catchupChanges: Changes the client missed
39
41
  * - newChanges: The client's changes after transformation
40
42
  */
41
- declare function commitChanges(store: OTStoreBackend, docId: string, changes: ChangeInput[], sessionTimeoutMillis: number, options?: CommitChangesOptions, maxStorageBytes?: number): Promise<CommitResult>;
43
+ declare function commitChanges(store: OTStoreBackend, docId: string, changes: ChangeInput[], sessionTimeoutMillis: number, options?: CommitChangesOptions): Promise<CommitResult>;
42
44
 
43
45
  export { CommitChangesOptions, CommitResult, commitChanges };
@@ -1,34 +1,26 @@
1
1
  import "../../../chunk-IZ2YBCUP.js";
2
- import { createId } from "crypto-id";
3
- import { filterSoftWritesAgainstState } from "../../../json-patch/utils/softWrites.js";
4
- import { applyChanges } from "../shared/applyChanges.js";
5
- import { createVersion } from "./createVersion.js";
6
- import { getSnapshotAtRevision } from "./getSnapshotAtRevision.js";
7
- import { getStateAtRevision } from "./getStateAtRevision.js";
2
+ import { RevConflictError } from "../../../server/RevConflictError.js";
3
+ import { createVersionAtRev } from "./createVersion.js";
8
4
  import { handleOfflineSessionsAndBatches } from "./handleOfflineSessionsAndBatches.js";
9
5
  import { transformIncomingChanges } from "./transformIncomingChanges.js";
10
- async function commitChanges(store, docId, changes, sessionTimeoutMillis, options, maxStorageBytes) {
6
+ const MAX_CONFLICT_RETRIES = 5;
7
+ async function commitChanges(store, docId, changes, sessionTimeoutMillis, options) {
11
8
  if (changes.length === 0) {
12
9
  return { catchupChanges: [], newChanges: [] };
13
10
  }
14
11
  const batchId = changes[0].batchId;
15
- const { state: initialState, rev: initialRev, changes: currentChanges } = await getSnapshotAtRevision(store, docId);
16
- const currentState = applyChanges(initialState, currentChanges);
17
- const currentRev = currentChanges.at(-1)?.rev ?? initialRev;
18
- let baseRev = changes[0].baseRev ?? currentRev;
12
+ const initialRev = await store.getCurrentRev(docId);
13
+ let baseRev = changes[0].baseRev ?? initialRev;
19
14
  const batchedContinuation = batchId && changes[0].rev > 1;
20
- const clientBaseRev = changes[0].baseRev ?? currentRev;
21
- let needsSyntheticCatchup = false;
22
- if (changes[0].baseRev === 0 && currentRev > 0 && !batchedContinuation) {
15
+ let docReloadRequired;
16
+ if (changes[0].baseRev === 0 && initialRev > 0 && !batchedContinuation) {
23
17
  const hasRootOp = changes.some((c) => c.ops.some((op) => op.path === ""));
24
18
  if (!hasRootOp) {
25
- needsSyntheticCatchup = true;
26
- baseRev = currentRev;
27
- changes = changes.filter((c) => {
19
+ docReloadRequired = true;
20
+ baseRev = initialRev;
21
+ for (const c of changes) {
28
22
  c.baseRev = baseRev;
29
- c.ops = filterSoftWritesAgainstState(c.ops, currentState);
30
- return c.ops.length > 0;
31
- });
23
+ }
32
24
  }
33
25
  }
34
26
  const serverNow = Date.now();
@@ -45,72 +37,63 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
45
37
  }
46
38
  c.createdAt = c.createdAt ? Math.min(c.createdAt, serverNow) : serverNow;
47
39
  });
48
- if (baseRev > currentRev) {
40
+ if (baseRev > initialRev) {
49
41
  throw new Error(
50
- `Client baseRev (${baseRev}) is ahead of server revision (${currentRev}) for doc ${docId}. Client needs to reload the document.`
42
+ `Client baseRev (${baseRev}) is ahead of server revision (${initialRev}) for doc ${docId}. Client needs to reload the document.`
51
43
  );
52
44
  }
53
- if (changes[0].baseRev === 0 && currentRev > 0 && !batchedContinuation && changes[0].ops[0]?.path === "") {
45
+ if (changes[0].baseRev === 0 && initialRev > 0 && !batchedContinuation && changes[0].ops[0]?.path === "") {
54
46
  throw new Error(
55
- `Document ${docId} already exists (rev ${currentRev}). Cannot apply root-level replace (path: '') with baseRev 0 - this would overwrite the existing document. Load the existing document first, or use nested paths instead of replacing at root.`
47
+ `Document ${docId} already exists (rev ${initialRev}). Cannot apply root-level replace (path: '') with baseRev 0 - this would overwrite the existing document. Load the existing document first, or use nested paths instead of replacing at root.`
56
48
  );
57
49
  }
58
- const lastChange = currentChanges[currentChanges.length - 1];
50
+ const [lastChange] = await store.listChanges(docId, { reverse: true, limit: 1 });
59
51
  const compareTime = options?.historicalImport ? changes[0].createdAt ?? serverNow : serverNow;
60
52
  if (lastChange && compareTime - lastChange.createdAt > sessionTimeoutMillis) {
61
- await createVersion(store, docId, currentState, currentChanges);
62
- }
63
- const committedChanges = await store.listChanges(docId, {
64
- startAfter: baseRev,
65
- withoutBatchId: batchId
66
- });
67
- const committedIds = new Set(committedChanges.map((c) => c.id));
68
- let incomingChanges = changes.filter((c) => !committedIds.has(c.id));
69
- if (incomingChanges.length === 0) {
70
- return { catchupChanges: committedChanges, newChanges: [] };
53
+ await createVersionAtRev(store, docId, lastChange.rev);
71
54
  }
72
- const isOfflineTimestamp = serverNow - incomingChanges[0].createdAt > sessionTimeoutMillis;
73
- if (isOfflineTimestamp || batchId) {
74
- const canFastForward = committedChanges.length === 0;
75
- const origin = options?.historicalImport ? "main" : canFastForward ? "main" : "offline-branch";
76
- incomingChanges = await handleOfflineSessionsAndBatches(
77
- store,
78
- sessionTimeoutMillis,
79
- docId,
80
- incomingChanges,
81
- baseRev,
82
- batchId,
83
- origin,
84
- maxStorageBytes
85
- );
86
- if (canFastForward) {
87
- await store.saveChanges(docId, incomingChanges);
88
- return { catchupChanges: [], newChanges: incomingChanges };
55
+ let offlineSessionsHandled = false;
56
+ for (let attempt = 0; attempt < MAX_CONFLICT_RETRIES; attempt++) {
57
+ try {
58
+ const currentRev = attempt === 0 ? initialRev : await store.getCurrentRev(docId);
59
+ const committedChanges = await store.listChanges(docId, {
60
+ startAfter: baseRev,
61
+ withoutBatchId: batchId
62
+ });
63
+ const committedIds = new Set(committedChanges.map((c) => c.id));
64
+ const incomingChanges = changes.filter((c) => !committedIds.has(c.id));
65
+ if (incomingChanges.length === 0) {
66
+ return { catchupChanges: committedChanges, newChanges: [], docReloadRequired };
67
+ }
68
+ const isOfflineTimestamp = serverNow - incomingChanges[0].createdAt > sessionTimeoutMillis;
69
+ if (isOfflineTimestamp || batchId) {
70
+ const canFastForward = committedChanges.length === 0;
71
+ if (!offlineSessionsHandled) {
72
+ const origin = options?.historicalImport ? "main" : canFastForward ? "main" : "offline-branch";
73
+ await handleOfflineSessionsAndBatches(store, sessionTimeoutMillis, docId, incomingChanges, origin);
74
+ offlineSessionsHandled = true;
75
+ }
76
+ if (canFastForward) {
77
+ await store.saveChanges(docId, incomingChanges);
78
+ return { catchupChanges: [], newChanges: incomingChanges, docReloadRequired };
79
+ }
80
+ }
81
+ const transformedChanges = transformIncomingChanges(
82
+ incomingChanges,
83
+ committedChanges,
84
+ currentRev,
85
+ options?.forceCommit
86
+ );
87
+ if (transformedChanges.length > 0) {
88
+ await store.saveChanges(docId, transformedChanges);
89
+ }
90
+ return { catchupChanges: committedChanges, newChanges: transformedChanges, docReloadRequired };
91
+ } catch (error) {
92
+ if (error instanceof RevConflictError && attempt < MAX_CONFLICT_RETRIES - 1) continue;
93
+ throw error;
89
94
  }
90
95
  }
91
- const stateAtBaseRev = (await getStateAtRevision(store, docId, baseRev)).state;
92
- const transformedChanges = transformIncomingChanges(
93
- incomingChanges,
94
- stateAtBaseRev,
95
- committedChanges,
96
- currentRev,
97
- options?.forceCommit
98
- );
99
- if (transformedChanges.length > 0) {
100
- await store.saveChanges(docId, transformedChanges);
101
- }
102
- if (needsSyntheticCatchup && clientBaseRev === 0) {
103
- const syntheticCatchup = {
104
- id: `catchup-${createId(8)}`,
105
- baseRev: clientBaseRev,
106
- rev: currentRev,
107
- ops: [{ op: "replace", path: "", value: currentState }],
108
- createdAt: serverNow,
109
- committedAt: serverNow
110
- };
111
- return { catchupChanges: [syntheticCatchup], newChanges: transformedChanges };
112
- }
113
- return { catchupChanges: committedChanges, newChanges: transformedChanges };
96
+ throw new Error(`commitChanges: exhausted ${MAX_CONFLICT_RETRIES} retries for doc ${docId}`);
114
97
  }
115
98
  export {
116
99
  commitChanges
@@ -1,18 +1,47 @@
1
1
  import { OTStoreBackend } from '../../../server/types.js';
2
- import { Change, EditableVersionMetadata, VersionMetadata } from '../../../types.js';
2
+ import { EditableVersionMetadata, Change, VersionMetadata } from '../../../types.js';
3
3
  import '../../../json-patch/types.js';
4
4
  import '../../../json-patch/JSONPatch.js';
5
5
  import '@dabble/delta';
6
6
 
7
7
  /**
8
- * Creates a new version snapshot of a document's state from changes.
8
+ * Options for creating a version.
9
+ */
10
+ interface CreateVersionOptions {
11
+ /** The origin of the version. Defaults to 'main'. */
12
+ origin?: 'main' | 'offline-branch';
13
+ /** ID of the parent version in the history chain. */
14
+ parentId?: string;
15
+ /** Optional additional metadata for the version. */
16
+ metadata?: EditableVersionMetadata;
17
+ }
18
+ /**
19
+ * Creates a version for all changes since the last version up to (and including) `endRev`.
20
+ *
21
+ * Looks up the last existing version to determine `startRev`, then loads all
22
+ * changes from there to `endRev` from the change log. This ensures the version
23
+ * covers the complete session, not just a single change.
24
+ *
25
+ * @param store The storage backend.
26
+ * @param docId The document ID.
27
+ * @param endRev The revision to end the version at (inclusive).
28
+ * @param options Options including origin and metadata.
29
+ * @returns The created version metadata, or undefined if no changes were found.
30
+ */
31
+ declare function createVersionAtRev(store: OTStoreBackend, docId: string, endRev: number, options?: CreateVersionOptions): Promise<VersionMetadata | undefined>;
32
+ /**
33
+ * Creates a new version record from changes.
34
+ *
35
+ * Saves metadata and changes to the store via `store.createVersion`. The store
36
+ * implementation is responsible for building and persisting version state — inline
37
+ * or queued — and must throw if that fails.
38
+ *
9
39
  * @param store The storage backend to save the version to.
10
40
  * @param docId The document ID.
11
- * @param state The document state at the time of the version.
12
- * @param changes The changes since the last version that created the state.
13
- * @param metadata Optional additional metadata for the version.
41
+ * @param changes The changes since the last version.
42
+ * @param options Options including origin and metadata.
14
43
  * @returns The created version metadata, or undefined if no changes provided.
15
44
  */
16
- declare function createVersion(store: OTStoreBackend, docId: string, state: any, changes: Change[], metadata?: EditableVersionMetadata): Promise<VersionMetadata | undefined>;
45
+ declare function createVersion(store: OTStoreBackend, docId: string, changes: Change[], options?: CreateVersionOptions): Promise<VersionMetadata | undefined>;
17
46
 
18
- export { createVersion };
47
+ export { type CreateVersionOptions, createVersion, createVersionAtRev };
@@ -1,22 +1,41 @@
1
1
  import "../../../chunk-IZ2YBCUP.js";
2
2
  import { createVersionMetadata } from "../../../data/version.js";
3
- async function createVersion(store, docId, state, changes, metadata) {
3
+ async function createVersionAtRev(store, docId, endRev, options) {
4
+ const [lastVersion] = await store.listVersions(docId, {
5
+ limit: 1,
6
+ reverse: true,
7
+ orderBy: "endRev"
8
+ });
9
+ const startAfterRev = lastVersion?.endRev ?? 0;
10
+ const changes = await store.listChanges(docId, {
11
+ startAfter: startAfterRev,
12
+ endBefore: endRev + 1
13
+ });
14
+ if (changes.length === 0) return void 0;
15
+ return createVersion(store, docId, changes, {
16
+ ...options,
17
+ parentId: options?.parentId ?? lastVersion?.id
18
+ });
19
+ }
20
+ async function createVersion(store, docId, changes, options) {
4
21
  if (changes.length === 0) return;
5
22
  const startRev = changes[0].rev;
6
23
  if (startRev === void 0) {
7
24
  throw new Error(`Client changes must include rev for doc ${docId}.`);
8
25
  }
9
26
  const sessionMetadata = createVersionMetadata({
10
- origin: "main",
27
+ origin: options?.origin ?? "main",
11
28
  startedAt: changes[0].createdAt,
12
29
  endedAt: changes[changes.length - 1].createdAt,
13
30
  endRev: changes[changes.length - 1].rev,
14
31
  startRev,
15
- ...metadata
32
+ ...options?.parentId !== void 0 && { parentId: options.parentId },
33
+ ...options?.metadata
16
34
  });
17
- await store.createVersion(docId, sessionMetadata, state, changes);
35
+ await store.createVersion(docId, sessionMetadata, changes);
18
36
  return sessionMetadata;
19
37
  }
20
38
  export {
21
- createVersion
39
+ createVersion,
40
+ createVersionAtRev
22
41
  };
@@ -7,10 +7,22 @@ import '@dabble/delta';
7
7
  /**
8
8
  * Retrieves the document state of the version before the given revision and changes after up to that revision or all
9
9
  * changes since that version.
10
+ *
11
+ * Note: This is NOT used in the hot path (commitChanges). It's used by explicit operations
12
+ * like captureCurrentVersion and createBranch that need the parsed state.
13
+ *
10
14
  * @param docId The document ID.
11
15
  * @param rev The revision number. If not provided, the latest state, its revision, and all changes since are returned.
12
16
  * @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).
13
17
  */
14
18
  declare function getSnapshotAtRevision(store: OTStoreBackend, docId: string, rev?: number): Promise<PatchesSnapshot>;
19
+ /**
20
+ * Returns a ReadableStream that streams the snapshot JSON envelope piece-by-piece:
21
+ * `{"state":...,"rev":N,"changes":[...]}`.
22
+ *
23
+ * The version state (typically the largest payload) flows through as a raw
24
+ * string from the store without parsing. Changes are stringified from the array.
25
+ */
26
+ declare function getSnapshotStream(store: OTStoreBackend, docId: string): Promise<ReadableStream<string>>;
15
27
 
16
- export { getSnapshotAtRevision };
28
+ export { getSnapshotAtRevision, getSnapshotStream };
@@ -1,28 +1,39 @@
1
1
  import "../../../chunk-IZ2YBCUP.js";
2
- async function getSnapshotAtRevision(store, docId, rev) {
2
+ import { concatStreams, parseVersionState } from "../../../server/jsonReadable.js";
3
+ async function getLatestMainVersion(store, docId, beforeRev) {
3
4
  const versions = await store.listVersions(docId, {
4
5
  limit: 1,
5
6
  reverse: true,
6
- startAfter: rev ? rev + 1 : void 0,
7
+ startAfter: beforeRev ? beforeRev + 1 : void 0,
7
8
  origin: "main",
8
9
  orderBy: "endRev"
9
10
  });
10
- const latestMainVersion = versions[0];
11
- const versionState = latestMainVersion && await store.loadVersionState(docId, latestMainVersion.id) || null;
12
- const versionRev = latestMainVersion?.endRev ?? 0;
11
+ const version = versions[0];
12
+ return {
13
+ rawState: version ? await store.loadVersionState(docId, version.id) : void 0,
14
+ versionRev: version?.endRev ?? 0
15
+ };
16
+ }
17
+ async function getSnapshotAtRevision(store, docId, rev) {
18
+ const { rawState, versionRev } = await getLatestMainVersion(store, docId, rev);
19
+ const versionState = rawState ? await parseVersionState(rawState) : null;
13
20
  const changesSinceVersion = await store.listChanges(docId, {
14
21
  startAfter: versionRev,
15
22
  endBefore: rev ? rev + 1 : void 0
16
23
  });
17
24
  return {
18
25
  state: versionState,
19
- // State from the base version
20
26
  rev: versionRev,
21
- // Revision of the base version's state
22
27
  changes: changesSinceVersion
23
- // Changes that occurred *after* the base version state
24
28
  };
25
29
  }
30
+ async function getSnapshotStream(store, docId) {
31
+ const { rawState, versionRev } = await getLatestMainVersion(store, docId);
32
+ const statePayload = rawState ?? "null";
33
+ const changes = await store.listChanges(docId, { startAfter: versionRev });
34
+ return concatStreams(`{"state":`, statePayload, `,"rev":${versionRev},"changes":`, JSON.stringify(changes), "}");
35
+ }
26
36
  export {
27
- getSnapshotAtRevision
37
+ getSnapshotAtRevision,
38
+ getSnapshotStream
28
39
  };
@@ -6,20 +6,20 @@ import '@dabble/delta';
6
6
 
7
7
  /**
8
8
  * Handles offline/large batch versioning logic for multi-batch uploads.
9
- * Groups changes into sessions, merges with previous batch if needed, and creates/extends versions.
10
9
  *
11
- * Each session's `isOffline` metadata is determined per-session by comparing `committedAt` and
12
- * `createdAt` on the first change if the gap exceeds `sessionTimeoutMillis`, the session is
13
- * marked offline.
10
+ * Detects session boundaries from timestamps and creates versions for each session.
11
+ * Changes are never collapsedthey're preserved individually.
14
12
  *
13
+ * Each session version gets a `parentId` linking it to the preceding version:
14
+ * - Session 1's parent: the last main-timeline version before the batch's first change
15
+ * - Session N's parent (N > 1): the immediately preceding session's version
16
+ *
17
+ * @param store Store backend for version creation
18
+ * @param sessionTimeoutMillis Timeout for detecting session boundaries
15
19
  * @param docId Document ID
16
- * @param changes The incoming changes (all with the same batchId)
17
- * @param baseRev The base revision for the batch
18
- * @param batchId The batch identifier
19
- * @param origin The origin to use for created versions (default: 'offline-branch')
20
- * @param maxStorageBytes If set, break collapsed changes that exceed this size
21
- * @returns The changes (collapsed into one if divergent, unchanged if fast-forward)
20
+ * @param changes The incoming changes
21
+ * @param origin The origin for version metadata
22
22
  */
23
- declare function handleOfflineSessionsAndBatches(store: OTStoreBackend, sessionTimeoutMillis: number, docId: string, changes: Change[], baseRev: number, batchId?: string, origin?: 'main' | 'offline-branch', maxStorageBytes?: number): Promise<Change[]>;
23
+ declare function handleOfflineSessionsAndBatches(store: OTStoreBackend, sessionTimeoutMillis: number, docId: string, changes: Change[], origin?: 'main' | 'offline-branch'): Promise<void>;
24
24
 
25
25
  export { handleOfflineSessionsAndBatches };