@abraca/dabra 2.9.0 → 2.11.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
@@ -3144,14 +3144,39 @@ declare function attachUpdatedAtObserver(treeMap: Y.Map<any>, childDocId: string
3144
3144
  *
3145
3145
  * Falls back to a no-op when IndexedDB is unavailable (SSR / Node.js).
3146
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";
3147
3164
  interface DocSyncState {
3148
3165
  docId: string;
3149
3166
  /** Current lifecycle status of this document's background sync. */
3150
- status: "pending" | "syncing" | "synced" | "error" | "skipped";
3167
+ status: DocSyncStatus;
3151
3168
  /** Unix ms of the last successful sync, or null if never synced. */
3152
3169
  lastSynced: number | null;
3153
- /** 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
+ */
3154
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;
3155
3180
  /** Whether the document uses E2E encryption. */
3156
3181
  isE2E: boolean;
3157
3182
  }
@@ -3171,7 +3196,7 @@ declare class BackgroundSyncPersistence {
3171
3196
  interface BackgroundSyncManagerOptions {
3172
3197
  /** Max parallel WS connections for background sync. Default: 2. */
3173
3198
  concurrency?: number;
3174
- /** Timeout (ms) waiting for a provider to sync. Default: 15 000. */
3199
+ /** Timeout (ms) waiting for a provider to sync. Default: 30 000. */
3175
3200
  syncTimeout?: number;
3176
3201
  /** Pre-cache file blobs after syncing a doc. Default: true. */
3177
3202
  prefetchFiles?: boolean;
@@ -3192,6 +3217,8 @@ declare class BackgroundSyncManager extends EventEmitter {
3192
3217
  private readonly _warnedInvalidIds;
3193
3218
  private _destroyed;
3194
3219
  private _initPromise;
3220
+ /** One-shot timer for the post-syncAll straggler retry pass. */
3221
+ private _stragglerTimer;
3195
3222
  constructor(rootProvider: AbracadabraProvider, client: AbracadabraClient, fileBlobStore?: FileBlobStore | null, opts?: BackgroundSyncManagerOptions);
3196
3223
  /**
3197
3224
  * Load persisted sync states from IndexedDB into the in-memory map.
@@ -3201,6 +3228,11 @@ declare class BackgroundSyncManager extends EventEmitter {
3201
3228
  private _loadPersistedStates;
3202
3229
  /** Sync all documents in the root tree. */
3203
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;
3204
3236
  /** Sync a single document by ID. */
3205
3237
  syncDoc(docId: string): Promise<DocSyncState>;
3206
3238
  /** Return a snapshot of all known sync states. */
@@ -5317,4 +5349,4 @@ declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<s
5317
5349
  allowLabelClear?: boolean;
5318
5350
  }): void;
5319
5351
  //#endregion
5320
- 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.9.0",
3
+ "version": "2.11.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.9.0"
44
+ "@abraca/schema": "2.11.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -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";