@abraca/dabra 2.9.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/abracadabra-provider.cjs +94 -9
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +94 -9
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +36 -4
- package/package.json +2 -2
- package/src/BackgroundSyncManager.ts +165 -14
- package/src/BackgroundSyncPersistence.ts +35 -2
- package/src/index.ts +4 -1
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:
|
|
3167
|
+
status: DocSyncStatus;
|
|
3151
3168
|
/** Unix ms of the last successful sync, or null if never synced. */
|
|
3152
3169
|
lastSynced: number | null;
|
|
3153
|
-
/**
|
|
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:
|
|
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.
|
|
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.
|
|
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'"
|
|
@@ -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:
|
|
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 ??
|
|
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[] =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
421
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
39
|
+
status: DocSyncStatus;
|
|
15
40
|
/** Unix ms of the last successful sync, or null if never synced. */
|
|
16
41
|
lastSynced: number | null;
|
|
17
|
-
/**
|
|
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 {
|
|
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";
|