@abraca/dabra 2.8.0 → 2.10.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
@@ -2212,7 +2212,19 @@ declare class AbracadabraWS extends EventEmitter {
2212
2212
  resolve: (value?: any) => void;
2213
2213
  reject: (reason?: any) => void;
2214
2214
  } | null;
2215
+ private onlineListener;
2216
+ private offlineListener;
2215
2217
  constructor(configuration: AbracadabraWSConfiguration);
2218
+ /**
2219
+ * Whether the device currently believes it has network connectivity.
2220
+ *
2221
+ * Treats "unknown" as online: in Node and other non-browser environments
2222
+ * `navigator` (or `navigator.onLine`) is absent, and we must not gate
2223
+ * reconnection there — only the browser exposes a trustworthy signal.
2224
+ */
2225
+ get isOnline(): boolean;
2226
+ handleOnline(): void;
2227
+ handleOffline(): void;
2216
2228
  receivedOnOpenPayload?: Event | undefined;
2217
2229
  onOpen(event: Event): Promise<void>;
2218
2230
  attach(provider: AbracadabraBaseProvider): void;
@@ -3132,14 +3144,39 @@ declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string
3132
3144
  *
3133
3145
  * Falls back to a no-op when IndexedDB is unavailable (SSR / Node.js).
3134
3146
  */
3147
+ /**
3148
+ * Background-sync lifecycle status.
3149
+ *
3150
+ * - `pending` — never synced; queued for first attempt.
3151
+ * - `syncing` — attempt in progress (transient, not persisted as terminal).
3152
+ * - `synced` — last attempt succeeded; `lastSynced` is set.
3153
+ * - `retrying` — a *transient* failure (timeout, network) that has NOT yet
3154
+ * crossed the failure threshold. Will be retried silently;
3155
+ * the UI should treat this calmly and NOT count it as an error.
3156
+ * - `error` — a transient failure that has recurred past the threshold
3157
+ * (`consecutiveFailures >= FAILURE_THRESHOLD`). Genuinely
3158
+ * needs attention; this is what the UI surfaces as red.
3159
+ * - `unavailable` — a *permanent* failure (doc deleted/forbidden/invalid id —
3160
+ * 404/403/410/422). Not retried, and not surfaced as an error.
3161
+ * - `skipped` — E2E doc we can't sync (no keystore). Benign.
3162
+ */
3163
+ type DocSyncStatus = "pending" | "syncing" | "synced" | "retrying" | "error" | "unavailable" | "skipped";
3135
3164
  interface DocSyncState {
3136
3165
  docId: string;
3137
3166
  /** Current lifecycle status of this document's background sync. */
3138
- status: "pending" | "syncing" | "synced" | "error" | "skipped";
3167
+ status: DocSyncStatus;
3139
3168
  /** Unix ms of the last successful sync, or null if never synced. */
3140
3169
  lastSynced: number | null;
3141
- /** Human-readable error message if status === "error". */
3170
+ /**
3171
+ * Human-readable message for the most recent failure. Populated for
3172
+ * `retrying`, `error`, and `unavailable` — cleared on a successful sync.
3173
+ */
3142
3174
  error?: string;
3175
+ /**
3176
+ * Count of consecutive failed attempts. Reset to 0 on success. Drives the
3177
+ * `retrying` → `error` escalation so a single transient blip never nags.
3178
+ */
3179
+ consecutiveFailures?: number;
3143
3180
  /** Whether the document uses E2E encryption. */
3144
3181
  isE2E: boolean;
3145
3182
  }
