@dabble/patches 0.6.0 → 0.7.0

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 (114) hide show
  1. package/README.md +221 -208
  2. package/dist/BaseDoc-DkP3tUhT.d.ts +206 -0
  3. package/dist/algorithms/client/applyCommittedChanges.d.ts +7 -0
  4. package/dist/algorithms/client/applyCommittedChanges.js +6 -3
  5. package/dist/algorithms/lww/consolidateOps.d.ts +40 -0
  6. package/dist/algorithms/lww/consolidateOps.js +103 -0
  7. package/dist/algorithms/lww/index.d.ts +2 -0
  8. package/dist/algorithms/lww/index.js +1 -0
  9. package/dist/algorithms/lww/mergeServerWithLocal.d.ts +22 -0
  10. package/dist/algorithms/lww/mergeServerWithLocal.js +32 -0
  11. package/dist/algorithms/server/commitChanges.d.ts +32 -8
  12. package/dist/algorithms/server/commitChanges.js +20 -5
  13. package/dist/algorithms/server/createVersion.d.ts +1 -1
  14. package/dist/algorithms/server/getSnapshotAtRevision.d.ts +1 -1
  15. package/dist/algorithms/server/getStateAtRevision.d.ts +1 -1
  16. package/dist/algorithms/server/handleOfflineSessionsAndBatches.d.ts +1 -1
  17. package/dist/client/BaseDoc.d.ts +6 -0
  18. package/dist/client/BaseDoc.js +70 -0
  19. package/dist/client/ClientAlgorithm.d.ts +101 -0
  20. package/dist/client/ClientAlgorithm.js +0 -0
  21. package/dist/client/InMemoryStore.d.ts +5 -7
  22. package/dist/client/InMemoryStore.js +6 -35
  23. package/dist/client/IndexedDBStore.d.ts +39 -73
  24. package/dist/client/IndexedDBStore.js +17 -220
  25. package/dist/client/LWWAlgorithm.d.ts +43 -0
  26. package/dist/client/LWWAlgorithm.js +87 -0
  27. package/dist/client/LWWClientStore.d.ts +73 -0
  28. package/dist/client/LWWClientStore.js +0 -0
  29. package/dist/client/LWWDoc.d.ts +56 -0
  30. package/dist/client/LWWDoc.js +84 -0
  31. package/dist/client/LWWInMemoryStore.d.ts +88 -0
  32. package/dist/client/LWWInMemoryStore.js +208 -0
  33. package/dist/client/LWWIndexedDBStore.d.ts +91 -0
  34. package/dist/client/LWWIndexedDBStore.js +275 -0
  35. package/dist/client/OTAlgorithm.d.ts +42 -0
  36. package/dist/client/OTAlgorithm.js +113 -0
  37. package/dist/client/OTClientStore.d.ts +50 -0
  38. package/dist/client/OTClientStore.js +0 -0
  39. package/dist/client/OTDoc.d.ts +6 -0
  40. package/dist/client/OTDoc.js +97 -0
  41. package/dist/client/OTIndexedDBStore.d.ts +84 -0
  42. package/dist/client/OTIndexedDBStore.js +163 -0
  43. package/dist/client/Patches.d.ts +36 -16
  44. package/dist/client/Patches.js +60 -27
  45. package/dist/client/PatchesDoc.d.ts +4 -113
  46. package/dist/client/PatchesDoc.js +3 -153
  47. package/dist/client/PatchesStore.d.ts +8 -105
  48. package/dist/client/factories.d.ts +72 -0
  49. package/dist/client/factories.js +80 -0
  50. package/dist/client/index.d.ts +14 -5
  51. package/dist/client/index.js +9 -0
  52. package/dist/compression/index.d.ts +1 -1
  53. package/dist/data/change.js +2 -0
  54. package/dist/fractionalIndex.d.ts +67 -0
  55. package/dist/fractionalIndex.js +241 -0
  56. package/dist/index.d.ts +13 -3
  57. package/dist/index.js +1 -0
  58. package/dist/json-patch/types.d.ts +2 -0
  59. package/dist/net/PatchesClient.js +15 -15
  60. package/dist/net/PatchesSync.d.ts +24 -12
  61. package/dist/net/PatchesSync.js +56 -64
  62. package/dist/net/index.d.ts +6 -10
  63. package/dist/net/index.js +6 -1
  64. package/dist/net/protocol/JSONRPCClient.d.ts +4 -4
  65. package/dist/net/protocol/JSONRPCClient.js +6 -4
  66. package/dist/net/protocol/JSONRPCServer.d.ts +45 -9
  67. package/dist/net/protocol/JSONRPCServer.js +63 -8
  68. package/dist/net/serverContext.d.ts +38 -0
  69. package/dist/net/serverContext.js +20 -0
  70. package/dist/net/webrtc/WebRTCTransport.js +1 -1
  71. package/dist/net/websocket/AuthorizationProvider.d.ts +3 -3
  72. package/dist/net/websocket/WebSocketServer.d.ts +29 -20
  73. package/dist/net/websocket/WebSocketServer.js +23 -12
  74. package/dist/server/BranchManager.d.ts +50 -0
  75. package/dist/server/BranchManager.js +0 -0
  76. package/dist/server/CompressedStoreBackend.d.ts +7 -5
  77. package/dist/server/CompressedStoreBackend.js +3 -9
  78. package/dist/server/LWWBranchManager.d.ts +82 -0
  79. package/dist/server/LWWBranchManager.js +99 -0
  80. package/dist/server/LWWMemoryStoreBackend.d.ts +78 -0
  81. package/dist/server/LWWMemoryStoreBackend.js +191 -0
  82. package/dist/server/LWWServer.d.ts +130 -0
  83. package/dist/server/LWWServer.js +207 -0
  84. package/dist/server/{PatchesBranchManager.d.ts → OTBranchManager.d.ts} +32 -12
  85. package/dist/server/{PatchesBranchManager.js → OTBranchManager.js} +25 -40
  86. package/dist/server/OTServer.d.ts +108 -0
  87. package/dist/server/OTServer.js +141 -0
  88. package/dist/server/PatchesHistoryManager.d.ts +20 -7
  89. package/dist/server/PatchesHistoryManager.js +26 -3
  90. package/dist/server/PatchesServer.d.ts +70 -81
  91. package/dist/server/PatchesServer.js +0 -175
  92. package/dist/server/branchUtils.d.ts +82 -0
  93. package/dist/server/branchUtils.js +66 -0
  94. package/dist/server/index.d.ts +17 -6
  95. package/dist/server/index.js +33 -4
  96. package/dist/server/tombstone.d.ts +29 -0
  97. package/dist/server/tombstone.js +32 -0
  98. package/dist/server/types.d.ts +128 -26
  99. package/dist/server/utils.d.ts +12 -0
  100. package/dist/server/utils.js +23 -0
  101. package/dist/solid/context.d.ts +5 -4
  102. package/dist/solid/doc-manager.d.ts +3 -3
  103. package/dist/solid/index.d.ts +5 -4
  104. package/dist/solid/primitives.d.ts +2 -3
  105. package/dist/types.d.ts +4 -2
  106. package/dist/vue/composables.d.ts +2 -3
  107. package/dist/vue/doc-manager.d.ts +3 -3
  108. package/dist/vue/index.d.ts +5 -4
  109. package/dist/vue/provider.d.ts +5 -4
  110. package/package.json +1 -1
  111. package/dist/algorithms/client/collapsePendingChanges.d.ts +0 -30
  112. package/dist/algorithms/client/collapsePendingChanges.js +0 -78
  113. package/dist/net/websocket/RPCServer.d.ts +0 -141
  114. package/dist/net/websocket/RPCServer.js +0 -204
