@abraca/dabra 0.5.0 → 0.7.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.
@@ -6,7 +6,9 @@ import type {
6
6
  PublicKeyInfo,
7
7
  PermissionEntry,
8
8
  HealthStatus,
9
+ ServerInfo,
9
10
  } from "./types.ts";
11
+ import type { DocumentCache } from "./DocumentCache.ts";
10
12
 
11
13
  export interface AbracadabraClientConfig {
12
14
  /** Server base URL (http or https). WebSocket URL is derived automatically. */
@@ -19,6 +21,13 @@ export interface AbracadabraClientConfig {
19
21
  storageKey?: string;
20
22
  /** Custom fetch implementation (useful for Node.js or testing). */
21
23
  fetch?: typeof globalThis.fetch;
24
+ /**
25
+ * Optional metadata cache. When provided, read methods (getDoc, listChildren,
26
+ * getMe, listPermissions, listUploads) check the cache before hitting the
27
+ * network. Write methods (deleteDoc, upload, deleteUpload) invalidate affected
28
+ * cache entries automatically.
29
+ */
30
+ cache?: DocumentCache;
22
31
  }
23
32
 
24
33
  export class AbracadabraClient {
@@ -27,12 +36,14 @@ export class AbracadabraClient {
27
36
  private readonly persistAuth: boolean;
28
37
  private readonly storageKey: string;
29
38
  private readonly _fetch: typeof globalThis.fetch;
39
+ readonly cache: DocumentCache | null;
30
40
 
31
41
  constructor(config: AbracadabraClientConfig) {
32
42
  this.baseUrl = config.url.replace(/\/+$/, "");
33
43
  this.persistAuth = config.persistAuth ?? typeof localStorage !== "undefined";
34
44
  this.storageKey = config.storageKey ?? "abracadabra:auth";
35
45
  this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
46
+ this.cache = config.cache ?? null;
36
47
 
37
48
  // Load token: explicit > persisted > null
38
49
  this._token = config.token ?? this.loadPersistedToken() ?? null;
@@ -175,7 +186,15 @@ export class AbracadabraClient {
175
186
 
176
187
  /** Get the current user's profile. */
177
188
  async getMe(): Promise<UserProfile> {
178
- return this.request<UserProfile>("GET", "/users/me");
189
+ if (this.cache) {
190
+ const cached = await this.cache.getCurrentProfile();
191
+ if (cached) return cached;
192
+ }
193
+ const profile = await this.request<UserProfile>("GET", "/users/me");
194
+ if (this.cache) {
195
+ await this.cache.setCurrentProfile(profile).catch(() => null);
196
+ }
197
+ return profile;
179
198
  }
180
199
 
181
200
  /** Update the current user's display name. */
@@ -192,20 +211,38 @@ export class AbracadabraClient {
192
211
 
193
212
  /** Get document metadata. */
194
213
  async getDoc(docId: string): Promise<DocumentMeta> {
195
- return this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
214
+ if (this.cache) {
215
+ const cached = await this.cache.getDoc(docId);
216
+ if (cached) return cached;
217
+ }
218
+ const meta = await this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
219
+ if (this.cache) {
220
+ await this.cache.setDoc(meta).catch(() => null);
221
+ }
222
+ return meta;
196
223
  }
197
224
 
198
225
  /** Delete a document (requires Owner role). Cascades to children and uploads. */
199
226
  async deleteDoc(docId: string): Promise<void> {
200
227
  await this.request("DELETE", `/docs/${encodeURIComponent(docId)}`);
228
+ if (this.cache) {
229
+ await this.cache.invalidateDoc(docId).catch(() => null);
230
+ }
201
231
  }
202
232
 
203
233
  /** List immediate child documents. */
204
234
  async listChildren(docId: string): Promise<string[]> {
235
+ if (this.cache) {
236
+ const cached = await this.cache.getChildren(docId);
237
+ if (cached) return cached;
238
+ }
205
239
  const res = await this.request<{ children: string[] }>(
206
240
  "GET",
207
241
  `/docs/${encodeURIComponent(docId)}/children`,
208
242
  );
243
+ if (this.cache) {
244
+ await this.cache.setChildren(docId, res.children).catch(() => null);
245
+ }
209
246
  return res.children;
210
247
  }
211
248
 
@@ -222,10 +259,17 @@ export class AbracadabraClient {
222
259
 
223
260
  /** List all permissions for a document (requires read access). */
224
261
  async listPermissions(docId: string): Promise<PermissionEntry[]> {
262
+ if (this.cache) {
263
+ const cached = await this.cache.getPermissions(docId);
264
+ if (cached) return cached;
265
+ }
225
266
  const res = await this.request<{ permissions: PermissionEntry[] }>(
226
267
  "GET",
227
268
  `/docs/${encodeURIComponent(docId)}/permissions`,
228
269
  );
270
+ if (this.cache) {
271
+ await this.cache.setPermissions(docId, res.permissions).catch(() => null);
272
+ }
229
273
  return res.permissions;
230
274
  }
231
275
 
@@ -273,15 +317,26 @@ export class AbracadabraClient {
273
317
  if (!res.ok) {
274
318
  throw await this.toError(res);
275
319
  }
276
- return res.json() as Promise<UploadMeta>;
320
+ const meta = await res.json() as UploadMeta;
321
+ if (this.cache) {
322
+ await this.cache.invalidateUploads(docId).catch(() => null);
323
+ }
324
+ return meta;
277
325
  }
278
326
 
279
327
  /** List all uploads for a document. */
280
328
  async listUploads(docId: string): Promise<UploadInfo[]> {
329
+ if (this.cache) {
330
+ const cached = await this.cache.getUploads(docId);
331
+ if (cached) return cached;
332
+ }
281
333
  const res = await this.request<{ uploads: UploadInfo[] }>(
282
334
  "GET",
283
335
  `/docs/${encodeURIComponent(docId)}/uploads`,
284
336
  );
337
+ if (this.cache) {
338
+ await this.cache.setUploads(docId, res.uploads).catch(() => null);
339
+ }
285
340
  return res.uploads;
286
341
  }
287
342
 
@@ -308,6 +363,9 @@ export class AbracadabraClient {
308
363
  "DELETE",
309
364
  `/docs/${encodeURIComponent(docId)}/uploads/${encodeURIComponent(uploadId)}`,
310
365
  );
366
+ if (this.cache) {
367
+ await this.cache.invalidateUploads(docId).catch(() => null);
368
+ }
311
369
  }
312
370
 
313
371
  // ── System ───────────────────────────────────────────────────────────────
@@ -317,6 +375,14 @@ export class AbracadabraClient {
317
375
  return this.request<HealthStatus>("GET", "/health", { auth: false });
318
376
  }
319
377
 
378
+ /**
379
+ * Fetch server metadata including the optional `index_doc_id` entry point.
380
+ * No auth required.
381
+ */
382
+ async serverInfo(): Promise<ServerInfo> {
383
+ return this.request<ServerInfo>("GET", "/info", { auth: false });
384
+ }
385
+
320
386
  // ── Internals ────────────────────────────────────────────────────────────
321
387
 
322
388
  private async request<T = void>(
@@ -1,7 +1,7 @@
1
1
  import * as Y from "yjs";
2
- import { HocuspocusProvider } from "./HocuspocusProvider.ts";
3
- import type { HocuspocusProviderConfiguration } from "./HocuspocusProvider.ts";
4
- import type { HocuspocusProviderWebsocket } from "./HocuspocusProviderWebsocket.ts";
2
+ import { AbracadabraBaseProvider } from "./AbracadabraBaseProvider.ts";
3
+ import type { AbracadabraBaseProviderConfiguration } from "./AbracadabraBaseProvider.ts";
4
+ import type { AbracadabraWS } from "./AbracadabraWS.ts";
5
5
  import { OfflineStore } from "./OfflineStore.ts";
6
6
  import { SubdocMessage } from "./OutgoingMessages/SubdocMessage.ts";
7
7
  import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
@@ -15,7 +15,7 @@ import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.
15
15
  import type { AbracadabraClient } from "./AbracadabraClient.ts";
16
16
 
17
17
  export interface AbracadabraProviderConfiguration
18
- extends Omit<HocuspocusProviderConfiguration, "url" | "websocketProvider"> {
18
+ extends Omit<AbracadabraBaseProviderConfiguration, "url" | "websocketProvider"> {
19
19
  /**
20
20
  * Subdocument loading strategy.
21
21
  * - "lazy" (default) – child providers are created only when explicitly requested.
@@ -58,7 +58,7 @@ export interface AbracadabraProviderConfiguration
58
58
  url?: string;
59
59
 
60
60
  /** Shared WebSocket connection (use when multiplexing multiple root documents). */
61
- websocketProvider?: HocuspocusProviderWebsocket;
61
+ websocketProvider?: AbracadabraWS;
62
62
  }
63
63
 
64
64
  /** Validate that a string is a UUID acceptable by the server's DocId parser. */
@@ -67,7 +67,7 @@ function isValidDocId(id: string): boolean {
67
67
  }
68
68
 
69
69
  /**
70
- * AbracadabraProvider extends HocuspocusProvider with:
70
+ * AbracadabraProvider extends AbracadabraBaseProvider with:
71
71
  *
72
72
  * 1. Subdocument lifecycle – intercepts Y.Doc subdoc events and syncs them
73
73
  * with the server via MSG_SUBDOC (4) frames. Child documents get their
@@ -87,7 +87,7 @@ function isValidDocId(id: string): boolean {
87
87
  * can gate write operations without a network round-trip. Role is
88
88
  * refreshed from the server on every reconnect.
89
89
  */
90
- export class AbracadabraProvider extends HocuspocusProvider {
90
+ export class AbracadabraProvider extends AbracadabraBaseProvider {
91
91
  public effectiveRole: EffectiveRole = null;
92
92
 
93
93
  private _client: AbracadabraClient | null;
@@ -110,7 +110,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
110
110
 
111
111
  constructor(configuration: AbracadabraProviderConfiguration) {
112
112
  // Derive URL and token from client when not explicitly set.
113
- const resolved = { ...configuration } as HocuspocusProviderConfiguration;
113
+ const resolved = { ...configuration } as AbracadabraBaseProviderConfiguration;
114
114
  const client = configuration.client ?? null;
115
115
 
116
116
  if (client) {
@@ -165,7 +165,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
165
165
  try {
166
166
  const url =
167
167
  config.url ??
168
- (config.websocketProvider as HocuspocusProviderWebsocket | undefined)?.url ??
168
+ (config.websocketProvider as AbracadabraWS | undefined)?.url ??
169
169
  client?.wsUrl;
170
170
  if (url) return new URL(url).hostname;
171
171
  } catch {
@@ -188,12 +188,16 @@ export class AbracadabraProvider extends HocuspocusProvider {
188
188
  private async _initFromOfflineStore(): Promise<void> {
189
189
  if (!this.offlineStore) return;
190
190
 
191
- const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
191
+ // Fetch snapshot and pending updates in parallel — each call opens the
192
+ // IDB database, so running them concurrently roughly halves the latency.
193
+ const [snapshot, pending] = await Promise.all([
194
+ this.offlineStore.getDocSnapshot().catch(() => null),
195
+ this.offlineStore.getPendingUpdates().catch(() => []),
196
+ ]);
197
+
192
198
  if (snapshot) {
193
199
  Y.applyUpdate(this.document, snapshot, this.offlineStore);
194
200
  }
195
-
196
- const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
197
201
  for (const update of pending) {
198
202
  Y.applyUpdate(this.document, update, this.offlineStore);
199
203
  }
@@ -425,7 +429,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
425
429
  this.registerSubdoc(childDoc);
426
430
 
427
431
  // Each child gets its own WebSocket connection. Omitting
428
- // websocketProvider lets HocuspocusProvider create one automatically
432
+ // websocketProvider lets AbracadabraBaseProvider create one automatically
429
433
  // (manageSocket = true), so we do NOT call attach() manually.
430
434
  const childProvider = new AbracadabraProvider({
431
435
  name: childId,
@@ -523,7 +527,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
523
527
 
524
528
  get isConnected(): boolean {
525
529
  return (
526
- (this.configuration.websocketProvider as HocuspocusProviderWebsocket)
530
+ (this.configuration.websocketProvider as AbracadabraWS)
527
531
  .status === "connected"
528
532
  );
529
533
  }
@@ -1,9 +1,9 @@
1
- import { WsReadyStates } from "@abraca/dabra-common";
1
+ import { WsReadyStates } from "./types.ts";
2
2
  import { retry } from "@lifeomic/attempt";
3
3
  import * as time from "lib0/time";
4
4
  import type { Event, MessageEvent } from "ws";
5
5
  import EventEmitter from "./EventEmitter.ts";
6
- import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
6
+ import type { AbracadabraBaseProvider } from "./AbracadabraBaseProvider.ts";
7
7
  import { IncomingMessage } from "./IncomingMessage.ts";
8
8
  import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts";
9
9
  import {
@@ -18,15 +18,21 @@ import {
18
18
  type onStatusParameters,
19
19
  } from "./types.ts";
20
20
 
21
- export type HocuspocusWebSocket = WebSocket & { identifier: string };
22
- export type HocusPocusWebSocket = HocuspocusWebSocket;
21
+ export type AbracadabraWebSocketConn = WebSocket & { identifier: string };
22
+ /** @deprecated Use AbracadabraWebSocketConn */
23
+ export type HocuspocusWebSocket = AbracadabraWebSocketConn;
24
+ /** @deprecated Use AbracadabraWebSocketConn */
25
+ export type HocusPocusWebSocket = AbracadabraWebSocketConn;
23
26
 
24
- export type HocuspocusProviderWebsocketConfiguration = Required<
25
- Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">
27
+ export type AbracadabraWSConfiguration = Required<
28
+ Pick<CompleteAbracadabraWSConfiguration, "url">
26
29
  > &
27
- Partial<CompleteHocuspocusProviderWebsocketConfiguration>;
30
+ Partial<CompleteAbracadabraWSConfiguration>;
28
31
 
29
- export interface CompleteHocuspocusProviderWebsocketConfiguration {
32
+ /** @deprecated Use AbracadabraWSConfiguration */
33
+ export type HocuspocusProviderWebsocketConfiguration = AbracadabraWSConfiguration;
34
+
35
+ export interface CompleteAbracadabraWSConfiguration {
30
36
  /**
31
37
  * Whether to connect automatically when creating the provider instance. Default=true
32
38
  */
@@ -99,13 +105,16 @@ export interface CompleteHocuspocusProviderWebsocketConfiguration {
99
105
  /**
100
106
  * Map of attached providers keyed by documentName.
101
107
  */
102
- providerMap: Map<string, HocuspocusProvider>;
108
+ providerMap: Map<string, AbracadabraBaseProvider>;
103
109
  }
104
110
 
105
- export class HocuspocusProviderWebsocket extends EventEmitter {
111
+ /** @deprecated Use CompleteAbracadabraWSConfiguration */
112
+ export type CompleteHocuspocusProviderWebsocketConfiguration = CompleteAbracadabraWSConfiguration;
113
+
114
+ export class AbracadabraWS extends EventEmitter {
106
115
  private messageQueue: any[] = [];
107
116
 
108
- public configuration: CompleteHocuspocusProviderWebsocketConfiguration = {
117
+ public configuration: CompleteAbracadabraWSConfiguration = {
109
118
  url: "",
110
119
  autoConnect: true,
111
120
  preserveTrailingSlash: false,
@@ -144,7 +153,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
144
153
  providerMap: new Map(),
145
154
  };
146
155
 
147
- webSocket: HocusPocusWebSocket | null = null;
156
+ webSocket: AbracadabraWebSocketConn | null = null;
148
157
 
149
158
  webSocketHandlers: { [key: string]: any } = {};
150
159
 
@@ -165,7 +174,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
165
174
  reject: (reason?: any) => void;
166
175
  } | null = null;
167
176
 
168
- constructor(configuration: HocuspocusProviderWebsocketConfiguration) {
177
+ constructor(configuration: AbracadabraWSConfiguration) {
169
178
  super();
170
179
  this.setConfiguration(configuration);
171
180
 
@@ -208,7 +217,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
208
217
  this.receivedOnOpenPayload = event;
209
218
  }
210
219
 
211
- attach(provider: HocuspocusProvider) {
220
+ attach(provider: AbracadabraBaseProvider) {
212
221
  this.configuration.providerMap.set(provider.configuration.name, provider);
213
222
 
214
223
  if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
@@ -220,7 +229,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
220
229
  }
221
230
  }
222
231
 
223
- detach(provider: HocuspocusProvider) {
232
+ detach(provider: AbracadabraBaseProvider) {
224
233
  if (this.configuration.providerMap.has(provider.configuration.name)) {
225
234
  provider.send(CloseMessage, {
226
235
  documentName: provider.configuration.name,
@@ -230,7 +239,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
230
239
  }
231
240
 
232
241
  public setConfiguration(
233
- configuration: Partial<HocuspocusProviderWebsocketConfiguration> = {},
242
+ configuration: Partial<AbracadabraWSConfiguration> = {},
234
243
  ): void {
235
244
  this.configuration = { ...this.configuration, ...configuration };
236
245
 
@@ -274,7 +283,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
274
283
  }
275
284
  },
276
285
  }).catch((error: any) => {
277
- // If we aborted the connection attempt then dont throw an error
286
+ // If we aborted the connection attempt then don't throw an error
278
287
  // ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
279
288
  if (error && error.code !== "ATTEMPT_ABORTED") {
280
289
  throw error;
@@ -296,7 +305,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
296
305
  }
297
306
 
298
307
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
299
- attachWebSocketListeners(ws: HocusPocusWebSocket, reject: Function) {
308
+ attachWebSocketListeners(ws: AbracadabraWebSocketConn, reject: Function) {
300
309
  const { identifier } = ws;
301
310
  const onMessageHandler = (payload: any) => this.emit("message", payload);
302
311
  const onCloseHandler = (payload: any) => this.emit("close", { event: payload });
@@ -410,17 +419,17 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
410
419
  closeTries = 0;
411
420
 
412
421
  checkConnection() {
413
- // Dont check the connection when its not even established
422
+ // Don't check the connection when it's not even established
414
423
  if (this.status !== WebSocketStatus.Connected) {
415
424
  return;
416
425
  }
417
426
 
418
- // Dont close the connection while waiting for the first message
427
+ // Don't close the connection while waiting for the first message
419
428
  if (!this.lastMessageReceived) {
420
429
  return;
421
430
  }
422
431
 
423
- // Dont close the connection when a message was received recently
432
+ // Don't close the connection when a message was received recently
424
433
  if (
425
434
  this.configuration.messageReconnectTimeout >=
426
435
  time.getUnixTime() - this.lastMessageReceived
@@ -497,7 +506,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
497
506
  this.rejectConnectionAttempt();
498
507
  }
499
508
 
500
- // Lets update the connection status.
509
+ // Let's update the connection status.
501
510
  this.status = WebSocketStatus.Disconnected;
502
511
  this.emit("status", { status: WebSocketStatus.Disconnected });
503
512
 
@@ -535,3 +544,8 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
535
544
  this.cleanupWebSocket();
536
545
  }
537
546
  }
547
+
548
+ /** @deprecated Use AbracadabraWS */
549
+ export const HocuspocusProviderWebsocket = AbracadabraWS;
550
+ /** @deprecated Use AbracadabraWS */
551
+ export type HocuspocusProviderWebsocket = AbracadabraWS;
@@ -0,0 +1,49 @@
1
+ export interface CloseEvent {
2
+ code: number;
3
+ reason: string;
4
+ }
5
+
6
+ /**
7
+ * The server is terminating the connection because a data frame was received
8
+ * that is too large.
9
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
10
+ */
11
+ export const MessageTooBig: CloseEvent = {
12
+ code: 1009,
13
+ reason: "Message Too Big",
14
+ };
15
+
16
+ /**
17
+ * The server successfully processed the request, asks that the requester reset
18
+ * its document view, and is not returning any content.
19
+ */
20
+ export const ResetConnection: CloseEvent = {
21
+ code: 4205,
22
+ reason: "Reset Connection",
23
+ };
24
+
25
+ /**
26
+ * Similar to Forbidden, but specifically for use when authentication is required and has
27
+ * failed or has not yet been provided.
28
+ */
29
+ export const Unauthorized: CloseEvent = {
30
+ code: 4401,
31
+ reason: "Unauthorized",
32
+ };
33
+
34
+ /**
35
+ * The request contained valid data and was understood by the server, but the server
36
+ * is refusing action.
37
+ */
38
+ export const Forbidden: CloseEvent = {
39
+ code: 4403,
40
+ reason: "Forbidden",
41
+ };
42
+
43
+ /**
44
+ * The server timed out waiting for the request.
45
+ */
46
+ export const ConnectionTimeout: CloseEvent = {
47
+ code: 4408,
48
+ reason: "Connection Timeout",
49
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * IndexedDB-backed cache for REST API metadata (documents, children, profiles,
3
+ * permissions, uploads). Provides offline-first read access to data fetched
4
+ * from the server.
5
+ *
6
+ * Each entry carries a `cachedAt` timestamp; reads return null once the TTL
7
+ * has elapsed. The cache is scoped by server origin so the same app can work
8
+ * against multiple backends without cross-contamination.
9
+ *
10
+ * Designed to be passed to AbracadabraClientConfig.cache so the client
11
+ * automatically checks the cache before hitting the network.
12
+ */
13
+
14
+ import type {
15
+ DocumentMeta,
16
+ UserProfile,
17
+ PermissionEntry,
18
+ UploadInfo,
19
+ } from "./types.ts";
20
+
21
+ const DB_VERSION = 1;
22
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
23
+
24
+ function idbAvailable(): boolean {
25
+ return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
26
+ }
27
+
28
+ function openDb(origin: string): Promise<IDBDatabase> {
29
+ return new Promise((resolve, reject) => {
30
+ const req = globalThis.indexedDB.open(
31
+ `abracadabra:meta-cache:${origin}`,
32
+ DB_VERSION,
33
+ );
34
+
35
+ req.onupgradeneeded = (event) => {
36
+ const db = (event.target as IDBOpenDBRequest).result;
37
+ for (const name of ["doc_meta", "children", "user_profile", "permissions", "uploads"]) {
38
+ if (!db.objectStoreNames.contains(name)) {
39
+ db.createObjectStore(name);
40
+ }
41
+ }
42
+ };
43
+
44
+ req.onsuccess = () => resolve(req.result);
45
+ req.onerror = () => reject(req.error);
46
+ });
47
+ }
48
+
49
+ function txPromise<T>(store: IDBObjectStore, request: IDBRequest<T>): Promise<T> {
50
+ return new Promise((resolve, reject) => {
51
+ request.onsuccess = () => resolve(request.result);
52
+ request.onerror = () => reject(request.error);
53
+ });
54
+ }
55
+
56
+ export interface DocumentCacheOptions {
57
+ /** How long cached entries remain valid. Default: 5 minutes. */
58
+ ttlMs?: number;
59
+ }
60
+
61
+ export class DocumentCache {
62
+ private readonly origin: string;
63
+ private readonly ttlMs: number;
64
+ private dbPromise: Promise<IDBDatabase | null> | null = null;
65
+ private db: IDBDatabase | null = null;
66
+
67
+ constructor(serverOrigin: string, opts?: DocumentCacheOptions) {
68
+ this.origin = serverOrigin;
69
+ this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
70
+ }
71
+
72
+ private getDb(): Promise<IDBDatabase | null> {
73
+ if (!idbAvailable()) return Promise.resolve(null);
74
+ if (!this.dbPromise) {
75
+ this.dbPromise = openDb(this.origin)
76
+ .catch(() => null)
77
+ .then((db) => {
78
+ this.db = db;
79
+ return db;
80
+ });
81
+ }
82
+ return this.dbPromise;
83
+ }
84
+
85
+ private isExpired(cachedAt: number): boolean {
86
+ return Date.now() - cachedAt > this.ttlMs;
87
+ }
88
+
89
+ // ── Generic TTL helpers ───────────────────────────────────────────────────
90
+
91
+ private async getWithTtl<T>(
92
+ storeName: string,
93
+ key: string,
94
+ ): Promise<T | null> {
95
+ const db = await this.getDb();
96
+ if (!db) return null;
97
+ const tx = db.transaction(storeName, "readonly");
98
+ const result = await txPromise<{ value: T; cachedAt: number } | undefined>(
99
+ tx.objectStore(storeName),
100
+ tx.objectStore(storeName).get(key),
101
+ );
102
+ if (!result) return null;
103
+ if (this.isExpired(result.cachedAt)) {
104
+ // Stale — evict asynchronously and signal miss
105
+ this.deleteKey(storeName, key).catch(() => null);
106
+ return null;
107
+ }
108
+ return result.value;
109
+ }
110
+
111
+ private async setWithTtl<T>(storeName: string, key: string, value: T): Promise<void> {
112
+ const db = await this.getDb();
113
+ if (!db) return;
114
+ const entry = { value, cachedAt: Date.now() };
115
+ const tx = db.transaction(storeName, "readwrite");
116
+ await txPromise(
117
+ tx.objectStore(storeName),
118
+ tx.objectStore(storeName).put(entry, key),
119
+ );
120
+ }
121
+
122
+ private async deleteKey(storeName: string, key: string): Promise<void> {
123
+ const db = await this.getDb();
124
+ if (!db) return;
125
+ const tx = db.transaction(storeName, "readwrite");
126
+ await txPromise(tx.objectStore(storeName), tx.objectStore(storeName).delete(key));
127
+ }
128
+
129
+ // ── Document metadata ─────────────────────────────────────────────────────
130
+
131
+ async getDoc(docId: string): Promise<DocumentMeta | null> {
132
+ return this.getWithTtl<DocumentMeta>("doc_meta", docId);
133
+ }
134
+
135
+ async setDoc(meta: DocumentMeta): Promise<void> {
136
+ return this.setWithTtl("doc_meta", meta.id, meta);
137
+ }
138
+
139
+ async invalidateDoc(docId: string): Promise<void> {
140
+ await this.deleteKey("doc_meta", docId).catch(() => null);
141
+ }
142
+
143
+ // ── Children list ─────────────────────────────────────────────────────────
144
+
145
+ async getChildren(parentId: string): Promise<string[] | null> {
146
+ return this.getWithTtl<string[]>("children", parentId);
147
+ }
148
+
149
+ async setChildren(parentId: string, items: string[]): Promise<void> {
150
+ return this.setWithTtl("children", parentId, items);
151
+ }
152
+
153
+ async invalidateChildren(parentId: string): Promise<void> {
154
+ await this.deleteKey("children", parentId).catch(() => null);
155
+ }
156
+
157
+ // ── User profile ──────────────────────────────────────────────────────────
158
+
159
+ async getProfile(userId: string): Promise<UserProfile | null> {
160
+ return this.getWithTtl<UserProfile>("user_profile", userId);
161
+ }
162
+
163
+ async setProfile(profile: UserProfile): Promise<void> {
164
+ return this.setWithTtl("user_profile", profile.id, profile);
165
+ }
166
+
167
+ /** Get the cached profile for the currently authenticated user. */
168
+ async getCurrentProfile(): Promise<UserProfile | null> {
169
+ return this.getWithTtl<UserProfile>("user_profile", "__current__");
170
+ }
171
+
172
+ /** Cache a profile both by its ID and as the current user. */
173
+ async setCurrentProfile(profile: UserProfile): Promise<void> {
174
+ await Promise.all([
175
+ this.setWithTtl("user_profile", profile.id, profile),
176
+ this.setWithTtl("user_profile", "__current__", profile),
177
+ ]);
178
+ }
179
+
180
+ // ── Permissions ───────────────────────────────────────────────────────────
181
+
182
+ async getPermissions(docId: string): Promise<PermissionEntry[] | null> {
183
+ return this.getWithTtl<PermissionEntry[]>("permissions", docId);
184
+ }
185
+
186
+ async setPermissions(docId: string, items: PermissionEntry[]): Promise<void> {
187
+ return this.setWithTtl("permissions", docId, items);
188
+ }
189
+
190
+ // ── Uploads list ──────────────────────────────────────────────────────────
191
+
192
+ async getUploads(docId: string): Promise<UploadInfo[] | null> {
193
+ return this.getWithTtl<UploadInfo[]>("uploads", docId);
194
+ }
195
+
196
+ async setUploads(docId: string, items: UploadInfo[]): Promise<void> {
197
+ return this.setWithTtl("uploads", docId, items);
198
+ }
199
+
200
+ async invalidateUploads(docId: string): Promise<void> {
201
+ await this.deleteKey("uploads", docId).catch(() => null);
202
+ }
203
+
204
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
205
+
206
+ destroy(): void {
207
+ this.db?.close();
208
+ this.db = null;
209
+ }
210
+ }