@@ -3159,7 +3196,7 @@ declare class BackgroundSyncPersistence {
3159
3196
  interface BackgroundSyncManagerOptions {
3160
3197
  /** Max parallel WS connections for background sync. Default: 2. */
3161
3198
  concurrency?: number;
3162
- /** Timeout (ms) waiting for a provider to sync. Default: 15 000. */
3199
+ /** Timeout (ms) waiting for a provider to sync. Default: 30 000. */
3163
3200
  syncTimeout?: number;
3164
3201
  /** Pre-cache file blobs after syncing a doc. Default: true. */
3165
3202
  prefetchFiles?: boolean;
@@ -3180,6 +3217,8 @@ declare class BackgroundSyncManager extends EventEmitter {
3180
3217
  private readonly _warnedInvalidIds;
3181
3218
  private _destroyed;
3182
3219
  private _initPromise;
3220
+ /** One-shot timer for the post-syncAll straggler retry pass. */
3221
+ private _stragglerTimer;
3183
3222
  constructor(rootProvider: AbracadabraProvider, client: AbracadabraClient, fileBlobStore?: FileBlobStore | null, opts?: BackgroundSyncManagerOptions);
3184
3223
  /**
3185
3224
  * Load persisted sync states from IndexedDB into the in-memory map.
@@ -3189,6 +3228,11 @@ declare class BackgroundSyncManager extends EventEmitter {
3189
3228
  private _loadPersistedStates;
3190
3229
  /** Sync all documents in the root tree. */
3191
3230
  syncAll(): Promise<void>;
3231
+ /**
3232
+ * Schedule a single delayed retry of the given docs. Replaces any pending
3233
+ * straggler timer so repeated `syncAll()` calls don't stack timers.
3234
+ */
3235
+ private _scheduleStragglerRetry;
3192
3236
  /** Sync a single document by ID. */
3193
3237
  syncDoc(docId: string): Promise<DocSyncState>;
3194
3238
  /** Return a snapshot of all known sync states. */
@@ -5305,4 +5349,4 @@ declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<s
5305
5349
  allowLabelClear?: boolean;
5306
5350
  }): void;
5307
5351
  //#endregion
5308
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AdminConfigField, AdminConfigOriginKind, AuditLogEntry, AuditQueryOpts, AuditVerifyResult, AuthFailureContext, AuthFailureReason, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, type ChatChannel, ChatClient, type ChatClientTransport, type MarkReadInput as ChatMarkReadInput, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, type ChildrenPage, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, ContentManager, type CreateNotificationInput, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DeleteMessageInput, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceSessionRecord, type DeviceSessionStorage, type DeviceTier, type DocEncryptionInfo, DocKeyManager, DocSearchHit, type DocSyncState, type DocumentBlock, DocumentCache, type DocumentCacheOptions, type DocumentContent, DocumentManager, type DocumentManagerConfig, DocumentMeta, type DocumentMetaInfo, type DocumentMetaWire, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, type EditMessageInput, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedChatClient, EncryptedYMap, EncryptedYText, EnvSnapshotExtension, EnvSnapshotItem, EnvSnapshotResponse, type FetchInboxInput, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, type FoldedMessage, Forbidden, GEO_TYPE_META_SCHEMAS, type GetChatHistoryInput, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, type InboxEntry, type MarkReadInput$1 as InboxMarkReadInput, InviteRow, KEY_EXCHANGE_CHANNEL, Kind, LocalStorageDeviceSessionStorage, ManualSignaling, type ManualSignalingBlob, type MessageRecord, MessageTooBig, MessageType, MetaFieldType, MetaManager, MetaValidationError, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PAGE_TYPES, PageMeta, PageTypeInfo, PageTypeMetaField, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, QUERY_PREFIX, QueryClient, QueryError, type QueryFrame, type QueryKind, type QuerySpec, type QuerySubscriptionHandle, type QuerySubscriptionHandlers, type QueryTransport, RPC_PREFIX, ReadyzStatus, ResetConnection, type RpcCallHandle, type RpcCallOptions, RpcClient, RpcError, type RpcErrorCode, type RpcErrorPayload, type RpcFrame, type RpcHandler, type RpcHandlerContext, type RpcKind, type RpcTarget, type RpcTransport, SERVER_ROOT_ID, type SchemaDocTypeName, type SchemaMetaOf, type SchemaRegistryLike, type SchemaValidatorLike, SearchIndex, SearchResult, type SendChatMessageInput, type SendMessageInput, type SendMessageResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotFileEntry, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, StatesArray, SubdocMessage, SubdocRegisteredEvent, TYPE_ALIASES, TokenManager, type TokenManagerOptions, TreeEntry, TreeManager, TreeNode, TreeSearchResult, TypedDocTypeMismatchError, type TypedDocsClient, type TypedTreeEntry, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserMetaField, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, isPlaceholderLabel, makeEncryptedYMap, makeEncryptedYText, makeEntryMap, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onCompactedParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, parseFrontmatter, patchEntry, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
5352
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AdminConfigField, AdminConfigOriginKind, AuditLogEntry, AuditQueryOpts, AuditVerifyResult, AuthFailureContext, AuthFailureReason, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, type ChatChannel, ChatClient, type ChatClientTransport, type MarkReadInput as ChatMarkReadInput, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, type ChildrenPage, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, ContentManager, type CreateNotificationInput, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DeleteMessageInput, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceSessionRecord, type DeviceSessionStorage, type DeviceTier, type DocEncryptionInfo, DocKeyManager, DocSearchHit, type DocSyncState, type DocSyncStatus, type DocumentBlock, DocumentCache, type DocumentCacheOptions, type DocumentContent, DocumentManager, type DocumentManagerConfig, DocumentMeta, type DocumentMetaInfo, type DocumentMetaWire, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, type EditMessageInput, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedChatClient, EncryptedYMap, EncryptedYText, EnvSnapshotExtension, EnvSnapshotItem, EnvSnapshotResponse, type FetchInboxInput, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, type FoldedMessage, Forbidden, GEO_TYPE_META_SCHEMAS, type GetChatHistoryInput, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, type InboxEntry, type MarkReadInput$1 as InboxMarkReadInput, InviteRow, KEY_EXCHANGE_CHANNEL, Kind, LocalStorageDeviceSessionStorage, ManualSignaling, type ManualSignalingBlob, type MessageRecord, MessageTooBig, MessageType, MetaFieldType, MetaManager, MetaValidationError, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PAGE_TYPES, PageMeta, PageTypeInfo, PageTypeMetaField, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, QUERY_PREFIX, QueryClient, QueryError, type QueryFrame, type QueryKind, type QuerySpec, type QuerySubscriptionHandle, type QuerySubscriptionHandlers, type QueryTransport, RPC_PREFIX, ReadyzStatus, ResetConnection, type RpcCallHandle, type RpcCallOptions, RpcClient, RpcError, type RpcErrorCode, type RpcErrorPayload, type RpcFrame, type RpcHandler, type RpcHandlerContext, type RpcKind, type RpcTarget, type RpcTransport, SERVER_ROOT_ID, type SchemaDocTypeName, type SchemaMetaOf, type SchemaRegistryLike, type SchemaValidatorLike, SearchIndex, SearchResult, type SendChatMessageInput, type SendMessageInput, type SendMessageResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotFileEntry, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, StatesArray, SubdocMessage, SubdocRegisteredEvent, TYPE_ALIASES, TokenManager, type TokenManagerOptions, TreeEntry, TreeManager, TreeNode, TreeSearchResult, TypedDocTypeMismatchError, type TypedDocsClient, type TypedTreeEntry, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserMetaField, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, isPlaceholderLabel, makeEncryptedYMap, makeEncryptedYText, makeEntryMap, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onCompactedParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, parseFrontmatter, patchEntry, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -41,7 +41,7 @@
41
41
  "yjs": "^13.6.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@abraca/schema": "2.8.0"
44
+ "@abraca/schema": "2.10.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -182,6 +182,12 @@ export class AbracadabraWS extends EventEmitter {
182
182
  reject: (reason?: any) => void;
183
183
  } | null = null;