@@ -0,0 +1,206 @@
1
+ import { Signal, Unsubscriber } from './event-signal.js';
2
+ import { JSONPatchOp } from './json-patch/types.js';
3
+ import { Change, PatchesSnapshot, SyncingState, ChangeMutator } from './types.js';
4
+
5
+ /**
6
+ * OT (Operational Transformation) document implementation.
7
+ * Uses a snapshot-based approach with revision tracking and rebasing
8
+ * for handling concurrent edits.
9
+ *
10
+ * The `change()` method (inherited from BaseDoc) captures ops and emits them
11
+ * via `onChange` - it does NOT apply locally. The OTStrategy handles packaging
12
+ * ops into Changes, persisting them, and calling `applyChanges()` to update state.
13
+ *
14
+ * ## State Model
15
+ * - `_committedState`: Base state from server (at `_committedRev`)
16
+ * - `_pendingChanges`: Local changes not yet committed by server
17
+ * - `_state` (from BaseDoc): Live state = committedState + pendingChanges applied
18
+ *
19
+ * ## Wire Efficiency
20
+ * For Worker-Tab communication, only changes are sent over the wire (not full state).
21
+ * The unified `applyChanges()` method handles both local and server changes.
22
+ */
23
+ declare class OTDoc<T extends object = object> extends BaseDoc<T> {
24
+ /** Base state from the server at the committed revision. */
25
+ protected _committedState: T;
26
+ /** Last committed revision number from the server. */
27
+ protected _committedRev: number;
28
+ /** Local changes not yet committed by server. */
29
+ protected _pendingChanges: Change[];
30
+ /**
31
+ * Creates an instance of OTDoc.
32
+ * @param id The unique identifier for this document.
33
+ * @param snapshot Optional snapshot to initialize from (state, rev, pending changes).
34
+ */
35
+ constructor(id: string, snapshot?: PatchesSnapshot<T>);
36
+ /** Last committed revision number from the server. */
37
+ get committedRev(): number;
38
+ /** Are there local changes that haven't been committed yet? */
39
+ get hasPending(): boolean;
40
+ /**
41
+ * Returns the pending changes for this document.
42
+ * @returns The pending changes.
43
+ */
44
+ getPendingChanges(): Change[];
45
+ /**
46
+ * Imports document state from a snapshot (e.g., for recovery when out of sync).
47
+ * Resets state and treats all imported changes as pending.
48
+ */
49
+ import(snapshot: PatchesSnapshot<T>): void;
50
+ /**
51
+ * Unified entry point for applying changes.
52
+ * Used for Worker→Tab communication where only changes are sent over the wire.
53
+ *
54
+ * The method distinguishes between committed and pending changes using `committedAt`:
55
+ * - `committedAt > 0`: Server-committed change (apply to committed state)
56
+ * - `committedAt === 0`: Pending local change (append to pending)
57
+ *
58
+ * For server changes, all committed changes come first, followed by rebased pending.
59
+ *
60
+ * @param changes Array of changes to apply
61
+ */
62
+ applyChanges(changes: Change[]): void;
63
+ /**
64
+ * Returns the document snapshot for serialization.
65
+ */
66
+ toJSON(): PatchesSnapshot<T>;
67
+ }
68
+
69
+ /**
70
+ * Options for creating a PatchesDoc instance
71
+ */
72
+ interface PatchesDocOptions {
73
+ /**
74
+ * Maximum size in bytes for a single change's storage representation.
75
+ * Changes exceeding this will be split. Used for backends with row size limits.
76
+ */
77
+ maxStorageBytes?: number;
78
+ /**
79
+ * Custom size calculator for storage limit checks.
80
+ * Import from '@dabble/patches/compression' for actual compression measurement,
81
+ * or provide your own function (e.g., ratio estimate).
82
+ *
83
+ * @example
84
+ * import { compressedSizeBase64 } from '@dabble/patches/compression';
85
+ * { sizeCalculator: compressedSizeBase64, maxStorageBytes: 1_000_000 }
86
+ */
87
+ sizeCalculator?: (data: unknown) => number;
88
+ }
89
+ /**
90
+ * Interface for a document synchronized using JSON patches.
91
+ *
92
+ * This is the app-facing interface. The doc captures user changes as JSON Patch
93
+ * ops and emits them via onChange. The strategy handles packaging ops into Changes,
94
+ * persisting them, and updating the doc's state.
95
+ *
96
+ * This interface is implemented by both OTDoc (Operational Transformation)
97
+ * and LWWDoc (Last-Write-Wins) implementations via BaseDoc.
98
+ *
99
+ * Internal methods (updateSyncing, applyChanges, import) are on BaseDoc, not this interface.
100
+ */
101
+ interface PatchesDoc<T extends object = object> {
102
+ /** The unique identifier for this document. */
103
+ readonly id: string;
104
+ /** Current local state (committed + pending merged). */
105
+ readonly state: T;
106
+ /** Last committed revision number from the server. */
107
+ readonly committedRev: number;
108
+ /** Are there local changes that haven't been committed yet? */
109
+ readonly hasPending: boolean;
110
+ /** Are we currently syncing this document? */
111
+ readonly syncing: SyncingState;
112
+ /**
113
+ * Subscribe to be notified when the user makes local changes.
114
+ * Emits the JSON Patch ops captured from the change() call.
115
+ * The strategy handles packaging these into Changes.
116
+ */
117
+ readonly onChange: Signal<(ops: JSONPatchOp[]) => void>;
118
+ /** Subscribe to be notified whenever state changes from any source. */
119
+ readonly onUpdate: Signal<(newState: T) => void>;
120
+ /** Subscribe to be notified when syncing state changes. */
121
+ readonly onSyncing: Signal<(newSyncing: SyncingState) => void>;
122
+ /** Subscribe to be notified whenever the state changes (calls immediately with current state). */
123
+ subscribe(onUpdate: (newValue: T) => void): Unsubscriber;
124
+ /**
125
+ * Captures an update to the document, emitting JSON Patch ops via onChange.
126
+ * Does NOT apply locally - the strategy handles state updates.
127
+ * @param mutator Function that uses JSONPatch methods with type-safe paths.
128
+ */
129
+ change(mutator: ChangeMutator<T>): void;
130
+ }
131
+
132
+ /**
133
+ * Abstract base class for document implementations.
134
+ * Contains shared state and methods used by both OTDoc and LWWDoc.
135
+ *
136
+ * The `change()` method captures ops and emits them via `onChange` - it does NOT
137
+ * apply locally. The strategy handles packaging ops, persisting them, and updating
138
+ * the doc's state via `applyChanges()`.
139
+ *
140
+ * Internal methods (updateSyncing, applyChanges, import) are on this class but not
141
+ * on the PatchesDoc interface, as they're only used by Strategy and PatchesSync.
142
+ */
143
+ declare abstract class BaseDoc<T extends object = object> implements PatchesDoc<T> {
144
+ protected _id: string;
145
+ protected _state: T;
146
+ protected _syncing: SyncingState;
147
+ /**
148
+ * Subscribe to be notified when the user makes local changes.
149
+ * Emits the JSON Patch ops captured from the change() call.
150
+ * The strategy handles packaging these into Changes.
151
+ */
152
+ readonly onChange: Signal<(ops: JSONPatchOp[]) => void>;
153
+ /** Subscribe to be notified whenever state changes from any source. */
154
+ readonly onUpdate: Signal<(newState: T) => void>;
155
+ /** Subscribe to be notified when syncing state changes. */
156
+ readonly onSyncing: Signal<(newSyncing: SyncingState) => void>;
157
+ /**
158
+ * Creates an instance of BaseDoc.
159
+ * @param id The unique identifier for this document.
160
+ * @param initialState Optional initial state.
161
+ */
162
+ constructor(id: string, initialState?: T);
163
+ /** The unique identifier for this document. */
164
+ get id(): string;
165
+ /** Current local state (committed + pending merged). */
166
+ get state(): T;
167
+ /** Are we currently syncing this document? */
168
+ get syncing(): SyncingState;
169
+ /** Last committed revision number from the server. */
170
+ abstract get committedRev(): number;
171
+ /** Are there local changes that haven't been committed yet? */
172
+ abstract get hasPending(): boolean;
173
+ /** Subscribe to be notified whenever the state changes (calls immediately with current state). */
174
+ subscribe(onUpdate: (newValue: T) => void): Unsubscriber;
175
+ /**
176
+ * Captures an update to the document, emitting JSON Patch ops via onChange.
177
+ * Does NOT apply locally - the strategy handles state updates via applyChanges.
178
+ * @param mutator Function that uses JSONPatch methods with type-safe paths.
179
+ */
180
+ change(mutator: ChangeMutator<T>): void;
181
+ /**
182
+ * Updates the syncing state of the document.
183
+ * Called by PatchesSync - not part of the app-facing PatchesDoc interface.
184
+ * @param newSyncing The new syncing state.
185
+ */
186
+ updateSyncing(newSyncing: SyncingState): void;
187
+ /**
188
+ * Applies changes to the document state.
189
+ * Called by Strategy for local changes and broadcasts - not part of PatchesDoc interface.
190
+ *
191
+ * For OT: Distinguishes committed (committedAt > 0) vs pending (committedAt === 0) changes.
192
+ * For LWW: Applies all ops from changes and updates metadata.
193
+ *
194
+ * @param changes Array of changes to apply.
195
+ */
196
+ abstract applyChanges(changes: Change[]): void;
197
+ /**
198
+ * Imports a full snapshot, resetting doc state.
199
+ * Used for recovery when doc gets out of sync - not part of PatchesDoc interface.
200
+ *
201
+ * @param snapshot The snapshot to import.
202
+ */
203
+ abstract import(snapshot: PatchesSnapshot<T>): void;
204
+ }
205
+
206
+ export { BaseDoc as B, OTDoc as O, type PatchesDocOptions as P, type PatchesDoc as a };
@@ -5,6 +5,13 @@ import '../../json-patch/types.js';
5
5
 
