@abraca/dabra 0.1.7 → 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.
@@ -1381,10 +1381,15 @@ var HocuspocusProviderWebsocket = class extends EventEmitter {
1381
1381
  if (this.connectionAttempt) this.rejectConnectionAttempt();
1382
1382
  this.status = WebSocketStatus.Disconnected;
1383
1383
  this.emit("status", { status: WebSocketStatus.Disconnected });
1384
+ const isRateLimited = event?.code === 4429;
1384
1385
  this.emit("disconnect", { event });
1385
- if (!this.cancelWebsocketRetry && this.shouldConnect) setTimeout(() => {
1386
- this.connect();
1387
- }, this.configuration.delay);
1386
+ if (isRateLimited) this.emit("rateLimited");
1387
+ if (!this.cancelWebsocketRetry && this.shouldConnect) {
1388
+ const delay = isRateLimited ? 6e4 : this.configuration.delay;
1389
+ setTimeout(() => {
1390
+ this.connect();
1391
+ }, delay);
1392
+ }
1388
1393
  }
1389
1394
  destroy() {
1390
1395
  this.emit("destroy");
@@ -1707,6 +1712,7 @@ var HocuspocusProvider = class extends EventEmitter {
1707
1712
  forceSyncInterval: false,
1708
1713
  onAuthenticated: () => null,
1709
1714
  onAuthenticationFailed: () => null,
1715
+ onRateLimited: () => null,
1710
1716
  onOpen: () => null,
1711
1717
  onConnect: () => null,
1712
1718
  onMessage: () => null,
@@ -1738,6 +1744,7 @@ var HocuspocusProvider = class extends EventEmitter {
1738
1744
  this.forwardClose = (e) => this.emit("close", e);
1739
1745
  this.forwardDisconnect = (e) => this.emit("disconnect", e);
1740
1746
  this.forwardDestroy = () => this.emit("destroy");
1747
+ this.forwardRateLimited = () => this.emit("rateLimited");
1741
1748
  this.setConfiguration(configuration);
1742
1749
  this.configuration.document = configuration.document ? configuration.document : new yjs.Doc();
1743
1750
  this.configuration.awareness = configuration.awareness !== void 0 ? configuration.awareness : new Awareness(this.document);
@@ -1752,6 +1759,7 @@ var HocuspocusProvider = class extends EventEmitter {
1752
1759
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
1753
1760
  this.on("authenticated", this.configuration.onAuthenticated);
1754
1761
  this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
1762
+ this.on("rateLimited", this.configuration.onRateLimited);
1755
1763
  this.awareness?.on("update", () => {
1756
1764
  this.emit("awarenessUpdate", { states: (0, _abraca_dabra_common.awarenessStatesToArray)(this.awareness.getStates()) });
1757
1765
  });
@@ -1944,6 +1952,7 @@ var HocuspocusProvider = class extends EventEmitter {
1944
1952
  this.configuration.websocketProvider.off("disconnect", this.forwardDisconnect);
1945
1953
  this.configuration.websocketProvider.off("destroy", this.configuration.onDestroy);
1946
1954
  this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
1955
+ this.configuration.websocketProvider.off("rateLimited", this.forwardRateLimited);
1947
1956
  this.configuration.websocketProvider.detach(this);
1948
1957
  this._isAttached = false;
1949
1958
  }
@@ -1961,6 +1970,7 @@ var HocuspocusProvider = class extends EventEmitter {
1961
1970
  this.configuration.websocketProvider.on("disconnect", this.forwardDisconnect);
1962
1971
  this.configuration.websocketProvider.on("destroy", this.configuration.onDestroy);
1963
1972
  this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
1973
+ this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
1964
1974
  this.configuration.websocketProvider.attach(this);
1965
1975
  this._isAttached = true;
1966
1976
  }
@@ -1981,18 +1991,19 @@ var HocuspocusProvider = class extends EventEmitter {
1981
1991
 
1982
1992
  //#endregion
1983
1993
  //#region packages/provider/src/OfflineStore.ts
1984
- const DB_VERSION = 1;
1994
+ const DB_VERSION = 2;
1985
1995
  function idbAvailable() {
1986
1996
  return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
1987
1997
  }
1988
- function openDb$1(docId) {
1998
+ function openDb$1(storeKey) {
1989
1999
  return new Promise((resolve, reject) => {
1990
- const req = globalThis.indexedDB.open(`abracadabra:${docId}`, DB_VERSION);
2000
+ const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
1991
2001
  req.onupgradeneeded = (event) => {
1992
2002
  const db = event.target.result;
1993
2003
  if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
1994
2004
  if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
1995
2005
  if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
2006
+ if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
1996
2007
  };
1997
2008
  req.onsuccess = () => resolve(req.result);
1998
2009
  req.onerror = () => reject(req.error);
@@ -2005,13 +2016,19 @@ function txPromise(store, request) {
2005
2016
  });
2006
2017
  }
2007
2018
  var OfflineStore = class {
2008
- 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) {
2009
2026
  this.db = null;
2010
- this.docId = docId;
2027
+ this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
2011
2028
  }
2012
2029
  async getDb() {
2013
2030
  if (!idbAvailable()) return null;
2014
- 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);
2015
2032
  return this.db;
2016
2033
  }
2017
2034
  async persistUpdate(update) {
@@ -2035,6 +2052,25 @@ var OfflineStore = class {
2035
2052
  const tx = db.transaction("updates", "readwrite");
2036
2053
  await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
2037
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
+ }
2038
2074
  async getStateVector() {
2039
2075
  const db = await this.getDb();
2040
2076
  if (!db) return null;
@@ -2128,10 +2164,16 @@ function isValidDocId(id) {
2128
2164
  * own AbracadabraProvider instances sharing the same WebSocket connection.
2129
2165
  *
2130
2166
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
2131
- * page reloads and network outages. On reconnect, pending updates are
2132
- * 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.
2133
2175
  *
2134
- * 3. Permission snapshotting – stores the resolved role locally so the UI
2176
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
2135
2177
  * can gate write operations without a network round-trip. Role is
2136
2178
  * refreshed from the server on every reconnect.
2137
2179
  */
@@ -2151,12 +2193,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2151
2193
  this._client = client;
2152
2194
  this.abracadabraConfig = configuration;
2153
2195
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
2154
- 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);
2155
2198
  this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
2156
2199
  this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
2157
2200
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2158
2201
  this.on("synced", () => this.flushPendingUpdates());
2159
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);
2160
2231
  }
2161
2232
  authenticatedHandler(scope) {
2162
2233
  super.authenticatedHandler(scope);
@@ -2283,9 +2354,9 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2283
2354
  * child document id. Each child opens its own WebSocket connection because
2284
2355
  * the server is document-scoped (one WebSocket ↔ one document).
2285
2356
  */
2286
- async loadChild(childId) {
2287
- if (!isValidDocId(childId)) throw new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`);
2288
- if (this.childProviders.has(childId)) return this.childProviders.get(childId);
2357
+ loadChild(childId) {
2358
+ if (!isValidDocId(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
2359
+ if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
2289
2360
  if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
2290
2361
  const load = this._doLoadChild(childId);
2291
2362
  this.pendingLoads.set(childId, load);
@@ -2294,7 +2365,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2294
2365
  }
2295
2366
  async _doLoadChild(childId) {
2296
2367
  const childDoc = new yjs.Doc({ guid: childId });
2297
- await new Promise((resolve) => {
2368
+ if (this.isConnected) await new Promise((resolve) => {
2298
2369
  const onRegistered = ({ childId: cid }) => {
2299
2370
  if (cid === childId) {
2300
2371
  this.off("subdocRegistered", onRegistered);
@@ -2305,6 +2376,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2305
2376
  this.registerSubdoc(childDoc);
2306
2377
  setTimeout(resolve, 3e3);
2307
2378
  });
2379
+ else this.registerSubdoc(childDoc);
2308
2380
  const childProvider = new AbracadabraProvider({
2309
2381
  name: childId,
2310
2382
  document: childDoc,
@@ -2337,31 +2409,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2337
2409
  /**
2338
2410
  * Override to persist every local update to IndexedDB before sending it
2339
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.
2340
2416
  */
2341
2417
  documentUpdateHandler(update, origin) {
2342
2418
  if (origin === this) return;
2419
+ if (this.offlineStore !== null && origin === this.offlineStore) return;
2343
2420
  this.offlineStore?.persistUpdate(update).catch(() => null);
2344
2421
  super.documentUpdateHandler(update, origin);
2345
2422
  }
2346
2423
  /**
2347
- * After reconnect + sync, flush any updates that were generated while
2348
- * 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.
2349
2432
  */
2350
2433
  async flushPendingUpdates() {
2351
- if (!this.offlineStore) return;
2352
- const updates = await this.offlineStore.getPendingUpdates();
2434
+ const store = this.offlineStore;
2435
+ if (!store) return;
2436
+ const updates = await store.getPendingUpdates();
2353
2437
  if (updates.length > 0) {
2354
2438
  for (const update of updates) this.send(UpdateMessage, {
2355
2439
  update,
2356
2440
  documentName: this.configuration.name
2357
2441
  });
2358
- await this.offlineStore.clearPendingUpdates();
2442
+ await store.clearPendingUpdates();
2359
2443
  }
2360
- const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
2444
+ const pendingSubdocs = await store.getPendingSubdocs();
2361
2445
  for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
2362
2446
  documentName: this.configuration.name,
2363
2447
  childDocumentName: childId
2364
2448
  });
2449
+ const snapshot = yjs.encodeStateAsUpdate(this.document);
2450
+ await store.saveDocSnapshot(snapshot).catch(() => null);
2365
2451
  }
2366
2452
  get isConnected() {
2367
2453
  return this.configuration.websocketProvider.status === "connected";