184
184
 
185
+ // Bound `online`/`offline` listeners, retained so we can detach them in
186
+ // destroy(). Only registered in browser-like environments (see constructor).
187
+ private onlineListener: (() => void) | null = null;
188
+
189
+ private offlineListener: (() => void) | null = null;
190
+
185
191
  constructor(configuration: AbracadabraWSConfiguration) {
186
192
  super();
187
193
  this.setConfiguration(configuration);
@@ -210,11 +216,57 @@ export class AbracadabraWS extends EventEmitter {
210
216
  this.configuration.messageReconnectTimeout / 10,
211
217
  );
212
218
 
219
+ // Offline-first reconnect gating. When the device reports it is offline we
220
+ // stop hammering the socket (failed attempts just flip status
221
+ // disconnected↔connecting and light up every connection indicator) and
222
+ // wait for the `online` event to resume immediately — no backoff wait.
223
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
224
+ this.onlineListener = this.handleOnline.bind(this);
225
+ this.offlineListener = this.handleOffline.bind(this);
226
+ window.addEventListener("online", this.onlineListener);
227
+ window.addEventListener("offline", this.offlineListener);
228
+ }
229
+
213
230
  if (this.shouldConnect) {
214
231
  this.connect();
215
232
  }
216
233
  }
217
234
 