6
6
  /**
7
7
  * Applies incoming changes from the server that were *not* initiated by this client.
8
+ *
9
+ * Changes must normally be sequential (each change's rev = previous rev + 1). However,
10
+ * a root-level replace (`{ op: 'replace', path: '' }`) is allowed to skip revisions.
11
+ * This occurs when an offline-first client syncs with an existing document - the server
12
+ * returns a synthetic catchup change containing the full current state instead of
13
+ * returning potentially thousands of individual historical changes.
14
+ *
8
15
  * @param snapshot The current state of the document (the state without pending changes applied) and the pending changes.
9
16
  * @param committedChangesFromServer An array of sequential changes from the server.
10
17
  * @returns The new committed state, the new committed revision, and the new/rebased pending changes.
@@ -10,9 +10,12 @@ function applyCommittedChanges(snapshot, committedChangesFromServer) {
10
10
  const firstChange = newServerChanges[0];
11
11
  const lastChange = newServerChanges[newServerChanges.length - 1];
12
12
  if (firstChange.rev !== rev + 1) {
13
- throw new Error(
14
- `Missing changes from the server. Expected rev ${rev + 1}, got ${firstChange.rev}. Request changes since ${rev}.`
15
- );
13
+ const isRootReplaceCatchup = firstChange.ops.length === 1 && firstChange.ops[0].op === "replace" && firstChange.ops[0].path === "";
14
+ if (!isRootReplaceCatchup) {
15
+ throw new Error(
16
+ `Missing changes from the server. Expected rev ${rev + 1}, got ${firstChange.rev}. Request changes since ${rev}.`
17
+ );
18
+ }
16
19
  }
17
20
  try {
18
21
  state = applyChanges(state, newServerChanges);
@@ -0,0 +1,40 @@
1
+ import { JSONPatchOp } from '../../json-patch/types.js';
2
+
3
+ /**
4
+ * Combiner for consolidating same-type ops on the same path.
5
+ */
6
+ type Combiner = {
7
+ apply: (a: any, b: any) => any;
8
+ combine: (a: any, b: any) => any;
9
+ };
10
+ /**
11
+ * Combinable operations - ops that can be merged rather than replaced.
12
+ * Exported for use by mergeServerWithLocal.
13
+ */
14
+ declare const combinableOps: Record<string, Combiner>;
15
+ /**
16
+ * Consolidates two ops on the same path.
17
+ * - @inc: sums values
18
+ * - @bit: combines bitmasks
19
+ * - @max: keeps maximum
20
+ * - @min: keeps minimum
21
+ * - replace/remove/other: incoming wins
22
+ *
23
+ * Returns null if existing wins (incoming should be dropped).
24
+ */
25
+ declare function consolidateFieldOp(existing: JSONPatchOp, incoming: JSONPatchOp): JSONPatchOp | null;
26
+ /**
27
+ * Consolidates new ops with existing pending ops.
28
+ * Returns ops to save and child paths to delete (when parent overwrites).
29
+ */
30
+ declare function consolidateOps(existingOps: JSONPatchOp[], newOps: JSONPatchOp[]): {
31
+ opsToSave: JSONPatchOp[];
32
+ pathsToDelete: string[];
33
+ opsToReturn: JSONPatchOp[];
34
+ };
35
+ /**
36
+ * Any delta ops that aren't combined in consolidateOps need to be converted to replace ops.
37
+ */
38
+ declare function convertDeltaOps(ops: JSONPatchOp[]): JSONPatchOp[];
39
+
40
+ export { type Combiner, combinableOps, consolidateFieldOp, consolidateOps, convertDeltaOps };
@@ -0,0 +1,103 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ import { applyBitmask, combineBitmasks } from "../../json-patch/ops/bitmask.js";
3
+ const combinableOps = {
4
+ "@inc": {
5
+ apply: (a, b) => a + b,
6
+ combine: (a, b) => a + b
7
+ },
8
+ "@bit": {
9
+ apply: applyBitmask,
10
+ combine: combineBitmasks
11
+ },
12
+ "@max": {
13
+ apply: (a, b) => a > b ? a : b,
14
+ combine: (a, b) => a > b ? a : b
15
+ },
16
+ "@min": {
17
+ apply: (a, b) => a < b ? a : b,
18
+ combine: (a, b) => a < b ? a : b
19
+ }
20
+ };
21
+ function isExistingNewer(existingTs, incomingTs) {
22
+ if (incomingTs === void 0) return false;
23
+ if (existingTs === void 0) return true;
24
+ return existingTs > incomingTs;
25
+ }
26
+ function consolidateFieldOp(existing, incoming) {
27
+ const combiner = combinableOps[incoming.op];
28
+ if (combiner) {
29
+ const op = existing.op === incoming.op ? incoming.op : existing.op;
30
+ const value = existing.op === incoming.op ? combiner.combine(existing.value ?? 0, incoming.value ?? 0) : combiner.apply(existing.value ?? 0, incoming.value ?? 0);
31
+ if (value === existing.value) {
32
+ return null;
33
+ }
34
+ return { ...incoming, op, value };
35
+ }
36
+ if (isExistingNewer(existing.ts, incoming.ts)) {
37
+ return null;
38
+ }
39
+ return incoming;
40
+ }
41
+ function consolidateOps(existingOps, newOps) {
42
+ const opsToSave = [];
43
+ const pathsToDelete = /* @__PURE__ */ new Set();
44
+ const existingByPath = new Map(existingOps.map((op) => [op.path, op]));
45
+ const opsToReturnMap = /* @__PURE__ */ new Map();
46
+ for (const newOp of newOps) {
47
+ const existing = existingByPath.get(newOp.path);
48
+ const fix = parentFixes(newOp.path, existingByPath);
49
+ if (!Array.isArray(fix)) {
50
+ if (!opsToReturnMap.has(fix.path)) opsToReturnMap.set(fix.path, fix);
51
+ continue;
52
+ } else if (fix.length > 0) {
53
+ fix.forEach((path) => pathsToDelete.add(path));
54
+ }
55
+ if (existing) {
56
+ const consolidated = consolidateFieldOp(existing, newOp);
57
+ if (consolidated !== null) {
58
+ opsToSave.push(consolidated);
59
+ }
60
+ } else {
61
+ for (const existingPath of existingByPath.keys()) {
62
+ if (existingPath.startsWith(newOp.path + "/")) {
63
+ pathsToDelete.add(existingPath);
64
+ }
65
+ }
66
+ opsToSave.push(newOp);
67
+ }
68
+ }
69
+ return { opsToSave, pathsToDelete: Array.from(pathsToDelete), opsToReturn: Array.from(opsToReturnMap.values()) };
70
+ }
71
+ function convertDeltaOps(ops) {
72
+ return ops.map((op) => {
73
+ const combiner = combinableOps[op.op];
74
+ const value = typeof op.value === "string" ? "" : 0;
75
+ if (combiner) return { ...op, op: "replace", value: combiner.apply(value, op.value) };
76
+ return { ...op, op: "replace", value: op.value };
77
+ });
78
+ }
79
+ function parentFixes(path, existing) {
80
+ let parent = path;
81
+ const pathsToDelete = [];
82
+ while (parent.lastIndexOf("/") > 0) {
83
+ parent = parent.substring(0, parent.lastIndexOf("/"));
84
+ const parentOp = existing.get(parent);
85
+ if (parentOp) {
86
+ if (parentOp.value === void 0 || parentOp.op === "remove") {
87
+ pathsToDelete.push(parent);
88
+ } else if (!isObject(parentOp.value)) {
89
+ return parentOp;
90
+ }
91
+ }
92
+ }
93
+ return pathsToDelete;
94
+ }
95
+ function isObject(value) {
96
+ return value !== null && typeof value === "object";
97
+ }
98
+ export {
99
+ combinableOps,
100
+ consolidateFieldOp,
101
+ consolidateOps,
102
+ convertDeltaOps
103
+ };
@@ -0,0 +1,2 @@
1
+ export { Combiner, combinableOps, consolidateFieldOp, consolidateOps, convertDeltaOps } from './consolidateOps.js';
2
+ import '../../json-patch/types.js';
@@ -0,0 +1 @@
1
+ export * from "./consolidateOps.js";
@@ -0,0 +1,22 @@
1
+ import { JSONPatchOp } from '../../json-patch/types.js';
2
+ import { Change } from '../../types.js';
3
+ import '../../json-patch/JSONPatch.js';
4
+ import '@dabble/delta';
5
+
6
+ /**
7
+ * Merges server changes with local ops (sending + pending) for doc display.
8
+ *
9
+ * For paths that server touched:
10
+ * - If local has a delta op (@inc, @bit, @max, @min), apply it to server value
11
+ * - If local has a non-delta op, keep server value (already committed)
12
+ *
13
+ * For paths server didn't touch:
14
+ * - Keep local ops so they still apply to doc state
15
+ *
16
+ * @param serverChanges Changes received from server
17
+ * @param localOps Local ops (sendingChange.ops + pendingOps) from the store
18
+ * @returns Changes to apply to the doc
19
+ */
20
+ declare function mergeServerWithLocal(serverChanges: Change[], localOps: JSONPatchOp[]): Change[];
21
+
22
+ export { mergeServerWithLocal };
@@ -0,0 +1,32 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ import { combinableOps } from "./consolidateOps.js";
3
+ function mergeServerWithLocal(serverChanges, localOps) {
4
+ if (localOps.length === 0) return serverChanges;
5
+ const localByPath = new Map(localOps.map((op) => [op.path, op]));
6
+ const serverPaths = /* @__PURE__ */ new Set();
7
+ const mergedChanges = serverChanges.map((change) => {
8
+ const mergedOps = change.ops.map((serverOp) => {
9
+ serverPaths.add(serverOp.path);
10
+ const local = localByPath.get(serverOp.path);
11
+ if (!local) return serverOp;
12
+ const combiner = combinableOps[local.op];
13
+ if (!combiner) return serverOp;
14
+ const mergedValue = combiner.apply(serverOp.value ?? 0, local.value ?? 0);
15
+ const mergedOp = serverOp.op === "remove" ? "replace" : serverOp.op;
16
+ return { ...serverOp, op: mergedOp, value: mergedValue };
17
+ });
18
+ return { ...change, ops: mergedOps };
19
+ });
20
+ const untouchedLocalOps = localOps.filter((op) => !serverPaths.has(op.path));
21
+ if (untouchedLocalOps.length > 0 && mergedChanges.length > 0) {
22
+ const lastChange = mergedChanges[mergedChanges.length - 1];
23
+ mergedChanges[mergedChanges.length - 1] = {
24
+ ...lastChange,
25
+ ops: [...lastChange.ops, ...untouchedLocalOps]
26
+ };
27
+ }
28
+ return mergedChanges;
29
+ }
30
+ export {
31
+ mergeServerWithLocal
32
+ };
@@ -1,19 +1,43 @@
1
+ import { CommitResult } from '../../server/PatchesServer.js';
1
2
  import { PatchesStoreBackend } from '../../server/types.js';
