@dabble/patches 0.2.31 → 0.3.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 (81) hide show
  1. package/dist/algorithms/client/applyCommittedChanges.d.ts +8 -0
  2. package/dist/algorithms/client/applyCommittedChanges.js +40 -0
  3. package/dist/{utils → algorithms/client}/batching.d.ts +1 -1
  4. package/dist/{utils → algorithms/client}/batching.js +2 -2
  5. package/dist/{utils → algorithms/client}/breakChange.d.ts +2 -3
  6. package/dist/algorithms/client/breakChange.js +258 -0
  7. package/dist/algorithms/client/createStateFromSnapshot.d.ts +7 -0
  8. package/dist/algorithms/client/createStateFromSnapshot.js +9 -0
  9. package/dist/algorithms/client/getJSONByteSize.js +12 -0
  10. package/dist/algorithms/client/makeChange.d.ts +3 -0
  11. package/dist/algorithms/client/makeChange.js +37 -0
  12. package/dist/algorithms/server/commitChanges.d.ts +12 -0
  13. package/dist/algorithms/server/commitChanges.js +80 -0
  14. package/dist/algorithms/server/createVersion.d.ts +12 -0
  15. package/dist/algorithms/server/createVersion.js +28 -0
  16. package/dist/algorithms/server/getSnapshotAtRevision.d.ts +10 -0
  17. package/dist/algorithms/server/getSnapshotAtRevision.js +29 -0
  18. package/dist/algorithms/server/getStateAtRevision.d.ts +9 -0
  19. package/dist/algorithms/server/getStateAtRevision.js +18 -0
  20. package/dist/algorithms/server/handleOfflineSessionsAndBatches.d.ts +12 -0
  21. package/dist/algorithms/server/handleOfflineSessionsAndBatches.js +80 -0
  22. package/dist/algorithms/server/transformIncomingChanges.d.ts +11 -0
  23. package/dist/algorithms/server/transformIncomingChanges.js +40 -0
  24. package/dist/algorithms/shared/applyChanges.d.ts +10 -0
  25. package/dist/algorithms/shared/applyChanges.js +17 -0
  26. package/dist/{utils.d.ts → algorithms/shared/rebaseChanges.d.ts} +1 -11
  27. package/dist/{utils.js → algorithms/shared/rebaseChanges.js} +3 -43
  28. package/dist/client/InMemoryStore.d.ts +4 -2
  29. package/dist/client/InMemoryStore.js +12 -3
  30. package/dist/client/IndexedDBStore.d.ts +36 -3
  31. package/dist/client/IndexedDBStore.js +399 -271
  32. package/dist/client/Patches.d.ts +11 -41
  33. package/dist/client/Patches.js +197 -208
  34. package/dist/client/PatchesDoc.d.ts +24 -41
  35. package/dist/client/PatchesDoc.js +57 -214
  36. package/dist/client/PatchesHistoryClient.js +1 -1
  37. package/dist/client/PatchesStore.d.ts +188 -10
  38. package/dist/data/change.d.ts +3 -0
  39. package/dist/data/change.js +20 -0
  40. package/dist/data/version.d.ts +12 -0
  41. package/dist/data/version.js +17 -0
  42. package/dist/event-signal.js +5 -13
  43. package/dist/json-patch/ops/add.js +1 -1
  44. package/dist/json-patch/ops/move.js +1 -1
  45. package/dist/json-patch/ops/remove.js +1 -1
  46. package/dist/json-patch/ops/replace.js +1 -1
  47. package/dist/json-patch/utils/get.js +0 -1
  48. package/dist/json-patch/utils/log.d.ts +4 -1
  49. package/dist/json-patch/utils/log.js +2 -5
  50. package/dist/json-patch/utils/ops.d.ts +1 -1
  51. package/dist/json-patch/utils/ops.js +4 -1
  52. package/dist/json-patch/utils/paths.js +2 -2
  53. package/dist/json-patch/utils/toArrayIndex.js +1 -1
  54. package/dist/net/PatchesSync.d.ts +55 -24
  55. package/dist/net/PatchesSync.js +338 -254
  56. package/dist/net/protocol/types.d.ts +2 -2
  57. package/dist/net/websocket/AuthorizationProvider.d.ts +9 -2
  58. package/dist/net/websocket/AuthorizationProvider.js +14 -2
  59. package/dist/net/websocket/PatchesWebSocket.d.ts +4 -4
  60. package/dist/net/websocket/PatchesWebSocket.js +5 -4
  61. package/dist/net/websocket/RPCServer.d.ts +2 -2
  62. package/dist/net/websocket/RPCServer.js +3 -3
  63. package/dist/net/websocket/SignalingService.js +1 -1
  64. package/dist/net/websocket/WebSocketServer.d.ts +1 -1
  65. package/dist/net/websocket/WebSocketServer.js +2 -2
  66. package/dist/net/websocket/WebSocketTransport.js +1 -1
  67. package/dist/net/websocket/onlineState.d.ts +1 -1
  68. package/dist/net/websocket/onlineState.js +8 -2
  69. package/dist/server/PatchesBranchManager.js +9 -16
  70. package/dist/server/PatchesHistoryManager.js +1 -1
  71. package/dist/server/PatchesServer.d.ts +11 -38
  72. package/dist/server/PatchesServer.js +32 -255
  73. package/dist/types.d.ts +8 -6
  74. package/dist/utils/concurrency.d.ts +26 -0
  75. package/dist/utils/concurrency.js +60 -0
  76. package/dist/utils/deferred.d.ts +7 -0
  77. package/dist/utils/deferred.js +23 -0
  78. package/package.json +11 -5
  79. package/dist/utils/breakChange.js +0 -302
  80. package/dist/utils/getJSONByteSize.js +0 -12
  81. /package/dist/{utils → algorithms/client}/getJSONByteSize.d.ts +0 -0
