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