2
- import { ChangeInput, CommitChangesOptions, Change } from '../../types.js';
3
+ import { ChangeInput, CommitChangesOptions } from '../../types.js';
4
+ import '../../net/protocol/JSONRPCServer.js';
5
+ import '../../event-signal.js';
6
+ import '../../net/websocket/AuthorizationProvider.js';
7
+ import '../../json-patch/types.js';
3
8
  import '../../json-patch/JSONPatch.js';
4
9
  import '@dabble/delta';
5
- import '../../json-patch/types.js';
10
+ import '../../net/protocol/types.js';
6
11
 
7
12
  /**
8
13
  * Commits a set of changes to a document, applying operational transformation as needed.
14
+ *
15
+ * ## Offline-First Catchup Optimization
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:
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
23
+ *
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 }`
27
+ *
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.
30
+ *
31
+ * @param store - The backend store for persistence.
9
32
  * @param docId - The ID of the document.
10
33
  * @param changes - The changes to commit.
11
- * @param originClientId - The ID of the client that initiated the commit.
34
+ * @param sessionTimeoutMillis - Timeout for session-based versioning.
12
35
  * @param options - Optional commit settings.
13
- * @returns A tuple of [committedChanges, transformedChanges] where:
14
- * - committedChanges: Changes that were already committed to the server after the client's base revision
15
- * - transformedChanges: The client's changes after being transformed against concurrent changes
36
+ * @param maxStorageBytes - Optional max bytes per change for storage limits.
37
+ * @returns A CommitResult containing:
38
+ * - catchupChanges: Changes the client missed (or a synthetic root-replace for offline-first clients)
39
+ * - newChanges: The client's changes after transformation
16
40
  */