235
+ /**
236
+ * Whether the device currently believes it has network connectivity.
237
+ *
238
+ * Treats "unknown" as online: in Node and other non-browser environments
239
+ * `navigator` (or `navigator.onLine`) is absent, and we must not gate
240
+ * reconnection there — only the browser exposes a trustworthy signal.
241
+ */
242
+ get isOnline(): boolean {
243
+ return typeof navigator === "undefined" || navigator.onLine !== false;
244
+ }
245
+
246
+ handleOnline() {
247
+ // Network came back — resume immediately if we still want a connection
248
+ // and aren't already connected. Bypasses the backoff that a pending
249
+ // retryer would otherwise wait out.
250
+ if (this.shouldConnect && this.status !== WebSocketStatus.Connected) {
251
+ this.connect();
252
+ }
253
+ }
254
+
255
+ handleOffline() {
256
+ // Stop attempting while offline, but preserve `shouldConnect` so intent
257
+ // survives — `handleOnline`/attach will resume once connectivity returns.
258
+ if (this.cancelWebsocketRetry) {
259
+ this.cancelWebsocketRetry();
260
+ this.cancelWebsocketRetry = undefined;
261
+ }
262
+ try {
263
+ this.webSocket?.close();
264
+ this.messageQueue = [];
265
+ } catch (e) {
266
+ console.error(e);
267
+ }
268
+ }
269
+
218
270
  receivedOnOpenPayload?: Event | undefined = undefined;
219
271
 
