@abraca/dabra 1.5.0 → 1.8.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;
@@ -1319,6 +1344,77 @@ interface DocEncryptionInfo {
1319
1344
  effective_mode: "none" | "cse" | "e2e";
1320
1345
  inherited_from?: string;
1321
1346
  }
1347
+ interface ChatMessage {
1348
+ id: string;
1349
+ channel: string;
1350
+ sender_id: string;
1351
+ sender_name?: string | null;
1352
+ content: string;
1353
+ created_at: number;
1354
+ }
1355
+ interface ChatChannel {
1356
+ channel: string;
1357
+ label?: string | null;
1358
+ last_message: ChatMessage;
1359
+ unread_count: number;
1360
+ }
1361
+ interface ChatTypingEvent {
1362
+ channel: string;
1363
+ sender_id: string;
1364
+ sender_name?: string | null;
1365
+ }
1366
+ interface ChatReadReceipt {
1367
+ channel: string;
1368
+ user_id: string;
1369
+ last_read_at: number;
1370
+ }
1371
+ interface ChatReadCursor {
1372
+ user_id: string;
1373
+ last_read_at: number;
1374
+ }
1375
+ interface SendChatMessageInput {
1376
+ channel: string;
1377
+ content: string;
1378
+ sender_name?: string;
1379
+ }
1380
+ interface GetChatHistoryInput {
1381
+ channel: string;
1382
+ before?: number;
1383
+ limit?: number;
1384
+ }
1385
+ interface NotificationRecord {
1386
+ id: string;
1387
+ recipient_id: string;
1388
+ notification_type: string;
1389
+ title: string;
1390
+ body: string;
1391
+ icon?: string | null;
1392
+ link?: string | null;
1393
+ source_id?: string | null;
1394
+ read: boolean;
1395
+ created_at: number;
1396
+ }
1397
+ interface NotificationReadUpdate {
1398
+ /** Present when a specific set of notifications was marked read. */
1399
+ ids?: string[];
1400
+ /** Present when mark_all_read was called. */
1401
+ all?: boolean;
1402
+ recipient_id: string;
1403
+ }
1404
+ interface CreateNotificationInput {
1405
+ recipient_id: string;
1406
+ notification_type?: string;
1407
+ title: string;
1408
+ body?: string;
1409
+ icon?: string;
1410
+ link?: string;
1411
+ source_id?: string;
1412
+ }
1413
+ interface FetchNotificationsInput {
1414
+ before?: number;
1415
+ limit?: number;
1416
+ unread_only?: boolean;
1417
+ }
1322
1418
  //#endregion
1323
1419
  //#region packages/provider/src/AbracadabraWS.d.ts
1324
1420
  type AbracadabraWebSocketConn = WebSocket & {
@@ -1499,6 +1595,7 @@ interface CompleteAbracadabraBaseProviderConfiguration {
1499
1595
  onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
1500
1596
  onAwarenessChange: (data: onAwarenessChangeParameters) => void;
1501
1597
  onStateless: (data: onStatelessParameters) => void;
1598
+ onServerError: (data: onServerErrorParameters) => void;
1502
1599
  onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
1503
1600
  }
1504
1601
  /** @deprecated Use CompleteAbracadabraBaseProviderConfiguration */
@@ -1812,19 +1909,26 @@ declare function makeEncryptedYText(ydoc: Y.Doc, fieldName: string, docKey: Cryp
1812
1909
  //#endregion
1813
1910
  //#region packages/provider/src/TreeTimestamps.d.ts
1814
1911
  /**
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.
1912
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
1913
+ * `childDocId` whenever the child doc receives a non-offline update.
1818
1914
  *
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.
1915
+ * Writes are throttled: the first qualifying update records the timestamp;
1916
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
1917
+ *
1918
+ * @param treeMap The root doc's "doc-tree" Y.Map.
1919
+ * @param childDocId The child document's UUID (key in treeMap).
1920
+ * @param childDoc The child Y.Doc to observe.
1822
1921
  * @param offlineStore The child provider's OfflineStore (used to detect
1823
1922
  * offline-replay origins and skip them). Pass null when
1824
1923
  * the offline store is disabled.
1825
- * @returns Cleanup function call on provider destroy.
1924
+ * @param options Optional config. `throttleMs` controls the write
1925
+ * interval (default 5000).
1926
+ * @returns Cleanup function — call on provider destroy. Flushes
1927
+ * any pending write before detaching.
1826
1928
  */
1827
- declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string, childDoc: Y.Doc, offlineStore: OfflineStore | null): () => void;
1929
+ declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string, childDoc: Y.Doc, offlineStore: OfflineStore | null, options?: {
1930
+ throttleMs?: number;
1931
+ }): () => void;
1828
1932
  //#endregion
