@abraca/dabra 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -289,6 +289,11 @@ declare class AbracadabraClient {
289
289
  private readonly _fetch;
290
290
  readonly cache: DocumentCache | null;
291
291
  private readonly _onAuthFailed;
292
+ /** Single-flight for root listings (`/docs?root=true`) — the per-row
293
+ * permission cascade makes this a multi-second / hundreds-of-KB call on
294
+ * a busy server, and a connect burst fires several identical ones.
295
+ * Keyed by the kind filter; cleared when the request settles. */
296
+ private readonly rootListInflight;
292
297
  constructor(config: AbracadabraClientConfig);
293
298
  get token(): string | null;
294
299
  set token(value: string | null);
@@ -576,7 +581,9 @@ declare class AbracadabraClient {
576
581
  * recursive tree walks; callers that need it can read `meta.id` from
577
582
  * the returned metas.
578
583
  */
579
- listChildren(parentId?: string): Promise<DocumentMeta[]>;
584
+ listChildren(parentId?: string, opts?: {
585
+ kind?: string;
586
+ }): Promise<DocumentMeta[]>;
580
587
  /**
581
588
  * Create a child document under a parent (requires write permission).
582
589
  *
@@ -729,6 +736,64 @@ declare class AbracadabraClient {
729
736
  refCountsRepaired: number;
730
737
  blobsSwept: number;
731
738
  }>;
739
+ /**
740
+ * Admin one-shot: populate the `snapshot_files` table for snapshots
741
+ * created before that migration ran, so `SnapshotMeta.file_count` and
742
+ * upload-ref tracking become accurate. Idempotent (insert-or-ignore).
743
+ * Requires elevated role.
744
+ */
745
+ adminSnapshotsBackfillRefs(): Promise<{
746
+ snapshotsScanned: number;
747
+ refsWritten: number;
748
+ }>;
749
+ /**
750
+ * Admin one-shot: migrate pre-dedup inline snapshot data into the
751
+ * content-addressed `snapshot_blobs` store. Idempotent (only migrates
752
+ * rows with `data_hash IS NULL`). Requires elevated role.
753
+ */
754
+ adminSnapshotsBackfillBlobs(): Promise<{
755
+ snapshotsMigrated: number;
756
+ blobsAfter: number;
757
+ totalBytesAfter: number;
758
+ }>;
759
+ /**
760
+ * Admin: server-wide upload listing, joined with the owning document
761
+ * (label is best-effort — labels live in the CRDT, not the SQL row)
762
+ * and the content-addressed blob (`ref_count` exposes dedup).
763
+ * Server-side paginated + filtered. Requires elevated role.
764
+ */
765
+ adminListUploads(opts?: {
766
+ q?: string;
767
+ docId?: string;
768
+ limit?: number;
769
+ offset?: number;
770
+ }): Promise<{
771
+ uploads: Array<{
772
+ id: string;
773
+ doc_id: string;
774
+ doc_label: string | null;
775
+ filename: string;
776
+ mime_type: string | null;
777
+ size: number | null;
778
+ content_hash: string | null;
779
+ ref_count: number | null;
780
+ owner_id: string;
781
+ created_at: number;
782
+ }>;
783
+ total: number;
784
+ }>;
785
+ /**
786
+ * Admin: aggregate storage figures. `logicalBytes` is what users
787
+ * uploaded; `physicalBytes` is on-disk after content-addressed dedup;
788
+ * `dedupSaved` is the difference. Requires elevated role.
789
+ */
790
+ adminStorageStats(): Promise<{
791
+ uploadCount: number;
792
+ blobCount: number;
793
+ logicalBytes: number;
794
+ physicalBytes: number;
795
+ dedupSaved: number;
796
+ }>;
732
797
  /**
733
798
  * Clear the lockout state on a user account: zeroes the failed-login
734
799
  * counter and `locked_until`. Requires elevated role (Admin or
@@ -736,6 +801,26 @@ declare class AbracadabraClient {
736
801
  * `admin.user_unlock`.
737
802
  */
738
803
  adminUnlockUser(userId: string): Promise<void>;
804
+ /**
805
+ * Admin: every non-deleted document the user owns (`source: "owner"`)
806
+ * or has an explicit permission grant on (`source: "grant"`, with
807
+ * `role`). Answers "what does this identity touch" without an N+1
808
+ * client tree walk. Labels are best-effort (they live in the CRDT).
809
+ * Requires elevated role.
810
+ */
811
+ adminUserDocs(userId: string, opts?: {
812
+ limit?: number;
813
+ }): Promise<{
814
+ docs: Array<{
815
+ id: string;
816
+ label: string | null;
817
+ kind: string | null;
818
+ doc_type: string | null;
819
+ parent_id: string | null;
820
+ source: "owner" | "grant";
821
+ role: string | null;
822
+ }>;
823
+ }>;
739
824
  /**
740
825
  * Page through the audit log. Filters AND-combine; `limit` defaults to
741
826
  * 100 server-side. Requires elevated role.
@@ -799,6 +884,22 @@ declare class AbracadabraClient {
799
884
  * Secrets are redacted (`value: null`, `redacted: true`).
800
885
  */
801
886
  adminConfigEnvSnapshot(): Promise<EnvSnapshotResponse>;
887
+ /**
888
+ * List every route pattern that currently has at least one per-route
889
+ * config override. Use {@link adminConfigGetRoute} to read individual
890
+ * fields. Requires elevated role.
891
+ */
892
+ adminConfigListRoutes(): Promise<string[]>;
893
+ /**
894
+ * Read a field's effective value scoped to `route`, falling back to
895
+ * the global value when no per-route override exists. `origin_kind`
896
+ * is `"route_override"` only when an override is actually set.
897
+ */
898
+ adminConfigGetRoute(route: string, path: string): Promise<AdminConfigField>;
899
+ /** Set or replace a per-route override. Mirrors {@link adminConfigSet}. */
900
+ adminConfigSetRoute(route: string, path: string, value: unknown): Promise<AdminConfigField>;
901
+ /** Clear a per-route override (falls back to global). True if one existed. */
902
+ adminConfigUnsetRoute(route: string, path: string): Promise<boolean>;
802
903
  /** List snapshot metadata for a document. */
803
904
  listSnapshots(docId: string, opts?: {
804
905
  limit?: number;
@@ -4651,17 +4752,67 @@ declare class TypedDocTypeMismatchError extends Error {
4651
4752
  }
4652
4753
  //#endregion
4653
4754
  //#region packages/provider/src/TreeManager.d.ts
4755
+ interface ChildrenPage {
4756
+ entries: TreeEntry[];
4757
+ /** Pass back as `cursor` for the next page; `null` when exhausted. */
4758
+ nextCursor: string | null;
4759
+ }
4654
4760
  declare class TreeManager {
4655
4761
  private dm;
4656
4762
  constructor(dm: DocumentManager);
4763
+ private _idxMap;
4764
+ private _idxObserver;
4765
+ private _idxDirty;
4766
+ private _byId;
4767
+ private _childrenByParent;
4768
+ /**
4769
+ * Ensure the index is enabled, bound to the current root doc's tree
4770
+ * map, and fresh. Returns `false` when the index is disabled or there
4771
+ * is no tree map yet — callers then use the legacy scan path.
4772
+ */
4773
+ private ensureIndex;
4774
+ private unbindIndex;
4775
+ private rebuildIndex;
4776
+ /**
4777
+ * Release the deep observer. Optional — the observer is auto-rebound
4778
+ * on space switch and becomes moot when the root Y.Doc is GC'd — but
4779
+ * available for consumers that want deterministic teardown.
4780
+ */
4781
+ dispose(): void;
4657
4782
  /** Read all tree entries as plain objects. */
4658
4783
  readEntries(): TreeEntry[];
4784
+ /**
4785
+ * Like {@link readEntries} but with every entry's *stored* parentId
4786
+ * run through {@link normalizeRootId} (parentId === rootDocId → null),
4787
+ * so a cou-sh / orphan-rescue top-level doc (parentId === spaceRoot)
4788
+ * resolves to top-level identically to a provider-created one
4789
+ * (parentId: null). Without this, the raw `parentId === spaceRoot`
4790
+ * form never matches the normalized `null` query and such docs are
4791
+ * silently invisible cross-client. Mirrors the Rust provider's
4792
+ * `normalized_entries`. readEntries/get keep raw values for
4793
+ * round-trip consumers; only tree-walk reads use this.
4794
+ */
4795
+ private normalizedEntries;
4659
4796
  /** Get immediate children of a parent (sorted by order). */
4660
4797
  childrenOf(parentId: string | null): TreeEntry[];
4798
+ /**
4799
+ * Paginated immediate children — the Path-1 surface for large fan-out
4800
+ * parents. Walks the same stable (order,id) sibling order as
4801
+ * {@link childrenOf}; `cursor` is opaque (round-trip `nextCursor`).
4802
+ * `limit` defaults to 100. A stale/garbage cursor restarts from the
4803
+ * head rather than throwing. Cursor stability is exact when the index
4804
+ * is enabled; on the legacy scan path siblings with equal `order`
4805
+ * may shift between calls.
4806
+ */
4807
+ childrenOfPage(parentId: string | null, opts?: {
4808
+ limit?: number;
4809
+ cursor?: string | null;
4810
+ }): ChildrenPage;
4661
4811
  /** Get all descendants recursively. */
4662
4812
  descendantsOf(parentId: string | null): TreeEntry[];
4663
4813
  /** Build nested tree JSON. */
4664
4814
  buildTree(rootId?: string | null, maxDepth?: number): TreeNode[];
4815
+ private _buildTreeIndexed;
4665
4816
  private _buildTree;
4666
4817
  /**
4667
4818
  * Schema-typed lookup. Returns a `TypedTreeEntry<TMap, N>` when the
@@ -4933,6 +5084,16 @@ interface DocumentManagerConfig {
4933
5084
  * the entry-point docId is already known.
4934
5085
  */
4935
5086
  rootDocId?: string;
5087
+ /**
5088
+ * PROTOTYPE (Path-1 doctree scaling): opt into TreeManager's in-memory
5089
+ * parent→children index. When `false` (default), every tree walk
5090
+ * re-scans the whole `doc-tree` Y.Map (O(n) per call, O(n²) recursive
5091
+ * traversal) — the historical behaviour, byte-for-byte. When `true`,
5092
+ * walks resolve against a lazily-rebuilt adjacency index (O(k) per
5093
+ * lookup, O(result) traversal) and `tree.childrenOfPage()` becomes
5094
+ * usable. Behind a flag so it ships dark until benchmarked.
5095
+ */
5096
+ treeIndex?: boolean;
4936
5097
  }
4937
5098
  declare class DocumentManager {
4938
5099
  readonly client: AbracadabraClient;
@@ -4968,6 +5129,11 @@ declare class DocumentManager {
4968
5129
  get displayColor(): string;
4969
5130
  get serverInfo(): ServerInfo | null;
4970
5131
  get rootDocId(): string | null;
5132
+ /**
5133
+ * Whether the TreeManager in-memory index is enabled (Path-1 prototype).
5134
+ * Off by default — see {@link DocumentManagerConfig.treeIndex}.
5135
+ */
5136
+ get treeIndexEnabled(): boolean;
4971
5137
  get rootDocument(): Y.Doc | null;
4972
5138
  get rootProvider(): AbracadabraProvider | null;
4973
5139
  /**
@@ -5030,5 +5196,29 @@ declare function normalizeRootId(id: string | null | undefined, rootDocId: strin
5030
5196
  * Safely read a tree map value, converting Y.Map to plain object if needed.
5031
5197
  */
5032
5198
  declare function toPlain(val: unknown): unknown;
5199
+ /**
5200
+ * Build a tree/trash entry as a nested `Y.Map`. Use for a brand-new or
5201
+ * re-created key (create / duplicate / restore) where no concurrent
5202
+ * writer exists, so a whole-value write is safe. `undefined` fields are
5203
+ * omitted; `null` is kept (a real value, e.g. top-level `parentId`).
5204
+ */
5205
+ declare function makeEntryMap(fields: Record<string, unknown>): Y.Map<unknown>;
5206
+ /**
5207
+ * Patch an EXISTING entry's fields per-key on its nested `Y.Map`, so a
5208
+ * concurrent edit to a *different* field by a peer is preserved instead
5209
+ * of being clobbered by a whole-entry write — the whole-entry-LWW fix
5210
+ * (audit ⑦), the mirror of the Rust provider's `with_entry_mut`.
5211
+ *
5212
+ * - nested `Y.Map` entry → set/delete only the touched keys in place;
5213
+ * - legacy opaque (plain-object) entry → migrated once to a `Y.Map`;
5214
+ * - missing entry → created from the patch (lenient; matches the prior
5215
+ * call-site behaviour of spreading `undefined`).
5216
+ *
5217
+ * A patch value of `undefined` deletes the key; `null` is written.
5218
+ * Self-transacting: it batches its writes in one `Y.Doc` transaction
5219
+ * (a safe reentrant no-op join when already inside one), so callers
5220
+ * don't need to pass or own a transaction.
5221
+ */
5222
+ declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<string, unknown>, removeKeys?: string[]): void;
5033
5223
  //#endregion
5034
- 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, 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, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onCompactedParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, parseFrontmatter, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
5224
+ 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, 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.0",
3
+ "version": "2.5.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.3.0"
44
+ "@abraca/schema": "2.5.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -95,6 +95,14 @@ export class AbracadabraClient {
95
95
  private readonly _fetch: typeof globalThis.fetch;
96
96
  readonly cache: DocumentCache | null;
97
97
  private readonly _onAuthFailed: ((ctx: AuthFailureContext) => void) | null;
98
+ /** Single-flight for root listings (`/docs?root=true`) — the per-row
99
+ * permission cascade makes this a multi-second / hundreds-of-KB call on
100
+ * a busy server, and a connect burst fires several identical ones.
101
+ * Keyed by the kind filter; cleared when the request settles. */
102
+ private readonly rootListInflight = new Map<
103
+ string,
104
+ Promise<DocumentMeta[]>
105
+ >();
98
106
 
99
107
  constructor(config: AbracadabraClientConfig) {
100
108
  this.baseUrl = config.url.replace(/\/+$/, "");
@@ -644,18 +652,50 @@ export class AbracadabraClient {
644
652
  * recursive tree walks; callers that need it can read `meta.id` from
645
653
  * the returned metas.
646
654
  */
647
- async listChildren(parentId?: string): Promise<DocumentMeta[]> {
648
- const path = parentId
649
- ? `/docs/${encodeURIComponent(parentId)}/children`
650
- : "/docs?root=true";
651
- const res = await this.request<{ documents: DocumentMeta[]; children?: string[] }>(
652
- "GET",
653
- path,
654
- );
655
- if (this.cache && parentId && res.children) {
656
- await this.cache.setChildren(parentId, res.children).catch(() => null);
655
+ async listChildren(
656
+ parentId?: string,
657
+ opts?: { kind?: string },
658
+ ): Promise<DocumentMeta[]> {
659
+ const kind = opts?.kind;
660
+ if (parentId) {
661
+ const res = await this.request<{
662
+ documents: DocumentMeta[];
663
+ children?: string[];
664
+ }>("GET", `/docs/${encodeURIComponent(parentId)}/children`);
665
+ if (this.cache && res.children) {
666
+ await this.cache.setChildren(parentId, res.children).catch(() => null);
667
+ }
668
+ return kind
669
+ ? res.documents.filter((d) => d.kind === kind)
670
+ : res.documents;
657
671
  }
658
- return res.documents;
672
+ // Root listing. The server runs the per-row permission cascade for
673
+ // every top-level doc, so this is the expensive call. Send the
674
+ // optional server-side `kind` filter — a fixed server skips the
675
+ // cascade for non-matching docs; an old server ignores the unknown
676
+ // param and we still filter client-side below, so the result is
677
+ // identical either way. Concurrent identical root listings (a
678
+ // connect fans out several) share one in-flight request.
679
+ const key = kind ?? "";
680
+ const existing = this.rootListInflight.get(key);
681
+ const docs = existing
682
+ ? await existing
683
+ : await (() => {
684
+ const p = this.request<{
685
+ documents: DocumentMeta[];
686
+ children?: string[];
687
+ }>(
688
+ "GET",
689
+ kind
690
+ ? `/docs?root=true&kind=${encodeURIComponent(kind)}`
691
+ : "/docs?root=true",
692
+ )
693
+ .then((res) => res.documents)
694
+ .finally(() => this.rootListInflight.delete(key));
695
+ this.rootListInflight.set(key, p);
696
+ return p;
697
+ })();
698
+ return kind ? docs.filter((d) => d.kind === kind) : docs;
659
699
  }
660
700
 
661
701
  /**
@@ -889,8 +929,7 @@ export class AbracadabraClient {
889
929
  * spaces resolving to any role; anonymous users see public ones.
890
930
  */
891
931
  async listSpaces(): Promise<DocumentMeta[]> {
892
- const docs = await this.listChildren();
893
- return docs.filter((d) => d.kind === Kind.Space);
932
+ return this.listChildren(undefined, { kind: Kind.Space });
894
933
  }
895
934
 
896
935
  /**
@@ -1044,6 +1083,79 @@ export class AbracadabraClient {
1044
1083
  return this.request("POST", "/admin/storage/repair");
1045
1084
  }
1046
1085
 
1086
+ /**
1087
+ * Admin one-shot: populate the `snapshot_files` table for snapshots
1088
+ * created before that migration ran, so `SnapshotMeta.file_count` and
1089
+ * upload-ref tracking become accurate. Idempotent (insert-or-ignore).
1090
+ * Requires elevated role.
1091
+ */
1092
+ async adminSnapshotsBackfillRefs(): Promise<{
1093
+ snapshotsScanned: number;
1094
+ refsWritten: number;
1095
+ }> {
1096
+ return this.request("POST", "/admin/snapshots/backfill-refs");
1097
+ }
1098
+
1099
+ /**
1100
+ * Admin one-shot: migrate pre-dedup inline snapshot data into the
1101
+ * content-addressed `snapshot_blobs` store. Idempotent (only migrates
1102
+ * rows with `data_hash IS NULL`). Requires elevated role.
1103
+ */
1104
+ async adminSnapshotsBackfillBlobs(): Promise<{
1105
+ snapshotsMigrated: number;
1106
+ blobsAfter: number;
1107
+ totalBytesAfter: number;
1108
+ }> {
1109
+ return this.request("POST", "/admin/snapshots/backfill-blobs");
1110
+ }
1111
+
1112
+ /**
1113
+ * Admin: server-wide upload listing, joined with the owning document
1114
+ * (label is best-effort — labels live in the CRDT, not the SQL row)
1115
+ * and the content-addressed blob (`ref_count` exposes dedup).
1116
+ * Server-side paginated + filtered. Requires elevated role.
1117
+ */
1118
+ async adminListUploads(
1119
+ opts: { q?: string; docId?: string; limit?: number; offset?: number } = {},
1120
+ ): Promise<{
1121
+ uploads: Array<{
1122
+ id: string;
1123
+ doc_id: string;
1124
+ doc_label: string | null;
1125
+ filename: string;
1126
+ mime_type: string | null;
1127
+ size: number | null;
1128
+ content_hash: string | null;
1129
+ ref_count: number | null;
1130
+ owner_id: string;
1131
+ created_at: number;
1132
+ }>;
1133
+ total: number;
1134
+ }> {
1135
+ const p = new URLSearchParams();
1136
+ if (opts.q) p.set("q", opts.q);
1137
+ if (opts.docId) p.set("doc_id", opts.docId);
1138
+ if (opts.limit != null) p.set("limit", String(opts.limit));
1139
+ if (opts.offset != null) p.set("offset", String(opts.offset));
1140
+ const qs = p.toString();
1141
+ return this.request("GET", `/admin/uploads${qs ? `?${qs}` : ""}`);
1142
+ }
1143
+
1144
+ /**
1145
+ * Admin: aggregate storage figures. `logicalBytes` is what users
1146
+ * uploaded; `physicalBytes` is on-disk after content-addressed dedup;
1147
+ * `dedupSaved` is the difference. Requires elevated role.
1148
+ */
1149
+ async adminStorageStats(): Promise<{
1150
+ uploadCount: number;
1151
+ blobCount: number;
1152
+ logicalBytes: number;
1153
+ physicalBytes: number;
1154
+ dedupSaved: number;
1155
+ }> {
1156
+ return this.request("GET", "/admin/storage/stats");
1157
+ }
1158
+
1047
1159
  /**
1048
1160
  * Clear the lockout state on a user account: zeroes the failed-login
1049
1161
  * counter and `locked_until`. Requires elevated role (Admin or
@@ -1054,6 +1166,34 @@ export class AbracadabraClient {
1054
1166
  await this.request("POST", `/admin/users/${encodeURIComponent(userId)}/unlock`);
1055
1167
  }
1056
1168
 
1169
+ /**
1170
+ * Admin: every non-deleted document the user owns (`source: "owner"`)
1171
+ * or has an explicit permission grant on (`source: "grant"`, with
1172
+ * `role`). Answers "what does this identity touch" without an N+1
1173
+ * client tree walk. Labels are best-effort (they live in the CRDT).
1174
+ * Requires elevated role.
1175
+ */
1176
+ async adminUserDocs(
1177
+ userId: string,
1178
+ opts: { limit?: number } = {},
1179
+ ): Promise<{
1180
+ docs: Array<{
1181
+ id: string;
1182
+ label: string | null;
1183
+ kind: string | null;
1184
+ doc_type: string | null;
1185
+ parent_id: string | null;
1186
+ source: "owner" | "grant";
1187
+ role: string | null;
1188
+ }>;
1189
+ }> {
1190
+ const qs = opts.limit != null ? `?limit=${opts.limit}` : "";
1191
+ return this.request(
1192
+ "GET",
1193
+ `/admin/users/${encodeURIComponent(userId)}/docs${qs}`,
1194
+ );
1195
+ }
1196
+
1057
1197
  /**
1058
1198
  * Page through the audit log. Filters AND-combine; `limit` defaults to
1059
1199
  * 100 server-side. Requires elevated role.
@@ -1203,6 +1343,48 @@ export class AbracadabraClient {
1203
1343
  return this.request<EnvSnapshotResponse>("GET", "/admin/config/env-snapshot");
1204
1344
  }
1205
1345
 
1346
+ /**
1347
+ * List every route pattern that currently has at least one per-route
1348
+ * config override. Use {@link adminConfigGetRoute} to read individual
1349
+ * fields. Requires elevated role.
1350
+ */
1351
+ async adminConfigListRoutes(): Promise<string[]> {
1352
+ const res = await this.request<{ routes: string[] }>(
1353
+ "GET", "/admin/config/routes",
1354
+ );
1355
+ return res.routes;
1356
+ }
1357
+
1358
+ /**
1359
+ * Read a field's effective value scoped to `route`, falling back to
1360
+ * the global value when no per-route override exists. `origin_kind`
1361
+ * is `"route_override"` only when an override is actually set.
1362
+ */
1363
+ async adminConfigGetRoute(route: string, path: string): Promise<AdminConfigField> {
1364
+ return this.request<AdminConfigField>(
1365
+ "GET",
1366
+ `/admin/config/routes/${encodeURIComponent(route)}/fields/${encodeURIComponent(path)}`,
1367
+ );
1368
+ }
1369
+
1370
+ /** Set or replace a per-route override. Mirrors {@link adminConfigSet}. */
1371
+ async adminConfigSetRoute(route: string, path: string, value: unknown): Promise<AdminConfigField> {
1372
+ return this.request<AdminConfigField>(
1373
+ "PUT",
1374
+ `/admin/config/routes/${encodeURIComponent(route)}/fields/${encodeURIComponent(path)}`,
1375
+ { body: { value } },
1376
+ );
1377
+ }
1378
+
1379
+ /** Clear a per-route override (falls back to global). True if one existed. */
1380
+ async adminConfigUnsetRoute(route: string, path: string): Promise<boolean> {
1381
+ const res = await this.request<{ existed: boolean }>(
1382
+ "DELETE",
1383
+ `/admin/config/routes/${encodeURIComponent(route)}/fields/${encodeURIComponent(path)}`,
1384
+ );
1385
+ return res.existed;
1386
+ }
1387
+
1206
1388
  // ── Snapshots ────────────────────────────────────────────────────────────
1207
1389
 
1208
1390
  /** List snapshot metadata for a document. */
@@ -712,30 +712,39 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
712
712
  * errors across async await boundaries.
713
713
  */
714
714
  private async flushPendingUpdates() {
715
- if (!this.canWrite) return;
716
715
  const store = this.offlineStore;
717
716
  if (!store) return;
718
717
 
719
- const updates = await store.getPendingUpdates();
720
- if (updates.length > 0) {
721
- for (const update of updates) {
722
- this.send(UpdateMessage, {
723
- update,
724
- documentName: this.configuration.name,
725
- });
718
+ // Flushing queued *local* writes / subdoc registrations over the wire
719
+ // is only meaningful for writers — a read-only session has none and
720
+ // must not send. The snapshot save below is deliberately OUTSIDE this
721
+ // guard.
722
+ if (this.canWrite) {
723
+ const updates = await store.getPendingUpdates();
724
+ if (updates.length > 0) {
725
+ for (const update of updates) {
726
+ this.send(UpdateMessage, {
727
+ update,
728
+ documentName: this.configuration.name,
729
+ });
730
+ }
731
+ await store.clearPendingUpdates();
726
732
  }
727
- await store.clearPendingUpdates();
728
- }
729
733
 
730
- const pendingSubdocs = await store.getPendingSubdocs();
731
- for (const { childId } of pendingSubdocs) {
732
- this.send(SubdocMessage, {
733
- documentName: this.configuration.name,
734
- childDocumentName: childId,
735
- } as any);
734
+ const pendingSubdocs = await store.getPendingSubdocs();
735
+ for (const { childId } of pendingSubdocs) {
736
+ this.send(SubdocMessage, {
737
+ documentName: this.configuration.name,
738
+ childDocumentName: childId,
739
+ } as any);
740
+ }
736
741
  }
737
742
 
738
743
  // Snapshot the current merged state so the next offline load sees it.
744
+ // This is the ONLY path that persists server-delivered content
745
+ // (documentUpdateHandler skips origin===this), so it MUST run for
746
+ // read-only roles too — otherwise observers/viewers get a permanently
747
+ // empty offline cache and every cold start waits for a full re-sync.
739
748
  const snapshot = Y.encodeStateAsUpdate(this.document);
740
749
  await store.saveDocSnapshot(snapshot).catch(() => null);
741
750
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as Y from "yjs";
7
7
  import type { PageMeta } from "./DocTypes.ts";
8
- import { toPlain } from "./DocUtils.ts";
8
+ import { toPlain, patchEntry } from "./DocUtils.ts";
9
9
  import {
10
10
  yjsToMarkdown,
11
11
  populateYDocFromMarkdown,
@@ -51,11 +51,9 @@ export class ContentManager {
51
51
  const provider = await this.dm.getChildProvider(docId);
52
52
  const fragment = provider.document.getXmlFragment("default");
53
53
 
54
- const { title, markdown } = yjsToMarkdown(fragment);
55
-
56
54
  // Get tree metadata + immediate children
57
55
  const treeMap = this.dm.getTreeMap();
58
- let label = title;
56
+ let label = "Untitled";
59
57
  let type: string | undefined;
60
58
  let meta: PageMeta | undefined;
61
59
  const childrenWithOrder: Array<{
@@ -70,7 +68,7 @@ export class ContentManager {
70
68
  const raw = treeMap.get(docId);
71
69
  if (raw) {
72
70
  const entry = toPlain(raw) as Record<string, unknown>;
73
- label = (entry.label as string) || title;
71
+ label = (entry.label as string) || label;
74
72
  type = entry.type as string | undefined;
75
73
  meta = entry.meta as PageMeta | undefined;
76
74
  }
@@ -97,6 +95,12 @@ export class ContentManager {
97
95
  meta,
98
96
  }));
99
97
 
98
+ // yjsToMarkdown returns a string and takes the resolved label/meta/type
99
+ // so frontmatter round-trips. (Older code destructured an object and
100
+ // passed only the fragment — that silently produced `undefined`.)
101
+ const markdown = yjsToMarkdown(fragment, label, meta, type);
102
+ const title = label;
103
+
100
104
  return { label, type, meta, title, markdown, children };
101
105
  }
102
106
 
@@ -127,18 +131,22 @@ export class ContentManager {
127
131
  const entry = treeMap.get(docId);
128
132
  if (entry) {
129
133
  rootDoc.transact(() => {
130
- const updates: Record<string, unknown> = {
131
- ...entry,
134
+ const cur = toPlain(entry) as Record<
135
+ string,
136
+ unknown
137
+ >;
138
+ const patch: Record<string, unknown> = {
132
139
  updatedAt: Date.now(),
133
140
  };
134
- if (title) updates.label = title;
141
+ if (title) patch.label = title;
135
142
  if (Object.keys(meta).length > 0) {
136
- updates.meta = {
137
- ...(entry.meta ?? {}),
143
+ patch.meta = {
144
+ ...((cur.meta as Record<string, unknown>) ??
145
+ {}),
138
146
  ...meta,
139
147
  };
140
148
  }
141
- treeMap.set(docId, updates);
149
+ patchEntry(treeMap, docId, patch);
142
150
  });
143
151
  }
144
152
  }