@@ -0,0 +1,8 @@
1
+ import type { Change, PatchesSnapshot } from '../../types';
2
+ /**
3
+ * Applies incoming changes from the server that were *not* initiated by this client.
4
+ * @param snapshot The current state of the document (the state without pending changes applied) and the pending changes.
5
+ * @param committedChangesFromServer An array of sequential changes from the server.
6
+ * @returns The new committed state, the new committed revision, and the new/rebased pending changes.
7
+ */
8
+ export declare function applyCommittedChanges(snapshot: PatchesSnapshot, committedChangesFromServer: Change[]): PatchesSnapshot;
@@ -0,0 +1,40 @@
1
+ import { applyChanges } from '../shared/applyChanges';
2
+ import { rebaseChanges } from '../shared/rebaseChanges';
3
+ /**
4
+ * Applies incoming changes from the server that were *not* initiated by this client.
5
+ * @param snapshot The current state of the document (the state without pending changes applied) and the pending changes.
6
+ * @param committedChangesFromServer An array of sequential changes from the server.
7
+ * @returns The new committed state, the new committed revision, and the new/rebased pending changes.
8
+ */
9
+ export function applyCommittedChanges(snapshot, committedChangesFromServer) {
10
+ let { state, rev, changes } = snapshot;
11
+ // Filter out any server changes that are already reflected in the current snapshot's revision.
12
+ // Server changes should always have a rev.
13
+ const newServerChanges = committedChangesFromServer.filter(change => change.rev > rev);
14
+ if (newServerChanges.length === 0) {
15
+ // No new changes to apply, return the snapshot as is.
16
+ return { state, rev, changes };
17
+ }
18
+ const firstChange = newServerChanges[0];
19
+ const lastChange = newServerChanges[newServerChanges.length - 1];
20
+ // Ensure the new server changes are sequential to the current snapshot's revision.
21
+ if (firstChange.rev !== rev + 1) {
22
+ throw new Error(`Missing changes from the server. Expected rev ${rev + 1}, got ${firstChange.rev}. Request changes since ${rev}.`);
23
+ }
24
+ // 1. Apply to committed state
25
+ try {
26
+ state = applyChanges(state, newServerChanges);
27
+ }
28
+ catch (error) {
29
+ console.error('Failed to apply server changes to committed state:', error);
30
+ const errorMessage = error instanceof Error ? error.message : String(error);
31
+ throw new Error(`Critical sync error applying server changes: ${errorMessage}`);
32
+ }
33
+ // 2. Update committed revision to the latest one from the applied server changes.
34
+ rev = lastChange.rev;
35
+ // 3. Rebase pending local changes against the newly applied server changes.
36
+ if (changes && changes.length > 0) {
37
+ changes = rebaseChanges(newServerChanges, changes);
38
+ }
39
+ return { state, rev, changes };
40
+ }
@@ -1,3 +1,3 @@
1
- import type { Change } from '../types.js';
1
+ import type { Change } from '../../types.js';
2
2
  /** Break changes into batches based on maxPayloadBytes. */
3
3
  export declare function breakIntoBatches(changes: Change[], maxPayloadBytes?: number): Change[][];
@@ -1,6 +1,6 @@
1
1
  import { createId } from 'crypto-id';
2
- import { breakChange } from './breakChange.js'; // Import from new file
3
- import { getJSONByteSize } from './getJSONByteSize.js'; // Import from new file
2
+ import { breakChange } from './breakChange.js';
3
+ import { getJSONByteSize } from './getJSONByteSize.js';
4
4
  /** Break changes into batches based on maxPayloadBytes. */
