@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.
@@ -1351,10 +1351,15 @@ var HocuspocusProviderWebsocket = class extends EventEmitter {
1351
1351
  if (this.connectionAttempt) this.rejectConnectionAttempt();
1352
1352
  this.status = WebSocketStatus.Disconnected;
1353
1353
  this.emit("status", { status: WebSocketStatus.Disconnected });
1354
+ const isRateLimited = event?.code === 4429;
1354
1355
  this.emit("disconnect", { event });
1355
- if (!this.cancelWebsocketRetry && this.shouldConnect) setTimeout(() => {
1356
- this.connect();
1357
- }, this.configuration.delay);
1356
+ if (isRateLimited) this.emit("rateLimited");
1357
+ if (!this.cancelWebsocketRetry && this.shouldConnect) {
1358
+ const delay = isRateLimited ? 6e4 : this.configuration.delay;
1359
+ setTimeout(() => {
1360
+ this.connect();
1361
+ }, delay);
1362
+ }
1358
1363
  }
1359
1364
  destroy() {
1360
1365
  this.emit("destroy");
@@ -1677,6 +1682,7 @@ var HocuspocusProvider = class extends EventEmitter {
1677
1682
  forceSyncInterval: false,
1678
1683
  onAuthenticated: () => null,
1679
1684
  onAuthenticationFailed: () => null,
1685
+ onRateLimited: () => null,
1680
1686
  onOpen: () => null,
1681
1687
  onConnect: () => null,
1682
1688
  onMessage: () => null,
@@ -1708,6 +1714,7 @@ var HocuspocusProvider = class extends EventEmitter {
1708
1714
  this.forwardClose = (e) => this.emit("close", e);
1709
1715
  this.forwardDisconnect = (e) => this.emit("disconnect", e);
1710
1716
  this.forwardDestroy = () => this.emit("destroy");
1717
+ this.forwardRateLimited = () => this.emit("rateLimited");
1711
1718
  this.setConfiguration(configuration);
1712
1719
  this.configuration.document = configuration.document ? configuration.document : new Y.Doc();
1713
1720
  this.configuration.awareness = configuration.awareness !== void 0 ? configuration.awareness : new Awareness(this.document);
@@ -1722,6 +1729,7 @@ var HocuspocusProvider = class extends EventEmitter {
1722
1729
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
1723
1730
  this.on("authenticated", this.configuration.onAuthenticated);
1724
1731
  this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
1732
+ this.on("rateLimited", this.configuration.onRateLimited);
1725
1733
  this.awareness?.on("update", () => {
1726
1734
  this.emit("awarenessUpdate", { states: awarenessStatesToArray(this.awareness.getStates()) });
1727
1735
  });
@@ -1914,6 +1922,7 @@ var HocuspocusProvider = class extends EventEmitter {
1914
1922
  this.configuration.websocketProvider.off("disconnect", this.forwardDisconnect);
1915
1923
  this.configuration.websocketProvider.off("destroy", this.configuration.onDestroy);
1916
1924
  this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
1925
+ this.configuration.websocketProvider.off("rateLimited", this.forwardRateLimited);
1917
1926
  this.configuration.websocketProvider.detach(this);
1918
1927
  this._isAttached = false;
1919
1928
  }
@@ -1931,6 +1940,7 @@ var HocuspocusProvider = class extends EventEmitter {
1931
1940
  this.configuration.websocketProvider.on("disconnect", this.forwardDisconnect);
1932
1941
  this.configuration.websocketProvider.on("destroy", this.configuration.onDestroy);
1933
1942
  this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
1943
+ this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
1934
1944
  this.configuration.websocketProvider.attach(this);
1935
1945
  this._isAttached = true;
1936
1946
  }
@@ -1951,18 +1961,19 @@ var HocuspocusProvider = class extends EventEmitter {
1951
1961
 
1952
1962
  //#endregion
1953
1963
  //#region packages/provider/src/OfflineStore.ts
1954
- const DB_VERSION = 1;
1964
+ const DB_VERSION = 2;
1955
1965
  function idbAvailable() {
1956
1966
  return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
1957
1967
  }
1958
- function openDb$1(docId) {
1968
+ function openDb$1(storeKey) {
1959
1969
  return new Promise((resolve, reject) => {
1960
- const req = globalThis.indexedDB.open(`abracadabra:${docId}`, DB_VERSION);
1970
+ const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
1961
1971
  req.onupgradeneeded = (event) => {
1962
1972
  const db = event.target.result;
1963
1973
  if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
1964
1974
  if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
1965
1975
  if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
1976
+ if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
1966
1977
  };
1967
1978
  req.onsuccess = () => resolve(req.result);
1968
1979
  req.onerror = () => reject(req.error);
@@ -1975,13 +1986,19 @@ function txPromise(store, request) {
1975
1986
  });
1976
1987
  }
1977
1988
  var OfflineStore = class {
1978
- 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) {
1979
1996
  this.db = null;
1980
- this.docId = docId;
1997
+ this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
1981
1998
  }
1982
1999
  async getDb() {
1983
2000
  if (!idbAvailable()) return null;
1984
- 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);
1985
2002
  return this.db;
1986
2003
  }
1987
2004
  async persistUpdate(update) {
@@ -2005,6 +2022,25 @@ var OfflineStore = class {
2005
2022
  const tx = db.transaction("updates", "readwrite");
2006
2023
  await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
2007
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
+ }
2008
2044
  async getStateVector() {
2009
2045
  const db = await this.getDb();
2010
2046
  if (!db) return null;
@@ -2098,10 +2134,16 @@ function isValidDocId(id) {
2098
2134
  * own AbracadabraProvider instances sharing the same WebSocket connection.
2099
2135
  *
2100
2136
  * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
2101
- * page reloads and network outages. On reconnect, pending updates are
2102
- * 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.
2103
2145
  *
2104
- * 3. Permission snapshotting – stores the resolved role locally so the UI
2146
+ * 4. Permission snapshotting – stores the resolved role locally so the UI
2105
2147
  * can gate write operations without a network round-trip. Role is
2106
2148
  * refreshed from the server on every reconnect.
2107
2149
  */
@@ -2121,12 +2163,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2121
2163
  this._client = client;
2122
2164
  this.abracadabraConfig = configuration;
2123
2165
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
2124
- 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);
2125
2168
  this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
2126
2169
  this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
2127
2170
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2128
2171
  this.on("synced", () => this.flushPendingUpdates());
2129
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);
2130
2201
  }
2131
2202
  authenticatedHandler(scope) {
2132
2203
  super.authenticatedHandler(scope);
@@ -2253,9 +2324,9 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2253
2324
  * child document id. Each child opens its own WebSocket connection because
2254
2325
  * the server is document-scoped (one WebSocket ↔ one document).
2255
2326
  */
2256
- async loadChild(childId) {
2257
- 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.`);
2258
- if (this.childProviders.has(childId)) return this.childProviders.get(childId);
2327
+ loadChild(childId) {
2328
+ 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.`));
2329
+ if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
2259
2330
  if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
2260
2331
  const load = this._doLoadChild(childId);
2261
2332
  this.pendingLoads.set(childId, load);
@@ -2264,7 +2335,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2264
2335
  }
2265
2336
  async _doLoadChild(childId) {
2266
2337
  const childDoc = new Y.Doc({ guid: childId });
2267
- await new Promise((resolve) => {
2338
+ if (this.isConnected) await new Promise((resolve) => {
2268
2339
  const onRegistered = ({ childId: cid }) => {
2269
2340
  if (cid === childId) {
2270
2341
  this.off("subdocRegistered", onRegistered);
@@ -2275,6 +2346,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2275
2346
  this.registerSubdoc(childDoc);
2276
2347
  setTimeout(resolve, 3e3);
2277
2348
  });
2349
+ else this.registerSubdoc(childDoc);
2278
2350
  const childProvider = new AbracadabraProvider({
2279
2351
  name: childId,
2280
2352
  document: childDoc,
@@ -2307,31 +2379,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
2307
2379
  /**
2308
2380
  * Override to persist every local update to IndexedDB before sending it
2309
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.
2310
2386
  */
2311
2387
  documentUpdateHandler(update, origin) {
2312
2388
  if (origin === this) return;
2389
+ if (this.offlineStore !== null && origin === this.offlineStore) return;
2313
2390
  this.offlineStore?.persistUpdate(update).catch(() => null);
2314
2391
  super.documentUpdateHandler(update, origin);
2315
2392
  }
2316
2393
  /**
2317
- * After reconnect + sync, flush any updates that were generated while
2318
- * 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.
2319
2402
  */
2320
2403
  async flushPendingUpdates() {
2321
- if (!this.offlineStore) return;
2322
- const updates = await this.offlineStore.getPendingUpdates();
2404
+ const store = this.offlineStore;
2405
+ if (!store) return;
2406
+ const updates = await store.getPendingUpdates();
2323
2407
  if (updates.length > 0) {
2324
2408
  for (const update of updates) this.send(UpdateMessage, {
2325
2409
  update,
2326
2410
  documentName: this.configuration.name
2327
2411
  });
2328
- await this.offlineStore.clearPendingUpdates();
2412
+ await store.clearPendingUpdates();
2329
2413
  }
2330
- const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
2414
+ const pendingSubdocs = await store.getPendingSubdocs();
2331
2415
  for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
2332
2416
  documentName: this.configuration.name,
2333
2417
  childDocumentName: childId
2334
2418
  });
2419
+ const snapshot = Y.encodeStateAsUpdate(this.document);
2420
+ await store.saveDocSnapshot(snapshot).catch(() => null);
2335
2421
  }
2336
2422
  get isConnected() {
2337
2423
  return this.configuration.websocketProvider.status === "connected";