@dabble/patches 0.5.16 → 0.5.18

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.
@@ -0,0 +1,30 @@
1
+ import { Change } from '../../types.js';
2
+ import '../../json-patch/JSONPatch.js';
3
+ import '@dabble/delta';
4
+ import '../../json-patch/types.js';
5
+
6
+ /**
7
+ * Collapses redundant pending changes before sync to reduce network traffic.
8
+ *
9
+ * This optimization automatically detects and collapses multiple "replace" operations
10
+ * on the same JSON path with primitive values (boolean, number, string, null) into
11
+ * a single change containing only the final value.
12
+ *
13
+ * Example: If a user toggles a folder's open state 100 times while offline,
14
+ * this collapses those 100 changes into just 1 change with the final state.
15
+ *
16
+ * Safety guarantees:
17
+ * - Only collapses single-op changes (multi-op changes are atomic, preserve intent)
18
+ * - Only collapses "replace" operations (not add, remove, move)
19
+ * - Only collapses primitive values (not objects/arrays)
20
+ * - Detects path invalidation from structural changes (remove, array shifts, move)
21
+ * - Respects the submission bookmark to never collapse already-submitted changes
22
+ *
23
+ * @param changes Array of pending changes to potentially collapse
24
+ * @param afterRev Optional revision bookmark - changes at or before this rev are not collapsed
25
+ * (they may have been partially submitted to the server)
26
+ * @returns Collapsed array of changes, maintaining correct ordering
27
+ */
28
+ declare function collapsePendingChanges(changes: Change[], afterRev?: number): Change[];
29
+
30
+ export { collapsePendingChanges };
@@ -0,0 +1,78 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ function collapsePendingChanges(changes, afterRev) {
3
+ if (changes.length <= 1) {
4
+ return changes;
5
+ }
6
+ const pathState = /* @__PURE__ */ new Map();
7
+ const outputSlots = new Array(changes.length).fill(null);
8
+ for (let i = 0; i < changes.length; i++) {
9
+ const change = changes[i];
10
+ if (afterRev !== void 0 && change.rev !== void 0 && change.rev <= afterRev) {
11
+ outputSlots[i] = change;
12
+ continue;
13
+ }
14
+ updatePathInvalidations(change, pathState);
15
+ if (!isCollapsibleChange(change)) {
16
+ outputSlots[i] = change;
17
+ continue;
18
+ }
19
+ const path = change.ops[0].path;
20
+ const existing = pathState.get(path);
21
+ if (existing) {
22
+ outputSlots[existing.lastIndex] = null;
23
+ }
24
+ pathState.set(path, { lastChange: change, lastIndex: i });
25
+ outputSlots[i] = change;
26
+ }
27
+ return outputSlots.filter((c) => c !== null);
28
+ }
29
+ function isCollapsibleChange(change) {
30
+ if (change.ops.length !== 1) {
31
+ return false;
32
+ }
33
+ const op = change.ops[0];
34
+ if (op.op !== "replace") {
35
+ return false;
36
+ }
37
+ return isPrimitiveValue(op.value);
38
+ }
39
+ function isPrimitiveValue(value) {
40
+ if (value === null) return true;
41
+ const type = typeof value;
42
+ return type === "boolean" || type === "number" || type === "string";
43
+ }
44
+ function updatePathInvalidations(change, pathState) {
45
+ for (const op of change.ops) {
46
+ if (op.op === "remove" || op.op === "move") {
47
+ invalidatePathAndChildren(op.path, pathState);
48
+ if (op.op === "move" && "from" in op) {
49
+ invalidatePathAndChildren(op.from, pathState);
50
+ }
51
+ }
52
+ if (op.op === "add" || op.op === "remove") {
53
+ invalidateShiftedArrayPaths(op.path, pathState);
54
+ }
55
+ }
56
+ }
57
+ function invalidatePathAndChildren(opPath, pathState) {
58
+ for (const trackedPath of pathState.keys()) {
59
+ if (trackedPath === opPath || trackedPath.startsWith(opPath + "/")) {
60
+ pathState.delete(trackedPath);
61
+ }
62
+ }
63
+ }
64
+ function invalidateShiftedArrayPaths(opPath, pathState) {
65
+ const segments = opPath.split("/");
66
+ const lastSegment = segments[segments.length - 1];
67
+ if (/^\d+$/.test(lastSegment)) {
68
+ const arrayPath = segments.slice(0, -1).join("/");
69
+ for (const trackedPath of pathState.keys()) {
70
+ if (trackedPath.startsWith(arrayPath + "/")) {
71
+ pathState.delete(trackedPath);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ export {
77
+ collapsePendingChanges
78
+ };
@@ -37,7 +37,7 @@ async function commitChanges(store, docId, changes, sessionTimeoutMillis, option
37
37
  const laterPartOfAnInitialBatch = batchId && changes[0].rev > 1;
38
38
  if (baseRev === 0 && currentRev > 0 && !laterPartOfAnInitialBatch && changes[0].ops[0]?.path === "") {
39
39
  throw new Error(
40
- `Document ${docId} already exists at rev ${currentRev}, but client is attempting to create it. Client needs to load the existing document.`
40
+ `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.`
41
41
  );
42
42
  }
43
43
  const lastChange = currentChanges[currentChanges.length - 1];
@@ -24,6 +24,8 @@ declare class InMemoryStore implements PatchesStore {
24
24
  deleteDoc(docId: string): Promise<void>;
25
25
  confirmDeleteDoc(docId: string): Promise<void>;
26
26
  close(): Promise<void>;
27
+ getLastAttemptedSubmissionRev(docId: string): Promise<number | undefined>;
28
+ setLastAttemptedSubmissionRev(docId: string, rev: number): Promise<void>;
27
29
  }
28
30
 
29
31
  export { InMemoryStore };
@@ -91,6 +91,16 @@ class InMemoryStore {
91
91
  async close() {
92
92
  this.docs.clear();
93
93
  }
94
+ // ─── Submission Bookmark ───────────────────────────────────────────────
95
+ async getLastAttemptedSubmissionRev(docId) {
96
+ return this.docs.get(docId)?.lastAttemptedSubmissionRev;
97
+ }
98
+ async setLastAttemptedSubmissionRev(docId, rev) {
99
+ const buf = this.docs.get(docId);
100
+ if (buf) {
101
+ buf.lastAttemptedSubmissionRev = rev;
102
+ }
103
+ }
94
104
  }
95
105
  export {
96
106
  InMemoryStore
@@ -1,4 +1,5 @@
1
1
  import { PatchesSnapshot, PatchesState, Change } from '../types.js';
2
+ import { Deferred } from '../utils/deferred.js';
2
3
  import { PatchesStore, TrackedDoc } from './PatchesStore.js';
3
4
  import '../json-patch/JSONPatch.js';
4
5
  import '@dabble/delta';
@@ -17,12 +18,12 @@ import '../json-patch/types.js';
17
18
  * A snapshot will not be created if there are pending changes based on revisions older than the 200th committed change until those pending changes are committed.
18
19
  */
19
20
  declare class IndexedDBStore implements PatchesStore {
20
- private db;
21
- private dbName?;
22
- private dbPromise;
21
+ protected db: IDBDatabase | null;
22
+ protected dbName?: string;
23
+ protected dbPromise: Deferred<IDBDatabase>;
23
24
  constructor(dbName?: string);
24
- private initDB;
25
- private getDB;
25
+ protected initDB(): Promise<void>;
26
+ protected getDB(): Promise<IDBDatabase>;
26
27
  /**
27
28
  * Set the name of the database, loads a new database connection.
28
29
  * @param dbName - The new name of the database.
@@ -35,7 +36,7 @@ declare class IndexedDBStore implements PatchesStore {
35
36
  */
36
37
  close(): Promise<void>;
37
38
  deleteDB(): Promise<void>;
38
- private transaction;
39
+ protected transaction(storeNames: string[], mode: IDBTransactionMode): Promise<[IDBTransactionWrapper, ...IDBStoreWrapper[]]>;
39
40
  /**
40
41
  * Rebuilds a document snapshot + pending queue *without* loading
41
42
  * the full PatchesDoc into memory.
@@ -113,6 +114,38 @@ declare class IndexedDBStore implements PatchesStore {
113
114
  * - build new patch: newChange.rev = pendingRev; baseRev = committedRev
114
115
  */
115
116
  getLastRevs(docId: string): Promise<[number, number]>;
117
+ /**
118
+ * Gets the last revision that was attempted to be submitted to the server.
119
+ * @param docId - The ID of the document.
120
+ * @returns The last attempted submission revision, or undefined if none.
121
+ */
122
+ getLastAttemptedSubmissionRev(docId: string): Promise<number | undefined>;
123
+ /**
124
+ * Sets the last revision that was attempted to be submitted to the server.
125
+ * @param docId - The ID of the document.
126
+ * @param rev - The revision being submitted.
127
+ */
128
+ setLastAttemptedSubmissionRev(docId: string, rev: number): Promise<void>;
129
+ }
130
+ declare class IDBTransactionWrapper {
131
+ protected tx: IDBTransaction;
132
+ protected promise: Promise<void>;
133
+ constructor(tx: IDBTransaction);
134
+ getStore(name: string): IDBStoreWrapper;
135
+ complete(): Promise<void>;
136
+ }
137
+ declare class IDBStoreWrapper {
138
+ protected store: IDBObjectStore;
139
+ constructor(store: IDBObjectStore);
140
+ protected createRange(lower?: any, upper?: any): IDBKeyRange | undefined;
141
+ getAll<T>(lower?: any, upper?: any, count?: number): Promise<T[]>;
142
+ get<T>(key: IDBValidKey): Promise<T | undefined>;
143
+ put<T>(value: T): Promise<IDBValidKey>;
144
+ delete(key: IDBValidKey): Promise<void>;
145
+ delete(lower: any, upper: any): Promise<void>;
146
+ count(lower?: any, upper?: any): Promise<number>;
147
+ getFirstFromCursor<T>(lower?: any, upper?: any): Promise<T | undefined>;
148
+ getLastFromCursor<T>(lower?: any, upper?: any): Promise<T | undefined>;
116
149
  }
117
150
 
118
151
  export { IndexedDBStore };
@@ -297,6 +297,31 @@ class IndexedDBStore {
297
297
  await tx.complete();
298
298
  return [lastCommitted?.rev ?? 0, lastPending?.rev ?? lastCommitted?.rev ?? 0];
299
299
  }
300
+ // ─── Submission Bookmark ───────────────────────────────────────────────
301
+ /**
302
+ * Gets the last revision that was attempted to be submitted to the server.
303
+ * @param docId - The ID of the document.
304
+ * @returns The last attempted submission revision, or undefined if none.
305
+ */
306
+ async getLastAttemptedSubmissionRev(docId) {
307
+ const [tx, docsStore] = await this.transaction(["docs"], "readonly");
308
+ const docMeta = await docsStore.get(docId);
309
+ await tx.complete();
310
+ return docMeta?.lastAttemptedSubmissionRev;
311
+ }
312
+ /**
313
+ * Sets the last revision that was attempted to be submitted to the server.
314
+ * @param docId - The ID of the document.
315
+ * @param rev - The revision being submitted.
316
+ */
317
+ async setLastAttemptedSubmissionRev(docId, rev) {
318
+ const [tx, docsStore] = await this.transaction(["docs"], "readwrite");
319
+ const docMeta = await docsStore.get(docId);
320
+ if (docMeta) {
321
+ await docsStore.put({ ...docMeta, lastAttemptedSubmissionRev: rev });
322
+ }
323
+ await tx.complete();
324
+ }
300
325
  }
301
326
  _init = __decoratorStart(null);
302
327
  __decorateElement(_init, 1, "getDoc", _getDoc_dec, IndexedDBStore);
@@ -10,6 +10,8 @@ interface TrackedDoc {
10
10
  committedRev: number;
11
11
  /** Optional flag indicating the document has been locally deleted. */
12
12
  deleted?: true;
13
+ /** The last revision that was attempted to be submitted to the server. */
14
+ lastAttemptedSubmissionRev?: number;
13
15
  }
14
16
  /**
15
17
  * Pluggable persistence layer contract used by Patches + PatchesSync.
@@ -217,6 +219,35 @@ interface PatchesStore {
217
219
  * // Store is no longer usable
218
220
  */
219
221
  close(): Promise<void>;
222
+ /**
223
+ * Gets the last revision that was attempted to be submitted to the server.
224
+ *
225
+ * This bookmark is used by change collapsing to avoid modifying changes that
226
+ * may have been partially committed by the server. Returns undefined if no
227
+ * submission has been attempted yet.
228
+ *
229
+ * @param docId Document identifier
230
+ * @returns The last attempted submission revision, or undefined if none
231
+ * @example
232
+ * const lastAttempted = await store.getLastAttemptedSubmissionRev('my-document');
233
+ * // Use this to protect changes from collapsing
234
+ */
235
+ getLastAttemptedSubmissionRev?(docId: string): Promise<number | undefined>;
236
+ /**
237
+ * Sets the last revision that was attempted to be submitted to the server.
238
+ *
239
+ * Called before sending changes to the server to mark them as "in flight".
240
+ * This prevents change collapsing from modifying these changes in case the
241
+ * server commits them but the client doesn't receive confirmation.
242
+ *
243
+ * @param docId Document identifier
244
+ * @param rev The revision being submitted
245
+ * @example
246
+ * // Before sending batch to server
247
+ * await store.setLastAttemptedSubmissionRev('my-document', lastChange.rev);
248
+ * await sendToServer(batch);
249
+ */
250
+ setLastAttemptedSubmissionRev?(docId: string, rev: number): Promise<void>;
220
251
  }
221
252
 
222
253
  export type { PatchesStore, TrackedDoc };
@@ -8,6 +8,7 @@ import '../types.js';
8
8
  import '../json-patch/JSONPatch.js';
9
9
  import '@dabble/delta';
10
10
  import '../json-patch/types.js';
11
+ import '../utils/deferred.js';
11
12
  import '../event-signal.js';
12
13
  import '../algorithms/shared/changeBatching.js';
13
14
  import '../net/protocol/types.js';
package/dist/index.d.ts CHANGED
@@ -26,5 +26,6 @@ export { move } from './json-patch/ops/move.js';
26
26
  export { remove } from './json-patch/ops/remove.js';
27
27
  export { replace } from './json-patch/ops/replace.js';
28
28
  export { test } from './json-patch/ops/test.js';
29
+ import './utils/deferred.js';
29
30
  import './algorithms/shared/changeBatching.js';
30
31
  import './net/protocol/types.js';
@@ -10,8 +10,14 @@ import { JSONPatchOp, JSONPatchOpHandlerMap, ApplyJSONPatchOptions } from './typ
10
10
  * (c) 2022 Jacob Wright
11
11
  *
12
12
  *
13
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
14
- * all situaions. Please avoid using this syntax when using Operational Transformations.
13
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
14
+ * Operational Transformations. It's safe when:
15
+ * - The appended value won't be modified by subsequent operations, OR
16
+ * - The append commits to the server before any operations reference the item by index
17
+ *
18
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
19
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
20
+ * target different items after concurrent changes. See docs/json-patch.md for details.
15
21
  */
16
22
 
17
23
  type PathLike = string | {
@@ -8,8 +8,14 @@ import "../chunk-IZ2YBCUP.js";
8
8
  * (c) 2022 Jacob Wright
9
9
  *
10
10
  *
11
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
12
- * all situaions. Please avoid using this syntax when using Operational Transformations.
11
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
12
+ * Operational Transformations. It's safe when:
13
+ * - The appended value won't be modified by subsequent operations, OR
14
+ * - The append commits to the server before any operations reference the item by index
15
+ *
16
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
17
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
18
+ * target different items after concurrent changes. See docs/json-patch.md for details.
13
19
  */
14
20
  import { Delta } from "@dabble/delta";
15
21
  import { applyPatch } from "./applyPatch.js";
@@ -26,10 +26,15 @@ const add = {
26
26
  if (index < 0 || target.length < index) {
27
27
  return `[op:add] invalid array index: ${path}`;
28
28
  }
29
- pluckWithShallowCopy(state, keys, true).splice(index, 0, value);
29
+ pluckWithShallowCopy(state, keys, true, true).splice(index, 0, value);
30
30
  } else {
31
31
  if (!deepEqual(target[lastKey], value)) {
32
- pluckWithShallowCopy(state, keys, true)[lastKey] = value;
32
+ const container = pluckWithShallowCopy(state, keys, true, true);
33
+ if (Array.isArray(container) && lastKey === "-") {
34
+ container.push(value);
35
+ } else {
36
+ container[lastKey] = value;
37
+ }
33
38
  }
34
39
  }
35
40
  },
@@ -9,8 +9,14 @@ import { JSONPatchOp, JSONPatchOpHandlerMap } from './types.js';
9
9
  * (c) 2022 Jacob Wright
10
10
  *
11
11
  *
12
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
13
- * all situaions. Please avoid using this syntax when using Operational Transformations.
12
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
13
+ * Operational Transformations. It's safe when:
14
+ * - The appended value won't be modified by subsequent operations, OR
15
+ * - The append commits to the server before any operations reference the item by index
16
+ *
17
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
18
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
19
+ * target different items after concurrent changes. See docs/json-patch.md for details.
14
20
  */
15
21
 
16
22
  /**
@@ -8,8 +8,14 @@ import "../chunk-IZ2YBCUP.js";
8
8
  * (c) 2022 Jacob Wright
9
9
  *
10
10
  *
11
- * WARNING: using /array/- syntax to indicate the end of the array makes it impossible to transform arrays correctly in
12
- * all situaions. Please avoid using this syntax when using Operational Transformations.
11
+ * NOTE ON ARRAY APPEND SYNTAX: The /array/- path syntax (append to end) has limitations with
12
+ * Operational Transformations. It's safe when:
13
+ * - The appended value won't be modified by subsequent operations, OR
14
+ * - The append commits to the server before any operations reference the item by index
15
+ *
16
+ * It causes problems when you append with /items/- then reference by index (e.g., /items/3/name)
17
+ * in uncommitted operations — the index can be transformed but the - cannot, causing them to
18
+ * target different items after concurrent changes. See docs/json-patch.md for details.
13
19
  */
14
20
  import { getTypes } from "./ops/index.js";
15
21
  import { runWithObject } from "./state.js";
@@ -5,7 +5,7 @@ export { getType, getTypeLike } from './getType.js';
5
5
  export { log, verbose } from './log.js';
6
6
  export { isAdd, mapAndFilterOps, transformRemove, updateRemovedOps } from './ops.js';
7
7
  export { getArrayIndex, getArrayPrefixAndIndex, getIndexAndEnd, getPrefix, getPrefixAndProp, getProp, getPropAfter, isArrayPath } from './paths.js';
8
- export { EMPTY, getValue, pluck, pluckWithShallowCopy } from './pluck.js';
8
+ export { EMPTY, EMPTY_ARRAY, getValue, pluck, pluckWithShallowCopy } from './pluck.js';
9
9
  export { shallowCopy } from './shallowCopy.js';
10
10
  export { isEmptyObject, updateSoftWrites } from './softWrites.js';
11
11
  export { toArrayIndex } from './toArrayIndex.js';
@@ -1,8 +1,9 @@
1
1
  import { State } from '../types.js';
2
2
 
3
3
  declare const EMPTY: {};
4
+ declare const EMPTY_ARRAY: any[];
4
5
  declare function pluck(state: State, keys: string[]): any;
5
- declare function pluckWithShallowCopy(state: State, keys: string[], createMissingObjects?: boolean): any;
6
+ declare function pluckWithShallowCopy(state: State, keys: string[], createMissingObjects?: boolean, createMissingArrays?: boolean): any;
6
7
  declare function getValue(state: State, value: any, addKey?: string, addValue?: any): any;
7
8
 
8
- export { EMPTY, getValue, pluck, pluckWithShallowCopy };
9
+ export { EMPTY, EMPTY_ARRAY, getValue, pluck, pluckWithShallowCopy };
@@ -1,6 +1,7 @@
1
1
  import "../../chunk-IZ2YBCUP.js";
2
2
  import { shallowCopy } from "./shallowCopy.js";
3
3
  const EMPTY = {};
4
+ const EMPTY_ARRAY = [];
4
5
  function pluck(state, keys) {
5
6
  let object = state.root;
6
7
  for (let i = 0, imax = keys.length - 1; i < imax; i++) {
@@ -12,11 +13,22 @@ function pluck(state, keys) {
12
13
  }
13
14
  return object;
14
15
  }
15
- function pluckWithShallowCopy(state, keys, createMissingObjects) {
16
+ function pluckWithShallowCopy(state, keys, createMissingObjects, createMissingArrays) {
16
17
  let object = state.root;
17
18
  for (let i = 0, imax = keys.length - 1; i < imax; i++) {
18
19
  const key = keys[i];
19
- object = object[key] = createMissingObjects && !object[key] ? getValue(state, EMPTY) : getValue(state, object[key]);
20
+ const container = createMissingArrays && (keys[i + 1] === "0" || keys[i + 1] === "-") ? EMPTY_ARRAY : EMPTY;
21
+ if (key === "-" && Array.isArray(object)) {
22
+ if (createMissingObjects && object.length === 0) {
23
+ const newItem = getValue(state, container);
24
+ object.push(newItem);
25
+ object = newItem;
26
+ } else {
27
+ object = getValue(state, object[object.length - 1]);
28
+ }
29
+ } else {
30
+ object = object[key] = createMissingObjects && !object[key] ? getValue(state, container) : getValue(state, object[key]);
31
+ }
20
32
  }
21
33
  return object;
22
34
  }
@@ -30,6 +42,7 @@ function getValue(state, value, addKey, addValue) {
30
42
  }
31
43
  export {
32
44
  EMPTY,
45
+ EMPTY_ARRAY,
33
46
  getValue,
34
47
  pluck,
35
48
  pluckWithShallowCopy
@@ -101,8 +101,9 @@ declare class PatchesSync {
101
101
  /**
102
102
  * Flushes a document to the server.
103
103
  * @param docId The ID of the document to flush.
104
+ * @param pending Optional pending changes to flush, to avoid redundant store fetch.
104
105
  */
105
- protected flushDoc(docId: string): Promise<void>;
106
+ protected flushDoc(docId: string, pending?: Change[]): Promise<void>;
106
107
  /**
107
108
  * Receives committed changes from the server and applies them to the document. This is a blockable function, so it
108
109
  * is separate from applyServerChangesToDoc, which is called by other blockable functions. Ensuring this is blockable
@@ -114,7 +115,7 @@ declare class PatchesSync {
114
115
  * Applies server changes to a document using the centralized sync algorithm.
115
116
  * This ensures consistent OT behavior regardless of whether the doc is open in memory.
116
117
  */
117
- protected _applyServerChangesToDoc(docId: string, serverChanges: Change[], sentPendingRange?: [number, number]): Promise<void>;
118
+ protected _applyServerChangesToDoc(docId: string, serverChanges: Change[], sentPendingRange?: [number, number]): Promise<Change[]>;
118
119
  /**
119
120
  * Initiates the deletion process for a document both locally and on the server.
120
121
  * This now delegates the local tombstone marking to Patches.
@@ -8,6 +8,7 @@ import {
8
8
  var __receiveCommittedChanges_dec, _syncDoc_dec, _init;
9
9
  import { isEqual } from "@dabble/delta";
10
10
  import { applyCommittedChanges } from "../algorithms/client/applyCommittedChanges.js";
11
+ import { collapsePendingChanges } from "../algorithms/client/collapsePendingChanges.js";
11
12
  import { breakChangesIntoBatches } from "../algorithms/shared/changeBatching.js";
12
13
  import { Patches } from "../client/Patches.js";
13
14
  import { signal } from "../event-signal.js";
@@ -169,7 +170,7 @@ class PatchesSync {
169
170
  try {
170
171
  const pending = await this.store.getPendingChanges(docId);
171
172
  if (pending.length > 0) {
172
- await this.flushDoc(docId);
173
+ await this.flushDoc(docId, pending);
173
174
  } else {
174
175
  const [committedRev] = await this.store.getLastRevs(docId);
175
176
  if (committedRev) {
@@ -203,8 +204,9 @@ class PatchesSync {
203
204
  /**
204
205
  * Flushes a document to the server.
205
206
  * @param docId The ID of the document to flush.
207
+ * @param pending Optional pending changes to flush, to avoid redundant store fetch.
206
208
  */
207
- async flushDoc(docId) {
209
+ async flushDoc(docId, pending) {
208
210
  if (!this.trackedDocs.has(docId)) {
209
211
  throw new Error(`Document ${docId} is not tracked`);
210
212
  }
@@ -212,10 +214,17 @@ class PatchesSync {
212
214
  throw new Error("Not connected to server");
213
215
  }
214
216
  try {
215
- let pending = await this.store.getPendingChanges(docId);
217
+ if (!pending) pending = await this.store.getPendingChanges(docId);
216
218
  if (!pending.length) {
217
219
  return;
218
220
  }
221
+ if (this.store.getLastAttemptedSubmissionRev && this.store.setLastAttemptedSubmissionRev) {
222
+ const afterRev = await this.store.getLastAttemptedSubmissionRev(docId);
223
+ pending = collapsePendingChanges(pending, afterRev);
224
+ if (!pending.length) {
225
+ return;
226
+ }
227
+ }
219
228
  const batches = breakChangesIntoBatches(pending, {
220
229
  maxPayloadBytes: this.maxPayloadBytes,
221
230
  maxStorageBytes: this.maxStorageBytes,
@@ -225,6 +234,10 @@ class PatchesSync {
225
234
  if (!this.state.connected) {
226
235
  throw new Error("Disconnected during flush");
227
236
  }
237
+ if (this.store.setLastAttemptedSubmissionRev) {
238
+ const lastRevInBatch = batch[batch.length - 1].rev;
239
+ await this.store.setLastAttemptedSubmissionRev(docId, lastRevInBatch);
240
+ }
228
241
  const range = [batch[0].rev, batch[batch.length - 1].rev];
229
242
  const committed = await this.ws.commitChanges(docId, batch);
230
243
  await this._applyServerChangesToDoc(docId, committed, range);
@@ -255,7 +268,7 @@ class PatchesSync {
255
268
  const currentSnapshot = await this.store.getDoc(docId);
256
269
  if (!currentSnapshot) {
257
270
  console.warn(`Cannot apply server changes to non-existent doc: ${docId}`);
258
- return;
271
+ return [];
259
272
  }
260
273
  const doc = this.patches.getOpenDoc(docId);
261
274
  if (doc) {
@@ -276,6 +289,7 @@ class PatchesSync {
276
289
  this.store.saveCommittedChanges(docId, serverChanges, sentPendingRange),
277
290
  this.store.replacePendingChanges(docId, rebasedPendingChanges)
278
291
  ]);
292
+ return rebasedPendingChanges;
279
293
  }
280
294
  /**
281
295
  * Initiates the deletion process for a document both locally and on the server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {