@steve31415/baselib 3.38.1 → 3.39.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.
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { SyncError, } from './types.js';
3
+ // Past this many events a client is told to reload (a full bootstrap)
4
+ // rather than catch up. Deliberate (2026-10-20, task 1e2cd274): a todo page
5
+ // 138 events behind resynced as designed; the fault was its cached, stale
6
+ // bootstrap, fixed in the app (no-store), not this limit.
3
7
  const DEFAULT_CATCHUP_LIMIT = 100;
4
8
  const DEFAULT_RETENTION_DAYS = 7;
5
9
  const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
@@ -154,6 +154,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
154
154
  * after the bootstrap installs. Non-null exactly while reconciling. */
155
155
  private gapBuffer;
156
156
  private resyncBackoffTimer;
157
+ /** The backstop retry after a failed resync. Unlike the loop backoff it
158
+ * absorbs nothing: a fresh reload request runs at once and cancels it. */
159
+ private resyncFailureTimer;
157
160
  private loopEscalated;
158
161
  private reconciliationTail;
159
162
  private initialization;
@@ -302,6 +305,8 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
302
305
  * cycle every reconcile succeeds. */
303
306
  private endResyncEpisode;
304
307
  private scheduleResyncRetry;
308
+ private scheduleResyncFailureRetry;
309
+ private cancelResyncFailureRetry;
305
310
  private sendConnect;
306
311
  private sendRaw;
307
312
  private trySend;
@@ -1,6 +1,9 @@
1
1
  import { FirstLoadRetry } from './first-load-retry.js';
2
2
  const RECONNECT_DELAYS_SECONDS = [1, 2, 4, 8, 16, 30];
3
3
  const PING_INTERVAL_MS = 30_000;
4
+ // Deliberate (2026-10-20 review, task 1e2cd274): todo's phone hit it 27
5
+ // times in 25 minutes on 10-19, but none of those handshakes ever reached
6
+ // the load balancer, so a longer limit would only have delayed the retry.
4
7
  const CONNECT_WATCHDOG_MS = 10_000;
5
8
  const DEFAULT_RECONNECT_BEFORE_MS = 55 * 60 * 1000;
6
9
  const FAILURE_TIMEOUT_MS = 60_000;
