@dabble/patches 0.1.1 → 0.2.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 +90 -169
- package/dist/client/Patches.d.ts +64 -0
- package/dist/client/Patches.js +166 -0
- package/dist/client/{PatchDoc.d.ts → PatchesDoc.d.ts} +33 -19
- package/dist/client/{PatchDoc.js → PatchesDoc.js} +57 -67
- package/dist/client/PatchesHistoryClient.d.ts +40 -0
- package/dist/client/PatchesHistoryClient.js +129 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -1
- package/dist/event-signal.js +13 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/net/AbstractTransport.js +2 -0
- package/dist/net/PatchesSync.d.ts +47 -0
- package/dist/net/PatchesSync.js +289 -0
- package/dist/net/index.d.ts +9 -7
- package/dist/net/index.js +8 -6
- package/dist/net/protocol/types.d.ts +3 -3
- package/dist/net/types.d.ts +6 -0
- package/dist/net/types.js +1 -0
- package/dist/net/websocket/PatchesWebSocket.d.ts +3 -3
- package/dist/net/websocket/WebSocketTransport.d.ts +13 -2
- package/dist/net/websocket/WebSocketTransport.js +105 -53
- package/dist/net/websocket/onlineState.d.ts +9 -0
- package/dist/net/websocket/onlineState.js +18 -0
- package/dist/persist/InMemoryStore.d.ts +23 -0
- package/dist/persist/InMemoryStore.js +103 -0
- package/dist/persist/IndexedDBStore.d.ts +13 -9
- package/dist/persist/IndexedDBStore.js +96 -14
- package/dist/persist/PatchesStore.d.ts +38 -0
- package/dist/persist/PatchesStore.js +1 -0
- package/dist/persist/index.d.ts +4 -2
- package/dist/persist/index.js +3 -1
- package/dist/server/{BranchManager.d.ts → PatchesBranchManager.d.ts} +4 -4
- package/dist/server/{BranchManager.js → PatchesBranchManager.js} +5 -5
- package/dist/server/{HistoryManager.d.ts → PatchesHistoryManager.d.ts} +4 -24
- package/dist/server/{HistoryManager.js → PatchesHistoryManager.js} +1 -34
- package/dist/server/{PatchServer.d.ts → PatchesServer.d.ts} +9 -9
- package/dist/server/{PatchServer.js → PatchesServer.js} +1 -1
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +3 -3
- package/dist/types.d.ts +11 -6
- package/dist/utils/batching.d.ts +5 -0
- package/dist/utils/batching.js +38 -0
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +8 -2
- package/package.json +1 -1
- package/dist/net/PatchesOfflineFirst.d.ts +0 -3
- package/dist/net/PatchesOfflineFirst.js +0 -3
- package/dist/net/PatchesRealtime.d.ts +0 -90
- package/dist/net/PatchesRealtime.js +0 -257
|
@@ -7,7 +7,7 @@ import { applyChanges } from '../utils.js';
|
|
|
7
7
|
* coordinating batches of changes, managing versioning based on sessions (including offline),
|
|
8
8
|
* and persisting data using a backend store.
|
|
9
9
|
*/
|
|
10
|
-
export class
|
|
10
|
+
export class PatchesServer {
|
|
11
11
|
constructor(store, options = {}) {
|
|
12
12
|
this.store = store;
|
|
13
13
|
this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1000;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export type { Change, PatchSnapshot, PatchState, VersionMetadata } from '../types';
|
|
1
|
+
export { PatchesBranchManager } from './PatchesBranchManager';
|
|
2
|
+
export { PatchesHistoryManager } from './PatchesHistoryManager';
|
|
3
|
+
export { PatchesServer } from './PatchesServer';
|
|
4
|
+
export type { Change, PatchesSnapshot as PatchSnapshot, PatchesState as PatchState, VersionMetadata } from '../types';
|
package/dist/server/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
1
|
+
export { PatchesBranchManager } from './PatchesBranchManager';
|
|
2
|
+
export { PatchesHistoryManager } from './PatchesHistoryManager';
|
|
3
|
+
export { PatchesServer } from './PatchesServer';
|
package/dist/types.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface Change {
|
|
|
20
20
|
* @property state - The state of the document.
|
|
21
21
|
* @property rev - The revision number of the state.
|
|
22
22
|
*/
|
|
23
|
-
export interface
|
|
23
|
+
export interface PatchesState<T = any> {
|
|
24
24
|
state: T;
|
|
25
25
|
rev: number;
|
|
26
26
|
}
|
|
@@ -30,7 +30,7 @@ export interface PatchState<T = any> {
|
|
|
30
30
|
* @property rev - The revision number of the state.
|
|
31
31
|
* @property changes - Any unapplied changes since `rev` that may be applied to the `state` to get the latest state.
|
|
32
32
|
*/
|
|
33
|
-
export interface
|
|
33
|
+
export interface PatchesSnapshot<T = any> extends PatchesState<T> {
|
|
34
34
|
changes: Change[];
|
|
35
35
|
}
|
|
36
36
|
/** Status options for a branch */
|
|
@@ -111,9 +111,9 @@ export interface ListVersionsOptions {
|
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
113
113
|
* Interface for a backend storage system for patch synchronization.
|
|
114
|
-
* Defines methods needed by
|
|
114
|
+
* Defines methods needed by PatchesServer, PatchesHistoryManager, etc.
|
|
115
115
|
*/
|
|
116
|
-
export interface
|
|
116
|
+
export interface PatchesStoreBackend {
|
|
117
117
|
/** Adds a subscription for a client to one or more documents. */
|
|
118
118
|
addSubscription(clientId: string, docIds: string[]): Promise<string[]>;
|
|
119
119
|
/** Removes a subscription for a client from one or more documents. */
|
|
@@ -139,9 +139,9 @@ export interface PatchStoreBackend {
|
|
|
139
139
|
deleteDoc(docId: string): Promise<void>;
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Extends
|
|
142
|
+
* Extends PatchesStoreBackend with methods specifically for managing branches.
|
|
143
143
|
*/
|
|
144
|
-
export interface BranchingStoreBackend extends
|
|
144
|
+
export interface BranchingStoreBackend extends PatchesStoreBackend {
|
|
145
145
|
/** Lists metadata records for branches originating from a document. */
|
|
146
146
|
listBranches(docId: string): Promise<Branch[]>;
|
|
147
147
|
/** Loads the metadata record for a specific branch ID. */
|
|
@@ -156,3 +156,8 @@ export interface BranchingStoreBackend extends PatchStoreBackend {
|
|
|
156
156
|
*/
|
|
157
157
|
closeBranch(branchId: string): Promise<void>;
|
|
158
158
|
}
|
|
159
|
+
export interface Deferred<T = void> {
|
|
160
|
+
promise: Promise<T>;
|
|
161
|
+
resolve: (value: T) => void;
|
|
162
|
+
reject: (reason?: any) => void;
|
|
163
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Change } from '../types.js';
|
|
2
|
+
/** Estimate JSON string byte size. */
|
|
3
|
+
export declare function getJSONByteSize(data: any): number;
|
|
4
|
+
/** Break changes into batches based on maxBatchSize. */
|
|
5
|
+
export declare function breakIntoBatches(changes: Change[], maxSize?: number): Change[][];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createId } from 'crypto-id';
|
|
2
|
+
/** Estimate JSON string byte size. */
|
|
3
|
+
export function getJSONByteSize(data) {
|
|
4
|
+
// Basic estimation, might not be perfectly accurate due to encoding nuances
|
|
5
|
+
return new TextEncoder().encode(JSON.stringify(data)).length;
|
|
6
|
+
}
|
|
7
|
+
/** Break changes into batches based on maxBatchSize. */
|
|
8
|
+
export function breakIntoBatches(changes, maxSize) {
|
|
9
|
+
if (!maxSize || getJSONByteSize(changes) < maxSize) {
|
|
10
|
+
return [changes];
|
|
11
|
+
}
|
|
12
|
+
const batchId = createId(12);
|
|
13
|
+
const batches = [];
|
|
14
|
+
let currentBatch = [];
|
|
15
|
+
let currentSize = 2; // Account for [] wrapper
|
|
16
|
+
for (const change of changes) {
|
|
17
|
+
// Add batchId if breaking up
|
|
18
|
+
const changeWithBatchId = { ...change, batchId };
|
|
19
|
+
const changeSize = getJSONByteSize(changeWithBatchId) + (currentBatch.length > 0 ? 1 : 0); // Add 1 for comma
|
|
20
|
+
// If a single change is too big, we have an issue (should be rare)
|
|
21
|
+
if (changeSize > maxSize && currentBatch.length === 0) {
|
|
22
|
+
console.error(`Single change ${change.id} (size ${changeSize}) exceeds maxBatchSize (${maxSize}). Sending as its own batch.`);
|
|
23
|
+
batches.push([changeWithBatchId]); // Send it anyway
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (currentSize + changeSize > maxSize) {
|
|
27
|
+
batches.push(currentBatch);
|
|
28
|
+
currentBatch = [];
|
|
29
|
+
currentSize = 2;
|
|
30
|
+
}
|
|
31
|
+
currentBatch.push(changeWithBatchId);
|
|
32
|
+
currentSize += changeSize;
|
|
33
|
+
}
|
|
34
|
+
if (currentBatch.length > 0) {
|
|
35
|
+
batches.push(currentBatch);
|
|
36
|
+
}
|
|
37
|
+
return batches;
|
|
38
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Change,
|
|
1
|
+
import type { Change, Deferred } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Splits an array of changes into two arrays based on the presence of a baseRev.
|
|
4
4
|
* The first array contains changes before the first change with a baseRev,
|
|
@@ -33,4 +33,4 @@ export declare function applyChanges<T>(state: T, changes: Change[]): T;
|
|
|
33
33
|
* @returns Array of rebased local changes with updated revision numbers
|
|
34
34
|
*/
|
|
35
35
|
export declare function rebaseChanges(serverChanges: Change[], localChanges: Change[]): Change[];
|
|
36
|
-
export declare function
|
|
36
|
+
export declare function deferred<T = void>(): Deferred<T>;
|
package/dist/utils.js
CHANGED
|
@@ -78,6 +78,12 @@ export function rebaseChanges(serverChanges, localChanges) {
|
|
|
78
78
|
})
|
|
79
79
|
.filter(Boolean);
|
|
80
80
|
}
|
|
81
|
-
export function
|
|
82
|
-
|
|
81
|
+
export function deferred() {
|
|
82
|
+
let resolve;
|
|
83
|
+
let reject;
|
|
84
|
+
const promise = new Promise((_resolve, _reject) => {
|
|
85
|
+
resolve = _resolve;
|
|
86
|
+
reject = _reject;
|
|
87
|
+
});
|
|
88
|
+
return { promise, resolve, reject };
|
|
83
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dabble/patches",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { PatchDoc } from '../client/PatchDoc.js';
|
|
2
|
-
import { type Signal } from '../event-signal.js';
|
|
3
|
-
import type { ConnectionState } from './protocol/types.js';
|
|
4
|
-
import type { WebSocketOptions } from './websocket/WebSocketTransport.js';
|
|
5
|
-
export interface PatchesRealtimeOptions {
|
|
6
|
-
/** Initial metadata to attach to changes from this client */
|
|
7
|
-
metadata?: Record<string, any>;
|
|
8
|
-
/** Custom WebSocket configuration (e.g. headers, protocols) */
|
|
9
|
-
wsOptions?: WebSocketOptions;
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* High-level client for real-time collaborative editing.
|
|
13
|
-
* Manages WebSocket connection and document synchronization.
|
|
14
|
-
*/
|
|
15
|
-
export declare class PatchesRealtime {
|
|
16
|
-
private ws;
|
|
17
|
-
private docs;
|
|
18
|
-
private options;
|
|
19
|
-
private wsChangesUnsubscriber;
|
|
20
|
-
private connectionState;
|
|
21
|
-
/** Emitted when the WebSocket connection state changes. */
|
|
22
|
-
readonly onStateChange: Signal<(state: ConnectionState) => void>;
|
|
23
|
-
/** Emitted when an error occurs during synchronization or connection. */
|
|
24
|
-
readonly onError: Signal<(details: {
|
|
25
|
-
type: "sendFailed" | "applyFailed" | "syncError" | "connectionError";
|
|
26
|
-
docId?: string;
|
|
27
|
-
error: Error;
|
|
28
|
-
recoveryAttempted?: boolean;
|
|
29
|
-
recoveryError?: Error;
|
|
30
|
-
}) => void>;
|
|
31
|
-
/**
|
|
32
|
-
* Creates an instance of PatchesRealtime.
|
|
33
|
-
* @param url The WebSocket URL of the Patches server.
|
|
34
|
-
* @param opts Configuration options.
|
|
35
|
-
*/
|
|
36
|
-
constructor(url: string, opts?: PatchesRealtimeOptions);
|
|
37
|
-
/**
|
|
38
|
-
* Establishes the WebSocket connection to the server.
|
|
39
|
-
* Automatically called by `openDoc` if not already connected.
|
|
40
|
-
*/
|
|
41
|
-
connect(): Promise<void>;
|
|
42
|
-
/**
|
|
43
|
-
* Opens a document by its ID, fetches its initial state, and sets up
|
|
44
|
-
* real-time synchronization. If the document is already open, returns
|
|
45
|
-
* the existing instance.
|
|
46
|
-
* @param docId The unique identifier for the document.
|
|
47
|
-
* @param opts Options, including optional metadata specific to this client's interaction with the document.
|
|
48
|
-
* @returns A Promise resolving to the synchronized PatchDoc instance.
|
|
49
|
-
* @throws If the connection fails, subscription fails, or fetching the initial document state fails.
|
|
50
|
-
*/
|
|
51
|
-
openDoc<T extends object>(docId: string, opts?: {
|
|
52
|
-
metadata?: Record<string, any>;
|
|
53
|
-
}): Promise<PatchDoc<T>>;
|
|
54
|
-
/**
|
|
55
|
-
* Closes a specific document locally, stops listening for its changes,
|
|
56
|
-
* and unsubscribes from server updates for that document.
|
|
57
|
-
* @param docId The ID of the document to close.
|
|
58
|
-
*/
|
|
59
|
-
closeDoc(docId: string): Promise<void>;
|
|
60
|
-
/**
|
|
61
|
-
* Closes all open documents, unsubscribes from all server updates,
|
|
62
|
-
* cleans up all local listeners, and disconnects the WebSocket.
|
|
63
|
-
*/
|
|
64
|
-
close(): void;
|
|
65
|
-
/**
|
|
66
|
-
* Sets up the synchronization logic for a single document. Listens for local
|
|
67
|
-
* changes on the `PatchDoc` and triggers sending them to the server.
|
|
68
|
-
* @param docId The document ID.
|
|
69
|
-
* @param doc The `PatchDoc` instance.
|
|
70
|
-
* @returns An unsubscriber function to remove the listener.
|
|
71
|
-
* @internal
|
|
72
|
-
*/
|
|
73
|
-
private _setupDocSync;
|
|
74
|
-
/**
|
|
75
|
-
* Checks if a document has pending local changes that haven't been sent
|
|
76
|
-
* to the server and attempts to send them if the document is not already
|
|
77
|
-
* in the process of sending. Handles server confirmation and potential
|
|
78
|
-
* new changes that occurred during the send operation.
|
|
79
|
-
* @param docId The document ID.
|
|
80
|
-
* @param doc The `PatchDoc` instance.
|
|
81
|
-
* @internal
|
|
82
|
-
*/
|
|
83
|
-
private _sendPendingIfNecessary;
|
|
84
|
-
/**
|
|
85
|
-
* Attempts to resynchronize a document by fetching its latest state from the server.
|
|
86
|
-
* @param docId The ID of the document to resynchronize.
|
|
87
|
-
* @internal
|
|
88
|
-
*/
|
|
89
|
-
private _resyncDoc;
|
|
90
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
import { PatchDoc } from '../client/PatchDoc.js';
|
|
2
|
-
import { signal } from '../event-signal.js';
|
|
3
|
-
import { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
|
|
4
|
-
/**
|
|
5
|
-
* High-level client for real-time collaborative editing.
|
|
6
|
-
* Manages WebSocket connection and document synchronization.
|
|
7
|
-
*/
|
|
8
|
-
export class PatchesRealtime {
|
|
9
|
-
/**
|
|
10
|
-
* Creates an instance of PatchesRealtime.
|
|
11
|
-
* @param url The WebSocket URL of the Patches server.
|
|
12
|
-
* @param opts Configuration options.
|
|
13
|
-
*/
|
|
14
|
-
constructor(url, opts = {}) {
|
|
15
|
-
this.docs = new Map();
|
|
16
|
-
this.wsChangesUnsubscriber = null;
|
|
17
|
-
this.connectionState = 'disconnected'; // Track internal state
|
|
18
|
-
/** Emitted when an error occurs during synchronization or connection. */
|
|
19
|
-
this.onError = signal();
|
|
20
|
-
this.ws = new PatchesWebSocket(url, opts.wsOptions);
|
|
21
|
-
this.options = opts;
|
|
22
|
-
// Re-emit the state change signal and track internally
|
|
23
|
-
this.onStateChange = signal(); // Explicit type
|
|
24
|
-
this.ws.onStateChange((state) => {
|
|
25
|
-
// Call signal directly to subscribe
|
|
26
|
-
this.connectionState = state;
|
|
27
|
-
this.onStateChange.emit(state); // Pass through the signal
|
|
28
|
-
});
|
|
29
|
-
// Register a single listener for all incoming changes from the WebSocket.
|
|
30
|
-
this.wsChangesUnsubscriber = this.ws.onChangesCommitted(({ docId, changes }) => {
|
|
31
|
-
const managedDoc = this.docs.get(docId);
|
|
32
|
-
if (managedDoc) {
|
|
33
|
-
try {
|
|
34
|
-
// Apply changes received from the server to the local document copy.
|
|
35
|
-
managedDoc.doc.applyExternalServerUpdate(changes);
|
|
36
|
-
// After applying, immediately check if there are new local changes to send.
|
|
37
|
-
this._sendPendingIfNecessary(docId, managedDoc.doc);
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
console.error(`Error applying external server update for doc ${docId}:`, error);
|
|
41
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
42
|
-
// Emit error and attempt recovery by resyncing
|
|
43
|
-
this.onError.emit({ type: 'applyFailed', docId, error: err, recoveryAttempted: true });
|
|
44
|
-
// Use void to explicitly ignore the promise returned by _resyncDoc
|
|
45
|
-
void this._resyncDoc(docId);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// If the document isn't open locally (e.g., already closed), ignore the incoming change.
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Establishes the WebSocket connection to the server.
|
|
53
|
-
* Automatically called by `openDoc` if not already connected.
|
|
54
|
-
*/
|
|
55
|
-
async connect() {
|
|
56
|
-
// The underlying PatchesWebSocket handles connection state and idempotency.
|
|
57
|
-
await this.ws.connect();
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Opens a document by its ID, fetches its initial state, and sets up
|
|
61
|
-
* real-time synchronization. If the document is already open, returns
|
|
62
|
-
* the existing instance.
|
|
63
|
-
* @param docId The unique identifier for the document.
|
|
64
|
-
* @param opts Options, including optional metadata specific to this client's interaction with the document.
|
|
65
|
-
* @returns A Promise resolving to the synchronized PatchDoc instance.
|
|
66
|
-
* @throws If the connection fails, subscription fails, or fetching the initial document state fails.
|
|
67
|
-
*/
|
|
68
|
-
async openDoc(docId, opts = {}) {
|
|
69
|
-
// Ensure connection is established before proceeding.
|
|
70
|
-
await this.connect();
|
|
71
|
-
// Return existing instance if already managed.
|
|
72
|
-
const existingManagedDoc = this.docs.get(docId);
|
|
73
|
-
if (existingManagedDoc) {
|
|
74
|
-
return existingManagedDoc.doc;
|
|
75
|
-
}
|
|
76
|
-
// Subscribe to server updates for this document ID *before* fetching state
|
|
77
|
-
// to avoid missing updates that might occur between getDoc and subscription completion.
|
|
78
|
-
await this.ws.subscribe(docId);
|
|
79
|
-
let snapshot;
|
|
80
|
-
try {
|
|
81
|
-
// Fetch the initial state (snapshot) of the document from the server.
|
|
82
|
-
snapshot = await this.ws.getDoc(docId);
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
// If fetching the document fails, attempt to clean up by unsubscribing.
|
|
86
|
-
console.error(`Failed to get initial state for doc ${docId}, attempting to unsubscribe:`, err);
|
|
87
|
-
this.ws.unsubscribe(docId).catch(unsubErr => {
|
|
88
|
-
// Log secondary error if unsubscribe also fails, but prioritize original error.
|
|
89
|
-
console.warn(`Failed to unsubscribe ${docId} after getDoc error:`, unsubErr);
|
|
90
|
-
});
|
|
91
|
-
// Re-throw the original error to signal failure to the caller.
|
|
92
|
-
throw err;
|
|
93
|
-
}
|
|
94
|
-
// Create the local PatchDoc instance with the fetched state and merged metadata.
|
|
95
|
-
const doc = new PatchDoc(snapshot.state, { ...this.options.metadata, ...opts.metadata });
|
|
96
|
-
// Import the snapshot details (like revision number) into the PatchDoc.
|
|
97
|
-
doc.import(snapshot);
|
|
98
|
-
// Set up the listener to send local changes to the server.
|
|
99
|
-
const onChangeUnsubscriber = this._setupDocSync(docId, doc);
|
|
100
|
-
// Store the document instance and its change listener unsubscriber.
|
|
101
|
-
this.docs.set(docId, { doc, onChangeUnsubscriber });
|
|
102
|
-
return doc;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Closes a specific document locally, stops listening for its changes,
|
|
106
|
-
* and unsubscribes from server updates for that document.
|
|
107
|
-
* @param docId The ID of the document to close.
|
|
108
|
-
*/
|
|
109
|
-
async closeDoc(docId) {
|
|
110
|
-
const managedDoc = this.docs.get(docId);
|
|
111
|
-
if (managedDoc) {
|
|
112
|
-
// Stop listening to local changes for this document.
|
|
113
|
-
managedDoc.onChangeUnsubscriber();
|
|
114
|
-
// Remove the document from local management.
|
|
115
|
-
this.docs.delete(docId);
|
|
116
|
-
// Unsubscribe from server updates for this document.
|
|
117
|
-
try {
|
|
118
|
-
await this.ws.unsubscribe(docId);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
// Log error but continue, as the primary goal (local cleanup) is done.
|
|
122
|
-
console.warn(`Error unsubscribing from doc ${docId} during closeDoc:`, err);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
// Log if closeDoc is called for a document not currently managed.
|
|
127
|
-
// This might indicate a logic error elsewhere or harmless redundant calls.
|
|
128
|
-
console.warn(`closeDoc called for non-existent or already closed doc: ${docId}`);
|
|
129
|
-
// No need to attempt unsubscribe if we don't think we were subscribed.
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Closes all open documents, unsubscribes from all server updates,
|
|
134
|
-
* cleans up all local listeners, and disconnects the WebSocket.
|
|
135
|
-
*/
|
|
136
|
-
close() {
|
|
137
|
-
// Attempt to unsubscribe from all currently managed documents on the server.
|
|
138
|
-
const docIds = Array.from(this.docs.keys());
|
|
139
|
-
if (docIds.length > 0) {
|
|
140
|
-
this.ws.unsubscribe(docIds).catch(err => {
|
|
141
|
-
console.warn('Error unsubscribing from documents during close:', err);
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
// Clean up local change listeners for all managed documents.
|
|
145
|
-
this.docs.forEach(managedDoc => managedDoc.onChangeUnsubscriber());
|
|
146
|
-
this.docs.clear(); // Remove all documents from local management.
|
|
147
|
-
// Clean up the global listener for incoming changes from the WebSocket.
|
|
148
|
-
if (this.wsChangesUnsubscriber) {
|
|
149
|
-
this.wsChangesUnsubscriber();
|
|
150
|
-
this.wsChangesUnsubscriber = null;
|
|
151
|
-
}
|
|
152
|
-
// Disconnect the WebSocket connection.
|
|
153
|
-
this.ws.disconnect();
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Sets up the synchronization logic for a single document. Listens for local
|
|
157
|
-
* changes on the `PatchDoc` and triggers sending them to the server.
|
|
158
|
-
* @param docId The document ID.
|
|
159
|
-
* @param doc The `PatchDoc` instance.
|
|
160
|
-
* @returns An unsubscriber function to remove the listener.
|
|
161
|
-
* @internal
|
|
162
|
-
*/
|
|
163
|
-
_setupDocSync(docId, doc) {
|
|
164
|
-
// Listen for local changes and attempt to send them.
|
|
165
|
-
return doc.onChange(async () => {
|
|
166
|
-
await this._sendPendingIfNecessary(docId, doc);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Checks if a document has pending local changes that haven't been sent
|
|
171
|
-
* to the server and attempts to send them if the document is not already
|
|
172
|
-
* in the process of sending. Handles server confirmation and potential
|
|
173
|
-
* new changes that occurred during the send operation.
|
|
174
|
-
* @param docId The document ID.
|
|
175
|
-
* @param doc The `PatchDoc` instance.
|
|
176
|
-
* @internal
|
|
177
|
-
*/
|
|
178
|
-
async _sendPendingIfNecessary(docId, doc) {
|
|
179
|
-
// Only proceed if there are pending changes and we are not already sending.
|
|
180
|
-
if (!doc.isSending && doc.hasPending) {
|
|
181
|
-
let changes;
|
|
182
|
-
try {
|
|
183
|
-
// Get the changes formatted for the server.
|
|
184
|
-
changes = doc.getUpdatesForServer();
|
|
185
|
-
// Basic sanity check - should not happen if hasPending is true.
|
|
186
|
-
if (changes.length === 0) {
|
|
187
|
-
console.warn(`_sendPendingIfNecessary called for ${docId} with hasPending=true but getUpdatesForServer returned empty.`);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
// Send the changes to the server via WebSocket.
|
|
191
|
-
const serverCommit = await this.ws.commitChanges(docId, changes);
|
|
192
|
-
// Apply the server's confirmation (e.g., updated revision number) to the local doc.
|
|
193
|
-
doc.applyServerConfirmation(serverCommit);
|
|
194
|
-
// IMPORTANT: After successful confirmation, immediately check again.
|
|
195
|
-
// New local changes might have occurred while the commit request was in flight.
|
|
196
|
-
// This recursive call ensures those are sent promptly.
|
|
197
|
-
await this._sendPendingIfNecessary(docId, doc);
|
|
198
|
-
}
|
|
199
|
-
catch (error) {
|
|
200
|
-
console.error(`Error sending changes to server for doc ${docId}:`, error);
|
|
201
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
202
|
-
// Notify PatchDoc to move sending changes back to pending
|
|
203
|
-
doc.handleSendFailure();
|
|
204
|
-
// Emit error and attempt recovery if online
|
|
205
|
-
this.onError.emit({ type: 'sendFailed', docId, error: err, recoveryAttempted: true });
|
|
206
|
-
// Use the internally tracked state
|
|
207
|
-
if (this.connectionState === 'connected' || this.connectionState === 'connecting') {
|
|
208
|
-
console.warn(`Attempting recovery via resync for doc ${docId} after send failure.`);
|
|
209
|
-
// Use void to explicitly ignore the promise returned by _resyncDoc
|
|
210
|
-
void this._resyncDoc(docId);
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
console.warn(`Send failure for doc ${docId} while offline. Recovery deferred until reconnection.`);
|
|
214
|
-
// No immediate recovery action needed, handleSendFailure already reset state.
|
|
215
|
-
// The standard reconnection logic should trigger a send attempt later.
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* Attempts to resynchronize a document by fetching its latest state from the server.
|
|
222
|
-
* @param docId The ID of the document to resynchronize.
|
|
223
|
-
* @internal
|
|
224
|
-
*/
|
|
225
|
-
async _resyncDoc(docId) {
|
|
226
|
-
const managedDoc = this.docs.get(docId);
|
|
227
|
-
if (!managedDoc) {
|
|
228
|
-
console.warn(`_resyncDoc called for non-managed doc: ${docId}`);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
console.log(`Attempting resync for doc ${docId}...`);
|
|
232
|
-
try {
|
|
233
|
-
const snapshot = await this.ws.getDoc(docId);
|
|
234
|
-
// Import will trigger recalculateLocalState and onUpdate
|
|
235
|
-
managedDoc.doc.import(snapshot);
|
|
236
|
-
console.log(`Successfully resynced doc ${docId} to revision ${snapshot.rev}.`);
|
|
237
|
-
// After successful resync, try sending any pending changes immediately
|
|
238
|
-
await this._sendPendingIfNecessary(docId, managedDoc.doc);
|
|
239
|
-
}
|
|
240
|
-
catch (recoveryError) {
|
|
241
|
-
console.error(`Failed to resync doc ${docId}:`, recoveryError);
|
|
242
|
-
const recErr = recoveryError instanceof Error ? recoveryError : new Error(String(recoveryError));
|
|
243
|
-
// Emit a specific syncError to indicate recovery failure
|
|
244
|
-
this.onError.emit({
|
|
245
|
-
type: 'syncError',
|
|
246
|
-
docId,
|
|
247
|
-
// TODO: Should we include the original error that triggered the resync attempt?
|
|
248
|
-
// For now, just include the recovery error itself.
|
|
249
|
-
error: recErr, // Error during the recovery attempt
|
|
250
|
-
recoveryAttempted: true,
|
|
251
|
-
recoveryError: recErr,
|
|
252
|
-
});
|
|
253
|
-
// If resync fails, the document might be in an inconsistent state.
|
|
254
|
-
// Further actions might be needed (e.g., closing the doc, user notification).
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|