@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.
@@ -1961,18 +1961,19 @@ var HocuspocusProvider = class extends EventEmitter {
1961
1961
 
1962
1962
  //#endregion
1963
1963
  //#region packages/provider/src/OfflineStore.ts
1964
- const DB_VERSION = 1;
1964
+ const DB_VERSION = 2;
1965
1965
  function idbAvailable() {
1966
1966
  return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
1967
1967
  }
1968
- function openDb$1(docId) {
1968
+ function openDb$1(storeKey) {
1969
1969
  return new Promise((resolve, reject) => {
1970
- const req = globalThis.indexedDB.open(`abracadabra:${docId}`, DB_VERSION);
1970
+ const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
1971
1971
  req.onupgradeneeded = (event) => {
1972
1972
  const db = event.target.result;
1973
1973
  if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
1974
1974
  if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
1975
1975
  if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
1976
+ if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
1976
1977
  };
1977
1978
  req.onsuccess = () => resolve(req.result);
1978
1979
  req.onerror = () => reject(req.error);
@@ -1985,13 +1986,19 @@ function txPromise(store, request) {
1985
1986
  });
1986
1987
  }
1987
1988
  var OfflineStore = class {
1988
- constructor(docId) {
1989
+ /**
1990
+ * @param docId The document UUID.
1991
+ * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
1992
+ * When provided the IndexedDB database is namespaced
1993
+ * per-server, preventing cross-server data contamination.
1994
+ */
1995
+ constructor(docId, serverOrigin) {
1989
1996
  this.db = null;
1990
- this.docId = docId;
1997
+ this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
1991
1998
  }
1992
1999
  async getDb() {
1993
2000
  if (!idbAvailable()) return null;
1994
- if (!this.db) this.db = await openDb$1(this.docId).catch(() => null);
2001
+ if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
1995
2002
  return this.db;
1996
2003
  }
1997
2004
  async persistUpdate(update) {
@@ -2015,6 +2022,25 @@ var OfflineStore = class {
2015
2022
  const tx = db.transaction("updates", "readwrite");
2016
2023
  await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
2017
2024
  }
2025
+ /**
2026
+ * Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
2027
+ * Replaces any previously stored snapshot.
2028
+ */
2029
+ async saveDocSnapshot(snapshot) {
2030
+ const db = await this.getDb();
2031
+ if (!db) return;
2032
+ const tx = db.transaction("doc_state", "readwrite");
2033
+ await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
2034
+ }
2035
+ /**
2036
+ * Retrieve the stored full document snapshot, or null if none exists.
2037
+ */
2038
+ async getDocSnapshot() {
2039
+ const db = await this.getDb();
2040
+ if (!db) return null;
2041
+ const tx = db.transaction("doc_state", "readonly");
2042
+ return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
2043
+ }
2018
2044
  async getStateVector() {
2019
2045
  const db = await this.getDb();
2020
2046
  if (!db) return null;
@@ -2108,10 +2134,16 @@ function isValidDocId(id) {
2108
2134
  * own AbracadabraProvider instances sharing the same WebSocket connection.
2109
2135
  *
2110
2136
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
2111
- * page reloads and network outages. On reconnect, pending updates are
2112
- * flushed before resuming normal sync.
2137
+ * page reloads and network outages. On startup, the last saved document
2138
+ * snapshot is applied immediately so the UI is usable without a server
2139
+ * connection. On reconnect, pending updates are flushed and a fresh
2140
+ * snapshot is saved.
2141
+ *
2142
+ * 3. Server-scoped storage – the IndexedDB database is keyed by server
2143
+ * hostname + docId, preventing cross-server data contamination when the
2144
+ * same docId is used on multiple servers.
2113
2145
  *
2114
- * 3. Permission snapshotting – stores the resolved role locally so the UI
2146
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
2115
2147
  * can gate write operations without a network round-trip. Role is
2116
2148
  * refreshed from the server on every reconnect.
2117
2149
  */
@@ -2131,12 +2163,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2131
2163
  this._client = client;
2132
2164
  this.abracadabraConfig = configuration;
2133
2165
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
2134
- this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name);
2166
+ const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
2167
+ this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
2135
2168
  this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
2136
2169
  this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
2137
2170
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2138
2171
  this.on("synced", () => this.flushPendingUpdates());
2139
2172
  this.restorePermissionSnapshot();
2173
+ this.ready = this._initFromOfflineStore();
2174
+ }
2175
+ /**
2176
+ * Extract the server hostname from the provider configuration.
2177
+ * Used to namespace the IndexedDB key so docs from different servers
2178
+ * never share the same database.
2179
+ */
2180
+ static deriveServerOrigin(config, client) {
2181
+ try {
2182
+ const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
2183
+ if (url) return new URL(url).hostname;
2184
+ } catch {}
2185
+ }
2186
+ /**
2187
+ * Load the stored document snapshot (and any pending local edits) from
2188
+ * IndexedDB and apply them to the Y.Doc so the UI can render without a
2189
+ * server connection.
2190
+ *
2191
+ * Uses `this.offlineStore` as the Y.js update origin so that
2192
+ * `documentUpdateHandler` ignores these replayed updates and does not
2193
+ * attempt to re-persist or re-send them.
2194
+ */
2195
+ async _initFromOfflineStore() {
2196
+ if (!this.offlineStore) return;
2197
+ const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
2198
+ if (snapshot) Y.applyUpdate(this.document, snapshot, this.offlineStore);
2199
+ const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
2200
+ for (const update of pending) Y.applyUpdate(this.document, update, this.offlineStore);
2140
2201
  }
2141
2202
  authenticatedHandler(scope) {
2142
2203
  super.authenticatedHandler(scope);
@@ -2274,7 +2335,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2274
2335
  }
2275
2336
  async _doLoadChild(childId) {
2276
2337
  const childDoc = new Y.Doc({ guid: childId });
2277
- await new Promise((resolve) => {
2338
+ if (this.isConnected) await new Promise((resolve) => {
2278
2339
  const onRegistered = ({ childId: cid }) => {
2279
2340
  if (cid === childId) {
2280
2341
  this.off("subdocRegistered", onRegistered);
@@ -2285,6 +2346,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2285
2346
  this.registerSubdoc(childDoc);
2286
2347
  setTimeout(resolve, 3e3);
2287
2348
  });
2349
+ else this.registerSubdoc(childDoc);
2288
2350
  const childProvider = new AbracadabraProvider({
2289
2351
  name: childId,
2290
2352
  document: childDoc,
@@ -2317,31 +2379,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2317
2379
  /**
2318
2380
  * Override to persist every local update to IndexedDB before sending it
2319
2381
  * over the wire, ensuring no work is lost during connection outages.
2382
+ *
2383
+ * Updates applied by the server (origin === this) and updates replayed
2384
+ * from the offline store (origin === this.offlineStore) are both skipped
2385
+ * to avoid loops.
2320
2386
  */
2321
2387
  documentUpdateHandler(update, origin) {
2322
2388
  if (origin === this) return;
2389
+ if (this.offlineStore !== null && origin === this.offlineStore) return;
2323
2390
  this.offlineStore?.persistUpdate(update).catch(() => null);
2324
2391
  super.documentUpdateHandler(update, origin);
2325
2392
  }
2326
2393
  /**
2327
- * After reconnect + sync, flush any updates that were generated while
2328
- * offline, then flush any queued subdoc registrations.
2394
+ * After reconnect + sync:
2395
+ * 1. Flush any updates that were generated while offline.
2396
+ * 2. Flush any queued subdoc registrations.
2397
+ * 3. Save a fresh full document snapshot for the next offline session.
2398
+ *
2399
+ * Uses a local `store` reference captured at the start so that a concurrent
2400
+ * `destroy()` call setting `offlineStore = null` does not cause null-ref
2401
+ * errors across async await boundaries.
2329
2402
  */
2330
2403
  async flushPendingUpdates() {
2331
- if (!this.offlineStore) return;
2332
- const updates = await this.offlineStore.getPendingUpdates();
2404
+ const store = this.offlineStore;
2405
+ if (!store) return;
2406
+ const updates = await store.getPendingUpdates();
2333
2407
  if (updates.length > 0) {
2334
2408
  for (const update of updates) this.send(UpdateMessage, {
2335
2409
  update,
2336
2410
  documentName: this.configuration.name
2337
2411
  });
2338
- await this.offlineStore.clearPendingUpdates();
2412
+ await store.clearPendingUpdates();
2339
2413
  }
2340
- const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
2414
+ const pendingSubdocs = await store.getPendingSubdocs();
2341
2415
  for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
2342
2416
  documentName: this.configuration.name,
2343
2417
  childDocumentName: childId
2344
2418
  });
2419
+ const snapshot = Y.encodeStateAsUpdate(this.document);
2420
+ await store.saveDocSnapshot(snapshot).catch(() => null);
2345
2421
  }
2346
2422
  get isConnected() {
2347
2423
  return this.configuration.websocketProvider.status === "connected";