@dabble/patches 0.5.17 → 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.
- package/dist/algorithms/client/collapsePendingChanges.d.ts +30 -0
- package/dist/algorithms/client/collapsePendingChanges.js +78 -0
- package/dist/algorithms/server/commitChanges.js +1 -1
- package/dist/client/InMemoryStore.d.ts +2 -0
- package/dist/client/InMemoryStore.js +10 -0
- package/dist/client/IndexedDBStore.d.ts +39 -6
- package/dist/client/IndexedDBStore.js +25 -0
- package/dist/client/PatchesStore.d.ts +31 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/json-patch/JSONPatch.d.ts +8 -2
- package/dist/json-patch/JSONPatch.js +8 -2
- package/dist/json-patch/ops/add.js +6 -1
- package/dist/json-patch/transformPatch.d.ts +8 -2
- package/dist/json-patch/transformPatch.js +8 -2
- package/dist/json-patch/utils/pluck.js +12 -2
- package/dist/net/PatchesSync.d.ts +0 -8
- package/dist/net/PatchesSync.js +12 -21
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
protected db: IDBDatabase | null;
|
|
22
|
+
protected dbName?: string;
|
|
23
|
+
protected dbPromise: Deferred<IDBDatabase>;
|
|
23
24
|
constructor(dbName?: string);
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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";
|
|
@@ -29,7 +29,12 @@ const add = {
|
|
|
29
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, true)
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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";
|
|
@@ -17,8 +17,18 @@ function pluckWithShallowCopy(state, keys, createMissingObjects, createMissingAr
|
|
|
17
17
|
let object = state.root;
|
|
18
18
|
for (let i = 0, imax = keys.length - 1; i < imax; i++) {
|
|
19
19
|
const key = keys[i];
|
|
20
|
-
const container = createMissingArrays && keys[i + 1] === "0" ? EMPTY_ARRAY : EMPTY;
|
|
21
|
-
|
|
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
|
+
}
|
|
22
32
|
}
|
|
23
33
|
return object;
|
|
24
34
|
}
|
|
@@ -134,14 +134,6 @@ declare class PatchesSync {
|
|
|
134
134
|
* Helper to detect DOC_DELETED (410) errors from the server.
|
|
135
135
|
*/
|
|
136
136
|
protected _isDocDeletedError(err: unknown): boolean;
|
|
137
|
-
/**
|
|
138
|
-
* Helper to detect "document already exists" errors from the server.
|
|
139
|
-
*/
|
|
140
|
-
private _isDocExistsError;
|
|
141
|
-
/**
|
|
142
|
-
* Recovers from a "document already exists" error by fetching server state and retrying.
|
|
143
|
-
*/
|
|
144
|
-
private _recoverFromDocExists;
|
|
145
137
|
}
|
|
146
138
|
|
|
147
139
|
export { PatchesSync, type PatchesSyncOptions, type PatchesSyncState };
|
package/dist/net/PatchesSync.js
CHANGED
|
@@ -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";
|
|
@@ -217,6 +218,13 @@ class PatchesSync {
|
|
|
217
218
|
if (!pending.length) {
|
|
218
219
|
return;
|
|
219
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
|
+
}
|
|
220
228
|
const batches = breakChangesIntoBatches(pending, {
|
|
221
229
|
maxPayloadBytes: this.maxPayloadBytes,
|
|
222
230
|
maxStorageBytes: this.maxStorageBytes,
|
|
@@ -226,6 +234,10 @@ class PatchesSync {
|
|
|
226
234
|
if (!this.state.connected) {
|
|
227
235
|
throw new Error("Disconnected during flush");
|
|
228
236
|
}
|
|
237
|
+
if (this.store.setLastAttemptedSubmissionRev) {
|
|
238
|
+
const lastRevInBatch = batch[batch.length - 1].rev;
|
|
239
|
+
await this.store.setLastAttemptedSubmissionRev(docId, lastRevInBatch);
|
|
240
|
+
}
|
|
229
241
|
const range = [batch[0].rev, batch[batch.length - 1].rev];
|
|
230
242
|
const committed = await this.ws.commitChanges(docId, batch);
|
|
231
243
|
await this._applyServerChangesToDoc(docId, committed, range);
|
|
@@ -236,10 +248,6 @@ class PatchesSync {
|
|
|
236
248
|
await this._handleRemoteDocDeleted(docId);
|
|
237
249
|
return;
|
|
238
250
|
}
|
|
239
|
-
if (this._isDocExistsError(err)) {
|
|
240
|
-
await this._recoverFromDocExists(docId);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
251
|
console.error(`Flush failed for doc ${docId}:`, err);
|
|
244
252
|
this.onError.emit(err, { docId });
|
|
245
253
|
throw err;
|
|
@@ -368,23 +376,6 @@ class PatchesSync {
|
|
|
368
376
|
_isDocDeletedError(err) {
|
|
369
377
|
return typeof err === "object" && err !== null && "code" in err && err.code === 410;
|
|
370
378
|
}
|
|
371
|
-
/**
|
|
372
|
-
* Helper to detect "document already exists" errors from the server.
|
|
373
|
-
*/
|
|
374
|
-
_isDocExistsError(err) {
|
|
375
|
-
const message = err?.message ?? "";
|
|
376
|
-
return message.includes("already exists");
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Recovers from a "document already exists" error by fetching server state and retrying.
|
|
380
|
-
*/
|
|
381
|
-
async _recoverFromDocExists(docId) {
|
|
382
|
-
const serverChanges = await this.ws.getChangesSince(docId, 0);
|
|
383
|
-
const rebasedPending = await this._applyServerChangesToDoc(docId, serverChanges);
|
|
384
|
-
if (rebasedPending.length > 0) {
|
|
385
|
-
await this.flushDoc(docId, rebasedPending);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
379
|
}
|
|
389
380
|
_init = __decoratorStart(null);
|
|
390
381
|
__decorateElement(_init, 1, "syncDoc", _syncDoc_dec, PatchesSync);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dabble/patches",
|
|
3
|
-
"version": "0.5.
|
|
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": {
|