@abraca/dabra 2.8.0 → 2.10.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.
@@ -1203,6 +1203,8 @@ var AbracadabraWS = class extends EventEmitter {
1203
1203
  this.identifier = 0;
1204
1204
  this.intervals = { connectionChecker: null };
1205
1205
  this.connectionAttempt = null;
1206
+ this.onlineListener = null;
1207
+ this.offlineListener = null;
1206
1208
  this.receivedOnOpenPayload = void 0;
1207
1209
  this.closeTries = 0;
1208
1210
  this.setConfiguration(configuration);
@@ -1221,8 +1223,39 @@ var AbracadabraWS = class extends EventEmitter {
1221
1223
  this.on("close", this.onClose.bind(this));
1222
1224
  this.on("message", this.onMessage.bind(this));
1223
1225
  this.intervals.connectionChecker = setInterval(this.checkConnection.bind(this), this.configuration.messageReconnectTimeout / 10);
1226
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1227
+ this.onlineListener = this.handleOnline.bind(this);
1228
+ this.offlineListener = this.handleOffline.bind(this);
1229
+ window.addEventListener("online", this.onlineListener);
1230
+ window.addEventListener("offline", this.offlineListener);
1231
+ }
1224
1232
  if (this.shouldConnect) this.connect();
1225
1233
  }