5
5
  export function breakIntoBatches(changes, maxPayloadBytes) {
6
6
  if (!maxPayloadBytes || getJSONByteSize(changes) < maxPayloadBytes) {
@@ -1,7 +1,6 @@
1
- import type { Change } from '../types.js';
1
+ import type { Change } from '../../types.js';
2
2
  /**
3
- * Break a single Change into multiple Changes so that
4
- * JSON.stringify(change).length never exceeds `maxBytes`.
3
+ * Break a single Change into multiple Changes so that the JSON string size never exceeds `maxBytes`.
5
4
  *
6
5
  * - Splits first by JSON-Patch *ops*
7
6
  * - If an individual op is still too big and is a "@txt" op,
@@ -0,0 +1,258 @@
1
+ import { createChange } from '../../data/change.js';
2
+ import { getJSONByteSize } from './getJSONByteSize.js';
3
+ /**
4
+ * Break a single Change into multiple Changes so that the JSON string size never exceeds `maxBytes`.
5
+ *
6
+ * - Splits first by JSON-Patch *ops*
7
+ * - If an individual op is still too big and is a "@txt" op,
8
+ * split its Delta payload into smaller Deltas
9
+ */
10
+ export function breakChange(orig, maxBytes) {
11
+ if (getJSONByteSize(orig) <= maxBytes)
12
+ return [orig];
13
+ // First pass: split by ops
14
+ const byOps = [];
15
+ let group = [];
16
+ let rev = orig.rev;
17
+ const flush = () => {
18
+ if (!group.length)
19
+ return;
20
+ byOps.push(deriveNewChange(orig, rev++, group));
21
+ group = [];
22
+ };
23
+ for (const op of orig.ops) {
24
+ const tentative = group.concat(op);
25
+ if (getJSONByteSize({ ...orig, ops: tentative }) > maxBytes)
26
+ flush();
27
+ // Handle the case where a single op is too large
28
+ if (group.length === 0 && getJSONByteSize({ ...orig, ops: [op] }) > maxBytes) {
29
+ // We have a single op that's too big - can only be @txt op with large delta
30
+ if (op.op === '@txt' && op.value) {
31
+ const pieces = breakTextOp(orig, op, maxBytes, rev);
32
+ byOps.push(...pieces);
33
+ // Only update rev if we got results from breakTextOp
34
+ if (pieces.length > 0) {
35
+ rev = pieces[pieces.length - 1].rev + 1; // Update rev for next changes
36
+ }
37
+ continue;
38
+ }
39
+ else if (op.op === 'replace' || op.op === 'add') {
40
+ // For replace/add operations with large value payloads, try to split the value if it's a string or array
41
+ const pieces = breakLargeValueOp(orig, op, maxBytes, rev);
42
+ byOps.push(...pieces);
43
+ if (pieces.length > 0) {
44
+ rev = pieces[pieces.length - 1].rev + 1;
45
+ }
46
+ continue;
47
+ }
48
+ else {
49
+ // Non-splittable op that's too large, include it anyway with a warning
50
+ console.warn(`Warning: Single operation of type ${op.op} exceeds maxBytes. Including it anyway.`);
51
+ group.push(op);
52
+ continue;
53
+ }
54
+ }
55
+ group.push(op);
56
+ }
57
+ flush();
58
+ return byOps;
59
+ }
60
+ /**
61
+ * Break a large @txt operation into multiple smaller operations
62
+ */
63
+ function breakTextOp(origChange, textOp, maxBytes, startRev) {
64
+ const results = [];
65
+ let rev = startRev;
66
+ const baseSize = getJSONByteSize({ ...origChange, ops: [{ ...textOp, value: '' }] });
67
+ const budget = maxBytes - baseSize;
68
+ const buffer = 20;
69
+ const maxLength = Math.max(1, budget - buffer);
70
+ let deltaOps = [];
71
+ if (textOp.value) {
72
+ if (Array.isArray(textOp.value)) {
73
+ deltaOps = textOp.value;
74
+ }
75
+ else if (textOp.value.ops && Array.isArray(textOp.value.ops)) {
76
+ deltaOps = textOp.value.ops;
77
+ }
78
+ else if (typeof textOp.value === 'object') {
79
+ deltaOps = [textOp.value];
80
+ }
81
+ }
82
+ let currentOpsForNextChangePiece = [];
83
+ let retainToPrefixCurrentPiece = 0; // Retain that should prefix the ops in currentOpsForNextChangePiece
84
+ const flushCurrentChangePiece = () => {
85
+ if (!currentOpsForNextChangePiece.length)
86
+ return;
87
+ const opsToFlush = [...currentOpsForNextChangePiece];
88
+ if (retainToPrefixCurrentPiece > 0) {
89
+ if (!opsToFlush[0]?.retain) {
90
+ // Only add if not already starting with a retain
91
+ opsToFlush.unshift({ retain: retainToPrefixCurrentPiece });
92
+ }
93
+ else {
94
+ // If it starts with retain, assume it's the intended one from deltaOps.
95
+ // This might need adjustment if a small retain op is batched after a large retain prefix.
96
+ // For now, this prioritizes an existing retain op at the start of the batch.
97
+ }
98
+ }
99
+ results.push(deriveNewChange(origChange, rev++, [{ ...textOp, value: opsToFlush }]));
100
+ currentOpsForNextChangePiece = [];
101
+ // retainToPrefixCurrentPiece is NOT reset here, it carries over for the start of the next piece IF it's non-zero from a previous retain op.
102
+ };
103
+ for (const op of deltaOps) {
104
+ // Try adding current op (with its necessary prefix) to the current batch
105
+ const testBatchOps = [...currentOpsForNextChangePiece];
106
+ if (retainToPrefixCurrentPiece > 0 && testBatchOps.length === 0) {
107
+ // If batch is empty, it needs the prefix
108
+ testBatchOps.push({ retain: retainToPrefixCurrentPiece });
109
+ }
110
+ testBatchOps.push(op);
111
+ const testBatchSize = getJSONByteSize({ ...origChange, ops: [{ ...textOp, value: testBatchOps }] });
112
+ if (currentOpsForNextChangePiece.length > 0 && testBatchSize > maxBytes) {
113
+ flushCurrentChangePiece();
114
+ // After flush, retainToPrefixCurrentPiece still holds the value for the *start* of the new piece (current op)
115
+ }
116
+ // Check if the op itself (with its prefix) is too large for a new piece
117
+ const opStandaloneOps = retainToPrefixCurrentPiece > 0 ? [{ retain: retainToPrefixCurrentPiece }, op] : [op];
118
+ const opStandaloneSize = getJSONByteSize({ ...origChange, ops: [{ ...textOp, value: opStandaloneOps }] });
119
+ if (currentOpsForNextChangePiece.length === 0 && opStandaloneSize > maxBytes) {
120
+ if (op.insert && typeof op.insert === 'string') {
121
+ const insertChunks = splitLargeInsertText(op.insert, maxLength, op.attributes);
122
+ for (let i = 0; i < insertChunks.length; i++) {
123
+ const chunkOp = insertChunks[i];
124
+ const opsForThisChunk = [];
125
+ if (i === 0 && retainToPrefixCurrentPiece > 0) {
126
+ // Prefix only the first chunk
127
+ opsForThisChunk.push({ retain: retainToPrefixCurrentPiece });
128
+ }
129
+ opsForThisChunk.push(chunkOp);
130
+ results.push(deriveNewChange(origChange, rev++, [{ ...textOp, value: opsForThisChunk }]));
131
+ }
132
+ retainToPrefixCurrentPiece = 0; // An insert consumes the preceding retain for the next original op
133
+ }
134
+ else {
135
+ // Non-splittable large op (e.g., large retain)
136
+ console.warn(`Warning: Single delta op too large, including with prefix: ${JSON.stringify(op)}`);
137
+ results.push(deriveNewChange(origChange, rev++, [{ ...textOp, value: opStandaloneOps }]));
138
+ retainToPrefixCurrentPiece = op.retain || 0;
139
+ }
140
+ }
141
+ else {
142
+ // Op fits into current batch (or starts a new one that fits)
143
+ currentOpsForNextChangePiece.push(op);
144
+ if (op.retain) {
145
+ retainToPrefixCurrentPiece += op.retain; // Accumulate retain for the next op or flush
146
+ }
147
+ else {
148
+ // Insert or delete
149
+ retainToPrefixCurrentPiece = 0; // Consumes retain for the next op
150
+ }
151
+ }
152
+ }
153
+ if (currentOpsForNextChangePiece.length > 0) {
154
+ flushCurrentChangePiece();
155
+ }
156
+ return results;
157
+ }
158
+ /**
159
+ * Split a large insert string into multiple delta insert operations.
160
+ * Each operation will have the original attributes.
161
+ */
162
+ function splitLargeInsertText(text, maxChunkLength, attributes) {
163
+ const results = [];
164
+ if (maxChunkLength <= 0) {
165
+ console.warn('splitLargeInsertText: maxChunkLength is invalid, returning original text as one chunk.');
166
+ return [{ insert: text, attributes }];
167
+ }
168
+ for (let i = 0; i < text.length; i += maxChunkLength) {
169
+ const chunkText = text.slice(i, i + maxChunkLength);
170
+ results.push({ insert: chunkText, attributes: attributes ? { ...attributes } : undefined });
171
+ }
172
+ return results;
173
+ }
174
+ /**
175
+ * Attempt to break a large value in a replace/add operation
176
+ */
177
+ function breakLargeValueOp(origChange, op, maxBytes, startRev) {
178
+ const results = [];
179
+ let rev = startRev;
180
+ const baseOpSize = getJSONByteSize({ ...op, value: '' });
181
+ const baseChangeSize = getJSONByteSize({ ...origChange, ops: [{ ...op, value: '' }] }) - baseOpSize;
182
+ const valueBudget = maxBytes - baseChangeSize - 50;
183
+ if (typeof op.value === 'string' && op.value.length > 100) {
184
+ const text = op.value;
185
+ const targetChunkSize = Math.max(1, valueBudget);
186
+ const numChunks = Math.ceil(text.length / targetChunkSize);
187
+ const chunkSize = Math.ceil(text.length / numChunks);
188
+ for (let i = 0; i < text.length; i += chunkSize) {
189
+ const chunk = text.slice(i, i + chunkSize);
190
+ const newOp = { op: 'add' };
191
+ if (i === 0) {
192
+ newOp.op = op.op;
193
+ newOp.path = op.path;
194
+ newOp.value = chunk;
195
+ }
196
+ else {
197
+ newOp.op = 'patch';
198
+ newOp.path = op.path;
199
+ newOp.appendString = chunk;
200
+ }
201
+ results.push(deriveNewChange(origChange, rev++, [newOp]));
202
+ }
203
+ return results;
204
+ }
205
+ else if (Array.isArray(op.value) && op.value.length > 1) {
206
+ const originalArray = op.value;
207
+ let currentChunk = [];
208
+ let chunkStartIndex = 0;
209
+ for (let i = 0; i < originalArray.length; i++) {
210
+ const item = originalArray[i];
211
+ const tentativeChunk = [...currentChunk, item];
212
+ const tentativeOp = { ...op, value: tentativeChunk };
213
+ const tentativeChangeSize = getJSONByteSize({ ...origChange, ops: [tentativeOp] });
214
+ if (currentChunk.length > 0 && tentativeChangeSize > maxBytes) {
215
+ const chunkOp = {};
216
+ if (chunkStartIndex === 0) {
217
+ chunkOp.op = op.op;
218
+ chunkOp.path = op.path;
219
+ chunkOp.value = currentChunk;
220
+ }
221
+ else {
222
+ chunkOp.op = 'patch';
223
+ chunkOp.path = op.path;
224
+ chunkOp.appendArray = currentChunk;
225
+ }
226
+ results.push(deriveNewChange(origChange, rev++, [chunkOp]));
227
+ currentChunk = [item];
228
+ chunkStartIndex = i;
229
+ }
230
+ else {
231
+ currentChunk.push(item);
232
+ }
233
+ }
234
+ if (currentChunk.length > 0) {
235
+ const chunkOp = {};
236
+ if (chunkStartIndex === 0) {
237
+ chunkOp.op = op.op;
238
+ chunkOp.path = op.path;
239
+ chunkOp.value = currentChunk;
240
+ }
241
+ else {
242
+ chunkOp.op = 'patch';
243
+ chunkOp.path = op.path;
244
+ chunkOp.appendArray = currentChunk;
245
+ }
246
+ results.push(deriveNewChange(origChange, rev++, [chunkOp]));
247
+ }
248
+ return results;
249
+ }
250
+ console.warn(`Warning: Single operation of type ${op.op} (path: ${op.path}) could not be split further by breakLargeValueOp despite exceeding maxBytes. Including as is.`);
251
+ return [deriveNewChange(origChange, rev++, [op])]; // Return original op in a new change if not splittable by this func
252
+ }
253
+ function deriveNewChange(origChange, rev, ops) {
254
+ // Filter out metadata that shouldn't be part of the new change object
255
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
256
+ const { id: _id, ops: _o, rev: _r, baseRev: _br, created: _c, batchId: _bi, ...metadata } = origChange;
257
+ return createChange(origChange.baseRev, rev, ops, metadata);
258
+ }
@@ -0,0 +1,7 @@
1
+ import type { PatchesSnapshot } from '../../types.js';
2
+ /**
3
+ * Creates the in-memory state from a snapshot.
4
+ * @param snapshot The snapshot to create a state from.
5
+ * @returns The new state.
6
+ */
7
+ export declare function createStateFromSnapshot<T = any>(snapshot: PatchesSnapshot<T>): T;
@@ -0,0 +1,9 @@
1
+ import { applyChanges } from '../shared/applyChanges.js';
2
+ /**
3
+ * Creates the in-memory state from a snapshot.
4
+ * @param snapshot The snapshot to create a state from.
5
+ * @returns The new state.
6
+ */
7
+ export function createStateFromSnapshot(snapshot) {
8
+ return applyChanges(snapshot.state, snapshot.changes);
9
+ }
@@ -0,0 +1,12 @@
1
+ /** Estimate JSON string byte size. */
2
+ export function getJSONByteSize(data) {
3
+ try {
4
+ const stringified = JSON.stringify(data);
5
+ return stringified ? new TextEncoder().encode(stringified).length : 0;
6
+ }
7
+ catch (e) {
8
+ // Handle circular structures (from JSON.stringify) or other errors.
9
+ console.error('Error calculating JSON size:', e);
10
+ throw new Error('Error calculating JSON size: ' + e);
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { JSONPatch } from '../..';
2
+ import type { Change, PatchesSnapshot } from '../../types';
3
+ export declare function makeChange<T = any>(snapshot: PatchesSnapshot<T>, mutator: (draft: T, patch: JSONPatch) => void, changeMetadata?: Record<string, any>, maxPayloadBytes?: number): Change[];
@@ -0,0 +1,37 @@
1
+ import { createChange } from '../../data/change';
2
+ import { createJSONPatch } from '../../json-patch/createJSONPatch';
3
+ import { breakChange } from './breakChange';
4
+ import { createStateFromSnapshot } from './createStateFromSnapshot';
5
+ export function makeChange(snapshot, mutator, changeMetadata, maxPayloadBytes) {
6
+ const pendingChanges = snapshot.changes;
7
+ const pendingRev = pendingChanges[pendingChanges.length - 1]?.rev ?? snapshot.rev;
8
+ const state = createStateFromSnapshot(snapshot); // Current state including pending
9
+ const patch = createJSONPatch(state, mutator);
10
+ if (patch.ops.length === 0) {
11
+ return [];
12
+ }
13
+ // Optimistic rev for local sorting, based on the latest known rev (committed or pending)
14
+ const rev = pendingRev + 1;
15
+ // Create the initial change. BaseRev is always the last *committed* revision from the snapshot.
16
+ let newChangesArray = [createChange(snapshot.rev, rev, patch.ops, changeMetadata)];
17
+ // Optimistically apply the patch to the current state (which includes pending changes)
18
+ // This state is temporary for validation/splitting and not stored back directly in PatchesDoc from here.
19
+ try {
20
+ // Note: The 'state' variable here is the one derived from createStateFromSnapshot (committed + pending).
21
+ // Applying the new patch to it is for the purpose of creating the correct Change object(s).
22
+ // PatchesDoc.change will apply these returned changes to its own _state later.
23
+ patch.apply(state); // This line primarily serves to ensure the patch is valid against the current view.
24
+ }
25
+ catch (error) {
26
+ console.error('Failed to apply change to state during makeChange:', error);
27
+ throw new Error(`Failed to apply change to state during makeChange: ${error}`);
28
+ }
29
+ if (maxPayloadBytes) {
30
+ // If the single change (or its parts) exceed maxPayloadBytes, break it down.
31
+ // breakChange will handle creating multiple Change objects if necessary,
32
+ // maintaining the original baseRev but incrementing revs for the pieces.
33
+ newChangesArray = breakChange(newChangesArray[0], maxPayloadBytes);
34
+ }
35
+ // PatchesDoc.change will take this returned array and push its contents onto its internal snapshot.
36
+ return newChangesArray;
37
+ }
@@ -0,0 +1,12 @@
1
+ import type { PatchesStoreBackend } from '../../server/types';
2
+ import type { Change } from '../../types';
3
+ /**
4
+ * Commits a set of changes to a document, applying operational transformation as needed.
5
+ * @param docId - The ID of the document.
6
+ * @param changes - The changes to commit.
7
+ * @param originClientId - The ID of the client that initiated the commit.
8
+ * @returns A tuple of [committedChanges, transformedChanges] where:
9
+ * - committedChanges: Changes that were already committed to the server after the client's base revision
10
+ * - transformedChanges: The client's changes after being transformed against concurrent changes
11
+ */
12
+ export declare function commitChanges(store: PatchesStoreBackend, docId: string, changes: Change[], sessionTimeoutMillis: number): Promise<[Change[], Change[]]>;
@@ -0,0 +1,80 @@
1
+ import { applyChanges } from '../shared/applyChanges';
2
+ import { createVersion } from './createVersion';
3
+ import { getSnapshotAtRevision } from './getSnapshotAtRevision';
4
+ import { getStateAtRevision } from './getStateAtRevision';
5
+ import { handleOfflineSessionsAndBatches } from './handleOfflineSessionsAndBatches';
6
+ import { transformIncomingChanges } from './transformIncomingChanges';
7
+ /**
8
+ * Commits a set of changes to a document, applying operational transformation as needed.
9
+ * @param docId - The ID of the document.
10
+ * @param changes - The changes to commit.
11
+ * @param originClientId - The ID of the client that initiated the commit.
12
+ * @returns A tuple of [committedChanges, transformedChanges] where:
13
+ * - committedChanges: Changes that were already committed to the server after the client's base revision
14
+ * - transformedChanges: The client's changes after being transformed against concurrent changes
15
+ */
16
+ export async function commitChanges(store, docId, changes, sessionTimeoutMillis) {
17
+ if (changes.length === 0) {
18
+ return [[], []];
19
+ }
20
+ // Assume all changes share the same baseRev. Client ensures
21
+ const batchId = changes[0].batchId;
22
+ const baseRev = changes[0].baseRev;
23
+ if (baseRev === undefined) {
24
+ throw new Error(`Client changes must include baseRev for doc ${docId}.`);
25
+ }
26
+ // Add check for inconsistent baseRev within the batch if needed
27
+ if (changes.some(c => c.baseRev !== baseRev)) {
28
+ throw new Error(`Client changes must have consistent baseRev in all changes for doc ${docId}.`);
29
+ }
30
+ // 1. Load server state details (assuming store methods exist)
31
+ const { state: initialState, rev: initialRev, changes: currentChanges } = await getSnapshotAtRevision(store, docId);
32
+ const currentState = applyChanges(initialState, currentChanges);
33
+ const currentRev = currentChanges.at(-1)?.rev ?? initialRev;
34
+ // Basic validation
35
+ if (baseRev > currentRev) {
36
+ throw new Error(`Client baseRev (${baseRev}) is ahead of server revision (${currentRev}) for doc ${docId}. Client needs to reload the document.`);
37
+ }
38
+ const partOfInitialBatch = batchId && changes[0].rev > 1;
39
+ if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch && changes[0].ops[0].path === '') {
40
+ throw new Error(`Client baseRev is 0 but server has already been created for doc ${docId}. Client needs to load the existing document.`);
41
+ }
42
+ // Ensure all new changes' `created` field is in the past, that each `rev` is correct, and that `baseRev` is set
43
+ changes.forEach(c => {
44
+ c.created = Math.min(c.created, Date.now());
45
+ c.baseRev = baseRev;
46
+ });
47
+ // 2. Check if we need to create a new version - if the last change was created more than a session ago
48
+ const lastChange = currentChanges[currentChanges.length - 1];
49
+ if (lastChange && lastChange.created < Date.now() - sessionTimeoutMillis) {
50
+ await createVersion(store, docId, currentState, currentChanges);
51
+ }
52
+ // 3. Load committed changes *after* the client's baseRev for transformation and idempotency checks
53
+ const committedChanges = await store.listChanges(docId, {
54
+ startAfter: baseRev,
55
+ withoutBatchId: batchId,
56
+ });
57
+ const committedIds = new Set(committedChanges.map(c => c.id));
58
+ changes = changes.filter(c => !committedIds.has(c.id));
59
+ // If all incoming changes were already committed, return the committed changes found
60
+ if (changes.length === 0) {
61
+ return [committedChanges, []];
62
+ }
63
+ // 4. Handle offline-session versioning:
64
+ // - batchId present (multi-batch uploads)
65
+ // - or the first change is older than the session timeout (single-batch offline)
66
+ const isOfflineTimestamp = changes[0].created < Date.now() - sessionTimeoutMillis;
67
+ if (isOfflineTimestamp || batchId) {
68
+ changes = await handleOfflineSessionsAndBatches(store, sessionTimeoutMillis, docId, changes, baseRev, batchId);
69
+ }
70
+ // 5. Transform the *entire batch* of incoming (and potentially collapsed offline) changes
71
+ // against committed changes that happened *after* the client's baseRev.
72
+ // The state used for transformation should be the server state *at the client's baseRev*.
73
+ const stateAtBaseRev = (await getStateAtRevision(store, docId, baseRev)).state;
74
+ const transformedChanges = transformIncomingChanges(changes, stateAtBaseRev, committedChanges, currentRev);
75
+ if (transformedChanges.length > 0) {
76
+ await store.saveChanges(docId, transformedChanges);
77
+ }
78
+ // Return committed changes and newly transformed changes separately
79
+ return [committedChanges, transformedChanges];
80
+ }
@@ -0,0 +1,12 @@
1
+ import type { Change, EditableVersionMetadata, VersionMetadata } from '../../types';
2
+ import type { PatchesStoreBackend } from '../../server/types';
3
+ /**
4
+ * Creates a new version snapshot of a document's state from changes.
5
+ * @param store The storage backend to save the version to.
6
+ * @param docId The document ID.
7
+ * @param state The document state at the time of the version.
8
+ * @param changes The changes since the last version that created the state.
9
+ * @param metadata Optional additional metadata for the version.
10
+ * @returns The created version metadata, or undefined if no changes provided.
11
+ */
12
+ export declare function createVersion(store: PatchesStoreBackend, docId: string, state: any, changes: Change[], metadata?: EditableVersionMetadata): Promise<VersionMetadata | undefined>;
@@ -0,0 +1,28 @@
1
+ import { createVersionMetadata } from '../../data/version.js';
2
+ /**
3
+ * Creates a new version snapshot of a document's state from changes.
4
+ * @param store The storage backend to save the version to.
5
+ * @param docId The document ID.
6
+ * @param state The document state at the time of the version.
7
+ * @param changes The changes since the last version that created the state.
8
+ * @param metadata Optional additional metadata for the version.
9
+ * @returns The created version metadata, or undefined if no changes provided.
10
+ */
11
+ export async function createVersion(store, docId, state, changes, metadata) {
12
+ if (changes.length === 0)
13
+ return;
14
+ const baseRev = changes[0].baseRev;
15
+ if (baseRev === undefined) {
16
+ throw new Error(`Client changes must include baseRev for doc ${docId}.`);
17
+ }
18
+ const sessionMetadata = createVersionMetadata({
19
+ origin: 'main',
20
+ startDate: changes[0].created,
21
+ endDate: changes[changes.length - 1].created,
22
+ rev: changes[changes.length - 1].rev,
23
+ baseRev,
24
+ ...metadata,
25
+ });
26
+ await store.createVersion(docId, sessionMetadata, state, changes);
27
+ return sessionMetadata;
28
+ }
@@ -0,0 +1,10 @@
1
+ import type { PatchesStoreBackend } from '../../server';
2
+ import type { PatchesSnapshot } from '../../types';
3
+ /**
4
+ * Retrieves the document state of the version before the given revision and changes after up to that revision or all
5
+ * changes since that version.
6
+ * @param docId The document ID.
7
+ * @param rev The revision number. If not provided, the latest state, its revision, and all changes since are returned.
8
+ * @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).
9
+ */
10
+ export declare function getSnapshotAtRevision(store: PatchesStoreBackend, docId: string, rev?: number): Promise<PatchesSnapshot>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Retrieves the document state of the version before the given revision and changes after up to that revision or all
3
+ * changes since that version.
4
+ * @param docId The document ID.
5
+ * @param rev The revision number. If not provided, the latest state, its revision, and all changes since are returned.
6
+ * @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).
7
+ */
8
+ export async function getSnapshotAtRevision(store, docId, rev) {
9
+ const versions = await store.listVersions(docId, {
10
+ limit: 1,
11
+ reverse: true,
12
+ startAfter: rev ? rev + 1 : undefined,
13
+ origin: 'main',
14
+ orderBy: 'rev',
15
+ });
16
+ const latestMainVersion = versions[0];
17
+ const versionState = (latestMainVersion && (await store.loadVersionState(docId, latestMainVersion.id))) || null;
18
+ const versionRev = latestMainVersion?.rev ?? 0;
19
+ // Get *all* changes since that version up to the target revision (if specified)
20
+ const changesSinceVersion = await store.listChanges(docId, {
21
+ startAfter: versionRev,
22
+ endBefore: rev ? rev + 1 : undefined,
23
+ });
24
+ return {
25
+ state: versionState, // State from the base version
26
+ rev: versionRev, // Revision of the base version's state
27
+ changes: changesSinceVersion, // Changes that occurred *after* the base version state
28
+ };
29
+ }
@@ -0,0 +1,9 @@
1
+ import type { PatchesStoreBackend } from '../../server';
2
+ import type { PatchesState } from '../../types';
3
+ /**
4
+ * Gets the state at a specific revision.
5
+ * @param docId The document ID.
6
+ * @param rev The revision number. If not provided, the latest state and its revision is returned.
7
+ * @returns The state at the specified revision *and* its revision number.
8
+ */
9
+ export declare function getStateAtRevision(store: PatchesStoreBackend, docId: string, rev?: number): Promise<PatchesState>;
@@ -0,0 +1,18 @@
1
+ import { applyChanges } from '../shared/applyChanges';
2
+ import { getSnapshotAtRevision } from './getSnapshotAtRevision';
3
+ /**
4
+ * Gets the state at a specific revision.
5
+ * @param docId The document ID.
6
+ * @param rev The revision number. If not provided, the latest state and its revision is returned.
7
+ * @returns The state at the specified revision *and* its revision number.
8
+ */
9
+ export async function getStateAtRevision(store, docId, rev) {
10
+ // Note: _getSnapshotAtRevision now returns the state *of the version* and changes *since* it.
11
+ // We need to apply the changes to get the state *at* the target revision.
12
+ const { state: versionState, rev: snapshotRev, changes } = await getSnapshotAtRevision(store, docId, rev);
13
+ return {
14
+ // Ensure null is passed if versionState or versionState.state is null/undefined
15
+ state: applyChanges(versionState?.state ?? null, changes),
16
+ rev: changes.at(-1)?.rev ?? snapshotRev,
17
+ };
18
+ }
@@ -0,0 +1,12 @@
1
+ import type { PatchesStoreBackend } from '../../server';
2
+ import type { Change } from '../../types';
3
+ /**
4
+ * Handles offline/large batch versioning logic for multi-batch uploads.
5
+ * Groups changes into sessions, merges with previous batch if needed, and creates/extends versions.
6
+ * @param docId Document ID
7
+ * @param changes The incoming changes (all with the same batchId)
8
+ * @param baseRev The base revision for the batch
9
+ * @param batchId The batch identifier
10
+ * @returns The collapsed changes for transformation
11
+ */
12
+ export declare function handleOfflineSessionsAndBatches(store: PatchesStoreBackend, sessionTimeoutMillis: number, docId: string, changes: Change[], baseRev: number, batchId?: string): Promise<Change[]>;