@@ -89,6 +92,9 @@ export class SyncClient {
89
92
  * after the bootstrap installs. Non-null exactly while reconciling. */
90
93
  gapBuffer = null;
91
94
  resyncBackoffTimer = null;
95
+ /** The backstop retry after a failed resync. Unlike the loop backoff it
96
+ * absorbs nothing: a fresh reload request runs at once and cancels it. */
97
+ resyncFailureTimer = null;
92
98
  loopEscalated = false;
93
99
  reconciliationTail = Promise.resolve();
94
100
  initialization = null;
@@ -647,7 +653,8 @@ export class SyncClient {
647
653
  }
648
654
  const planned = this.plannedClose === socket;
649
655
  this.plannedClose = null;
650
- this.startOutage(event.code, event.wasClean === true);
656
+ const openForMs = this.socketOpenedAt === null ? null : Math.max(0, this.now() - this.socketOpenedAt);
657
+ this.startOutage(event.code, event.wasClean === true, openForMs);
651
658
  if (!this.isHidden()) {
652
659
  this.noteReturnFailure(event.code);
653
660
  this.scheduleReconnect();
@@ -972,7 +979,7 @@ export class SyncClient {
972
979
  void this.probeSession();
973
980
  }
974
981
  }
975
- startOutage(closeCode, wasClean = false) {
982
+ startOutage(closeCode, wasClean = false, openForMs = null) {
976
983
  if (this.outageStartedAt !== null)
977
984
  return;
978
985
  // A sleep or freeze that ended in this close happened before the outage
@@ -982,7 +989,7 @@ export class SyncClient {
982
989
  this.outageStartedAt = this.options.runtime.now();
983
990
  this.outageEscalated = false;
984
991
  this.outageCloseCode = closeCode;
985
- this.outageInfo = { closeCode, wasClean, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
992
+ this.outageInfo = { closeCode, wasClean, openForMs, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
986
993
  this.armEscalation();
987
994
  }
988
995
  /** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
@@ -1002,6 +1009,7 @@ export class SyncClient {
1002
1009
  durationMs: this.options.runtime.now() - this.outageStartedAt,
1003
1010
  closeCode: this.outageCloseCode,
1004
1011
  attempts: this.reconnectAttempts,
1012
+ openForMs: this.outageInfo?.openForMs ?? null,
1005
1013
  });
1006
1014
  }
1007
1015
  }, OUTAGE_ESCALATE_MS);
@@ -1327,6 +1335,7 @@ export class SyncClient {
1327
1335
  totalAttempts: this.totalAttempts,
1328
1336
  closeCode: info?.closeCode,
1329
1337
  wasClean: info?.wasClean,
1338
+ openForMs: info ? info.openForMs : undefined,
1330
1339
  visibleAtDrop: info?.visibleAtDrop,
1331
1340
  hiddenMs: delta?.hiddenMs,
1332
1341
  stalledMs: delta?.stalledMs,
@@ -1387,6 +1396,7 @@ export class SyncClient {
1387
1396
  async resyncFromServer(reason) {
1388
1397
  if (this.refetchInFlight || this.halted || this.disposed)
1389
1398
  return;
1399
+ this.cancelResyncFailureRetry();
1390
1400
  const now = this.options.runtime.now();
1391
1401
  // `time <= now` drops entries stranded in the future by a backward
1392
1402
  // clock jump, which would otherwise pin the detector tripped.
@@ -1420,7 +1430,17 @@ export class SyncClient {
1420
1430
  }
1421
1431
  catch (error) {
1422
1432
  if (!isLifecycleAbort(error) && !this.disposed && !this.halted) {
1423
- this.options.logger.error('resync failed', { reason, error });
1433
+ // As for the first bootstrap: fetch's TypeError means no HTTP answer
1434
+ // reached the page (the device's network), a WARN; an answer that was
1435
+ // an error is the server's, an ERROR. Either way the store may be
1436
+ // stale, so a retry is scheduled rather than left to the next reload
1437
+ // message or sequence gap, which a quiet page may never get.
1438
+ const reachedServer = !(error instanceof TypeError);
1439
+ if (reachedServer)
1440
+ this.options.logger.error('resync failed', { reason, reachedServer, error });
1441
+ else
1442
+ this.options.logger.warn('resync failed', { reason, reachedServer, error });
1443
+ this.scheduleResyncFailureRetry();
1424
1444
  }
1425
1445
  }
1426
1446
  finally {
@@ -1472,6 +1492,23 @@ export class SyncClient {
1472
1492
  void this.resyncFromServer('retry after resync backoff');
1473
1493
  }, RESYNC_RETRY_MS);
1474
1494
  }
1495
+ scheduleResyncFailureRetry() {
1496
+ if (this.resyncFailureTimer !== null || this.halted || this.disposed)
1497
+ return;
1498
+ this.resyncFailureTimer = this.setTimer(() => {
1499
+ this.resyncFailureTimer = null;
1500
+ // Hidden: no minutely retry (a phone without network would log one a
1501
+ // minute); the return's HTTP catch-up re-triggers the resync.
1502
+ if (this.isHidden())
1503
+ return;
1504
+ void this.resyncFromServer('retry after failed resync');
1505
+ }, RESYNC_RETRY_MS);
1506
+ }
1507
+ cancelResyncFailureRetry() {
1508
+ if (this.resyncFailureTimer !== null)
1509
+ this.options.runtime.clearTimeout(this.resyncFailureTimer);
1510
+ this.resyncFailureTimer = null;
1511
+ }
1475
1512
  sendConnect() {
1476
1513
  this.sendRaw({
1477
1514
  type: 'connect',
@@ -1600,6 +1637,7 @@ export class SyncClient {
1600
1637
  this.options.runtime.clearTimeout(this.escalateTimer);
1601
1638
  if (this.resyncBackoffTimer !== null)
1602
1639
  this.options.runtime.clearTimeout(this.resyncBackoffTimer);
1640
+ this.cancelResyncFailureRetry();
1603
1641
  for (const timer of this.failureTimers.values())
1604
1642
  this.options.runtime.clearTimeout(timer);
1605
1643
  this.cancelBackgroundTick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.38.1",
3
+ "version": "3.39.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",