@docukit/docsync 0.0.1-alpha.1
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/LICENSE.md +20 -0
- package/README.md +1 -0
- package/dist/src/client/index.d.ts +158 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +832 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/client/providers/indexeddb.d.ts +15 -0
- package/dist/src/client/providers/indexeddb.d.ts.map +1 -0
- package/dist/src/client/providers/indexeddb.js +79 -0
- package/dist/src/client/providers/indexeddb.js.map +1 -0
- package/dist/src/exports/client.d.ts +4 -0
- package/dist/src/exports/client.d.ts.map +1 -0
- package/dist/src/exports/client.js +3 -0
- package/dist/src/exports/client.js.map +1 -0
- package/dist/src/exports/docnode.d.ts +2 -0
- package/dist/src/exports/docnode.d.ts.map +1 -0
- package/dist/src/exports/docnode.js +2 -0
- package/dist/src/exports/docnode.js.map +1 -0
- package/dist/src/exports/index.d.ts +3 -0
- package/dist/src/exports/index.d.ts.map +1 -0
- package/dist/src/exports/index.js +3 -0
- package/dist/src/exports/index.js.map +1 -0
- package/dist/src/exports/server.d.ts +5 -0
- package/dist/src/exports/server.d.ts.map +1 -0
- package/dist/src/exports/server.js +4 -0
- package/dist/src/exports/server.js.map +1 -0
- package/dist/src/exports/testing.d.ts +7 -0
- package/dist/src/exports/testing.d.ts.map +1 -0
- package/dist/src/exports/testing.js +6 -0
- package/dist/src/exports/testing.js.map +1 -0
- package/dist/src/server/cli.d.ts +2 -0
- package/dist/src/server/cli.d.ts.map +1 -0
- package/dist/src/server/cli.js +10 -0
- package/dist/src/server/cli.js.map +1 -0
- package/dist/src/server/index.d.ts +37 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +450 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/providers/memory.d.ts +17 -0
- package/dist/src/server/providers/memory.d.ts.map +1 -0
- package/dist/src/server/providers/memory.js +72 -0
- package/dist/src/server/providers/memory.js.map +1 -0
- package/dist/src/server/providers/postgres/drizzle.config.d.ts +3 -0
- package/dist/src/server/providers/postgres/drizzle.config.d.ts.map +1 -0
- package/dist/src/server/providers/postgres/drizzle.config.js +10 -0
- package/dist/src/server/providers/postgres/drizzle.config.js.map +1 -0
- package/dist/src/server/providers/postgres/index.d.ts +6 -0
- package/dist/src/server/providers/postgres/index.d.ts.map +1 -0
- package/dist/src/server/providers/postgres/index.js +83 -0
- package/dist/src/server/providers/postgres/index.js.map +1 -0
- package/dist/src/server/providers/postgres/schema.d.ts +159 -0
- package/dist/src/server/providers/postgres/schema.d.ts.map +1 -0
- package/dist/src/server/providers/postgres/schema.js +34 -0
- package/dist/src/server/providers/postgres/schema.js.map +1 -0
- package/dist/src/shared/debounce.d.ts +2 -0
- package/dist/src/shared/debounce.d.ts.map +1 -0
- package/dist/src/shared/debounce.js +10 -0
- package/dist/src/shared/debounce.js.map +1 -0
- package/dist/src/shared/docBinding.d.ts +17 -0
- package/dist/src/shared/docBinding.d.ts.map +1 -0
- package/dist/src/shared/docBinding.js +41 -0
- package/dist/src/shared/docBinding.js.map +1 -0
- package/dist/src/shared/throttle.d.ts +30 -0
- package/dist/src/shared/throttle.d.ts.map +1 -0
- package/dist/src/shared/throttle.js +51 -0
- package/dist/src/shared/throttle.js.map +1 -0
- package/dist/src/shared/types.d.ts +387 -0
- package/dist/src/shared/types.d.ts.map +1 -0
- package/dist/src/shared/types.js +6 -0
- package/dist/src/shared/types.js.map +1 -0
- package/dist/src/shared/utils.d.ts +2 -0
- package/dist/src/shared/utils.d.ts.map +1 -0
- package/dist/src/shared/utils.js +11 -0
- package/dist/src/shared/utils.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
|
+
import { io } from "socket.io-client";
|
|
3
|
+
export class DocSyncClient {
|
|
4
|
+
_docBinding;
|
|
5
|
+
_docsCache = new Map();
|
|
6
|
+
_localPromise;
|
|
7
|
+
_deviceId;
|
|
8
|
+
/** Client-generated id for presence (works offline; sent in auth so server uses same key) */
|
|
9
|
+
_clientId;
|
|
10
|
+
_shouldBroadcast = true;
|
|
11
|
+
_broadcastChannel;
|
|
12
|
+
_socket;
|
|
13
|
+
// Flow control state (batching, debouncing, push queueing)
|
|
14
|
+
_localOpsBatchState = new Map();
|
|
15
|
+
_batchDelay = 50;
|
|
16
|
+
_presenceDebounceState = new Map();
|
|
17
|
+
_presenceDebounce = 200;
|
|
18
|
+
_pushStatusByDocId = new Map();
|
|
19
|
+
// Event handlers - ChangeHandler and SyncHandler use default (unknown) to allow covariance
|
|
20
|
+
_connectHandlers = new Set();
|
|
21
|
+
_disconnectHandlers = new Set();
|
|
22
|
+
_changeHandlers = new Set();
|
|
23
|
+
_syncHandlers = new Set();
|
|
24
|
+
_docLoadHandlers = new Set();
|
|
25
|
+
_docUnloadHandlers = new Set();
|
|
26
|
+
constructor(config) {
|
|
27
|
+
if (typeof window === "undefined")
|
|
28
|
+
throw new Error("DocSyncClient can only be used in the browser");
|
|
29
|
+
const { docBinding, local } = config;
|
|
30
|
+
this._docBinding = docBinding;
|
|
31
|
+
this._clientId = crypto.randomUUID();
|
|
32
|
+
// Initialize local provider (if configured)
|
|
33
|
+
this._localPromise = (async () => {
|
|
34
|
+
const identity = await local.getIdentity();
|
|
35
|
+
const provider = new local.provider(identity);
|
|
36
|
+
// Initialize BroadcastChannel with user-specific channel name
|
|
37
|
+
// This ensures only tabs of the same user share operations
|
|
38
|
+
this._broadcastChannel = new BroadcastChannel(`docsync:${identity.userId}`);
|
|
39
|
+
this._broadcastChannel.onmessage = async (ev) => {
|
|
40
|
+
// RECEIVED MESSAGES
|
|
41
|
+
if (ev.data.type === "OPERATIONS") {
|
|
42
|
+
// Another tab is pushing operations - they are responsible for pushing to server
|
|
43
|
+
// We just need to coordinate push status to avoid conflicts
|
|
44
|
+
const currentStatus = this._pushStatusByDocId.get(ev.data.docId) ?? "idle";
|
|
45
|
+
if (currentStatus === "pushing") {
|
|
46
|
+
// Mark as busy to avoid concurrent pushes
|
|
47
|
+
this._pushStatusByDocId.set(ev.data.docId, "pushing-with-pending");
|
|
48
|
+
}
|
|
49
|
+
// Note: We don't call saveRemote here - the sender is responsible for pushing
|
|
50
|
+
// If the sender is offline, the push will happen when they reconnect
|
|
51
|
+
void this._applyOperations(ev.data.operations, ev.data.docId);
|
|
52
|
+
// Apply presence after ops so the doc is updated first (avoids cursor lag)
|
|
53
|
+
if (ev.data.presence) {
|
|
54
|
+
const cacheEntry = this._docsCache.get(ev.data.docId);
|
|
55
|
+
if (cacheEntry)
|
|
56
|
+
this._applyPresencePatch(cacheEntry, ev.data.presence);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (ev.data.type === "PRESENCE") {
|
|
61
|
+
const { docId, presence } = ev.data;
|
|
62
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
63
|
+
if (!cacheEntry)
|
|
64
|
+
return;
|
|
65
|
+
this._applyPresencePatch(cacheEntry, presence);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return { provider, identity };
|
|
69
|
+
})();
|
|
70
|
+
this._deviceId = getDeviceId();
|
|
71
|
+
this._socket = io(config.server.url, {
|
|
72
|
+
auth: (cb) => {
|
|
73
|
+
void config.server.auth.getToken().then((token) => {
|
|
74
|
+
cb({ token, deviceId: this._deviceId, clientId: this._clientId });
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
// Performance optimizations for testing
|
|
78
|
+
transports: ["websocket"], // Skip polling, go straight to WebSocket
|
|
79
|
+
});
|
|
80
|
+
this._socket.on("connect", () => {
|
|
81
|
+
// Emit connect event
|
|
82
|
+
this._emit(this._connectHandlers);
|
|
83
|
+
// Push pending operations for all loaded docs
|
|
84
|
+
for (const docId of this._docsCache.keys()) {
|
|
85
|
+
this.saveRemote({ docId });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
this._socket.on("disconnect", (reason) => {
|
|
89
|
+
this._pushStatusByDocId.clear();
|
|
90
|
+
// Clear pending presence debounce timers so their callbacks never run after disconnect
|
|
91
|
+
for (const state of this._presenceDebounceState.values()) {
|
|
92
|
+
clearTimeout(state.timeout);
|
|
93
|
+
}
|
|
94
|
+
this._presenceDebounceState.clear();
|
|
95
|
+
// Tell other tabs to remove this client's presence (clientId works offline)
|
|
96
|
+
for (const docId of this._docsCache.keys()) {
|
|
97
|
+
this._sendMessage({
|
|
98
|
+
type: "PRESENCE",
|
|
99
|
+
docId,
|
|
100
|
+
presence: { [this._clientId]: null },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
this._emit(this._disconnectHandlers, { reason });
|
|
104
|
+
});
|
|
105
|
+
this._socket.on("connect_error", (err) => {
|
|
106
|
+
this._emit(this._disconnectHandlers, { reason: err.message });
|
|
107
|
+
});
|
|
108
|
+
// Listen for dirty notifications from server
|
|
109
|
+
this._socket.on("dirty", (payload) => {
|
|
110
|
+
this.saveRemote({ docId: payload.docId });
|
|
111
|
+
});
|
|
112
|
+
this._socket.on("presence", (payload) => {
|
|
113
|
+
const cacheEntry = this._docsCache.get(payload.docId);
|
|
114
|
+
if (!cacheEntry)
|
|
115
|
+
return;
|
|
116
|
+
this._applyPresencePatch(cacheEntry, payload.presence);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
connect() {
|
|
120
|
+
this._socket.connect();
|
|
121
|
+
}
|
|
122
|
+
disconnect() {
|
|
123
|
+
this._socket.disconnect();
|
|
124
|
+
}
|
|
125
|
+
async _applyOperations(operations, docId) {
|
|
126
|
+
const docFromCache = this._docsCache.get(docId);
|
|
127
|
+
if (!docFromCache)
|
|
128
|
+
return;
|
|
129
|
+
const doc = await docFromCache.promisedDoc;
|
|
130
|
+
if (!doc)
|
|
131
|
+
return;
|
|
132
|
+
this._shouldBroadcast = false;
|
|
133
|
+
this._docBinding.applyOperations(doc, operations);
|
|
134
|
+
this._shouldBroadcast = true;
|
|
135
|
+
// Emit change event for broadcast operations
|
|
136
|
+
this._emit(this._changeHandlers, {
|
|
137
|
+
docId,
|
|
138
|
+
origin: "broadcast",
|
|
139
|
+
operations: [operations],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
_applyPresencePatch(cacheEntry, patch) {
|
|
143
|
+
const newPresence = { ...cacheEntry.presence };
|
|
144
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
145
|
+
if (key === this._clientId)
|
|
146
|
+
continue; // never store own presence in cache; local tab must not render self as remote
|
|
147
|
+
if (value === undefined || value === null) {
|
|
148
|
+
delete newPresence[key];
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
newPresence[key] = value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
cacheEntry.presence = newPresence;
|
|
155
|
+
cacheEntry.presenceHandlers.forEach((handler) => handler(cacheEntry.presence));
|
|
156
|
+
}
|
|
157
|
+
/** Current presence for this client (debounce state or cache); does not clear the timer */
|
|
158
|
+
_getOwnPresencePatch(docId) {
|
|
159
|
+
const debounced = this._presenceDebounceState.get(docId);
|
|
160
|
+
if (debounced)
|
|
161
|
+
return { [this._clientId]: debounced.data };
|
|
162
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
163
|
+
if (cacheEntry?.presence[this._clientId] !== undefined)
|
|
164
|
+
return { [this._clientId]: cacheEntry.presence[this._clientId] };
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
// TODO: used when server responds with a new doc (squashing)
|
|
168
|
+
async _replaceDocInCache({ docId, doc, serializedDoc, }) {
|
|
169
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
170
|
+
if (!cacheEntry)
|
|
171
|
+
return;
|
|
172
|
+
// Deserialize if needed
|
|
173
|
+
const newDoc = doc ?? this._docBinding.deserialize(serializedDoc);
|
|
174
|
+
// Replace the cached document with the new one
|
|
175
|
+
// Keep the same refCount
|
|
176
|
+
// Note: We don't setup a new change listener here because:
|
|
177
|
+
// 1. The doc already has all operations applied from the sync
|
|
178
|
+
// 2. A listener will be setup when the doc is loaded via getDoc
|
|
179
|
+
// 3. Multiple listeners would cause operations to be applied multiple times
|
|
180
|
+
this._docsCache.set(docId, {
|
|
181
|
+
promisedDoc: Promise.resolve(newDoc),
|
|
182
|
+
refCount: cacheEntry.refCount,
|
|
183
|
+
presence: cacheEntry.presence,
|
|
184
|
+
presenceHandlers: cacheEntry.presenceHandlers,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async _applyServerOperations({ docId, operations, }) {
|
|
188
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
189
|
+
if (!cacheEntry)
|
|
190
|
+
return;
|
|
191
|
+
// Get the cached document and apply server operations to it
|
|
192
|
+
const doc = await cacheEntry.promisedDoc;
|
|
193
|
+
if (!doc)
|
|
194
|
+
return;
|
|
195
|
+
this._shouldBroadcast = false;
|
|
196
|
+
for (const op of operations) {
|
|
197
|
+
this._docBinding.applyOperations(doc, op);
|
|
198
|
+
}
|
|
199
|
+
this._shouldBroadcast = true;
|
|
200
|
+
// Emit change event for remote operations
|
|
201
|
+
this._emit(this._changeHandlers, {
|
|
202
|
+
docId,
|
|
203
|
+
origin: "remote",
|
|
204
|
+
operations,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Subscribe to a document with reactive state updates.
|
|
209
|
+
*
|
|
210
|
+
* The behavior depends on which fields are provided:
|
|
211
|
+
* - `{ type, id }` → Try to get an existing doc. Returns `undefined` if not found.
|
|
212
|
+
* - `{ type, createIfMissing: true }` → Create a new doc with auto-generated ID (ulid).
|
|
213
|
+
* - `{ type, id, createIfMissing: true }` → Get existing doc or create it if not found.
|
|
214
|
+
*
|
|
215
|
+
* The callback will be invoked with state updates:
|
|
216
|
+
* 1. `{ status: "loading" }` - Initial state while fetching
|
|
217
|
+
* 2. `{ status: "success", data: { doc, docId } }` - Document loaded successfully
|
|
218
|
+
* 3. `{ status: "error", error }` - Failed to load document
|
|
219
|
+
*
|
|
220
|
+
* To observe document content changes, use `doc.onChange()` directly on the returned doc.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const unsubscribe = client.getDoc(
|
|
225
|
+
* { type: "notes", id: "abc123" },
|
|
226
|
+
* (result) => {
|
|
227
|
+
* if (result.status === "loading") console.log("Loading...");
|
|
228
|
+
* if (result.status === "success") console.log("Doc:", result.data.doc);
|
|
229
|
+
* if (result.status === "error") console.error(result.error);
|
|
230
|
+
* }
|
|
231
|
+
* );
|
|
232
|
+
*
|
|
233
|
+
* // Clean up when done
|
|
234
|
+
* unsubscribe();
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
getDoc(args, onChange) {
|
|
238
|
+
const type = args.type;
|
|
239
|
+
const argId = "id" in args ? args.id : undefined;
|
|
240
|
+
const createIfMissing = "createIfMissing" in args && args.createIfMissing;
|
|
241
|
+
// Internal emit uses wider type; runtime logic ensures correct data per overload
|
|
242
|
+
const emit = onChange;
|
|
243
|
+
let docId;
|
|
244
|
+
// Case: { type, createIfMissing: true } → Create new doc with auto-generated ID (sync).
|
|
245
|
+
if (!argId && createIfMissing) {
|
|
246
|
+
const { doc, docId: createdDocId } = this._docBinding.create(type);
|
|
247
|
+
docId = createdDocId;
|
|
248
|
+
this._docsCache.set(createdDocId, {
|
|
249
|
+
promisedDoc: Promise.resolve(doc),
|
|
250
|
+
refCount: 1,
|
|
251
|
+
presence: {},
|
|
252
|
+
presenceHandlers: new Set(),
|
|
253
|
+
});
|
|
254
|
+
this._setupChangeListener(doc, createdDocId);
|
|
255
|
+
emit({ status: "success", data: { doc, docId: createdDocId } });
|
|
256
|
+
// Emit doc load event
|
|
257
|
+
this._emit(this._docLoadHandlers, {
|
|
258
|
+
docId: createdDocId,
|
|
259
|
+
source: "created",
|
|
260
|
+
refCount: 1,
|
|
261
|
+
});
|
|
262
|
+
void (async () => {
|
|
263
|
+
const local = await this._localPromise;
|
|
264
|
+
if (!local)
|
|
265
|
+
return;
|
|
266
|
+
await local.provider.transaction("readwrite", (ctx) => ctx.saveSerializedDoc({
|
|
267
|
+
serializedDoc: this._docBinding.serialize(doc),
|
|
268
|
+
docId: createdDocId,
|
|
269
|
+
clock: 0,
|
|
270
|
+
}));
|
|
271
|
+
})();
|
|
272
|
+
// We don't trigger a initial saveRemote here because argId is undefined,
|
|
273
|
+
// so this is truly a new doc. Initial operations will be pushed to server
|
|
274
|
+
return () => void this._unloadDoc(createdDocId);
|
|
275
|
+
}
|
|
276
|
+
// Preparing for the async cases
|
|
277
|
+
emit({ status: "loading" });
|
|
278
|
+
// Case: { type, id } or { type, id, createIfMissing } → Load or create (async).
|
|
279
|
+
if (argId) {
|
|
280
|
+
docId = argId;
|
|
281
|
+
// Check cache BEFORE async block to avoid race conditions with getPresence
|
|
282
|
+
const existingCacheEntry = this._docsCache.get(docId);
|
|
283
|
+
if (existingCacheEntry) {
|
|
284
|
+
existingCacheEntry.refCount += 1;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Create cache entry immediately so getPresence can subscribe
|
|
288
|
+
const promisedDoc = this._loadOrCreateDoc(docId, createIfMissing ? type : undefined);
|
|
289
|
+
this._docsCache.set(docId, {
|
|
290
|
+
promisedDoc,
|
|
291
|
+
refCount: 1,
|
|
292
|
+
presence: {},
|
|
293
|
+
presenceHandlers: new Set(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
void (async () => {
|
|
297
|
+
try {
|
|
298
|
+
let doc;
|
|
299
|
+
let source = "local";
|
|
300
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
301
|
+
if (existingCacheEntry) {
|
|
302
|
+
doc = await cacheEntry.promisedDoc;
|
|
303
|
+
source = "cache";
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
doc = await cacheEntry.promisedDoc;
|
|
307
|
+
if (doc) {
|
|
308
|
+
// Register listener only for new docs (not cache hits)
|
|
309
|
+
this._setupChangeListener(doc, docId);
|
|
310
|
+
source = createIfMissing ? "created" : "local";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Emit doc load event
|
|
314
|
+
if (doc) {
|
|
315
|
+
const refCount = this._docsCache.get(docId)?.refCount ?? 1;
|
|
316
|
+
this._emit(this._docLoadHandlers, {
|
|
317
|
+
docId,
|
|
318
|
+
source,
|
|
319
|
+
refCount,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
emit({
|
|
323
|
+
status: "success",
|
|
324
|
+
data: doc ? { doc, docId } : undefined,
|
|
325
|
+
});
|
|
326
|
+
// Fetch from server to check if document exists there
|
|
327
|
+
if (doc) {
|
|
328
|
+
void this.saveRemote({ docId });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
333
|
+
emit({ status: "error", error });
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
}
|
|
337
|
+
return () => {
|
|
338
|
+
if (docId)
|
|
339
|
+
void this._unloadDoc(docId);
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Subscribe to presence updates for a document.
|
|
344
|
+
* Multiple handlers can be registered for the same document.
|
|
345
|
+
* @param args - The arguments for the getPresence request.
|
|
346
|
+
* @param onChange - The callback to invoke when the presence changes.
|
|
347
|
+
* @returns A function to unsubscribe from presence updates.
|
|
348
|
+
*/
|
|
349
|
+
getPresence(args, onChange) {
|
|
350
|
+
const { docId } = args;
|
|
351
|
+
if (!docId)
|
|
352
|
+
return () => void undefined;
|
|
353
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
354
|
+
if (!cacheEntry) {
|
|
355
|
+
throw new Error(`Cannot subscribe to presence for document "${docId}" - document not loaded.`);
|
|
356
|
+
}
|
|
357
|
+
// Add handler to the set
|
|
358
|
+
cacheEntry.presenceHandlers.add(onChange);
|
|
359
|
+
// Immediately call with current presence if available
|
|
360
|
+
if (Object.keys(cacheEntry.presence).length > 0) {
|
|
361
|
+
onChange(cacheEntry.presence);
|
|
362
|
+
}
|
|
363
|
+
// Return unsubscribe function that removes only this handler
|
|
364
|
+
return () => {
|
|
365
|
+
const entry = this._docsCache.get(docId);
|
|
366
|
+
if (entry) {
|
|
367
|
+
entry.presenceHandlers.delete(onChange);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async setPresence({ docId, presence }) {
|
|
372
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
373
|
+
if (!cacheEntry)
|
|
374
|
+
throw new Error(`Doc ${docId} is not loaded, cannot set presence`);
|
|
375
|
+
// Clear existing timeout if any
|
|
376
|
+
const existingState = this._presenceDebounceState.get(docId);
|
|
377
|
+
clearTimeout(existingState?.timeout);
|
|
378
|
+
// Debounce the presence update
|
|
379
|
+
const timeout = setTimeout(() => {
|
|
380
|
+
const state = this._presenceDebounceState.get(docId);
|
|
381
|
+
if (!state)
|
|
382
|
+
return;
|
|
383
|
+
this._presenceDebounceState.delete(docId);
|
|
384
|
+
const patch = { [this._clientId]: state.data };
|
|
385
|
+
// Update local cache and notify handlers (so own cursor shows and UI stays in sync)
|
|
386
|
+
this._applyPresencePatch(cacheEntry, patch);
|
|
387
|
+
// Same device: broadcast to other tabs (works offline)
|
|
388
|
+
this._sendMessage({
|
|
389
|
+
type: "PRESENCE",
|
|
390
|
+
docId,
|
|
391
|
+
presence: patch,
|
|
392
|
+
});
|
|
393
|
+
// Other devices: send via WebSocket only when connected
|
|
394
|
+
if (this._socket.connected) {
|
|
395
|
+
void (async () => {
|
|
396
|
+
if (!this._socket.connected)
|
|
397
|
+
return;
|
|
398
|
+
const { error } = await this._request("presence", {
|
|
399
|
+
docId,
|
|
400
|
+
presence: state.data,
|
|
401
|
+
});
|
|
402
|
+
if (error) {
|
|
403
|
+
console.error(`Error setting presence for doc ${docId}:`, error);
|
|
404
|
+
}
|
|
405
|
+
})();
|
|
406
|
+
}
|
|
407
|
+
}, this._presenceDebounce);
|
|
408
|
+
this._presenceDebounceState.set(docId, { timeout, data: presence });
|
|
409
|
+
}
|
|
410
|
+
_setupChangeListener(doc, docId) {
|
|
411
|
+
this._docBinding.onChange(doc, ({ operations }) => {
|
|
412
|
+
if (this._shouldBroadcast) {
|
|
413
|
+
void this.onLocalOperations({ docId, operations: [operations] });
|
|
414
|
+
this._emit(this._changeHandlers, {
|
|
415
|
+
docId,
|
|
416
|
+
origin: "local",
|
|
417
|
+
operations: [operations],
|
|
418
|
+
});
|
|
419
|
+
// Defer BC send so Lexical can update selection first; then the presence we
|
|
420
|
+
// include is the new cursor. Two frames so setPresence (from selection change) has run.
|
|
421
|
+
requestAnimationFrame(() => {
|
|
422
|
+
requestAnimationFrame(() => {
|
|
423
|
+
const presencePatch = this._getOwnPresencePatch(docId);
|
|
424
|
+
this._sendMessage({
|
|
425
|
+
type: "OPERATIONS",
|
|
426
|
+
operations,
|
|
427
|
+
docId,
|
|
428
|
+
...(presencePatch && { presence: presencePatch }),
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Don't automatically reset _shouldBroadcast here!
|
|
434
|
+
// Let the caller explicitly control when to re-enable broadcasting
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async _loadOrCreateDoc(docId, type) {
|
|
438
|
+
const local = await this._localPromise;
|
|
439
|
+
if (!local)
|
|
440
|
+
return undefined;
|
|
441
|
+
return local.provider.transaction("readwrite", async (ctx) => {
|
|
442
|
+
// Try to load existing doc
|
|
443
|
+
const stored = await ctx.getSerializedDoc(docId);
|
|
444
|
+
const localOperations = await ctx.getOperations({ docId });
|
|
445
|
+
if (stored) {
|
|
446
|
+
const doc = this._docBinding.deserialize(stored.serializedDoc);
|
|
447
|
+
this._shouldBroadcast = false;
|
|
448
|
+
localOperations.forEach((operationsBatch) => {
|
|
449
|
+
operationsBatch.forEach((operations) => {
|
|
450
|
+
this._docBinding.applyOperations(doc, operations);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
this._shouldBroadcast = true;
|
|
454
|
+
return doc;
|
|
455
|
+
}
|
|
456
|
+
// Create new doc if type provided
|
|
457
|
+
if (type) {
|
|
458
|
+
const { doc } = this._docBinding.create(type, docId);
|
|
459
|
+
this._shouldBroadcast = false;
|
|
460
|
+
if (localOperations.length)
|
|
461
|
+
throw new Error(`Doc ${docId} has operations stored locally but no serialized doc found`);
|
|
462
|
+
this._shouldBroadcast = true;
|
|
463
|
+
// Save the new doc to IDB
|
|
464
|
+
await ctx.saveSerializedDoc({
|
|
465
|
+
serializedDoc: this._docBinding.serialize(doc),
|
|
466
|
+
docId,
|
|
467
|
+
clock: 0,
|
|
468
|
+
});
|
|
469
|
+
return doc;
|
|
470
|
+
}
|
|
471
|
+
return undefined;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Decrease the reference count of a document and, if it is 0, delete the document from the cache.
|
|
476
|
+
*/
|
|
477
|
+
async _unloadDoc(docId) {
|
|
478
|
+
const cacheEntry = this._docsCache.get(docId);
|
|
479
|
+
if (!cacheEntry)
|
|
480
|
+
return;
|
|
481
|
+
if (cacheEntry.refCount > 1) {
|
|
482
|
+
cacheEntry.refCount -= 1;
|
|
483
|
+
this._emit(this._docUnloadHandlers, {
|
|
484
|
+
docId,
|
|
485
|
+
refCount: cacheEntry.refCount,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
// Mark refCount as 0 but keep in cache until promise resolves
|
|
490
|
+
cacheEntry.refCount = 0;
|
|
491
|
+
// Emit immediately
|
|
492
|
+
this._emit(this._docUnloadHandlers, {
|
|
493
|
+
docId,
|
|
494
|
+
refCount: 0,
|
|
495
|
+
});
|
|
496
|
+
// Dispose when promise resolves
|
|
497
|
+
const doc = await cacheEntry.promisedDoc;
|
|
498
|
+
const currentEntry = this._docsCache.get(docId);
|
|
499
|
+
if (currentEntry?.refCount === 0) {
|
|
500
|
+
this._docsCache.delete(docId);
|
|
501
|
+
if (doc) {
|
|
502
|
+
await this.unsubscribeDoc(docId);
|
|
503
|
+
this._docBinding.dispose(doc);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
_sendMessage(message) {
|
|
509
|
+
this._broadcastChannel?.postMessage(message);
|
|
510
|
+
}
|
|
511
|
+
onLocalOperations({ docId, operations }) {
|
|
512
|
+
// Get or create the batch state for this document
|
|
513
|
+
let state = this._localOpsBatchState.get(docId);
|
|
514
|
+
if (!state) {
|
|
515
|
+
// Create new state with empty queue
|
|
516
|
+
state = { data: [] };
|
|
517
|
+
this._localOpsBatchState.set(docId, state);
|
|
518
|
+
}
|
|
519
|
+
// Add operations to queue
|
|
520
|
+
if (operations.length > 0) {
|
|
521
|
+
state.data.push(...operations);
|
|
522
|
+
}
|
|
523
|
+
// If there is already a pending timeout, we just wait
|
|
524
|
+
if (state.timeout !== undefined) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// Otherwise, schedule the batch save
|
|
528
|
+
state.timeout = setTimeout(() => {
|
|
529
|
+
void (async () => {
|
|
530
|
+
const currentState = this._localOpsBatchState.get(docId);
|
|
531
|
+
if (!currentState)
|
|
532
|
+
return;
|
|
533
|
+
const opsToSave = currentState.data;
|
|
534
|
+
this._localOpsBatchState.delete(docId);
|
|
535
|
+
if (opsToSave && opsToSave.length > 0) {
|
|
536
|
+
const local = await this._localPromise;
|
|
537
|
+
await local?.provider.transaction("readwrite", (ctx) => ctx.saveOperations({ docId, operations: opsToSave }));
|
|
538
|
+
this.saveRemote({ docId });
|
|
539
|
+
}
|
|
540
|
+
})();
|
|
541
|
+
}, this._batchDelay);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Push local operations to the server for a specific document.
|
|
545
|
+
* Uses a per-docId queue to prevent concurrent pushes for the same doc.
|
|
546
|
+
*/
|
|
547
|
+
saveRemote({ docId }) {
|
|
548
|
+
const status = this._pushStatusByDocId.get(docId) ?? "idle";
|
|
549
|
+
if (status !== "idle") {
|
|
550
|
+
this._pushStatusByDocId.set(docId, "pushing-with-pending");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
void this._doPush({ docId });
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Unsubscribe from real-time updates for a document.
|
|
557
|
+
* Should be called when a document is unloaded (refCount 1 → 0).
|
|
558
|
+
*/
|
|
559
|
+
async unsubscribeDoc(docId) {
|
|
560
|
+
// Skip if socket is not connected (e.g., in local-only mode or during tests)
|
|
561
|
+
if (!this._socket.connected)
|
|
562
|
+
return;
|
|
563
|
+
try {
|
|
564
|
+
await this._request("unsubscribe-doc", { docId });
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Silently ignore errors during cleanup (e.g., socket
|
|
568
|
+
// disconnected during request, timeout, or server error)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async _doPush({ docId }) {
|
|
572
|
+
this._pushStatusByDocId.set(docId, "pushing");
|
|
573
|
+
const provider = (await this._localPromise).provider;
|
|
574
|
+
// Get the current clock value and operations from provider
|
|
575
|
+
const [operationsBatches, stored] = await provider.transaction("readonly", async (ctx) => {
|
|
576
|
+
return Promise.all([
|
|
577
|
+
ctx.getOperations({ docId }),
|
|
578
|
+
ctx.getSerializedDoc(docId),
|
|
579
|
+
]);
|
|
580
|
+
});
|
|
581
|
+
const operations = operationsBatches.flat();
|
|
582
|
+
const clientClock = stored?.clock ?? 0;
|
|
583
|
+
let response;
|
|
584
|
+
try {
|
|
585
|
+
const presenceState = this._presenceDebounceState.get(docId);
|
|
586
|
+
if (presenceState) {
|
|
587
|
+
clearTimeout(presenceState.timeout);
|
|
588
|
+
this._presenceDebounceState.delete(docId);
|
|
589
|
+
this._sendMessage({
|
|
590
|
+
type: "PRESENCE",
|
|
591
|
+
docId,
|
|
592
|
+
presence: { [this._clientId]: presenceState.data },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
response = await this._request("sync-operations", {
|
|
596
|
+
clock: clientClock,
|
|
597
|
+
docId,
|
|
598
|
+
operations,
|
|
599
|
+
...(presenceState ? { presence: presenceState.data } : {}),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
// Emit sync event (network error)
|
|
604
|
+
this._emit(this._syncHandlers, {
|
|
605
|
+
req: {
|
|
606
|
+
docId,
|
|
607
|
+
operations,
|
|
608
|
+
clock: clientClock,
|
|
609
|
+
},
|
|
610
|
+
error: {
|
|
611
|
+
type: "NetworkError",
|
|
612
|
+
message: error instanceof Error ? error.message : String(error),
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
// Retry on failure
|
|
616
|
+
this._pushStatusByDocId.set(docId, "idle");
|
|
617
|
+
void this._doPush({ docId });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Check if server returned an error
|
|
621
|
+
if ("error" in response && response.error) {
|
|
622
|
+
// Emit sync event with server error
|
|
623
|
+
this._emit(this._syncHandlers, {
|
|
624
|
+
req: {
|
|
625
|
+
docId,
|
|
626
|
+
operations,
|
|
627
|
+
clock: clientClock,
|
|
628
|
+
},
|
|
629
|
+
error: response.error,
|
|
630
|
+
});
|
|
631
|
+
// Retry on error
|
|
632
|
+
this._pushStatusByDocId.set(docId, "idle");
|
|
633
|
+
void this._doPush({ docId });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// At this point, response must have data
|
|
637
|
+
const { data } = response;
|
|
638
|
+
// Emit sync event (success)
|
|
639
|
+
this._emit(this._syncHandlers, {
|
|
640
|
+
req: {
|
|
641
|
+
docId,
|
|
642
|
+
operations,
|
|
643
|
+
clock: clientClock,
|
|
644
|
+
},
|
|
645
|
+
data: {
|
|
646
|
+
...(data.operations ? { operations: data.operations } : {}),
|
|
647
|
+
...(data.serializedDoc ? { serializedDoc: data.serializedDoc } : {}),
|
|
648
|
+
clock: data.clock,
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
// Atomically: delete synced operations + consolidate into serialized doc
|
|
652
|
+
let didConsolidate = false; // Track if we actually saved new operations to IDB
|
|
653
|
+
await provider.transaction("readwrite", async (ctx) => {
|
|
654
|
+
// Delete client operations that were synced (delete batches, not individual ops)
|
|
655
|
+
if (operationsBatches.length > 0) {
|
|
656
|
+
await ctx.deleteOperations({
|
|
657
|
+
docId,
|
|
658
|
+
count: operationsBatches.length,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
// Consolidate operations into serialized doc
|
|
662
|
+
const stored = await ctx.getSerializedDoc(docId);
|
|
663
|
+
if (!stored)
|
|
664
|
+
return;
|
|
665
|
+
// Skip consolidation if another client (same IDB) already updated to this clock
|
|
666
|
+
// This handles the case where another tab/client already wrote this update
|
|
667
|
+
if (stored.clock >= data.clock) {
|
|
668
|
+
didConsolidate = false;
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// Collect all operations to apply: server ops first, then client ops
|
|
672
|
+
const serverOps = data.operations ?? [];
|
|
673
|
+
const allOps = [...serverOps, ...operations];
|
|
674
|
+
// Only proceed if there are operations to apply
|
|
675
|
+
if (allOps.length > 0) {
|
|
676
|
+
const doc = this._docBinding.deserialize(stored.serializedDoc);
|
|
677
|
+
// Apply all operations in order (server ops first, then client ops)
|
|
678
|
+
for (const op of allOps) {
|
|
679
|
+
this._docBinding.applyOperations(doc, op);
|
|
680
|
+
}
|
|
681
|
+
const serializedDoc = this._docBinding.serialize(doc);
|
|
682
|
+
// Before saving, verify clock hasn't changed (another concurrent write)
|
|
683
|
+
// This prevents race conditions when multiple tabs/clients share the same IDB
|
|
684
|
+
const recheckStored = await ctx.getSerializedDoc(docId);
|
|
685
|
+
if (!recheckStored || recheckStored?.clock !== stored.clock) {
|
|
686
|
+
// Clock changed during our transaction - another client beat us
|
|
687
|
+
// Silently skip to avoid duplicate operations
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
await ctx.saveSerializedDoc({
|
|
691
|
+
serializedDoc,
|
|
692
|
+
docId,
|
|
693
|
+
clock: data.clock, // Use clock from server
|
|
694
|
+
});
|
|
695
|
+
didConsolidate = true; // Mark that we successfully saved
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
// CRITICAL: Only apply serverOps to memory if we actually saved to IDB
|
|
699
|
+
// If we skipped (clock already up-to-date), operations are already in memory via BC
|
|
700
|
+
if (didConsolidate && data.operations && data.operations.length > 0) {
|
|
701
|
+
// Apply to our own memory
|
|
702
|
+
void this._applyServerOperations({
|
|
703
|
+
docId,
|
|
704
|
+
operations: data.operations,
|
|
705
|
+
});
|
|
706
|
+
// Broadcast server operations to other tabs so they can apply them too
|
|
707
|
+
const presencePatch = this._getOwnPresencePatch(docId);
|
|
708
|
+
for (const op of data.operations) {
|
|
709
|
+
this._sendMessage({
|
|
710
|
+
type: "OPERATIONS",
|
|
711
|
+
operations: op,
|
|
712
|
+
docId,
|
|
713
|
+
...(presencePatch && { presence: presencePatch }),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// Status may have changed to "pushing-with-pending" during async ops
|
|
718
|
+
const currentStatus = this._pushStatusByDocId.get(docId);
|
|
719
|
+
const shouldRetry = currentStatus === "pushing-with-pending";
|
|
720
|
+
if (shouldRetry) {
|
|
721
|
+
// Keep status as "pushing" and retry immediately to avoid race window
|
|
722
|
+
// where a dirty event could trigger another concurrent _doPush
|
|
723
|
+
void this._doPush({ docId });
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
this._pushStatusByDocId.set(docId, "idle");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async _request(event, payload) {
|
|
730
|
+
// TO-DO: should I reject on disconnect?
|
|
731
|
+
return new Promise((resolve, reject) => {
|
|
732
|
+
// Add a timeout to prevent hanging forever if socket disconnects during request
|
|
733
|
+
const timeout = setTimeout(() => {
|
|
734
|
+
reject(new Error(`Request timeout: ${event}`));
|
|
735
|
+
}, 5000); // 5 second timeout
|
|
736
|
+
this._socket.emit(event, payload, (response) => {
|
|
737
|
+
clearTimeout(timeout);
|
|
738
|
+
resolve(response);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
// ============================================================================
|
|
743
|
+
// Event Registration Methods
|
|
744
|
+
// ============================================================================
|
|
745
|
+
/**
|
|
746
|
+
* Register a handler for connection events.
|
|
747
|
+
* @returns Unsubscribe function
|
|
748
|
+
*/
|
|
749
|
+
onConnect(handler) {
|
|
750
|
+
this._connectHandlers.add(handler);
|
|
751
|
+
return () => {
|
|
752
|
+
this._connectHandlers.delete(handler);
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Register a handler for disconnection events.
|
|
757
|
+
* @returns Unsubscribe function
|
|
758
|
+
*/
|
|
759
|
+
onDisconnect(handler) {
|
|
760
|
+
this._disconnectHandlers.add(handler);
|
|
761
|
+
return () => {
|
|
762
|
+
this._disconnectHandlers.delete(handler);
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Register a handler for document change events.
|
|
767
|
+
* @returns Unsubscribe function
|
|
768
|
+
*/
|
|
769
|
+
onChange(handler) {
|
|
770
|
+
const h = handler;
|
|
771
|
+
this._changeHandlers.add(h);
|
|
772
|
+
return () => {
|
|
773
|
+
this._changeHandlers.delete(h);
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Register a handler for sync events.
|
|
778
|
+
* @returns Unsubscribe function
|
|
779
|
+
*/
|
|
780
|
+
onSync(handler) {
|
|
781
|
+
const h = handler;
|
|
782
|
+
this._syncHandlers.add(h);
|
|
783
|
+
return () => {
|
|
784
|
+
this._syncHandlers.delete(h);
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Register a handler for document load events.
|
|
789
|
+
* @returns Unsubscribe function
|
|
790
|
+
*/
|
|
791
|
+
onDocLoad(handler) {
|
|
792
|
+
this._docLoadHandlers.add(handler);
|
|
793
|
+
return () => {
|
|
794
|
+
this._docLoadHandlers.delete(handler);
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Register a handler for document unload events.
|
|
799
|
+
* @returns Unsubscribe function
|
|
800
|
+
*/
|
|
801
|
+
onDocUnload(handler) {
|
|
802
|
+
this._docUnloadHandlers.add(handler);
|
|
803
|
+
return () => {
|
|
804
|
+
this._docUnloadHandlers.delete(handler);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
_emit(handlers, event) {
|
|
808
|
+
for (const handler of handlers) {
|
|
809
|
+
if (event !== undefined) {
|
|
810
|
+
handler(event);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
handler();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Get or create a unique device ID stored in localStorage.
|
|
820
|
+
* This ID is shared across all tabs/windows on the same device.
|
|
821
|
+
*/
|
|
822
|
+
function getDeviceId() {
|
|
823
|
+
const key = "docsync:deviceId";
|
|
824
|
+
let deviceId = localStorage.getItem(key);
|
|
825
|
+
if (!deviceId) {
|
|
826
|
+
// Generate a new device ID using crypto.randomUUID()
|
|
827
|
+
deviceId = crypto.randomUUID();
|
|
828
|
+
localStorage.setItem(key, deviceId);
|
|
829
|
+
}
|
|
830
|
+
return deviceId;
|
|
831
|
+
}
|
|
832
|
+
//# sourceMappingURL=index.js.map
|