1829
1933
  //#region packages/provider/src/BackgroundSyncPersistence.d.ts
1830
1934
  /**
@@ -2501,14 +2605,22 @@ declare class DevicePairingChannel extends EventEmitter {
2501
2605
  /**
2502
2606
  * Approve the pending pairing request. Calls `client.addKey()` to
2503
2607
  * register Device B's public key, then notifies Device B.
2608
+ *
2609
+ * @param client Authenticated REST client.
2610
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
2611
+ * Sent to Device B so it can adopt the master's identity doc.
2504
2612
  */
2505
- approve(client: AbracadabraClient): Promise<PairingResult>;
2613
+ approve(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
2506
2614
  /**
2507
2615
  * Approve via server-side device invite. Creates a single-use invite code
2508
2616
  * and sends it to Device B over the E2EE channel. Device B redeems it
2509
2617
  * independently via HTTP — Device A can go offline after this.
2618
+ *
2619
+ * @param client Authenticated REST client.
2620
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
2621
+ * Sent to Device B so it can adopt the master's identity doc.
2510
2622
  */
2511
- approveWithInvite(client: AbracadabraClient): Promise<PairingResult>;
2623
+ approveWithInvite(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
2512
2624
  /**
2513
2625
  * Reject the pending pairing request.
2514
2626
  */
@@ -2728,6 +2840,12 @@ declare class IdentityDocProvider extends EventEmitter {
2728
2840
  * Call this to decide whether to run migration from localStorage.
2729
2841
  */
2730
2842
  isEmpty(): boolean;
2843
+ /**
2844
+ * Enable WebRTC P2P sync at runtime.
2845
+ * Use this for claimed/passkey users where E2EE identity derivation
2846
+ * was deferred to avoid biometric prompts on page load.
2847
+ */
2848
+ enableWebRTC(webrtcConfig: NonNullable<IdentityDocConfiguration["webrtc"]>): void;
2731
2849
  /**
2732
2850
  * Update the sync server URL at runtime (e.g. when user changes their
2733
2851
  * designated sync server in settings).
@@ -2793,6 +2911,111 @@ declare class DeviceRegistrationService {
2793
2911
  }): Promise<void>;
2794
2912
  }
2795
2913
  //#endregion
2914
+ //#region packages/provider/src/ChatClient.d.ts
2915
+ /**
2916
+ * Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
2917
+ * Kept as an interface so consumers can pass any compatible transport.
2918
+ */
2919
+ interface ChatClientTransport {
2920
+ sendStateless(payload: string): void;
2921
+ on(event: string, fn: Function): unknown;
2922
+ off(event: string, fn?: Function): unknown;
2923
+ }
2924
+ /**
2925
+ * Typed client for the Abracadabra chat feature.
2926
+ *
2927
+ * Wraps a connected provider (or base provider) and translates JSON envelopes
2928
+ * on the stateless channel into typed method calls and events.
2929
+ *
2930
+ * Events emitted:
2931
+ * - `message` → ChatMessage (new message broadcast)
2932
+ * - `typing` → ChatTypingEvent (typing indicator broadcast)
2933
+ * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
2934
+ */
2935
+ declare class ChatClient extends EventEmitter {
2936
+ private readonly provider;
2937
+ private readonly responseTimeoutMs;
2938
+ private readonly pending;
2939
+ private readonly boundOnStateless;
2940
+ constructor(provider: ChatClientTransport, options?: {
2941
+ responseTimeoutMs?: number;
2942
+ });
2943
+ /** Stop listening for chat messages. Does not disconnect the underlying provider. */
2944
+ destroy(): void;
2945
+ /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
2946
+ sendMessage(input: SendChatMessageInput): void;
2947
+ /** Fetch historical messages for a channel. Resolves with the server response. */
2948
+ getHistory(input: GetChatHistoryInput): Promise<{
2949
+ channel: string;
2950
+ messages: ChatMessage[];
2951
+ }>;
2952
+ /** Broadcast a typing indicator on a channel. */
2953
+ sendTyping(channel: string): void;
2954
+ /** List the current user's channels (ordered by last activity). */
2955
+ listChannels(): Promise<{
2956
+ channels: ChatChannel[];
2957
+ }>;
2958
+ /** Mark a channel read up to `timestamp` (unix ms). */
2959
+ markRead(channel: string, timestamp: number): void;
2960
+ /** Fetch per-user read cursors for a channel. */
2961
+ getReadCursors(channel: string): Promise<{
2962
+ channel: string;
2963
+ cursors: ChatReadCursor[];
2964
+ }>;
2965
+ onMessage(fn: (m: ChatMessage) => void): this;
2966
+ onTyping(fn: (e: ChatTypingEvent) => void): this;
2967
+ onReadReceipt(fn: (e: ChatReadReceipt) => void): this;
2968
+ private enqueue;
2969
+ private removePending;
2970
+ private resolveNext;
2971
+ private handleStateless;
2972
+ }
2973
+ //#endregion
2974
+ //#region packages/provider/src/NotificationsClient.d.ts
2975
+ /**
2976
+ * Typed client for the Abracadabra notifications feature.
2977
+ *
2978
+ * Emits:
2979
+ * - `new` → NotificationRecord (incoming notify:new broadcast)
2980
+ * - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
2981
+ */
2982
+ declare class NotificationsClient extends EventEmitter {
2983
+ private readonly provider;
2984
+ private readonly responseTimeoutMs;
2985
+ private readonly pending;
2986
+ private readonly boundOnStateless;
2987
+ constructor(provider: ChatClientTransport, options?: {
2988
+ responseTimeoutMs?: number;
2989
+ });
2990
+ destroy(): void;
2991
+ /**
2992
+ * Create a notification targeting a specific recipient. Requires elevated role
2993
+ * (service or admin); a `server:error` event with code `forbidden` is emitted
2994
+ * by the underlying provider if the caller lacks permission.
2995
+ */
2996
+ create(input: CreateNotificationInput): void;
2997
+ /** Fetch notification history for the current user. */
2998
+ fetch(input?: FetchNotificationsInput): Promise<{
2999
+ notifications: NotificationRecord[];
3000
+ }>;
3001
+ /** Mark a single notification, or a batch, as read. */
3002
+ markRead(target: {
3003
+ id: string;
3004
+ } | {
3005
+ ids: string[];
3006
+ }): void;
3007
+ /** Mark every notification for the current user as read. */
3008
+ markAllRead(): void;
3009
+ /** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
3010
+ markReadBySource(sourceId: string): void;
3011
+ onNew(fn: (n: NotificationRecord) => void): this;
3012
+ onReadUpdate(fn: (u: NotificationReadUpdate) => void): this;
3013
+ private enqueue;
3014
+ private removePending;
3015
+ private resolveNext;
3016
+ private handleStateless;
3017
+ }
3018
+ //#endregion
2796
3019
  //#region node_modules/@scure/bip39/esm/wordlists/english.d.ts
2797
3020
  declare const wordlist: string[];
2798
3021
  //#endregion
@@ -2860,4 +3083,4 @@ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promi
2860
3083
  */
2861
3084
  declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
2862
3085
  //#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 };
3086
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, type ChatChannel, ChatClient, type ChatClientTransport, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, type CreateNotificationInput, 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, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, type GetChatHistoryInput, 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, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, type SendChatMessageInput, 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.8.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
  }