1234
+ /**
1235
+ * Whether the device currently believes it has network connectivity.
1236
+ *
1237
+ * Treats "unknown" as online: in Node and other non-browser environments
1238
+ * `navigator` (or `navigator.onLine`) is absent, and we must not gate
1239
+ * reconnection there — only the browser exposes a trustworthy signal.
1240
+ */
1241
+ get isOnline() {
1242
+ return typeof navigator === "undefined" || navigator.onLine !== false;
1243
+ }
1244
+ handleOnline() {
1245
+ if (this.shouldConnect && this.status !== WebSocketStatus.Connected) this.connect();
1246
+ }
1247
+ handleOffline() {
1248
+ if (this.cancelWebsocketRetry) {
1249
+ this.cancelWebsocketRetry();
1250
+ this.cancelWebsocketRetry = void 0;
1251
+ }
1252
+ try {
1253
+ this.webSocket?.close();
1254
+ this.messageQueue = [];
1255
+ } catch (e) {
1256
+ console.error(e);
1257
+ }
1258
+ }
1226
1259
  async onOpen(event) {
1227
1260
  this.status = WebSocketStatus.Connected;
1228
1261
  this.emit("status", { status: WebSocketStatus.Connected });
@@ -1252,6 +1285,10 @@ var AbracadabraWS = class extends EventEmitter {
1252
1285
  }
1253
1286
  async connect() {
1254
1287
  if (this.status === WebSocketStatus.Connected) return;
1288
+ if (!this.isOnline) {
1289
+ this.shouldConnect = true;
1290
+ return;
1291
+ }
1255
1292
  if (this.cancelWebsocketRetry) {
1256
1293
  this.cancelWebsocketRetry();
1257
1294
  this.cancelWebsocketRetry = void 0;
@@ -1416,7 +1453,7 @@ var AbracadabraWS = class extends EventEmitter {
1416
1453
  const isRateLimited = event?.code === 4429 || event === 4429;
1417
1454
  this.emit("disconnect", { event });
1418
1455
  if (isRateLimited) this.emit("rateLimited");
1419
- if (!this.cancelWebsocketRetry && this.shouldConnect) {
1456
+ if (!this.cancelWebsocketRetry && this.shouldConnect && this.isOnline) {
1420
1457
  const delay = isRateLimited ? 6e4 : this.configuration.delay;
1421
1458
  setTimeout(() => {
1422
1459
  this.connect();
@@ -1426,6 +1463,12 @@ var AbracadabraWS = class extends EventEmitter {
1426
1463
  destroy() {
1427
1464
  this.shouldConnect = false;
1428
1465
  this.emit("destroy");
1466
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
1467
+ if (this.onlineListener) window.removeEventListener("online", this.onlineListener);
1468
+ if (this.offlineListener) window.removeEventListener("offline", this.offlineListener);
1469
+ }
1470
+ this.onlineListener = null;
1471
+ this.offlineListener = null;
1429
1472
  clearInterval(this.intervals.connectionChecker);
1430
1473
  this.stopConnectionAttempt();
1431
1474
  this.disconnect();
@@ -15917,6 +15960,31 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15917
15960
  function isValidDocId(id) {
15918
15961
  return UUID_RE.test(id);
15919
15962
  }
15963
+ /**
15964
+ * Consecutive transient failures before a doc is promoted from the calm
15965
+ * `retrying` status to a genuine `error`. Keeps a single timeout/network blip
15966
+ * from flashing a red error badge — the doc only "counts" as broken once it
15967
+ * has failed this many passes in a row.
15968
+ */
15969
+ const FAILURE_THRESHOLD = 3;
15970
+ /** Delay before a one-shot follow-up retry of stragglers after `syncAll()`. */
15971
+ const STRAGGLER_RETRY_DELAY_MS = 6e4;
15972
+ /**
15973
+ * Decide whether a sync failure is permanent (the server will never accept
15974
+ * this doc — deleted, forbidden, or an unparseable id) or merely transient
15975
+ * (timeout, dropped socket, momentary 5xx). Permanent failures are parked as
15976
+ * `unavailable` and never retried; transient ones go through `retrying`.
15977
+ *
15978
+ * Best-effort: inspects a numeric `status`/`code` if the thrown value carries
15979
+ * one, otherwise falls back to message keywords. Defaults to transient so we
15980
+ * never give up on a doc we're unsure about.
15981
+ */
15982
+ function isPermanentSyncError(err) {
15983
+ const status = typeof err === "object" && err !== null ? Number(err.status ?? err.statusCode ?? err.code) : NaN;
15984
+ if (status === 403 || status === 404 || status === 410 || status === 422) return true;
15985
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
15986
+ return /\b(403|404|410|422)\b/.test(msg) || msg.includes("forbidden") || msg.includes("not found") || msg.includes("does not exist") || msg.includes("unprocessable");
15987
+ }
15920
15988
  var BackgroundSyncManager = class extends EventEmitter {
15921
15989
  constructor(rootProvider, client, fileBlobStore, opts) {
15922
15990
  super();
@@ -15924,12 +15992,13 @@ var BackgroundSyncManager = class extends EventEmitter {
15924
15992
  this._warnedInvalidIds = /* @__PURE__ */ new Set();
15925
15993
  this._destroyed = false;
15926
15994
  this._initPromise = null;
15995
+ this._stragglerTimer = null;
15927
15996
  this.rootProvider = rootProvider;
15928
15997
  this.client = client;
15929
15998
  this.fileBlobStore = fileBlobStore ?? null;
15930
15999
  this.opts = {
15931
16000
  concurrency: opts?.concurrency ?? 2,
15932
- syncTimeout: opts?.syncTimeout ?? 15e3,
16001
+ syncTimeout: opts?.syncTimeout ?? 3e4,
15933
16002
  prefetchFiles: opts?.prefetchFiles ?? true,
15934
16003
  throttleMs: opts?.throttleMs ?? 200,
15935
16004
  maxRetries: opts?.maxRetries ?? 2
@@ -15998,6 +16067,28 @@ var BackgroundSyncManager = class extends EventEmitter {
15998
16067
  const retryWorkers = Array.from({ length: this.opts.concurrency }, () => retryNext());
15999
16068
  await Promise.all(retryWorkers);
16000
16069
  }
16070
+ if (failed.length > 0) this._scheduleStragglerRetry(failed.slice());
16071
+ }
16072
+ /**
16073
+ * Schedule a single delayed retry of the given docs. Replaces any pending
16074
+ * straggler timer so repeated `syncAll()` calls don't stack timers.
16075
+ */
16076
+ _scheduleStragglerRetry(docIds) {
16077
+ if (this._destroyed || docIds.length === 0) return;
16078
+ if (this._stragglerTimer) clearTimeout(this._stragglerTimer);
16079
+ this._stragglerTimer = setTimeout(() => {
16080
+ this._stragglerTimer = null;
16081
+ if (this._destroyed) return;
16082
+ (async () => {
16083
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
16084
+ for (const docId of docIds) {
16085
+ if (this._destroyed) return;
16086
+ const entry = treeMap.get(docId);
16087
+ const updatedAt = entry?.updatedAt ?? entry?.createdAt ?? 0;
16088
+ await this._syncWithSemaphore(docId, updatedAt);
16089
+ }
16090
+ })();
16091
+ }, STRAGGLER_RETRY_DELAY_MS);
16001
16092
  }
16002
16093
  /** Sync a single document by ID. */
16003
16094
  async syncDoc(docId) {
@@ -16054,6 +16145,10 @@ var BackgroundSyncManager = class extends EventEmitter {
16054
16145
  }
16055
16146
  destroy() {
16056
16147
  this._destroyed = true;
16148
+ if (this._stragglerTimer) {
16149
+ clearTimeout(this._stragglerTimer);
16150
+ this._stragglerTimer = null;
16151
+ }
16057
16152
  this.removeAllListeners();
16058
16153
  }
16059
16154
  /**
@@ -16075,18 +16170,20 @@ var BackgroundSyncManager = class extends EventEmitter {
16075
16170
  console.warn(`[BackgroundSyncManager] skipping non-UUID doc id "${docId}" in doc-tree — server would reject it (422). Likely a stale entry from an earlier seeder; remove it from the tree to silence this.`);
16076
16171
  }
16077
16172
  }
16078
- const items = filtered.map(([docId, v]) => {
16173
+ const items = [];
16174
+ for (const [docId, v] of filtered) {
16079
16175
  const state = this.syncStates.get(docId);
16176
+ if (state?.status === "unavailable") continue;
16080
16177
  const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
16081
16178
  let priority;
16082
16179
  if (!state || state.status === "pending") priority = Number.MAX_SAFE_INTEGER - updatedAt;
16083
- else if (state.status === "error") priority = -1;
16180
+ else if (state.status === "error" || state.status === "retrying") priority = -1;
16084
16181
  else priority = updatedAt;
16085
- return {
16182
+ items.push({
16086
16183
  docId,
16087
16184
  priority
16088
- };
16089
- });
16185
+ });
16186
+ }
16090
16187
  items.sort((a, b) => b.priority - a.priority);
16091
16188
  return items.map((i) => i.docId);
16092
16189
  }
@@ -16094,6 +16191,13 @@ var BackgroundSyncManager = class extends EventEmitter {
16094
16191
  async _syncWithSemaphore(docId, updatedAt) {
16095
16192
  if (this._destroyed) return true;
16096
16193
  const existing = this.syncStates.get(docId);
16194
+ if (existing && existing.status === "unavailable") {
16195
+ this.emit("stateChanged", {
16196
+ docId,
16197
+ state: existing
16198
+ });
16199
+ return true;
16200
+ }
16097
16201
  if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
16098
16202
  this.emit("stateChanged", {
16099
16203
  docId,
@@ -16110,12 +16214,20 @@ var BackgroundSyncManager = class extends EventEmitter {
16110
16214
  docId,
16111
16215
  state
16112
16216
  });
16113
- return state.status !== "error";
16217
+ return state.status !== "error" && state.status !== "retrying";
16114
16218
  } finally {
16115
16219
  this.semaphore.release();
16116
16220
  }
16117
16221
  }
16118
16222
  async _doSyncDoc(docId) {
16223
+ if (!isValidDocId(docId)) return {
16224
+ docId,
16225
+ status: "unavailable",
16226
+ lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
16227
+ error: "Invalid document id (not a UUID)",
16228
+ consecutiveFailures: 0,
16229
+ isE2E: false
16230
+ };
16119
16231
  const syncing = {
16120
16232
  docId,
16121
16233
  status: "syncing",
@@ -16136,11 +16248,23 @@ var BackgroundSyncManager = class extends EventEmitter {
16136
16248
  else return await this._syncNonE2EDoc(docId);
16137
16249
  } catch (err) {
16138
16250
  const error = err instanceof Error ? err.message : String(err);
16251
+ const prev = this.syncStates.get(docId);
16252
+ const lastSynced = prev?.lastSynced ?? null;
16253
+ if (isPermanentSyncError(err)) return {
16254
+ docId,
16255
+ status: "unavailable",
16256
+ lastSynced,
16257
+ error,
16258
+ consecutiveFailures: 0,
16259
+ isE2E
16260
+ };
16261
+ const consecutiveFailures = (prev?.consecutiveFailures ?? 0) + 1;
16139
16262
  return {
16140
16263
  docId,
16141
- status: "error",
16142
- lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
16264
+ status: consecutiveFailures >= FAILURE_THRESHOLD ? "error" : "retrying",
16265
+ lastSynced,
16143
16266
  error,
16267
+ consecutiveFailures,
16144
16268
  isE2E
16145
16269
  };
16146
16270
  }
@@ -16153,6 +16277,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16153
16277
  docId,
16154
16278
  status: "synced",
16155
16279
  lastSynced: Date.now(),
16280
+ consecutiveFailures: 0,
16156
16281
  isE2E: false
16157
16282
  };
16158
16283
  }
@@ -16176,6 +16301,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16176
16301
  docId,
16177
16302
  status: "synced",
16178
16303
  lastSynced: Math.max(Date.now(), treeUpdatedAt),
16304
+ consecutiveFailures: 0,
16179
16305
  isE2E: false
16180
16306
  };
16181
16307
  } finally {
@@ -16190,6 +16316,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16190
16316
  docId,
16191
16317
  status: "skipped",
16192
16318
  lastSynced: null,
16319
+ consecutiveFailures: 0,
16193
16320
  isE2E: true
16194
16321
  };
16195
16322
  const childDoc = new Y.Doc({ guid: docId });
@@ -16219,6 +16346,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16219
16346
  docId,
16220
16347
  status: "synced",
16221
16348
  lastSynced: Math.max(Date.now(), treeUpdatedAt),
16349
+ consecutiveFailures: 0,
16222
16350
  isE2E: true
16223
16351
  };
16224
16352
  } finally {