@abraca/dabra 1.3.4 → 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 +160 -36
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +160 -36
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +86 -9
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +15 -0
- package/src/AbracadabraClient.ts +55 -0
- package/src/AbracadabraProvider.ts +56 -0
- package/src/BackgroundSyncManager.ts +44 -30
- package/src/TreeTimestamps.ts +47 -16
- package/src/types.ts +37 -0
package/dist/index.d.ts
CHANGED
|
@@ -528,6 +528,23 @@ declare class AbracadabraClient {
|
|
|
528
528
|
refCountsRepaired: number;
|
|
529
529
|
blobsSwept: number;
|
|
530
530
|
}>;
|
|
531
|
+
/** List snapshot metadata for a document. */
|
|
532
|
+
listSnapshots(docId: string, opts?: {
|
|
533
|
+
limit?: number;
|
|
534
|
+
offset?: number;
|
|
535
|
+
}): Promise<SnapshotMeta[]>;
|
|
536
|
+
/** Fetch a single snapshot including its base64-encoded data blob. */
|
|
537
|
+
getSnapshot(docId: string, version: number): Promise<SnapshotData>;
|
|
538
|
+
/** Create a manual snapshot of the current document state. */
|
|
539
|
+
createSnapshot(docId: string, opts?: {
|
|
540
|
+
label?: string;
|
|
541
|
+
}): Promise<SnapshotCreateResult>;
|
|
542
|
+
/** Delete a specific snapshot version. Requires manage permission. */
|
|
543
|
+
deleteSnapshot(docId: string, version: number): Promise<void>;
|
|
544
|
+
/** Restore a snapshot by merging it forward into the current document state. */
|
|
545
|
+
restoreSnapshot(docId: string, version: number): Promise<SnapshotRestoreResult>;
|
|
546
|
+
/** Fork a snapshot into a new document. */
|
|
547
|
+
forkSnapshot(docId: string, version: number): Promise<SnapshotForkResult>;
|
|
531
548
|
/** Health check — no auth required. */
|
|
532
549
|
health(): Promise<HealthStatus>;
|
|
533
550
|
/**
|
|
@@ -794,6 +811,12 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
794
811
|
private childProviders;
|
|
795
812
|
private pendingLoads;
|
|
796
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;
|
|
797
820
|
private abracadabraConfig;
|
|
798
821
|
private readonly boundHandleYSubdocsChange;
|
|
799
822
|
/**
|
|
@@ -867,6 +890,20 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
867
890
|
loadChild(childId: string): Promise<AbracadabraProvider>;
|
|
868
891
|
private _doLoadChild;
|
|
869
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;
|
|
870
907
|
/** Return all currently-loaded child providers. */
|
|
871
908
|
get children(): Map<string, AbracadabraProvider>;
|
|
872
909
|
/**
|
|
@@ -1116,6 +1153,11 @@ type onAwarenessChangeParameters = {
|
|
|
1116
1153
|
type onStatelessParameters = {
|
|
1117
1154
|
payload: string;
|
|
1118
1155
|
};
|
|
1156
|
+
type onServerErrorParameters = {
|
|
1157
|
+
source: string;
|
|
1158
|
+
code: string;
|
|
1159
|
+
message: string;
|
|
1160
|
+
};
|
|
1119
1161
|
type StatesArray = {
|
|
1120
1162
|
clientId: number;
|
|
1121
1163
|
[key: string | number]: any;
|
|
@@ -1202,6 +1244,33 @@ interface EffectivePermissionsResponse {
|
|
|
1202
1244
|
permissions: EffectivePermissionEntry[];
|
|
1203
1245
|
default_role: string;
|
|
1204
1246
|
}
|
|
1247
|
+
interface SnapshotMeta {
|
|
1248
|
+
version: number;
|
|
1249
|
+
size_bytes: number;
|
|
1250
|
+
trigger: string;
|
|
1251
|
+
label?: string | null;
|
|
1252
|
+
created_by?: string | null;
|
|
1253
|
+
created_at: number;
|
|
1254
|
+
}
|
|
1255
|
+
interface SnapshotData extends SnapshotMeta {
|
|
1256
|
+
data: string;
|
|
1257
|
+
}
|
|
1258
|
+
interface SnapshotCreateResult {
|
|
1259
|
+
version: number;
|
|
1260
|
+
size_bytes: number;
|
|
1261
|
+
label?: string | null;
|
|
1262
|
+
}
|
|
1263
|
+
interface SnapshotRestoreResult {
|
|
1264
|
+
restored_from: number;
|
|
1265
|
+
diff_bytes: number;
|
|
1266
|
+
}
|
|
1267
|
+
interface SnapshotForkResult {
|
|
1268
|
+
doc_id: string;
|
|
1269
|
+
forked_from: {
|
|
1270
|
+
doc_id: string;
|
|
1271
|
+
version: number;
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1205
1274
|
interface HealthStatus {
|
|
1206
1275
|
status: string;
|
|
1207
1276
|
version: string;
|
|
@@ -1455,6 +1524,7 @@ interface CompleteAbracadabraBaseProviderConfiguration {
|
|
|
1455
1524
|
onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
|
|
1456
1525
|
onAwarenessChange: (data: onAwarenessChangeParameters) => void;
|
|
1457
1526
|
onStateless: (data: onStatelessParameters) => void;
|
|
1527
|
+
onServerError: (data: onServerErrorParameters) => void;
|
|
1458
1528
|
onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
|
|
1459
1529
|
}
|
|
1460
1530
|
/** @deprecated Use CompleteAbracadabraBaseProviderConfiguration */
|
|
@@ -1768,19 +1838,26 @@ declare function makeEncryptedYText(ydoc: Y.Doc, fieldName: string, docKey: Cryp
|
|
|
1768
1838
|
//#endregion
|
|
1769
1839
|
//#region packages/provider/src/TreeTimestamps.d.ts
|
|
1770
1840
|
/**
|
|
1771
|
-
* Attach an observer that writes `updatedAt
|
|
1772
|
-
*
|
|
1773
|
-
* 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.
|
|
1774
1843
|
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
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.
|
|
1778
1850
|
* @param offlineStore The child provider's OfflineStore (used to detect
|
|
1779
1851
|
* offline-replay origins and skip them). Pass null when
|
|
1780
1852
|
* the offline store is disabled.
|
|
1781
|
-
* @
|
|
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.
|
|
1782
1857
|
*/
|
|
1783
|
-
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;
|
|
1784
1861
|
//#endregion
|
|
1785
1862
|
//#region packages/provider/src/BackgroundSyncPersistence.d.ts
|
|
1786
1863
|
/**
|
|
@@ -2816,4 +2893,4 @@ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promi
|
|
|
2816
2893
|
*/
|
|
2817
2894
|
declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
|
|
2818
2895
|
//#endregion
|
|
2819
|
-
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, 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
|
|
package/src/AbracadabraClient.ts
CHANGED
|
@@ -10,6 +10,11 @@ import type {
|
|
|
10
10
|
ServerInfo,
|
|
11
11
|
InviteRow,
|
|
12
12
|
SpaceMeta,
|
|
13
|
+
SnapshotMeta,
|
|
14
|
+
SnapshotData,
|
|
15
|
+
SnapshotCreateResult,
|
|
16
|
+
SnapshotRestoreResult,
|
|
17
|
+
SnapshotForkResult,
|
|
13
18
|
} from "./types.ts";
|
|
14
19
|
import type { DocEncryptionInfo } from "./types.ts";
|
|
15
20
|
import type { DocumentCache } from "./DocumentCache.ts";
|
|
@@ -699,6 +704,56 @@ export class AbracadabraClient {
|
|
|
699
704
|
return this.request("POST", "/admin/storage/repair");
|
|
700
705
|
}
|
|
701
706
|
|
|
707
|
+
// ── Snapshots ────────────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
/** List snapshot metadata for a document. */
|
|
710
|
+
async listSnapshots(docId: string, opts?: { limit?: number; offset?: number }): Promise<SnapshotMeta[]> {
|
|
711
|
+
const params = new URLSearchParams();
|
|
712
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
713
|
+
if (opts?.offset != null) params.set("offset", String(opts.offset));
|
|
714
|
+
const qs = params.toString();
|
|
715
|
+
const res = await this.request<{ snapshots: SnapshotMeta[] }>(
|
|
716
|
+
"GET", `/docs/${encodeURIComponent(docId)}/snapshots${qs ? `?${qs}` : ""}`,
|
|
717
|
+
);
|
|
718
|
+
return res.snapshots;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Fetch a single snapshot including its base64-encoded data blob. */
|
|
722
|
+
async getSnapshot(docId: string, version: number): Promise<SnapshotData> {
|
|
723
|
+
return this.request<SnapshotData>(
|
|
724
|
+
"GET", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Create a manual snapshot of the current document state. */
|
|
729
|
+
async createSnapshot(docId: string, opts?: { label?: string }): Promise<SnapshotCreateResult> {
|
|
730
|
+
return this.request<SnapshotCreateResult>(
|
|
731
|
+
"POST", `/docs/${encodeURIComponent(docId)}/snapshots`,
|
|
732
|
+
{ body: opts ?? {} },
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Delete a specific snapshot version. Requires manage permission. */
|
|
737
|
+
async deleteSnapshot(docId: string, version: number): Promise<void> {
|
|
738
|
+
await this.request("DELETE", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Restore a snapshot by merging it forward into the current document state. */
|
|
742
|
+
async restoreSnapshot(docId: string, version: number): Promise<SnapshotRestoreResult> {
|
|
743
|
+
return this.request<SnapshotRestoreResult>(
|
|
744
|
+
"POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/restore`,
|
|
745
|
+
{ body: {} },
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Fork a snapshot into a new document. */
|
|
750
|
+
async forkSnapshot(docId: string, version: number): Promise<SnapshotForkResult> {
|
|
751
|
+
return this.request<SnapshotForkResult>(
|
|
752
|
+
"POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/fork`,
|
|
753
|
+
{ body: {} },
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
702
757
|
// ── System ───────────────────────────────────────────────────────────────
|
|
703
758
|
|
|
704
759
|
/** Health check — no auth required. */
|
|
@@ -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 ────────────────────────────────────────────────────
|
|
@@ -226,6 +232,37 @@ export interface EffectivePermissionsResponse {
|
|
|
226
232
|
default_role: string;
|
|
227
233
|
}
|
|
228
234
|
|
|
235
|
+
// ── Snapshots ────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export interface SnapshotMeta {
|
|
238
|
+
version: number;
|
|
239
|
+
size_bytes: number;
|
|
240
|
+
trigger: string;
|
|
241
|
+
label?: string | null;
|
|
242
|
+
created_by?: string | null;
|
|
243
|
+
created_at: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface SnapshotData extends SnapshotMeta {
|
|
247
|
+
data: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface SnapshotCreateResult {
|
|
251
|
+
version: number;
|
|
252
|
+
size_bytes: number;
|
|
253
|
+
label?: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface SnapshotRestoreResult {
|
|
257
|
+
restored_from: number;
|
|
258
|
+
diff_bytes: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface SnapshotForkResult {
|
|
262
|
+
doc_id: string;
|
|
263
|
+
forked_from: { doc_id: string; version: number };
|
|
264
|
+
}
|
|
265
|
+
|
|
229
266
|
export interface HealthStatus {
|
|
230
267
|
status: string;
|
|
231
268
|
version: string;
|