@abraca/dabra 1.5.0 → 1.6.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/dist/index.d.ts CHANGED
@@ -811,6 +811,12 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
811
811
  private childProviders;
812
812
  private pendingLoads;
813
813
  private subdocLoading;
814
+ /** LRU tracking: childId → last access timestamp */
815
+ private childAccessTimes;
816
+ /** Pinned children that must not be evicted (e.g. actively viewed docs) */
817
+ private pinnedChildren;
818
+ /** Max simultaneously cached child providers before LRU eviction kicks in */
819
+ private static readonly MAX_CHILDREN;
814
820
  private abracadabraConfig;
815
821
  private readonly boundHandleYSubdocsChange;
816
822
  /**
@@ -884,6 +890,20 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
884
890
  loadChild(childId: string): Promise<AbracadabraProvider>;
885
891
  private _doLoadChild;
886
892
  unloadChild(childId: string): void;
893
+ /**
894
+ * Mark a child as pinned so LRU eviction will not remove it.
895
+ * Use this when a document is actively being viewed by the user.
896
+ */
897
+ pinChild(childId: string): void;
898
+ /**
899
+ * Unpin a child, allowing LRU eviction to reclaim it when capacity is exceeded.
900
+ */
901
+ unpinChild(childId: string): void;
902
+ /**
903
+ * Evict least-recently-used unpinned child providers until the cache is
904
+ * at or below MAX_CHILDREN.
905
+ */
906
+ private evictLRU;
887
907
  /** Return all currently-loaded child providers. */
888
908
  get children(): Map<string, AbracadabraProvider>;
889
909
  /**
@@ -1133,6 +1153,11 @@ type onAwarenessChangeParameters = {
1133
1153
  type onStatelessParameters = {
1134
1154
  payload: string;
1135
1155
  };
1156
+ type onServerErrorParameters = {
1157
+ source: string;
1158
+ code: string;
1159
+ message: string;
1160
+ };
1136
1161
  type StatesArray = {
1137
1162
  clientId: number;
1138
1163
  [key: string | number]: any;
@@ -1499,6 +1524,7 @@ interface CompleteAbracadabraBaseProviderConfiguration {
1499
1524
  onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
1500
1525
  onAwarenessChange: (data: onAwarenessChangeParameters) => void;
1501
1526
  onStateless: (data: onStatelessParameters) => void;
1527
+ onServerError: (data: onServerErrorParameters) => void;
1502
1528
  onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
1503
1529
  }
1504
1530
  /** @deprecated Use CompleteAbracadabraBaseProviderConfiguration */
@@ -1812,19 +1838,26 @@ declare function makeEncryptedYText(ydoc: Y.Doc, fieldName: string, docKey: Cryp
1812
1838
  //#endregion
1813
1839
  //#region packages/provider/src/TreeTimestamps.d.ts
1814
1840
  /**
1815
- * Attach an observer that writes `updatedAt: Date.now()` to the root
1816
- * doc-tree entry for `childDocId` whenever the child doc receives a
1817
- * non-offline update.
1841
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
1842
+ * `childDocId` whenever the child doc receives a non-offline update.
1818
1843
  *
1819
- * @param treeMap The root doc's "doc-tree" Y.Map.
1820
- * @param childDocId The child document's UUID (key in treeMap).
1821
- * @param childDoc The child Y.Doc to observe.
1844
+ * Writes are throttled: the first qualifying update records the timestamp;
1845
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
1846
+ *
1847
+ * @param treeMap The root doc's "doc-tree" Y.Map.
1848
+ * @param childDocId The child document's UUID (key in treeMap).
1849
+ * @param childDoc The child Y.Doc to observe.
1822
1850
  * @param offlineStore The child provider's OfflineStore (used to detect
1823
1851
  * offline-replay origins and skip them). Pass null when
1824
1852
  * the offline store is disabled.
1825
- * @returns Cleanup function call on provider destroy.
1853
+ * @param options Optional config. `throttleMs` controls the write
1854
+ * interval (default 5000).
1855
+ * @returns Cleanup function — call on provider destroy. Flushes
1856
+ * any pending write before detaching.
1826
1857
  */
1827
- declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string, childDoc: Y.Doc, offlineStore: OfflineStore | null): () => void;
1858
+ declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string, childDoc: Y.Doc, offlineStore: OfflineStore | null, options?: {
1859
+ throttleMs?: number;
1860
+ }): () => void;
1828
1861
  //#endregion