17
- declare function commitChanges(store: PatchesStoreBackend, docId: string, changes: ChangeInput[], sessionTimeoutMillis: number, options?: CommitChangesOptions, maxStorageBytes?: number): Promise<[Change[], Change[]]>;
41
+ declare function commitChanges(store: PatchesStoreBackend, docId: string, changes: ChangeInput[], sessionTimeoutMillis: number, options?: CommitChangesOptions, maxStorageBytes?: number): Promise<CommitResult>;
18
42
 
19
- export { CommitChangesOptions, commitChanges };
43
+ export { CommitChangesOptions, CommitResult, commitChanges };
@@ -1,4 +1,5 @@
1
1
  import "../../chunk-IZ2YBCUP.js";
2
+ import { createId } from "crypto-id";
2
3
  import { filterSoftWritesAgainstState } from "../../json-patch/utils/softWrites.js";
3
4
  import { applyChanges } from "../shared/applyChanges.js";
4
5
  import { createVersion } from "./createVersion.js";
@@ -8,7 +9,7 @@ import { handleOfflineSessionsAndBatches } from "./handleOfflineSessionsAndBatch
8
9
  import { transformIncomingChanges } from "./transformIncomingChanges.js";
9
10
  async function commitChanges(store, docId, changes, sessionTimeoutMillis, options, maxStorageBytes) {
10
11
  if (changes.length === 0) {
11
- return [[], []];
12
+ return { catchupChanges: [], newChanges: [] };
12
13
  }
13
14
  const batchId = changes[0].batchId;
14
15
  const { state: initialState, rev: initialRev, changes: currentChanges } = await getSnapshotAtRevision(store, docId);
@@ -16,9 +17,12 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
16
17
  const currentRev = currentChanges.at(-1)?.rev ?? initialRev;
17
18
  let baseRev = changes[0].baseRev ?? currentRev;
18
19
  const batchedContinuation = batchId && changes[0].rev > 1;
20
+ const clientBaseRev = changes[0].baseRev ?? currentRev;
21
+ let needsSyntheticCatchup = false;
19
22
  if (changes[0].baseRev === 0 && currentRev > 0 && !batchedContinuation) {
20
23
  const hasRootOp = changes.some((c) => c.ops.some((op) => op.path === ""));
21
24
  if (!hasRootOp) {
25
+ needsSyntheticCatchup = true;
22
26
  baseRev = currentRev;
23
27
  changes = changes.filter((c) => {
24
28
  c.baseRev = baseRev;
@@ -52,7 +56,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
52
56
  );
53
57
  }
54
58
  const lastChange = currentChanges[currentChanges.length - 1];
55
- const compareTime = options?.historicalImport ? changes[0].createdAt : serverNow;
59
+ const compareTime = options?.historicalImport ? changes[0].createdAt ?? serverNow : serverNow;
56
60
  if (lastChange && compareTime - lastChange.createdAt > sessionTimeoutMillis) {
57
61
  await createVersion(store, docId, currentState, currentChanges);
58
62
  }
@@ -63,7 +67,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
63
67
  const committedIds = new Set(committedChanges.map((c) => c.id));
64
68
  let incomingChanges = changes.filter((c) => !committedIds.has(c.id));
65
69
  if (incomingChanges.length === 0) {
66
- return [committedChanges, []];
70
+ return { catchupChanges: committedChanges, newChanges: [] };
67
71
  }
68
72
  const isOfflineTimestamp = serverNow - incomingChanges[0].createdAt > sessionTimeoutMillis;
69
73
  if (isOfflineTimestamp || batchId) {
@@ -83,7 +87,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
83
87
  );
84
88
  if (canFastForward) {
85
89
  await store.saveChanges(docId, incomingChanges);
86
- return [[], incomingChanges];
90
+ return { catchupChanges: [], newChanges: incomingChanges };
87
91
  }
88
92
  }
89
93
  const stateAtBaseRev = (await getStateAtRevision(store, docId, baseRev)).state;
@@ -97,7 +101,18 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
97
101
  if (transformedChanges.length > 0) {
98
102
  await store.saveChanges(docId, transformedChanges);
99
103
  }
100
- return [committedChanges, transformedChanges];
104
+ if (needsSyntheticCatchup && clientBaseRev === 0) {
105
+ const syntheticCatchup = {
106
+ id: `catchup-${createId(8)}`,
107
+ baseRev: clientBaseRev,
108
+ rev: currentRev,
109
+ ops: [{ op: "replace", path: "", value: currentState }],
110
+ createdAt: serverNow,
111
+ committedAt: serverNow
112
+ };
113
+ return { catchupChanges: [syntheticCatchup], newChanges: transformedChanges };
114
+ }
115
+ return { catchupChanges: committedChanges, newChanges: transformedChanges };
101
116
  }
102
117
  export {
103
118
  commitChanges
@@ -1,8 +1,8 @@
1
1
  import { PatchesStoreBackend } from '../../server/types.js';
2
2
  import { Change, EditableVersionMetadata, VersionMetadata } from '../../types.js';
3
+ import '../../json-patch/types.js';
3
4
  import '../../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
5
- import '../../json-patch/types.js';
6
6
 
7
7
  /**
8
8
  * Creates a new version snapshot of a document's state from changes.
@@ -1,8 +1,8 @@
1
1
  import { PatchesStoreBackend } from '../../server/types.js';
2
2
  import { PatchesSnapshot } from '../../types.js';
3
+ import '../../json-patch/types.js';
3
4
  import '../../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
5
- import '../../json-patch/types.js';
6
6
 
7
7
  /**
8
8
  * Retrieves the document state of the version before the given revision and changes after up to that revision or all
@@ -1,8 +1,8 @@
1
1
  import { PatchesStoreBackend } from '../../server/types.js';
2
2
  import { PatchesState } from '../../types.js';
3
+ import '../../json-patch/types.js';
3
4
  import '../../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
5
- import '../../json-patch/types.js';
6
6
 
7
7
  /**
8
8
  * Gets the state at a specific revision.
@@ -1,8 +1,8 @@
1
1
  import { PatchesStoreBackend } from '../../server/types.js';
2
2
  import { Change } from '../../types.js';
3
+ import '../../json-patch/types.js';
3
4
  import '../../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
5
- import '../../json-patch/types.js';
6
6
 
7
7
  /**
8
8
  * Handles offline/large batch versioning logic for multi-batch uploads.
@@ -0,0 +1,6 @@
1
+ import '../event-signal.js';
2
+ import '../json-patch/types.js';
3
+ import '../types.js';
4
+ export { B as BaseDoc } from '../BaseDoc-DkP3tUhT.js';
5
+ import '../json-patch/JSONPatch.js';
6
+ import '@dabble/delta';