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