1829
1862
  //#region packages/provider/src/BackgroundSyncPersistence.d.ts
1830
1863
  /**
@@ -2860,4 +2893,4 @@ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promi
2860
2893
  */
2861
2894
  declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
2862
2895
  //#endregion
2863
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptField, generateMnemonic, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, unwrapSeed, validateMnemonic, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
2896
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptField, generateMnemonic, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, unwrapSeed, validateMnemonic, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -25,6 +25,7 @@ import type {
25
25
  onMessageParameters,
26
26
  onOpenParameters,
27
27
  onOutgoingMessageParameters,
28
+ onServerErrorParameters,
28
29
  onStatelessParameters,
29
30
  onStatusParameters,
30
31
  onSyncedParameters,
@@ -95,6 +96,7 @@ export interface CompleteAbracadabraBaseProviderConfiguration {
95
96
  onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
96
97
  onAwarenessChange: (data: onAwarenessChangeParameters) => void;
97
98
  onStateless: (data: onStatelessParameters) => void;
99
+ onServerError: (data: onServerErrorParameters) => void;
98
100
  onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
99
101
  }
100
102
 
@@ -129,6 +131,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
129
131
  onAwarenessUpdate: () => null,
130
132
  onAwarenessChange: () => null,
131
133
  onStateless: () => null,
134
+ onServerError: () => null,
132
135
  onUnsyncedChanges: () => null,
133
136
  };
134
137
 
@@ -172,6 +175,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
172
175
  this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
173
176
  this.on("awarenessChange", this.configuration.onAwarenessChange);
174
177
  this.on("stateless", this.configuration.onStateless);
178
+ this.on("serverError", this.configuration.onServerError);
175
179
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
176
180
 
177
181
  this.on("authenticated", this.configuration.onAuthenticated);
@@ -368,6 +372,17 @@ export class AbracadabraBaseProvider extends EventEmitter {
368
372
  }
369
373
 
370
374
  receiveStateless(payload: string) {
375
+ try {
376
+ const parsed = JSON.parse(payload);
377
+ if (parsed?.type === "error" && parsed.source && parsed.code) {
378
+ const { source, code, message } = parsed;
379
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
380
+ this.emit("serverError", { source, code, message: message ?? "" });
381
+ return;
382
+ }
383
+ } catch {
384
+ // not JSON — fall through to generic stateless event
385
+ }
371
386
  this.emit("stateless", { payload });
372
387
  }
373
388
 
@@ -110,6 +110,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
110
110
  private pendingLoads = new Map<string, Promise<AbracadabraProvider>>();
111
111
  private subdocLoading: "lazy" | "eager";
112
112
 
113
+ /** LRU tracking: childId → last access timestamp */
114
+ private childAccessTimes = new Map<string, number>();
115
+ /** Pinned children that must not be evicted (e.g. actively viewed docs) */
116
+ private pinnedChildren = new Set<string>();
117
+ /** Max simultaneously cached child providers before LRU eviction kicks in */
118
+ private static readonly MAX_CHILDREN = 20;
119
+
113
120
  private abracadabraConfig: AbracadabraProviderConfiguration;
114
121
 
115
122
  private readonly boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
@@ -454,6 +461,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
454
461
  }
455
462
 
456
463
  if (this.childProviders.has(childId)) {
464
+ this.childAccessTimes.set(childId, Date.now());
457
465
  return Promise.resolve(this.childProviders.get(childId)!);
458
466
  }
459
467
 
@@ -500,6 +508,10 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
500
508
  childProvider.attach();
501
509
 
502
510
  this.childProviders.set(childId, childProvider);
511
+ this.childAccessTimes.set(childId, Date.now());
512
+
513
+ // Evict least-recently-used children if over capacity
514
+ this.evictLRU();
503
515
 
504
516
  this.emit("subdocLoaded", { childId, provider: childProvider });
