@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.
@@ -1991,18 +1991,19 @@ var HocuspocusProvider = class extends EventEmitter {
1991
1991
 
1992
1992
  //#endregion
1993
1993
  //#region packages/provider/src/OfflineStore.ts
1994
- const DB_VERSION = 1;
1994
+ const DB_VERSION = 2;
1995
1995
  function idbAvailable() {
1996
1996
  return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
1997
1997
  }
1998
- function openDb$1(docId) {
1998
+ function openDb$1(storeKey) {
1999
1999
  return new Promise((resolve, reject) => {
2000
- const req = globalThis.indexedDB.open(`abracadabra:${docId}`, DB_VERSION);
2000
+ const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
2001
2001
  req.onupgradeneeded = (event) => {
2002
2002
  const db = event.target.result;
2003
2003
  if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
2004
2004
  if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
2005
2005
  if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
2006
+ if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
2006
2007
  };
2007
2008
  req.onsuccess = () => resolve(req.result);
2008
2009
  req.onerror = () => reject(req.error);
@@ -2015,13 +2016,19 @@ function txPromise(store, request) {
2015
2016
  });
2016
2017
  }
2017
2018
  var OfflineStore = class {
2018
- constructor(docId) {
2019
+ /**
2020
+ * @param docId The document UUID.
2021
+ * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
2022
+ * When provided the IndexedDB database is namespaced
2023
+ * per-server, preventing cross-server data contamination.
2024
+ */
2025
+ constructor(docId, serverOrigin) {
2019
2026
  this.db = null;
2020
- this.docId = docId;
2027
+ this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
2021
2028
  }
2022
2029
  async getDb() {
2023
2030
  if (!idbAvailable()) return null;
2024
- if (!this.db) this.db = await openDb$1(this.docId).catch(() => null);
2031
+ if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
2025
2032
  return this.db;
2026
2033
  }
2027
2034
  async persistUpdate(update) {
@@ -2045,6 +2052,25 @@ var OfflineStore = class {
2045
2052
  const tx = db.transaction("updates", "readwrite");
2046
2053
  await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
2047
2054
  }
2055
+ /**
2056
+ * Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
2057
+ * Replaces any previously stored snapshot.
2058
+ */
2059
+ async saveDocSnapshot(snapshot) {
2060
+ const db = await this.getDb();
2061
+ if (!db) return;
2062
+ const tx = db.transaction("doc_state", "readwrite");
2063
+ await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
2064
+ }
2065
+ /**
2066
+ * Retrieve the stored full document snapshot, or null if none exists.
2067
+ */
2068
+ async getDocSnapshot() {
2069
+ const db = await this.getDb();
2070
+ if (!db) return null;
2071
+ const tx = db.transaction("doc_state", "readonly");
2072
+ return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
2073
+ }
2048
2074
  async getStateVector() {
2049
2075
  const db = await this.getDb();
2050
2076
  if (!db) return null;
@@ -2138,10 +2164,16 @@ function isValidDocId(id) {
2138
2164
  * own AbracadabraProvider instances sharing the same WebSocket connection.
2139
2165
  *
2140
2166
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
2141
- * page reloads and network outages. On reconnect, pending updates are
2142
- * flushed before resuming normal sync.
2167
+ * page reloads and network outages. On startup, the last saved document
2168
+ * snapshot is applied immediately so the UI is usable without a server
2169
+ * connection. On reconnect, pending updates are flushed and a fresh
2170
+ * snapshot is saved.
2171
+ *
2172
+ * 3. Server-scoped storage – the IndexedDB database is keyed by server
2173
+ * hostname + docId, preventing cross-server data contamination when the
2174
+ * same docId is used on multiple servers.
2143
2175
  *
2144
- * 3. Permission snapshotting – stores the resolved role locally so the UI
2176
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
2145
2177
  * can gate write operations without a network round-trip. Role is
2146
2178
  * refreshed from the server on every reconnect.
2147
2179
  */
@@ -2161,12 +2193,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2161
2193
  this._client = client;
2162
2194
  this.abracadabraConfig = configuration;
2163
2195
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
2164
- this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name);
2196
+ const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
2197
+ this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
2165
2198
  this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
2166
2199
  this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
2167
2200
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2168
2201
  this.on("synced", () => this.flushPendingUpdates());
2169
2202
  this.restorePermissionSnapshot();
2203
+ this.ready = this._initFromOfflineStore();
2204
+ }
2205
+ /**
2206
+ * Extract the server hostname from the provider configuration.
2207
+ * Used to namespace the IndexedDB key so docs from different servers
2208
+ * never share the same database.
2209
+ */
2210
+ static deriveServerOrigin(config, client) {
2211
+ try {
2212
+ const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
2213
+ if (url) return new URL(url).hostname;
2214
+ } catch {}
2215
+ }
2216
+ /**
2217
+ * Load the stored document snapshot (and any pending local edits) from
2218
+ * IndexedDB and apply them to the Y.Doc so the UI can render without a
2219
+ * server connection.
2220
+ *
2221
+ * Uses `this.offlineStore` as the Y.js update origin so that
2222
+ * `documentUpdateHandler` ignores these replayed updates and does not
2223
+ * attempt to re-persist or re-send them.
2224
+ */
2225
+ async _initFromOfflineStore() {
2226
+ if (!this.offlineStore) return;
2227
+ const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
2228
+ if (snapshot) yjs.applyUpdate(this.document, snapshot, this.offlineStore);
2229
+ const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
2230
+ for (const update of pending) yjs.applyUpdate(this.document, update, this.offlineStore);
2170
2231
  }
2171
2232
  authenticatedHandler(scope) {
2172
2233
  super.authenticatedHandler(scope);
@@ -2304,7 +2365,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2304
2365
  }
2305
2366
  async _doLoadChild(childId) {
2306
2367
  const childDoc = new yjs.Doc({ guid: childId });
2307
- await new Promise((resolve) => {
2368
+ if (this.isConnected) await new Promise((resolve) => {
2308
2369
  const onRegistered = ({ childId: cid }) => {
2309
2370
  if (cid === childId) {
2310
2371
  this.off("subdocRegistered", onRegistered);
@@ -2315,6 +2376,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2315
2376
  this.registerSubdoc(childDoc);
2316
2377
  setTimeout(resolve, 3e3);
2317
2378
  });
2379
+ else this.registerSubdoc(childDoc);
2318
2380
  const childProvider = new AbracadabraProvider({
2319
2381
  name: childId,
2320
2382
  document: childDoc,
@@ -2347,31 +2409,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2347
2409
  /**
2348
2410
  * Override to persist every local update to IndexedDB before sending it
2349
2411
  * over the wire, ensuring no work is lost during connection outages.
2412
+ *
2413
+ * Updates applied by the server (origin === this) and updates replayed
2414
+ * from the offline store (origin === this.offlineStore) are both skipped
2415
+ * to avoid loops.
2350
2416
  */
2351
2417
  documentUpdateHandler(update, origin) {
2352
2418
  if (origin === this) return;
2419
+ if (this.offlineStore !== null && origin === this.offlineStore) return;
2353
2420
  this.offlineStore?.persistUpdate(update).catch(() => null);
2354
2421
  super.documentUpdateHandler(update, origin);
2355
2422
  }
2356
2423
  /**
2357
- * After reconnect + sync, flush any updates that were generated while
2358
- * offline, then flush any queued subdoc registrations.
2424
+ * After reconnect + sync:
2425
+ * 1. Flush any updates that were generated while offline.
2426
+ * 2. Flush any queued subdoc registrations.
2427
+ * 3. Save a fresh full document snapshot for the next offline session.
2428
+ *
2429
+ * Uses a local `store` reference captured at the start so that a concurrent
2430
+ * `destroy()` call setting `offlineStore = null` does not cause null-ref
2431
+ * errors across async await boundaries.
2359
2432
  */
2360
2433
  async flushPendingUpdates() {
2361
- if (!this.offlineStore) return;
2362
- const updates = await this.offlineStore.getPendingUpdates();
2434
+ const store = this.offlineStore;
2435
+ if (!store) return;
2436
+ const updates = await store.getPendingUpdates();
2363
2437
  if (updates.length > 0) {
2364
2438
  for (const update of updates) this.send(UpdateMessage, {
2365
2439
  update,
2366
2440
  documentName: this.configuration.name
2367
2441
  });
2368
- await this.offlineStore.clearPendingUpdates();
2442
+ await store.clearPendingUpdates();
2369
2443
  }
2370
- const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
2444
+ const pendingSubdocs = await store.getPendingSubdocs();
2371
2445
  for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
2372
2446
  documentName: this.configuration.name,
2373
2447
  childDocumentName: childId
2374
2448
  });
2449
+ const snapshot = yjs.encodeStateAsUpdate(this.document);
2450
+ await store.saveDocSnapshot(snapshot).catch(() => null);
2375
2451
  }
2376
2452
  get isConnected() {
2377
2453
  return this.configuration.websocketProvider.status === "connected";