@abraca/dabra 0.2.0 → 0.3.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
@@ -299,10 +299,16 @@ interface AbracadabraProviderConfiguration extends Omit<HocuspocusProviderConfig
299
299
  * own AbracadabraProvider instances sharing the same WebSocket connection.
300
300
  *
301
301
  * 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.
302
+ * page reloads and network outages. On startup, the last saved document
303
+ * snapshot is applied immediately so the UI is usable without a server
304
+ * connection. On reconnect, pending updates are flushed and a fresh
305
+ * snapshot is saved.
304
306
  *
305
- * 3. Permission snapshottingstores the resolved role locally so the UI
307
+ * 3. Server-scoped storage – the IndexedDB database is keyed by server
308
+ * hostname + docId, preventing cross-server data contamination when the
309
+ * same docId is used on multiple servers.
310
+ *
311
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
306
312
  * can gate write operations without a network round-trip. Role is
307
313
  * refreshed from the server on every reconnect.
308
314
  */
@@ -315,7 +321,30 @@ declare class AbracadabraProvider extends HocuspocusProvider {
315
321
  private subdocLoading;
316
322
  private abracadabraConfig;
317
323
  private readonly boundHandleYSubdocsChange;
324
+ /**
325
+ * Resolves once the document has been pre-populated from the local
326
+ * IndexedDB snapshot (if one exists). Await this before rendering UI
327
+ * that depends on the document content — it resolves immediately when
328
+ * the offline store is disabled or when no snapshot has been saved yet.
329
+ */
330
+ readonly ready: Promise<void>;
318
331
  constructor(configuration: AbracadabraProviderConfiguration);
332
+ /**
333
+ * Extract the server hostname from the provider configuration.
334
+ * Used to namespace the IndexedDB key so docs from different servers
335
+ * never share the same database.
336
+ */
337
+ private static deriveServerOrigin;
338
+ /**
339
+ * Load the stored document snapshot (and any pending local edits) from
340
+ * IndexedDB and apply them to the Y.Doc so the UI can render without a
341
+ * server connection.
342
+ *
343
+ * Uses `this.offlineStore` as the Y.js update origin so that
344
+ * `documentUpdateHandler` ignores these replayed updates and does not
345
+ * attempt to re-persist or re-send them.
346
+ */
347
+ private _initFromOfflineStore;
319
348
  authenticatedHandler(scope: string): void;
320
349
  /**
321
350
  * Override sendToken to send a pubkey-only identity declaration instead of a
@@ -361,11 +390,21 @@ declare class AbracadabraProvider extends HocuspocusProvider {
361
390
  /**
362
391
  * Override to persist every local update to IndexedDB before sending it
363
392
  * over the wire, ensuring no work is lost during connection outages.
393
+ *
394
+ * Updates applied by the server (origin === this) and updates replayed
395
+ * from the offline store (origin === this.offlineStore) are both skipped
396
+ * to avoid loops.
364
397
  */
365
398
  documentUpdateHandler(update: Uint8Array, origin: unknown): void;
366
399
  /**
367
- * After reconnect + sync, flush any updates that were generated while
368
- * offline, then flush any queued subdoc registrations.
400
+ * After reconnect + sync:
401
+ * 1. Flush any updates that were generated while offline.
402
+ * 2. Flush any queued subdoc registrations.
403
+ * 3. Save a fresh full document snapshot for the next offline session.
404
+ *
405
+ * Uses a local `store` reference captured at the start so that a concurrent
406
+ * `destroy()` call setting `offlineStore = null` does not cause null-ref
407
+ * errors across async await boundaries.
369
408
  */
370
409
  private flushPendingUpdates;
371
410
  get isConnected(): boolean;
@@ -864,8 +903,12 @@ declare class HocuspocusProvider extends EventEmitter {
864
903
  * - Store the last-known state vector for fast reconnect diffs.
865
904
  * - Store a permission snapshot so the UI can gate writes without a network round-trip.
866
905
  * - Queue subdoc registration events created while offline.
906
+ * - Store a full document snapshot so the app can load content without a server connection.
867
907
  *
868
908
  * Falls back to a silent no-op when IndexedDB is unavailable (e.g. SSR / Node.js).
909
+ *
910
+ * Database key is scoped by server origin to prevent cross-server data contamination
911
+ * when the same docId is used on multiple servers.
869
912
  */
870
913
  interface PendingSubdoc {
871
914
  childId: string;
@@ -873,13 +916,28 @@ interface PendingSubdoc {
873
916
  createdAt: number;
874
917
  }
875
918
  declare class OfflineStore {
876
- private docId;
919
+ private storeKey;
877
920
  private db;
878
- constructor(docId: string);
921
+ /**
922
+ * @param docId The document UUID.
923
+ * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
924
+ * When provided the IndexedDB database is namespaced
925
+ * per-server, preventing cross-server data contamination.
926
+ */
927
+ constructor(docId: string, serverOrigin?: string);
879
928
  private getDb;
880
929
  persistUpdate(update: Uint8Array): Promise<void>;
881
930
  getPendingUpdates(): Promise<Uint8Array[]>;
882
931
  clearPendingUpdates(): Promise<void>;
932
+ /**
933
+ * Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
934
+ * Replaces any previously stored snapshot.
935
+ */
936
+ saveDocSnapshot(snapshot: Uint8Array): Promise<void>;
937
+ /**
938
+ * Retrieve the stored full document snapshot, or null if none exists.
939
+ */
940
+ getDocSnapshot(): Promise<Uint8Array | null>;
883
941
  getStateVector(): Promise<Uint8Array | null>;
884
942
  saveStateVector(sv: Uint8Array): Promise<void>;
885
943
  getPermissionSnapshot(): Promise<string | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -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> {