505
517
 
@@ -511,6 +523,48 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
511
523
  if (provider) {
512
524
  provider.destroy();
513
525
  this.childProviders.delete(childId);
526
+ this.childAccessTimes.delete(childId);
527
+ this.pinnedChildren.delete(childId);
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Mark a child as pinned so LRU eviction will not remove it.
533
+ * Use this when a document is actively being viewed by the user.
534
+ */
535
+ pinChild(childId: string) {
536
+ this.pinnedChildren.add(childId);
537
+ }
538
+
539
+ /**
540
+ * Unpin a child, allowing LRU eviction to reclaim it when capacity is exceeded.
541
+ */
542
+ unpinChild(childId: string) {
543
+ this.pinnedChildren.delete(childId);
544
+ // Run eviction now in case we're over capacity
545
+ this.evictLRU();
546
+ }
547
+
548
+ /**
549
+ * Evict least-recently-used unpinned child providers until the cache is
550
+ * at or below MAX_CHILDREN.
551
+ */
552
+ private evictLRU() {
553
+ if (this.childProviders.size <= AbracadabraProvider.MAX_CHILDREN) return;
554
+
555
+ // Build a list of evictable children sorted by last access (oldest first)
556
+ const evictable: Array<{ id: string; accessTime: number }> = [];
557
+ for (const [id] of this.childProviders) {
558
+ if (this.pinnedChildren.has(id)) continue;
559
+ evictable.push({ id, accessTime: this.childAccessTimes.get(id) ?? 0 });
560
+ }
561
+ evictable.sort((a, b) => a.accessTime - b.accessTime);
562
+
563
+ let toEvict = this.childProviders.size - AbracadabraProvider.MAX_CHILDREN;
564
+ for (const entry of evictable) {
565
+ if (toEvict <= 0) break;
566
+ this.unloadChild(entry.id);
567
+ toEvict--;
514
568
  }
515
569
  }
516
570
 
@@ -605,6 +659,8 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
605
659
  provider.destroy();
606
660
  }
607
661
  this.childProviders.clear();
662
+ this.childAccessTimes.clear();
663
+ this.pinnedChildren.clear();
608
664
 
609
665
  // Force-clear any stale providerMap entries for our children.
610
666
  // detach() only removes if current === provider, so orphans can remain
