@abraca/dabra 0.2.0 → 0.4.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
@@ -227,6 +227,8 @@ declare class AbracadabraClient {
227
227
  createChild(docId: string, opts?: {
228
228
  child_id?: string;
229
229
  }): Promise<DocumentMeta>;
230
+ /** List all permissions for a document (requires read access). */
231
+ listPermissions(docId: string): Promise<PermissionEntry[]>;
230
232
  /** Grant or change a user's role on a document (requires Owner). */
231
233
  setPermission(docId: string, opts: {
232
234
  user_id: string;
@@ -299,10 +301,16 @@ interface AbracadabraProviderConfiguration extends Omit<HocuspocusProviderConfig
299
301
  * own AbracadabraProvider instances sharing the same WebSocket connection.
300
302
  *
301
303
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
302
- * page reloads and network outages. On reconnect, pending updates are
303
- * flushed before resuming normal sync.
304
+ * page reloads and network outages. On startup, the last saved document
305
+ * snapshot is applied immediately so the UI is usable without a server
306
+ * connection. On reconnect, pending updates are flushed and a fresh
307
+ * snapshot is saved.
304
308
  *
305
- * 3. Permission snapshottingstores the resolved role locally so the UI
309
+ * 3. Server-scoped storage – the IndexedDB database is keyed by server
310
+ * hostname + docId, preventing cross-server data contamination when the
311
+ * same docId is used on multiple servers.
312
+ *
313
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
306
314
  * can gate write operations without a network round-trip. Role is
307
315
  * refreshed from the server on every reconnect.
308
316
  */
@@ -315,7 +323,30 @@ declare class AbracadabraProvider extends HocuspocusProvider {
315
323
  private subdocLoading;
316
324
  private abracadabraConfig;
317
325
  private readonly boundHandleYSubdocsChange;
326
+ /**
327
+ * Resolves once the document has been pre-populated from the local
328
+ * IndexedDB snapshot (if one exists). Await this before rendering UI
329
+ * that depends on the document content — it resolves immediately when
330
+ * the offline store is disabled or when no snapshot has been saved yet.
331
+ */
332
+ readonly ready: Promise<void>;
318
333
  constructor(configuration: AbracadabraProviderConfiguration);
334
+ /**
335
+ * Extract the server hostname from the provider configuration.
336
+ * Used to namespace the IndexedDB key so docs from different servers
337
+ * never share the same database.
338
+ */
339
+ private static deriveServerOrigin;
340
+ /**
341
+ * Load the stored document snapshot (and any pending local edits) from
342
+ * IndexedDB and apply them to the Y.Doc so the UI can render without a
343
+ * server connection.
344
+ *
345
+ * Uses `this.offlineStore` as the Y.js update origin so that
346
+ * `documentUpdateHandler` ignores these replayed updates and does not
347
+ * attempt to re-persist or re-send them.
348
+ */
349
+ private _initFromOfflineStore;
319
350
  authenticatedHandler(scope: string): void;
320
351
  /**
321
352
  * Override sendToken to send a pubkey-only identity declaration instead of a
@@ -361,11 +392,21 @@ declare class AbracadabraProvider extends HocuspocusProvider {
361
392
  /**
362
393
  * Override to persist every local update to IndexedDB before sending it
363
394
  * over the wire, ensuring no work is lost during connection outages.
395
+ *
396
+ * Updates applied by the server (origin === this) and updates replayed
397
+ * from the offline store (origin === this.offlineStore) are both skipped
398
+ * to avoid loops.
364
399
  */
365
400
  documentUpdateHandler(update: Uint8Array, origin: unknown): void;
366
401
  /**
367
- * After reconnect + sync, flush any updates that were generated while
368
- * offline, then flush any queued subdoc registrations.
402
+ * After reconnect + sync:
403
+ * 1. Flush any updates that were generated while offline.
404
+ * 2. Flush any queued subdoc registrations.
405
+ * 3. Save a fresh full document snapshot for the next offline session.
406
+ *
407
+ * Uses a local `store` reference captured at the start so that a concurrent
408
+ * `destroy()` call setting `offlineStore = null` does not cause null-ref
409
+ * errors across async await boundaries.
369
410
  */
370
411
  private flushPendingUpdates;
371
412
  get isConnected(): boolean;
@@ -609,6 +650,12 @@ interface PublicKeyInfo {
609
650
  deviceName: string | null;
610
651
  revoked: boolean;
611
652
  }
653
+ interface PermissionEntry {
654
+ user_id: string;
655
+ role: "owner" | "editor" | "viewer" | "observer";
656
+ username: string;
657
+ display_name: string | null;
658
+ }
612
659
  interface HealthStatus {
613
660
  status: string;
614
661
  version: string;
@@ -864,8 +911,12 @@ declare class HocuspocusProvider extends EventEmitter {
864
911
  * - Store the last-known state vector for fast reconnect diffs.
865
912
  * - Store a permission snapshot so the UI can gate writes without a network round-trip.
866
913
  * - Queue subdoc registration events created while offline.
914
+ * - Store a full document snapshot so the app can load content without a server connection.
867
915
  *
868
916
  * Falls back to a silent no-op when IndexedDB is unavailable (e.g. SSR / Node.js).
917
+ *
918
+ * Database key is scoped by server origin to prevent cross-server data contamination
919
+ * when the same docId is used on multiple servers.
869
920
  */
870
921
  interface PendingSubdoc {
871
922
  childId: string;
@@ -873,13 +924,28 @@ interface PendingSubdoc {
873
924
  createdAt: number;
874
925
  }
875
926
  declare class OfflineStore {
876
- private docId;
927
+ private storeKey;
877
928
  private db;
878
- constructor(docId: string);
929
+ /**
930
+ * @param docId The document UUID.
931
+ * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
932
+ * When provided the IndexedDB database is namespaced
933
+ * per-server, preventing cross-server data contamination.
934
+ */
935
+ constructor(docId: string, serverOrigin?: string);
879
936
  private getDb;
880
937
  persistUpdate(update: Uint8Array): Promise<void>;
881
938
  getPendingUpdates(): Promise<Uint8Array[]>;
882
939
  clearPendingUpdates(): Promise<void>;
940
+ /**
941
+ * Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
942
+ * Replaces any previously stored snapshot.
943
+ */
944
+ saveDocSnapshot(snapshot: Uint8Array): Promise<void>;
945
+ /**
946
+ * Retrieve the stored full document snapshot, or null if none exists.
947
+ */
948
+ getDocSnapshot(): Promise<Uint8Array | null>;
883
949
  getStateVector(): Promise<Uint8Array | null>;
884
950
  saveStateVector(sv: Uint8Array): Promise<void>;
885
951
  getPermissionSnapshot(): Promise<string | null>;
@@ -962,4 +1028,4 @@ declare class CryptoIdentityKeystore {
962
1028
  clear(): Promise<void>;
963
1029
  }
964
1030
  //#endregion
965
- export { AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AuthorizedScope, AwarenessError, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DocumentMeta, EffectiveRole, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PendingSubdoc, PublicKeyInfo, StatesArray, SubdocMessage, SubdocRegisteredEvent, UploadInfo, UploadMeta, UserProfile, WebSocketStatus, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters };
1031
+ export { AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AuthorizedScope, AwarenessError, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DocumentMeta, EffectiveRole, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PendingSubdoc, PermissionEntry, PublicKeyInfo, StatesArray, SubdocMessage, SubdocRegisteredEvent, UploadInfo, UploadMeta, UserProfile, WebSocketStatus, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -4,6 +4,7 @@ import type {
4
4
  UploadMeta,
5
5
  UploadInfo,
6
6
  PublicKeyInfo,
7
+ PermissionEntry,
7
8
  HealthStatus,
8
9
  } from "./types.ts";
9
10
 
@@ -219,6 +220,15 @@ export class AbracadabraClient {
219
220
 
220
221
  // ── Permissions ──────────────────────────────────────────────────────────
221
222
 
223
+ /** List all permissions for a document (requires read access). */
224
+ async listPermissions(docId: string): Promise<PermissionEntry[]> {
225
+ const res = await this.request<{ permissions: PermissionEntry[] }>(
226
+ "GET",
227
+ `/docs/${encodeURIComponent(docId)}/permissions`,
228
+ );
229
+ return res.permissions;
230
+ }
231
+
222
232
  /** Grant or change a user's role on a document (requires Owner). */
223
233
  async setPermission(
224
234
  docId: string,
@@ -74,10 +74,16 @@ function isValidDocId(id: string): boolean {
74
74
  * own AbracadabraProvider instances sharing the same WebSocket connection.
75
75
  *
76
76
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
77
- * page reloads and network outages. On reconnect, pending updates are
78
- * flushed before resuming normal sync.
77
+ * page reloads and network outages. On startup, the last saved document
78
+ * snapshot is applied immediately so the UI is usable without a server
79
+ * connection. On reconnect, pending updates are flushed and a fresh
80
+ * snapshot is saved.
79
81
  *
80
- * 3. Permission snapshottingstores the resolved role locally so the UI
82
+ * 3. Server-scoped storage – the IndexedDB database is keyed by server
83
+ * hostname + docId, preventing cross-server data contamination when the
84
+ * same docId is used on multiple servers.
85
+ *
86
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
81
87
  * can gate write operations without a network round-trip. Role is
82
88
  * refreshed from the server on every reconnect.
83
89
  */
@@ -94,6 +100,14 @@ export class AbracadabraProvider extends HocuspocusProvider {
94
100
 
95
101
  private readonly boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
96
102
 
103
+ /**
104
+ * Resolves once the document has been pre-populated from the local
105
+ * IndexedDB snapshot (if one exists). Await this before rendering UI
106
+ * that depends on the document content — it resolves immediately when
107
+ * the offline store is disabled or when no snapshot has been saved yet.
108
+ */
109
+ public readonly ready: Promise<void>;
110
+
97
111
  constructor(configuration: AbracadabraProviderConfiguration) {
98
112
  // Derive URL and token from client when not explicitly set.
99
113
  const resolved = { ...configuration } as HocuspocusProviderConfiguration;
@@ -113,9 +127,10 @@ export class AbracadabraProvider extends HocuspocusProvider {
113
127
  this.abracadabraConfig = configuration;
114
128
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
115
129
 
130
+ const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
116
131
  this.offlineStore = configuration.disableOfflineStore
117
132
  ? null
118
- : new OfflineStore(configuration.name);
133
+ : new OfflineStore(configuration.name, serverOrigin);
119
134
 
120
135
  this.on(
121
136
  "subdocRegistered",
@@ -131,6 +146,57 @@ export class AbracadabraProvider extends HocuspocusProvider {
131
146
 
132
147
  // Restore permission snapshot while offline.
133
148
  this.restorePermissionSnapshot();
149
+
150
+ // Pre-populate the Y.Doc from the local snapshot so the UI works offline.
151
+ this.ready = this._initFromOfflineStore();
152
+ }
153
+
154
+ // ── Server origin derivation ──────────────────────────────────────────────
155
+
156
+ /**
157
+ * Extract the server hostname from the provider configuration.
158
+ * Used to namespace the IndexedDB key so docs from different servers
159
+ * never share the same database.
160
+ */
161
+ private static deriveServerOrigin(
162
+ config: AbracadabraProviderConfiguration,
163
+ client: AbracadabraClient | null,
164
+ ): string | undefined {
165
+ try {
166
+ const url =
167
+ config.url ??
168
+ (config.websocketProvider as HocuspocusProviderWebsocket | undefined)?.url ??
169
+ client?.wsUrl;
170
+ if (url) return new URL(url).hostname;
171
+ } catch {
172
+ // Malformed URL — fall back to no scoping
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ // ── Offline-first initialisation ──────────────────────────────────────────
178
+
179
+ /**
180
+ * Load the stored document snapshot (and any pending local edits) from
181
+ * IndexedDB and apply them to the Y.Doc so the UI can render without a
182
+ * server connection.
183
+ *
184
+ * Uses `this.offlineStore` as the Y.js update origin so that
185
+ * `documentUpdateHandler` ignores these replayed updates and does not
186
+ * attempt to re-persist or re-send them.
187
+ */
188
+ private async _initFromOfflineStore(): Promise<void> {
189
+ if (!this.offlineStore) return;
190
+
191
+ const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
192
+ if (snapshot) {
193
+ Y.applyUpdate(this.document, snapshot, this.offlineStore);
194
+ }
195
+
196
+ const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
197
+ for (const update of pending) {
198
+ Y.applyUpdate(this.document, update, this.offlineStore);
199
+ }
134
200
  }
135
201
 
136
202
  // ── Auth / permission snapshot ────────────────────────────────────────────
@@ -351,23 +417,29 @@ export class AbracadabraProvider extends HocuspocusProvider {
351
417
  private async _doLoadChild(childId: string): Promise<AbracadabraProvider> {
352
418
  const childDoc = new Y.Doc({ guid: childId });
353
419
 
354
- // Notify the server that this child belongs to the parent document and
355
- // wait for server confirmation before opening the child WebSocket.
356
- // Without waiting, the child's SyncStep1 may race against subdoc
357
- // creation and get a NotFound error on first sync.
358
- await new Promise<void>((resolve) => {
359
- const onRegistered = ({ childId: cid }: onSubdocRegisteredParameters) => {
360
- if (cid === childId) {
361
- this.off("subdocRegistered", onRegistered);
362
- resolve();
363
- }
364
- };
365
- this.on("subdocRegistered", onRegistered);
420
+ if (this.isConnected) {
421
+ // Online: notify the server that this child belongs to the parent and
422
+ // wait for server confirmation before opening the child WebSocket.
423
+ // Without waiting, the child's SyncStep1 may race against subdoc
424
+ // creation and get a NotFound error on first sync.
425
+ await new Promise<void>((resolve) => {
426
+ const onRegistered = ({ childId: cid }: onSubdocRegisteredParameters) => {
427
+ if (cid === childId) {
428
+ this.off("subdocRegistered", onRegistered);
429
+ resolve();
430
+ }
431
+ };
432
+ this.on("subdocRegistered", onRegistered);
433
+ this.registerSubdoc(childDoc);
434
+ // Fallback: don't block forever if the server is slow or the
435
+ // doc was already registered in a previous session.
436
+ setTimeout(resolve, 3000);
437
+ });
438
+ } else {
439
+ // Offline: queue the registration for replay on reconnect and proceed
440
+ // immediately so the child provider can populate from its local store.
366
441
  this.registerSubdoc(childDoc);
367
- // Fallback: don't block forever if the server is slow or the
368
- // doc was already registered in a previous session.
369
- setTimeout(resolve, 3000);
370
- });
442
+ }
371
443
 
372
444
  // Each child gets its own WebSocket connection. Omitting
373
445
  // websocketProvider lets HocuspocusProvider create one automatically
@@ -409,9 +481,18 @@ export class AbracadabraProvider extends HocuspocusProvider {
409
481
  /**
410
482
  * Override to persist every local update to IndexedDB before sending it
411
483
  * over the wire, ensuring no work is lost during connection outages.
484
+ *
485
+ * Updates applied by the server (origin === this) and updates replayed
486
+ * from the offline store (origin === this.offlineStore) are both skipped
487
+ * to avoid loops.
412
488
  */
413
489
  override documentUpdateHandler(update: Uint8Array, origin: unknown) {
414
490
  if (origin === this) return;
491
+ // Only skip when the store exists AND origin matches it.
492
+ // Without the null-guard, a null offlineStore would match any update
493
+ // with origin=null (the default for user transactions), silently
494
+ // dropping all local writes when disableOfflineStore is true.
495
+ if (this.offlineStore !== null && origin === this.offlineStore) return;
415
496
 
416
497
  // Persist locally first (fire-and-forget; errors are non-fatal).
417
498
  this.offlineStore?.persistUpdate(update).catch(() => null);
@@ -420,13 +501,20 @@ export class AbracadabraProvider extends HocuspocusProvider {
420
501
  }
421
502
 
422
503
  /**
423
- * After reconnect + sync, flush any updates that were generated while
424
- * offline, then flush any queued subdoc registrations.
504
+ * After reconnect + sync:
505
+ * 1. Flush any updates that were generated while offline.
506
+ * 2. Flush any queued subdoc registrations.
507
+ * 3. Save a fresh full document snapshot for the next offline session.
508
+ *
509
+ * Uses a local `store` reference captured at the start so that a concurrent
510
+ * `destroy()` call setting `offlineStore = null` does not cause null-ref
511
+ * errors across async await boundaries.
425
512
  */
426
513
  private async flushPendingUpdates() {
427
- if (!this.offlineStore) return;
514
+ const store = this.offlineStore;
515
+ if (!store) return;
428
516
 
429
- const updates = await this.offlineStore.getPendingUpdates();
517
+ const updates = await store.getPendingUpdates();
430
518
  if (updates.length > 0) {
431
519
  for (const update of updates) {
432
520
  this.send(UpdateMessage, {
@@ -434,16 +522,20 @@ export class AbracadabraProvider extends HocuspocusProvider {
434
522
  documentName: this.configuration.name,
435
523
  });
436
524
  }
437
- await this.offlineStore.clearPendingUpdates();
525
+ await store.clearPendingUpdates();
438
526
  }
439
527
 
440
- const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
528
+ const pendingSubdocs = await store.getPendingSubdocs();
441
529
  for (const { childId } of pendingSubdocs) {
442
530
  this.send(SubdocMessage, {
443
531
  documentName: this.configuration.name,
444
532
  childDocumentName: childId,
445
533
  } as any);
446
534
  }
535
+
536
+ // Snapshot the current merged state so the next offline load sees it.
537
+ const snapshot = Y.encodeStateAsUpdate(this.document);
538
+ await store.saveDocSnapshot(snapshot).catch(() => null);
447
539
  }
448
540
 
449
541
  get isConnected(): boolean {
@@ -6,8 +6,12 @@
6
6
  * - Store the last-known state vector for fast reconnect diffs.
7
7
  * - Store a permission snapshot so the UI can gate writes without a network round-trip.
8
8
  * - Queue subdoc registration events created while offline.
9
+ * - Store a full document snapshot so the app can load content without a server connection.
9
10
  *
10
11
  * Falls back to a silent no-op when IndexedDB is unavailable (e.g. SSR / Node.js).
12
+ *
13
+ * Database key is scoped by server origin to prevent cross-server data contamination
14
+ * when the same docId is used on multiple servers.
11
15
  */
12
16
 
13
17
  export interface PendingSubdoc {
@@ -16,15 +20,15 @@ export interface PendingSubdoc {
16
20
  createdAt: number;
17
21
  }
18
22
 
19
- const DB_VERSION = 1;
23
+ const DB_VERSION = 2;
20
24
 
21
25
  function idbAvailable(): boolean {
22
26
  return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
23
27
  }
24
28
 
25
- function openDb(docId: string): Promise<IDBDatabase> {
29
+ function openDb(storeKey: string): Promise<IDBDatabase> {
26
30
  return new Promise((resolve, reject) => {
27
- const req = globalThis.indexedDB.open(`abracadabra:${docId}`, DB_VERSION);
31
+ const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
28
32
 
29
33
  req.onupgradeneeded = (event) => {
30
34
  const db = (event.target as IDBOpenDBRequest).result;
@@ -38,6 +42,10 @@ function openDb(docId: string): Promise<IDBDatabase> {
38
42
  if (!db.objectStoreNames.contains("subdoc_queue")) {
39
43
  db.createObjectStore("subdoc_queue", { keyPath: "childId" });
40
44
  }
45
+ // v2: full document snapshot store
46
+ if (!db.objectStoreNames.contains("doc_state")) {
47
+ db.createObjectStore("doc_state");
48
+ }
41
49
  };
42
50
 
43
51
  req.onsuccess = () => resolve(req.result);
@@ -56,22 +64,28 @@ function txPromise<T>(
56
64
  }
57
65
 
58
66
  export class OfflineStore {
59
- private docId: string;
67
+ private storeKey: string;
60
68
  private db: IDBDatabase | null = null;
61
69
 
62
- constructor(docId: string) {
63
- this.docId = docId;
70
+ /**
71
+ * @param docId The document UUID.
72
+ * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
73
+ * When provided the IndexedDB database is namespaced
74
+ * per-server, preventing cross-server data contamination.
75
+ */
76
+ constructor(docId: string, serverOrigin?: string) {
77
+ this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
64
78
  }
65
79
 
66
80
  private async getDb(): Promise<IDBDatabase | null> {
67
81
  if (!idbAvailable()) return null;
68
82
  if (!this.db) {
69
- this.db = await openDb(this.docId).catch(() => null);
83
+ this.db = await openDb(this.storeKey).catch(() => null);
70
84
  }
71
85
  return this.db;
72
86
  }
73
87
 
74
- // ── Updates ─────────────────────────────────────────────────────────────
88
+ // ── Pending (unsynced) updates ────────────────────────────────────────────
75
89
 
76
90
  async persistUpdate(update: Uint8Array): Promise<void> {
77
91
  const db = await this.getDb();
@@ -100,6 +114,36 @@ export class OfflineStore {
100
114
  await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
101
115
  }
102
116
 
117
+ // ── Full document snapshot ────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
121
+ * Replaces any previously stored snapshot.
122
+ */
123
+ async saveDocSnapshot(snapshot: Uint8Array): Promise<void> {
124
+ const db = await this.getDb();
125
+ if (!db) return;
126
+ const tx = db.transaction("doc_state", "readwrite");
127
+ await txPromise(
128
+ tx.objectStore("doc_state"),
129
+ tx.objectStore("doc_state").put(snapshot, "snapshot"),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Retrieve the stored full document snapshot, or null if none exists.
135
+ */
136
+ async getDocSnapshot(): Promise<Uint8Array | null> {
137
+ const db = await this.getDb();
138
+ if (!db) return null;
139
+ const tx = db.transaction("doc_state", "readonly");
140
+ const result = await txPromise<Uint8Array | undefined>(
141
+ tx.objectStore("doc_state"),
142
+ tx.objectStore("doc_state").get("snapshot"),
143
+ );
144
+ return result ?? null;
145
+ }
146
+
103
147
  // ── State vector ─────────────────────────────────────────────────────────
104
148
 
105
149
  async getStateVector(): Promise<Uint8Array | null> {
package/src/types.ts CHANGED
@@ -181,6 +181,13 @@ export interface PublicKeyInfo {
181
181
  revoked: boolean;
182
182
  }
183
183
 
184
+ export interface PermissionEntry {
185
+ user_id: string;
186
+ role: "owner" | "editor" | "viewer" | "observer";
187
+ username: string;
188
+ display_name: string | null;
189
+ }
190
+
184
191
  export interface HealthStatus {
185
192
  status: string;
186
193
  version: string;