@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.
- 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 +7 -2
- package/dist/json-patch/transformPatch.d.ts +8 -2
- package/dist/json-patch/transformPatch.js +8 -2
- package/dist/json-patch/utils/index.d.ts +1 -1
- package/dist/json-patch/utils/pluck.d.ts +3 -2
- package/dist/json-patch/utils/pluck.js +15 -2
- package/dist/net/PatchesSync.d.ts +3 -2
- package/dist/net/PatchesSync.js +18 -4
- 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";
|
|
@@ -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)
|
|
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";
|
|
@@ -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
|
-
|
|
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<
|
|
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.
|
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";
|
|
@@ -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
|
-
|
|
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.
|
|
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": {
|