@@ -328,13 +328,15 @@ export class BackgroundSyncManager extends EventEmitter {
328
328
  ): Promise<boolean> {
329
329
  if (this._destroyed) return true;
330
330
 
331
- // Skip if already synced and doc hasn't changed since last sync
331
+ // Skip if already synced and doc hasn't changed since last sync.
332
+ // The 200ms grace margin handles edge cases where the updatedAt
333
+ // observer writes a timestamp milliseconds after lastSynced was recorded.
332
334
  const existing = this.syncStates.get(docId);
333
335
  if (
334
336
  existing &&
335
337
  existing.status === "synced" &&
336
338
  existing.lastSynced !== null &&
337
- existing.lastSynced >= updatedAt
339
+ existing.lastSynced + 200 >= updatedAt
338
340
  ) {
339
341
  this.emit("stateChanged", { docId, state: existing });
340
342
  return true;
@@ -391,6 +393,8 @@ export class BackgroundSyncManager extends EventEmitter {
391
393
  }
392
394
 
393
395
  private async _syncNonE2EDoc(docId: string): Promise<DocSyncState> {
396
+ const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
397
+
394
398
  // Check if the provider already exists (user is viewing it) before loading.
395
399
  const alreadyCached = this.rootProvider.children.has(docId);
396
400
 
@@ -415,39 +419,46 @@ export class BackgroundSyncManager extends EventEmitter {
415
419
 
416
420
  const childProvider = await this.rootProvider.loadChild(docId);
417
421
 
418
- // Wait for ready (offline snapshot loaded) then synced (server sync done)
419
- await childProvider.ready;
420
- await this._waitForSynced(childProvider);
422
+ try {
423
+ // Wait for ready (offline snapshot loaded) then synced (server sync done)
424
+ await childProvider.ready;
425
+ await this._waitForSynced(childProvider);
421
426
 
422
- // Emit docSynced while the child is still loaded so listeners can
423
- // extract data (search text, file refs) without opening a new IDB.
424
- {
425
- const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
426
- const treeEntry = treeMap.get(docId);
427
- this.emit("docSynced", {
428
- docId,
429
- document: childProvider.document,
430
- label: treeEntry?.label ?? "",
431
- meta: treeEntry?.meta,
432
- });
433
- }
427
+ // Emit docSynced while the child is still loaded so listeners can
428
+ // extract data (search text, file refs) without opening a new IDB.
429
+ {
430
+ const treeEntry = treeMap.get(docId);
431
+ this.emit("docSynced", {
432
+ docId,
433
+ document: childProvider.document,
434
+ label: treeEntry?.label ?? "",
435
+ meta: treeEntry?.meta,
436
+ });
437
+ }
434
438
 
435
- // Prefetch file blobs
436
- if (this.opts.prefetchFiles && this.fileBlobStore) {
437
- this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
438
- }
439
+ // Prefetch file blobs
440
+ if (this.opts.prefetchFiles && this.fileBlobStore) {
441
+ this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
442
+ }
439
443
 
440
- // Release provider if it was created solely for background sync.
441
- // This closes its IDB database, freeing the file descriptor.
442
- // Providers that were already cached (user is viewing them) are kept alive.
443
- if (!alreadyCached) {
444
- this.rootProvider.unloadChild(docId);
444
+ // Use the tree's updatedAt so lastSynced >= updatedAt, preventing
445
+ // the observer's timestamp write from triggering a spurious re-sync.
446
+ const treeEntry = treeMap.get(docId);
447
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
448
+ return { docId, status: "synced", lastSynced: Math.max(Date.now(), treeUpdatedAt), isE2E: false };
449
+ } finally {
450
+ // Always release the provider if it was created solely for background sync.
451
+ // This closes its IDB database, freeing the file descriptor.
452
+ // Providers that were already cached (user is viewing them) are kept alive.
453
+ if (!alreadyCached) {
454
+ this.rootProvider.unloadChild(docId);
455
+ }
445
456
  }
446
-
447
- return { docId, status: "synced", lastSynced: Date.now(), isE2E: false };
448
457
  }
449
458
 
450
459
  private async _syncE2EDoc(docId: string): Promise<DocSyncState> {
460
+ const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
461
+
451
462
  // Attempt E2E sync only when docKeyManager and keystore are available
452
463
  const docKeyManager = (this.rootProvider as any).abracadabraConfig
453
464
  ?.docKeyManager;
@@ -474,7 +485,6 @@ export class BackgroundSyncManager extends EventEmitter {
474
485
  // Emit docSynced while the child is still alive so listeners can
475
486
  // extract data without opening a new IDB connection.
476
487
  {
477
- const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
478
488
  const treeEntry = treeMap.get(docId);
479
489
  this.emit("docSynced", {
480
490
  docId,
@@ -488,7 +498,11 @@ export class BackgroundSyncManager extends EventEmitter {
488
498
  this._prefetchDocFiles(docId, childDoc).catch(() => null);
489
499
  }
490
500
 
491
- return { docId, status: "synced", lastSynced: Date.now(), isE2E: true };
501
+ // Use the tree's updatedAt so lastSynced >= updatedAt, preventing
502
+ // the observer's timestamp write from triggering a spurious re-sync.
503
+ const treeEntry = treeMap.get(docId);
504
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
505
+ return { docId, status: "synced", lastSynced: Math.max(Date.now(), treeUpdatedAt), isE2E: true };
492
506
  } finally {
493
507
  childProvider.destroy();
494
508
  }
@@ -8,47 +8,78 @@
8
8
  * This propagates "last edited" timestamps to all peers via the root CRDT,
9
9
  * without requiring any server-side changes.
10
10
  *
11
- * Limitation: at least one client must have the child doc open after an edit
12
- * for the timestamp to propagate (eventually consistent).
11
+ * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
12
+ * on the root doc during rapid typing.
13
13
  */
14
14
 
15
15
  import * as Y from "yjs";
16
16
  import type { OfflineStore } from "./OfflineStore.ts";
17
17
 
18
18
  /**
19
- * Attach an observer that writes `updatedAt: Date.now()` to the root
20
- * doc-tree entry for `childDocId` whenever the child doc receives a
21
- * non-offline update.
19
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
20
+ * `childDocId` whenever the child doc receives a non-offline update.
22
21
  *
23
- * @param treeMap The root doc's "doc-tree" Y.Map.
24
- * @param childDocId The child document's UUID (key in treeMap).
25
- * @param childDoc The child Y.Doc to observe.
22
+ * Writes are throttled: the first qualifying update records the timestamp;
23
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
24
+ *
25
+ * @param treeMap The root doc's "doc-tree" Y.Map.
26
+ * @param childDocId The child document's UUID (key in treeMap).
27
+ * @param childDoc The child Y.Doc to observe.
26
28
  * @param offlineStore The child provider's OfflineStore (used to detect
27
29
  * offline-replay origins and skip them). Pass null when
28
30
  * the offline store is disabled.
29
- * @returns Cleanup function call on provider destroy.
31
+ * @param options Optional config. `throttleMs` controls the write
32
+ * interval (default 5000).
33
+ * @returns Cleanup function — call on provider destroy. Flushes
34
+ * any pending write before detaching.
30
35
  */
31
36
  export function attachUpdatedAtObserver(
32
37
  treeMap: Y.Map<any>,
33
38
  childDocId: string,
34
39
  childDoc: Y.Doc,
35
40
  offlineStore: OfflineStore | null,
41
+ options?: { throttleMs?: number },
36
42
  ): () => void {
37
- function handler(update: Uint8Array, origin: unknown): void {
38
- // Skip updates replayed from the local offline store — they represent
39
- // content that was already "seen" and shouldn't advance updatedAt.
40
- if (offlineStore !== null && origin === offlineStore) return;
43
+ const throttleMs = options?.throttleMs ?? 5000;
44
+
45
+ let latestTs = 0;
46
+ let timer: ReturnType<typeof setTimeout> | null = null;
47
+
48
+ function flush(): void {
49
+ if (latestTs === 0) return;
50
+ const ts = latestTs;
51
+ latestTs = 0;
52
+ timer = null;
41
53
 
42
- // Update the root tree entry (no-op if the entry doesn't exist).
43
54
  const raw = treeMap.get(childDocId);
44
55
  if (!raw) return;
45
56
 
46
57
  // Guard: if the entry is a nested Y.Map (possible after Yrs
47
58
  // document compaction), convert to plain object so spread works.
48
59
  const entry = raw instanceof Y.Map ? (raw as any).toJSON() : raw;
49
- treeMap.set(childDocId, { ...entry, updatedAt: Date.now() });
60
+ treeMap.set(childDocId, { ...entry, updatedAt: ts });
61
+ }
62
+
63
+ function handler(_update: Uint8Array, origin: unknown): void {
64
+ // Skip updates replayed from the local offline store — they represent
65
+ // content that was already "seen" and shouldn't advance updatedAt.
66
+ if (offlineStore !== null && origin === offlineStore) return;
67
+
68
+ latestTs = Date.now();
69
+
70
+ // Schedule a trailing-edge flush if none is pending.
71
+ if (timer === null) {
72
+ timer = setTimeout(flush, throttleMs);
73
+ }
50
74
  }
51
75
 
52
76
  childDoc.on("update", handler);
53
- return () => childDoc.off("update", handler);
77
+
78
+ return () => {
79
+ childDoc.off("update", handler);
80
+ if (timer !== null) {
81
+ clearTimeout(timer);
82
+ flush(); // persist the last pending timestamp
83
+ }
84
+ };
54
85
  }
package/src/types.ts CHANGED
@@ -124,6 +124,12 @@ export type onStatelessParameters = {
124
124
  payload: string;
125
125
  };
126
126
 
127
+ export type onServerErrorParameters = {
128
+ source: string;
129
+ code: string;
130
+ message: string;
131
+ };
132
+
127
133
  export type StatesArray = { clientId: number; [key: string | number]: any }[];
128
134
 
129
135
  // ── Abracadabra extensions ────────────────────────────────────────────────────