220
272
  async onOpen(event: Event) {
@@ -271,6 +323,14 @@ export class AbracadabraWS extends EventEmitter {
271
323
  return;
272
324
  }
273
325
 
326
+ // Don't attempt while the device is offline — record the intent and let
327
+ // the `online` event drive the resume. Avoids the reconnect storm of
328
+ // instantly-failing socket attempts against a known-dead network.
329
+ if (!this.isOnline) {
330
+ this.shouldConnect = true;
331
+ return;
332
+ }
333
+
274
334
  // Always cancel any previously initiated connection retryer instances
275
335
  if (this.cancelWebsocketRetry) {
276
336
  this.cancelWebsocketRetry();
@@ -548,8 +608,11 @@ export class AbracadabraWS extends EventEmitter {
548
608
  this.emit("rateLimited");
549
609
  }
550
610
 
551
- // trigger connect if no retry is running and we want to have a connection
552
- if (!this.cancelWebsocketRetry && this.shouldConnect) {
611
+ // trigger connect if no retry is running and we want to have a connection.
612
+ // Skip scheduling entirely while offline — `handleOnline` resumes the
613
+ // moment connectivity returns, so there's no point burning a timer (and a
614
+ // failed attempt) against a dead network.
615
+ if (!this.cancelWebsocketRetry && this.shouldConnect && this.isOnline) {
553
616
  // Apply a much longer delay for rate-limited closes to let the server window reset.
554
617
  const delay = isRateLimited ? 60_000 : this.configuration.delay;
555
618
  setTimeout(() => {
@@ -565,6 +628,13 @@ export class AbracadabraWS extends EventEmitter {
565
628
 
566
629
  this.emit("destroy");
567
630
 
631
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
632
+ if (this.onlineListener) window.removeEventListener("online", this.onlineListener);
633
+ if (this.offlineListener) window.removeEventListener("offline", this.offlineListener);
634
+ }
635
+ this.onlineListener = null;
636
+ this.offlineListener = null;
637
+
568
638
  clearInterval(this.intervals.connectionChecker);
569
639
 
570
640
  // If there is still a connection attempt outstanding then we should stop
@@ -34,7 +34,7 @@ import { E2EAbracadabraProvider } from "./E2EAbracadabraProvider.ts";
34
34
  export interface BackgroundSyncManagerOptions {
35
35
  /** Max parallel WS connections for background sync. Default: 2. */
36
36
  concurrency?: number;
37
- /** Timeout (ms) waiting for a provider to sync. Default: 15 000. */
37
+ /** Timeout (ms) waiting for a provider to sync. Default: 30 000. */
38
38
  syncTimeout?: number;
39
39
  /** Pre-cache file blobs after syncing a doc. Default: true. */
40
40
  prefetchFiles?: boolean;
@@ -86,6 +86,50 @@ function isValidDocId(id: string): boolean {
86
86
  return UUID_RE.test(id);
87
87
  }
88
88
 
89
+ /**
90
+ * Consecutive transient failures before a doc is promoted from the calm
91
+ * `retrying` status to a genuine `error`. Keeps a single timeout/network blip
92
+ * from flashing a red error badge — the doc only "counts" as broken once it
93
+ * has failed this many passes in a row.
94
+ */
95
+ const FAILURE_THRESHOLD = 3;
96
+
97
+ /** Delay before a one-shot follow-up retry of stragglers after `syncAll()`. */
98
+ const STRAGGLER_RETRY_DELAY_MS = 60_000;
99
+
100
+ /**
101
+ * Decide whether a sync failure is permanent (the server will never accept
102
+ * this doc — deleted, forbidden, or an unparseable id) or merely transient
103
+ * (timeout, dropped socket, momentary 5xx). Permanent failures are parked as
104
+ * `unavailable` and never retried; transient ones go through `retrying`.
105
+ *
106
+ * Best-effort: inspects a numeric `status`/`code` if the thrown value carries
107
+ * one, otherwise falls back to message keywords. Defaults to transient so we
108
+ * never give up on a doc we're unsure about.
109
+ */
110
+ function isPermanentSyncError(err: unknown): boolean {
111
+ const status =
112
+ typeof err === "object" && err !== null
113
+ ? Number(
114
+ (err as { status?: unknown; statusCode?: unknown; code?: unknown })
115
+ .status ??
116
+ (err as { statusCode?: unknown }).statusCode ??
117
+ (err as { code?: unknown }).code,
118
+ )
119
+ : NaN;
120
+ if (status === 403 || status === 404 || status === 410 || status === 422) {
121
+ return true;
122
+ }
123
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
124
+ return (
125
+ /\b(403|404|410|422)\b/.test(msg) ||
126
+ msg.includes("forbidden") ||
127
+ msg.includes("not found") ||
128
+ msg.includes("does not exist") ||
129
+ msg.includes("unprocessable")
130
+ );
131
+ }
132
+
89
133
  export class BackgroundSyncManager extends EventEmitter {
90
134
  private readonly rootProvider: AbracadabraProvider;
91
135
  private readonly client: AbracadabraClient;
@@ -100,6 +144,8 @@ export class BackgroundSyncManager extends EventEmitter {
100
144
 
101
145
  private _destroyed = false;
102
146
  private _initPromise: Promise<void> | null = null;
147
+ /** One-shot timer for the post-syncAll straggler retry pass. */
148
+ private _stragglerTimer: ReturnType<typeof setTimeout> | null = null;
103
149
 
104
150
  constructor(
105
151
  rootProvider: AbracadabraProvider,
@@ -113,7 +159,7 @@ export class BackgroundSyncManager extends EventEmitter {
113
159
  this.fileBlobStore = fileBlobStore ?? null;
114
160
  this.opts = {
115
161
  concurrency: opts?.concurrency ?? 2,
116
- syncTimeout: opts?.syncTimeout ?? 15_000,
162
+ syncTimeout: opts?.syncTimeout ?? 30_000,
117
163
  prefetchFiles: opts?.prefetchFiles ?? true,
118
164
  throttleMs: opts?.throttleMs ?? 200,
119
165
  maxRetries: opts?.maxRetries ?? 2,
@@ -224,6 +270,38 @@ export class BackgroundSyncManager extends EventEmitter {
224
270
  const retryWorkers = Array.from({ length: this.opts.concurrency }, () => retryNext());
225
271
  await Promise.all(retryWorkers);
226
272
  }
273
+
274
+ // Any docs still failing after the in-run retries are "stragglers" — the
275
+ // usual 1-2 slow docs that just needed a moment. Rather than make the
276
+ // user stare at them until the next periodic pass (which can be far off),
277
+ // schedule one quiet follow-up attempt while the connection is still warm.
278
+ if (failed.length > 0) {
279
+ this._scheduleStragglerRetry(failed.slice());
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Schedule a single delayed retry of the given docs. Replaces any pending
285
+ * straggler timer so repeated `syncAll()` calls don't stack timers.
286
+ */
287
+ private _scheduleStragglerRetry(docIds: string[]): void {
288
+ if (this._destroyed || docIds.length === 0) return;
289
+ if (this._stragglerTimer) clearTimeout(this._stragglerTimer);
290
+ this._stragglerTimer = setTimeout(() => {
291
+ this._stragglerTimer = null;
292
+ if (this._destroyed) return;
293
+ void (async () => {
294
+ const treeMap = this.rootProvider.document.getMap(
295
+ "doc-tree",
296
+ ) as Y.Map<any>;
297
+ for (const docId of docIds) {
298
+ if (this._destroyed) return;
299
+ const entry = treeMap.get(docId);
300
+ const updatedAt = entry?.updatedAt ?? entry?.createdAt ?? 0;
301
+ await this._syncWithSemaphore(docId, updatedAt);
302
+ }
303
+ })();
304
+ }, STRAGGLER_RETRY_DELAY_MS);
227
305
  }
228
306
 
229
307
  /** Sync a single document by ID. */
@@ -299,6 +377,10 @@ export class BackgroundSyncManager extends EventEmitter {
299
377
 
300
378
  destroy(): void {
301
379
  this._destroyed = true;
380
+ if (this._stragglerTimer) {
381
+ clearTimeout(this._stragglerTimer);
382
+ this._stragglerTimer = null;
383
+ }
302
384
  this.removeAllListeners();
303
385
  }
304
386
 
@@ -331,23 +413,29 @@ export class BackgroundSyncManager extends EventEmitter {
331
413
  }
332
414
  }
333
415
 
334
- const items: Sortable[] = filtered.map(([docId, v]) => {
416
+ const items: Sortable[] = [];
417
+ for (const [docId, v] of filtered) {
335
418
  const state = this.syncStates.get(docId);
419
+
420
+ // Permanently unavailable docs are never queued — the server would
421
+ // just reject them again. They stay parked until a resync clears them.
422
+ if (state?.status === "unavailable") continue;
423
+
336
424
  const updatedAt: number = v?.updatedAt ?? v?.createdAt ?? 0;
337
425
 
338
426
  let priority: number;
339
427
  if (!state || state.status === "pending") {
340
428
  // Never synced — high priority (large number so it sorts first when DESC)
341
429
  priority = Number.MAX_SAFE_INTEGER - updatedAt;
342
- } else if (state.status === "error") {
343
- // Errored — lowest priority (large negative offset)
430
+ } else if (state.status === "error" || state.status === "retrying") {
431
+ // Failing — lowest priority (large negative offset)
344
432
  priority = -1;
345
433
  } else {
346
434
  // Synced — sort by updatedAt desc (most recent = highest priority)
347
435
  priority = updatedAt;
348
436
  }
349
- return { docId, priority };
350
- });
437
+ items.push({ docId, priority });
438
+ }
351
439
 
352
440
  // Sort descending by priority
353
441
  items.sort((a, b) => b.priority - a.priority);
@@ -361,10 +449,18 @@ export class BackgroundSyncManager extends EventEmitter {
361
449
  ): Promise<boolean> {
362
450
  if (this._destroyed) return true;
363
451
 
452
+ const existing = this.syncStates.get(docId);
453
+
454
+ // Permanently unavailable (deleted/forbidden) — don't keep hammering a
455
+ // doc the server will never accept. Re-emit so the UI keeps the state.
456
+ if (existing && existing.status === "unavailable") {
457
+ this.emit("stateChanged", { docId, state: existing });
458
+ return true;
459
+ }
460
+
364
461
  // Skip if already synced and doc hasn't changed since last sync.
365
462
  // The 200ms grace margin handles edge cases where the updatedAt
366
463
  // observer writes a timestamp milliseconds after lastSynced was recorded.
367
- const existing = this.syncStates.get(docId);
368
464
  if (
369
465
  existing &&
370
466
  existing.status === "synced" &&
@@ -381,13 +477,28 @@ export class BackgroundSyncManager extends EventEmitter {
381
477
  this.syncStates.set(docId, state);
382
478
  await this.persistence.setState(state).catch(() => null);
383
479
  this.emit("stateChanged", { docId, state });
384
- return state.status !== "error";
480
+ // `retrying` and `error` are both failures that warrant another pass;
481
+ // `unavailable` is permanent so we report it as done (no retry).
482
+ return state.status !== "error" && state.status !== "retrying";
385
483
  } finally {
386
484
  this.semaphore.release();
387
485
  }
388
486
  }
389
487
 
390
488
  private async _doSyncDoc(docId: string): Promise<DocSyncState> {
489
+ // Non-UUID ids the server can't parse (422) are permanently unsyncable —
490
+ // park them as `unavailable` rather than churning through retries.
491
+ if (!isValidDocId(docId)) {
492
+ return {
493
+ docId,
494
+ status: "unavailable",
495
+ lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
496
+ error: "Invalid document id (not a UUID)",
497
+ consecutiveFailures: 0,
498
+ isE2E: false,
499
+ };
500
+ }
501
+
391
502
  // Mark as syncing
392
503
  const syncing: DocSyncState = {
393
504
  docId,
@@ -415,11 +526,32 @@ export class BackgroundSyncManager extends EventEmitter {
415
526
  }
416
527
  } catch (err) {
417
528
  const error = err instanceof Error ? err.message : String(err);
529
+ const prev = this.syncStates.get(docId);
530
+ const lastSynced = prev?.lastSynced ?? null;
531
+
532
+ // Permanent failure (deleted / forbidden / unparseable) — park it as
533
+ // `unavailable` so we stop retrying and never surface it as an error.
534
+ if (isPermanentSyncError(err)) {
535
+ return {
536
+ docId,
537
+ status: "unavailable",
538
+ lastSynced,
539
+ error,
540
+ consecutiveFailures: 0,
541
+ isE2E,
542
+ };
543
+ }
544
+
545
+ // Transient failure — escalate to a genuine `error` only once it has
546
+ // recurred past the threshold; otherwise keep it calm as `retrying`.
547
+ const consecutiveFailures = (prev?.consecutiveFailures ?? 0) + 1;
418
548
  return {
419
549
  docId,
420
- status: "error",
421
- lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
550
+ status:
551
+ consecutiveFailures >= FAILURE_THRESHOLD ? "error" : "retrying",
552
+ lastSynced,
422
553
  error,
554
+ consecutiveFailures,
423
555
  isE2E,
424
556
  };
425
557
  }
@@ -445,6 +577,7 @@ export class BackgroundSyncManager extends EventEmitter {
445
577
  docId,
446
578
  status: "synced",
447
579
  lastSynced: Date.now(),
580
+ consecutiveFailures: 0,
448
581
  isE2E: false,
449
582
  };
450
583
  }
@@ -478,7 +611,13 @@ export class BackgroundSyncManager extends EventEmitter {
478
611
  // the observer's timestamp write from triggering a spurious re-sync.
479
612
  const treeEntry = treeMap.get(docId);
480
613
  const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
481
- return { docId, status: "synced", lastSynced: Math.max(Date.now(), treeUpdatedAt), isE2E: false };
614
+ return {
615
+ docId,
616
+ status: "synced",
617
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
618
+ consecutiveFailures: 0,
619
+ isE2E: false,
620
+ };
482
621
  } finally {
483
622
  // Always release the provider if it was created solely for background sync.
484
623
  // This closes its IDB database, freeing the file descriptor.
@@ -499,7 +638,13 @@ export class BackgroundSyncManager extends EventEmitter {
499
638
 
500
639
  if (!docKeyManager || !keystore) {
501
640
  // No key management configured — skip silently
502
- return { docId, status: "skipped", lastSynced: null, isE2E: true };
641
+ return {
642
+ docId,
643
+ status: "skipped",
644
+ lastSynced: null,
645
+ consecutiveFailures: 0,
646
+ isE2E: true,
647
+ };
503
648
  }
504
649
 
505
650
  const childDoc = new Y.Doc({ guid: docId });
@@ -535,7 +680,13 @@ export class BackgroundSyncManager extends EventEmitter {
535
680
  // the observer's timestamp write from triggering a spurious re-sync.
536
681
  const treeEntry = treeMap.get(docId);
537
682
  const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
538
- return { docId, status: "synced", lastSynced: Math.max(Date.now(), treeUpdatedAt), isE2E: true };
683
+ return {
684
+ docId,
685
+ status: "synced",
686
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
687
+ consecutiveFailures: 0,
688
+ isE2E: true,
689
+ };
539
690
  } finally {
540
691
  childProvider.destroy();
541
692
  }
@@ -8,14 +8,47 @@
8
8
  * Falls back to a no-op when IndexedDB is unavailable (SSR / Node.js).
9
9
  */
10
10
 
11
+ /**
12
+ * Background-sync lifecycle status.
13
+ *
14
+ * - `pending` — never synced; queued for first attempt.
15
+ * - `syncing` — attempt in progress (transient, not persisted as terminal).
16
+ * - `synced` — last attempt succeeded; `lastSynced` is set.
17
+ * - `retrying` — a *transient* failure (timeout, network) that has NOT yet
18
+ * crossed the failure threshold. Will be retried silently;
19
+ * the UI should treat this calmly and NOT count it as an error.
20
+ * - `error` — a transient failure that has recurred past the threshold
21
+ * (`consecutiveFailures >= FAILURE_THRESHOLD`). Genuinely
22
+ * needs attention; this is what the UI surfaces as red.
23
+ * - `unavailable` — a *permanent* failure (doc deleted/forbidden/invalid id —
24
+ * 404/403/410/422). Not retried, and not surfaced as an error.
25
+ * - `skipped` — E2E doc we can't sync (no keystore). Benign.
26
+ */
27
+ export type DocSyncStatus =
28
+ | "pending"
29
+ | "syncing"
30
+ | "synced"
31
+ | "retrying"
32
+ | "error"
33
+ | "unavailable"
34
+ | "skipped";
35
+
11
36
  export interface DocSyncState {
12
37
  docId: string;
13
38
  /** Current lifecycle status of this document's background sync. */
14
- status: "pending" | "syncing" | "synced" | "error" | "skipped";
39
+ status: DocSyncStatus;
15
40
  /** Unix ms of the last successful sync, or null if never synced. */
16
41
  lastSynced: number | null;
17
- /** Human-readable error message if status === "error". */
42
+ /**
43
+ * Human-readable message for the most recent failure. Populated for
44
+ * `retrying`, `error`, and `unavailable` — cleared on a successful sync.
45
+ */
18
46
  error?: string;
47
+ /**
48
+ * Count of consecutive failed attempts. Reset to 0 on success. Drives the
49
+ * `retrying` → `error` escalation so a single transient blip never nags.
50
+ */
51
+ consecutiveFailures?: number;
19
52
  /** Whether the document uses E2E encryption. */
20
53
  isE2E: boolean;
21
54
  }
package/src/index.ts CHANGED
@@ -31,7 +31,10 @@ export { attachUpdatedAtObserver } from "./TreeTimestamps.ts";
31
31
  export { BackgroundSyncManager } from "./BackgroundSyncManager.ts";
32
32
  export type { BackgroundSyncManagerOptions } from "./BackgroundSyncManager.ts";
33
33
  export { BackgroundSyncPersistence } from "./BackgroundSyncPersistence.ts";
34
- export type { DocSyncState } from "./BackgroundSyncPersistence.ts";
34
+ export type {
35
+ DocSyncState,
36
+ DocSyncStatus,
37
+ } from "./BackgroundSyncPersistence.ts";
35
38
  export * from "./webrtc/index.ts";
36
39
  export { BroadcastChannelSync } from "./sync/BroadcastChannelSync.ts";
37
40
  export { IdentityDocProvider, deriveIdentityDocId } from "./IdentityDoc.ts";