@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.
Files changed (76) hide show
  1. package/LICENSE.md +20 -0
  2. package/README.md +1 -0
  3. package/dist/src/client/index.d.ts +158 -0
  4. package/dist/src/client/index.d.ts.map +1 -0
  5. package/dist/src/client/index.js +832 -0
  6. package/dist/src/client/index.js.map +1 -0
  7. package/dist/src/client/providers/indexeddb.d.ts +15 -0
  8. package/dist/src/client/providers/indexeddb.d.ts.map +1 -0
  9. package/dist/src/client/providers/indexeddb.js +79 -0
  10. package/dist/src/client/providers/indexeddb.js.map +1 -0
  11. package/dist/src/exports/client.d.ts +4 -0
  12. package/dist/src/exports/client.d.ts.map +1 -0
  13. package/dist/src/exports/client.js +3 -0
  14. package/dist/src/exports/client.js.map +1 -0
  15. package/dist/src/exports/docnode.d.ts +2 -0
  16. package/dist/src/exports/docnode.d.ts.map +1 -0
  17. package/dist/src/exports/docnode.js +2 -0
  18. package/dist/src/exports/docnode.js.map +1 -0
  19. package/dist/src/exports/index.d.ts +3 -0
  20. package/dist/src/exports/index.d.ts.map +1 -0
  21. package/dist/src/exports/index.js +3 -0
  22. package/dist/src/exports/index.js.map +1 -0
  23. package/dist/src/exports/server.d.ts +5 -0
  24. package/dist/src/exports/server.d.ts.map +1 -0
  25. package/dist/src/exports/server.js +4 -0
  26. package/dist/src/exports/server.js.map +1 -0
  27. package/dist/src/exports/testing.d.ts +7 -0
  28. package/dist/src/exports/testing.d.ts.map +1 -0
  29. package/dist/src/exports/testing.js +6 -0
  30. package/dist/src/exports/testing.js.map +1 -0
  31. package/dist/src/server/cli.d.ts +2 -0
  32. package/dist/src/server/cli.d.ts.map +1 -0
  33. package/dist/src/server/cli.js +10 -0
  34. package/dist/src/server/cli.js.map +1 -0
  35. package/dist/src/server/index.d.ts +37 -0
  36. package/dist/src/server/index.d.ts.map +1 -0
  37. package/dist/src/server/index.js +450 -0
  38. package/dist/src/server/index.js.map +1 -0
  39. package/dist/src/server/providers/memory.d.ts +17 -0
  40. package/dist/src/server/providers/memory.d.ts.map +1 -0
  41. package/dist/src/server/providers/memory.js +72 -0
  42. package/dist/src/server/providers/memory.js.map +1 -0
  43. package/dist/src/server/providers/postgres/drizzle.config.d.ts +3 -0
  44. package/dist/src/server/providers/postgres/drizzle.config.d.ts.map +1 -0
  45. package/dist/src/server/providers/postgres/drizzle.config.js +10 -0
  46. package/dist/src/server/providers/postgres/drizzle.config.js.map +1 -0
  47. package/dist/src/server/providers/postgres/index.d.ts +6 -0
  48. package/dist/src/server/providers/postgres/index.d.ts.map +1 -0
  49. package/dist/src/server/providers/postgres/index.js +83 -0
  50. package/dist/src/server/providers/postgres/index.js.map +1 -0
  51. package/dist/src/server/providers/postgres/schema.d.ts +159 -0
  52. package/dist/src/server/providers/postgres/schema.d.ts.map +1 -0
  53. package/dist/src/server/providers/postgres/schema.js +34 -0
  54. package/dist/src/server/providers/postgres/schema.js.map +1 -0
  55. package/dist/src/shared/debounce.d.ts +2 -0
  56. package/dist/src/shared/debounce.d.ts.map +1 -0
  57. package/dist/src/shared/debounce.js +10 -0
  58. package/dist/src/shared/debounce.js.map +1 -0
  59. package/dist/src/shared/docBinding.d.ts +17 -0
  60. package/dist/src/shared/docBinding.d.ts.map +1 -0
  61. package/dist/src/shared/docBinding.js +41 -0
  62. package/dist/src/shared/docBinding.js.map +1 -0
  63. package/dist/src/shared/throttle.d.ts +30 -0
  64. package/dist/src/shared/throttle.d.ts.map +1 -0
  65. package/dist/src/shared/throttle.js +51 -0
  66. package/dist/src/shared/throttle.js.map +1 -0
  67. package/dist/src/shared/types.d.ts +387 -0
  68. package/dist/src/shared/types.d.ts.map +1 -0
  69. package/dist/src/shared/types.js +6 -0
  70. package/dist/src/shared/types.js.map +1 -0
  71. package/dist/src/shared/utils.d.ts +2 -0
  72. package/dist/src/shared/utils.d.ts.map +1 -0
  73. package/dist/src/shared/utils.js +11 -0
  74. package/dist/src/shared/utils.js.map +1 -0
  75. package/dist/tsconfig.build.tsbuildinfo +1 -0
  76. 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