cry-synced-db-client 0.1.139 → 0.1.141

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### BREAKING: Self-healing sync/reconnect lifecycle
6
+
7
+ Fixes a class of bugs where the 60s auto-sync scheduler silently died after a
8
+ sync failure or leader flap and was never re-armed until a page reload (see
9
+ `/tmp/cry-synced-db-client-sync-interval-bug.md`). Reproducers spanned 5
10
+ tenants, 62–296 min of dead scheduler with dirty items accumulating.
11
+
12
+ - Removed `onForcedOffline` callback and `ConnectionManager.goOffline()` method.
13
+ - Added `onSyncFailed(reason)` callback — fires on each sync failure but does
14
+ **not** mutate online state. The next auto-sync / reconnect tick retries.
15
+ - Added `onlineRetryIntervalMs` config (default 60000, 0 = disable) — periodic
16
+ `tryGoOnline()` probe while offline but not forcedOffline. Always-live from
17
+ `init()` to `close()` so recovery does not depend on external signals.
18
+ - `autoSyncTimer` and the new reconnect timer are both always-live from `init()`
19
+ to `close()`. `setOnline(false)`, `forceOffline(true)`, and sync failure no
20
+ longer clear timers — only flip flags; each tick is defensive.
21
+ - `SyncedDb.sync()` now opportunistically calls `tryGoOnline()` when internally
22
+ offline (but not forcedOffline). Syncs while `forceOffline(true)` still throw
23
+ `Cannot sync while in forced offline mode`.
24
+ - `onBecameLeader` now triggers `tryGoOnline()` when offline and post-init,
25
+ covering visibility re-claim and leader flap after mobile browser discards
26
+ state.
27
+ - `syncLock` is now acquired before the early `tryGoOnline()` in
28
+ `SyncedDb.sync()` so the internal `INITIAL SYNC` that `tryGoOnline()` kicks
29
+ off is a no-op inside the outer call (avoids double sync).
30
+
31
+ **Migration for consumers:**
32
+ `onForcedOffline: (reason) => log(reason)` → `onSyncFailed: (reason) => log(reason)`.
33
+ Signature is identical. No other callback changes.
34
+
5
35
  - Add `refreshInBackground` `QueryOpts` option for `findById` / `findByIds`
6
36
  - Stale-while-revalidate: cache-hit returns local result immediately and
7
37
  triggers a background fetch that updates Dexie + in-mem through conflict
package/dist/index.js CHANGED
@@ -842,46 +842,41 @@ var CrossTabSyncManager = class {
842
842
  };
843
843
 
844
844
  // src/db/managers/ConnectionManager.ts
845
+ var DEFAULT_ONLINE_RETRY_INTERVAL_MS = 6e4;
845
846
  var ConnectionManager = class {
846
847
  constructor(config) {
847
848
  this.online = false;
848
849
  this.forcedOffline = false;
850
+ this.tryGoOnlineInFlight = false;
851
+ this.closed = false;
852
+ var _a;
849
853
  this.restInterface = config.restInterface;
850
854
  this.restTimeoutMs = config.restTimeoutMs;
851
855
  this.syncTimeoutMs = config.syncTimeoutMs;
852
856
  this.autoSyncIntervalMs = config.autoSyncIntervalMs;
857
+ this.onlineRetryIntervalMs = (_a = config.onlineRetryIntervalMs) != null ? _a : DEFAULT_ONLINE_RETRY_INTERVAL_MS;
853
858
  this.callbacks = config.callbacks;
854
859
  this.deps = config.deps;
855
860
  }
856
- /**
857
- * Current online status (considering forcedOffline).
858
- */
861
+ /** Current online status (considering forcedOffline). */
859
862
  isOnline() {
860
863
  return this.online && !this.forcedOffline;
861
864
  }
862
- /**
863
- * Is forced offline mode active?
864
- */
865
865
  isForcedOffline() {
866
866
  return this.forcedOffline;
867
867
  }
868
- /**
869
- * Can we sync with server?
870
- */
871
868
  canSync() {
872
869
  return this.online && !this.forcedOffline;
873
870
  }
874
- /**
875
- * Can we receive server updates?
876
- */
877
871
  canReceiveServerUpdates() {
878
872
  return !this.forcedOffline;
879
873
  }
880
874
  /**
881
- * Set online status.
882
- * Note: Going offline does NOT release leadership. With window-scoped locks,
883
- * a tab should remain leader within its window to process WebSocket notifications
884
- * even while offline.
875
+ * Set online status. Does NOT stop any timers.
876
+ *
877
+ * - `setOnline(true)` attempts `tryGoOnline` (ping flip state).
878
+ * - `setOnline(false)` flips state to offline and fires `onOnlineStatusChange`.
879
+ * The reconnect timer will continue attempting to come back online.
885
880
  */
886
881
  async setOnline(online) {
887
882
  var _a, _b;
@@ -889,19 +884,19 @@ var ConnectionManager = class {
889
884
  if (online) {
890
885
  await this.tryGoOnline();
891
886
  } else {
892
- this.online = online;
893
- this.stopAutoSync();
887
+ this.online = false;
894
888
  (_b = (_a = this.callbacks).onOnlineStatusChange) == null ? void 0 : _b.call(_a, false);
895
889
  }
896
890
  }
897
891
  /**
898
- * Force offline mode.
892
+ * Force offline mode. Does NOT stop timers — reconnect timer will still
893
+ * check `forcedOffline` and skip while true. When released, `tryGoOnline`
894
+ * fires immediately to avoid waiting for the next tick.
899
895
  */
900
896
  forceOffline(forced) {
901
897
  if (this.forcedOffline === forced) return;
902
898
  this.forcedOffline = forced;
903
899
  if (forced) {
904
- this.stopAutoSync();
905
900
  this.deps.releaseLeaderLock();
906
901
  } else {
907
902
  this.deps.tryBecomeLeader();
@@ -911,50 +906,86 @@ var ConnectionManager = class {
911
906
  }
912
907
  }
913
908
  /**
914
- * Go offline with reason.
915
- * Note: This does NOT release leadership. With window-scoped locks,
916
- * a tab should remain leader within its window to process WebSocket notifications
917
- * even while offline due to network issues.
909
+ * Attempt to transition from offline to online.
910
+ * Idempotent, guards against concurrent calls and forcedOffline.
918
911
  */
919
- goOffline(reason) {
920
- var _a, _b;
921
- const wasOnline = this.online;
922
- this.online = false;
923
- this.stopAutoSync();
924
- if (wasOnline) {
925
- (_b = (_a = this.callbacks).onOnlineStatusChange) == null ? void 0 : _b.call(_a, false);
926
- }
927
- if (this.callbacks.onForcedOffline) {
912
+ async tryGoOnline() {
913
+ var _a, _b, _c;
914
+ if (this.closed) return;
915
+ if (this.forcedOffline) return;
916
+ if (this.tryGoOnlineInFlight) return;
917
+ this.tryGoOnlineInFlight = true;
918
+ try {
919
+ const wasOffline = !this.online;
920
+ if (wasOffline) {
921
+ let pingResult;
922
+ try {
923
+ pingResult = await this.withSyncTimeout(
924
+ this.restInterface.ping(),
925
+ "ping"
926
+ );
927
+ } catch (err) {
928
+ console.warn("tryGoOnline: ping failed:", err);
929
+ this.online = false;
930
+ return;
931
+ }
932
+ if (!pingResult) {
933
+ const url = (_a = this.restInterface.endpoint) != null ? _a : "unknown";
934
+ console.warn(`Ping to ${url} failed - staying offline`);
935
+ return;
936
+ }
937
+ this.online = true;
938
+ (_c = (_b = this.callbacks).onOnlineStatusChange) == null ? void 0 : _c.call(_b, true);
939
+ if (!this.deps.isLeader()) {
940
+ this.deps.tryBecomeLeader();
941
+ }
942
+ }
928
943
  try {
929
- this.callbacks.onForcedOffline(reason);
944
+ await this.deps.sync("INITIAL SYNC");
930
945
  } catch (err) {
931
- console.error("onForcedOffline callback failed:", err);
946
+ console.warn("INITIAL SYNC after tryGoOnline failed (stays online):", err);
932
947
  }
948
+ } finally {
949
+ this.tryGoOnlineInFlight = false;
933
950
  }
934
951
  }
935
952
  /**
936
- * Start auto-sync timer.
953
+ * Start both timers. Idempotent. Called by SyncedDb.init().
937
954
  */
938
- startAutoSync() {
939
- this.stopAutoSync();
940
- if (this.forcedOffline || !this.autoSyncIntervalMs || this.autoSyncIntervalMs <= 0) {
941
- return;
955
+ startTimers() {
956
+ this.closed = false;
957
+ if (!this.autoSyncTimer && this.autoSyncIntervalMs && this.autoSyncIntervalMs > 0) {
958
+ const intervalMs = this.autoSyncIntervalMs;
959
+ this.autoSyncTimer = setInterval(() => {
960
+ if (this.forcedOffline || !this.online) return;
961
+ this.deps.sync(`interval ${intervalMs}ms`).catch((err) => {
962
+ console.error("Auto-sync failed:", err);
963
+ });
964
+ }, intervalMs);
965
+ }
966
+ if (!this.reconnectTimer && this.onlineRetryIntervalMs && this.onlineRetryIntervalMs > 0) {
967
+ const retryMs = this.onlineRetryIntervalMs;
968
+ this.reconnectTimer = setInterval(() => {
969
+ if (this.forcedOffline || this.online || this.tryGoOnlineInFlight) return;
970
+ this.tryGoOnline().catch((err) => {
971
+ console.error("Reconnect tryGoOnline failed:", err);
972
+ });
973
+ }, retryMs);
942
974
  }
943
- const intervalMs = this.autoSyncIntervalMs;
944
- this.autoSyncTimer = setInterval(() => {
945
- this.deps.sync(`interval ${intervalMs}ms`).catch((err) => {
946
- console.error("Auto-sync failed:", err);
947
- });
948
- }, intervalMs);
949
975
  }
950
976
  /**
951
- * Stop auto-sync timer.
977
+ * Stop both timers. Called by SyncedDb.close().
952
978
  */
953
- stopAutoSync() {
979
+ stopTimers() {
980
+ this.closed = true;
954
981
  if (this.autoSyncTimer) {
955
982
  clearInterval(this.autoSyncTimer);
956
983
  this.autoSyncTimer = void 0;
957
984
  }
985
+ if (this.reconnectTimer) {
986
+ clearInterval(this.reconnectTimer);
987
+ this.reconnectTimer = void 0;
988
+ }
958
989
  }
959
990
  /**
960
991
  * Ping server.
@@ -1025,6 +1056,19 @@ var ConnectionManager = class {
1025
1056
  }
1026
1057
  }
1027
1058
  }
1059
+ /**
1060
+ * Notify consumers of a sync failure. Does not mutate state.
1061
+ * Called from SyncEngine via deps.onSyncFailed wiring.
1062
+ */
1063
+ callOnSyncFailed(reason) {
1064
+ if (this.callbacks.onSyncFailed) {
1065
+ try {
1066
+ this.callbacks.onSyncFailed(reason);
1067
+ } catch (err) {
1068
+ console.error("onSyncFailed callback failed:", err);
1069
+ }
1070
+ }
1071
+ }
1028
1072
  /**
1029
1073
  * Call onWsConnect callback.
1030
1074
  */
@@ -1083,43 +1127,6 @@ var ConnectionManager = class {
1083
1127
  getOnWsReconnect() {
1084
1128
  return this.callbacks.onWsReconnect;
1085
1129
  }
1086
- // ============================================================
1087
- // Private Methods
1088
- // ============================================================
1089
- async tryGoOnline() {
1090
- var _a, _b, _c, _d, _e;
1091
- if (this.forcedOffline) {
1092
- return;
1093
- }
1094
- try {
1095
- const pingResult = await this.withSyncTimeout(
1096
- this.restInterface.ping(),
1097
- "ping"
1098
- );
1099
- if (!pingResult) {
1100
- const url = (_a = this.restInterface.endpoint) != null ? _a : "unknown";
1101
- console.warn(`Ping to ${url} failed - staying offline`);
1102
- return;
1103
- }
1104
- const wasOffline = !this.online;
1105
- this.online = true;
1106
- if (wasOffline) {
1107
- (_c = (_b = this.callbacks).onOnlineStatusChange) == null ? void 0 : _c.call(_b, true);
1108
- if (!this.deps.isLeader()) {
1109
- this.deps.tryBecomeLeader();
1110
- }
1111
- }
1112
- this.startAutoSync();
1113
- await this.deps.sync("INITIAL SYNC");
1114
- } catch (err) {
1115
- console.warn("Failed to go online (ping failed or timed out):", err);
1116
- const wasOnline = this.online;
1117
- this.online = false;
1118
- if (wasOnline) {
1119
- (_e = (_d = this.callbacks).onOnlineStatusChange) == null ? void 0 : _e.call(_d, false);
1120
- }
1121
- }
1122
- }
1123
1130
  };
1124
1131
 
1125
1132
  // node_modules/superjson/dist/double-indexed-kv.js
@@ -2541,8 +2548,8 @@ var _SyncEngine = class _SyncEngine {
2541
2548
  });
2542
2549
  } catch (err) {
2543
2550
  const reason = err instanceof Error ? err.message : String(err);
2544
- console.error("Sync failed, going offline:", err);
2545
- this.deps.goOffline(`Sync failed: ${reason}`);
2551
+ console.error("Sync failed:", err);
2552
+ this.deps.onSyncFailed(`Sync failed: ${reason}`);
2546
2553
  this.callOnSyncEnd({
2547
2554
  durationMs: Date.now() - startTime,
2548
2555
  receivedCount,
@@ -3511,7 +3518,20 @@ var _SyncedDb = class _SyncedDb {
3511
3518
  tenant: this.tenant,
3512
3519
  windowId,
3513
3520
  callbacks: {
3514
- onBecameLeader: config.onBecameLeader,
3521
+ onBecameLeader: () => {
3522
+ if (this.initialized && !this.connectionManager.isOnline() && !this.connectionManager.isForcedOffline()) {
3523
+ this.connectionManager.tryGoOnline().catch((err) => {
3524
+ console.error("tryGoOnline on becameLeader failed:", err);
3525
+ });
3526
+ }
3527
+ if (config.onBecameLeader) {
3528
+ try {
3529
+ config.onBecameLeader();
3530
+ } catch (err) {
3531
+ console.error("onBecameLeader callback failed:", err);
3532
+ }
3533
+ }
3534
+ },
3515
3535
  onLostLeadership: config.onLostLeadership,
3516
3536
  onInfrastructureError: config.onInfrastructureError ? (type, message, error) => {
3517
3537
  config.onInfrastructureError({
@@ -3556,9 +3576,10 @@ var _SyncedDb = class _SyncedDb {
3556
3576
  restTimeoutMs: (_h = config.restTimeoutMs) != null ? _h : 9e4,
3557
3577
  syncTimeoutMs: (_i = config.syncTimeoutMs) != null ? _i : 12e4,
3558
3578
  autoSyncIntervalMs: config.autoSyncIntervalMs,
3579
+ onlineRetryIntervalMs: config.onlineRetryIntervalMs,
3559
3580
  callbacks: {
3560
3581
  onOnlineStatusChange: config.onOnlineStatusChange,
3561
- onForcedOffline: config.onForcedOffline,
3582
+ onSyncFailed: config.onSyncFailed,
3562
3583
  onWsConnect: config.onWsConnect,
3563
3584
  onWsDisconnect: config.onWsDisconnect,
3564
3585
  onWsReconnect: config.onWsReconnect,
@@ -3628,7 +3649,7 @@ var _SyncedDb = class _SyncedDb {
3628
3649
  },
3629
3650
  getInMemById: (collection, id) => this.inMemDb.getById(collection, id),
3630
3651
  withSyncTimeout: (promise, operation) => this.connectionManager.withSyncTimeout(promise, operation),
3631
- goOffline: (reason) => this.connectionManager.goOffline(reason),
3652
+ onSyncFailed: (reason) => this.connectionManager.callOnSyncFailed(reason),
3632
3653
  flushAllPendingChanges: () => this.pendingChanges.flushAll(),
3633
3654
  cancelRestUploadTimer: () => this.pendingChanges.cancelRestUploadTimer(),
3634
3655
  awaitRestUpload: () => this.pendingChanges.awaitRestUpload(),
@@ -3769,6 +3790,7 @@ var _SyncedDb = class _SyncedDb {
3769
3790
  this.crossTabSync.init();
3770
3791
  (_a = this.wakeSync) == null ? void 0 : _a.init();
3771
3792
  (_b = this.networkStatus) == null ? void 0 : _b.init();
3793
+ this.connectionManager.startTimers();
3772
3794
  if (this.serverUpdateNotifier) {
3773
3795
  if (this.serverUpdateNotifier.setCallbacks) {
3774
3796
  const cleanup = this.serverUpdateNotifier.setCallbacks({
@@ -3875,7 +3897,7 @@ var _SyncedDb = class _SyncedDb {
3875
3897
  var _a, _b;
3876
3898
  this.leaderElection.setClosing(true);
3877
3899
  this.pendingChanges.cancelRestUploadTimer();
3878
- this.connectionManager.stopAutoSync();
3900
+ this.connectionManager.stopTimers();
3879
3901
  await this.pendingChanges.flushAll();
3880
3902
  (_a = this.networkStatus) == null ? void 0 : _a.dispose();
3881
3903
  (_b = this.wakeSync) == null ? void 0 : _b.dispose();
@@ -4423,39 +4445,45 @@ var _SyncedDb = class _SyncedDb {
4423
4445
  }
4424
4446
  // ==================== Sync Operations ====================
4425
4447
  async sync(calledFrom) {
4426
- if (!this.connectionManager.canSync()) {
4427
- if (this.connectionManager.isForcedOffline()) {
4428
- throw new Error("Cannot sync while in forced offline mode");
4429
- }
4430
- return;
4431
- }
4432
4448
  if (this.syncLock) return;
4433
4449
  this.syncLock = true;
4434
- this.syncing = true;
4435
- this.crossTabSync.startServerSync();
4436
4450
  try {
4437
- await this.syncEngine.sync(calledFrom);
4438
- if (!this.syncOnlyCollections) {
4439
- const now = /* @__PURE__ */ new Date();
4440
- if (!this._lastFullSyncDate) {
4441
- this._setLastInitialSync(now).catch((err) => {
4442
- console.error("Failed to persist lastInitialSync:", err);
4451
+ if (!this.connectionManager.isOnline() && !this.connectionManager.isForcedOffline()) {
4452
+ await this.connectionManager.tryGoOnline();
4453
+ }
4454
+ if (!this.connectionManager.canSync()) {
4455
+ if (this.connectionManager.isForcedOffline()) {
4456
+ throw new Error("Cannot sync while in forced offline mode");
4457
+ }
4458
+ return;
4459
+ }
4460
+ this.syncing = true;
4461
+ this.crossTabSync.startServerSync();
4462
+ try {
4463
+ await this.syncEngine.sync(calledFrom);
4464
+ if (!this.syncOnlyCollections) {
4465
+ const now = /* @__PURE__ */ new Date();
4466
+ if (!this._lastFullSyncDate) {
4467
+ this._setLastInitialSync(now).catch((err) => {
4468
+ console.error("Failed to persist lastInitialSync:", err);
4469
+ });
4470
+ }
4471
+ this._setLastFullSync(now).catch((err) => {
4472
+ console.error("Failed to persist lastFullSync:", err);
4443
4473
  });
4444
4474
  }
4445
- this._setLastFullSync(now).catch((err) => {
4446
- console.error("Failed to persist lastFullSync:", err);
4447
- });
4475
+ } finally {
4476
+ this.syncing = false;
4477
+ this.crossTabSync.endServerSync();
4478
+ await this.processQueuedWsUpdates();
4479
+ try {
4480
+ await this.maybeAutoEvict();
4481
+ } catch (err) {
4482
+ console.error("Auto-eviction failed:", err);
4483
+ }
4448
4484
  }
4449
4485
  } finally {
4450
- this.syncing = false;
4451
4486
  this.syncLock = false;
4452
- this.crossTabSync.endServerSync();
4453
- await this.processQueuedWsUpdates();
4454
- try {
4455
- await this.maybeAutoEvict();
4456
- } catch (err) {
4457
- console.error("Auto-eviction failed:", err);
4458
- }
4459
4487
  }
4460
4488
  }
4461
4489
  async processQueuedWsUpdates() {
@@ -1,11 +1,19 @@
1
1
  /**
2
- * ConnectionManager - Manages online/offline state and auto-sync.
2
+ * ConnectionManager - Manages online/offline state, auto-sync and reconnect.
3
3
  *
4
- * Handles:
5
- * - Online/offline state transitions
6
- * - Forced offline mode
7
- * - Auto-sync timer management
8
- * - REST/sync timeouts
4
+ * Invariants:
5
+ * - `autoSyncTimer` and `reconnectTimer` are always-live from `startTimers()`
6
+ * (called by SyncedDb during init) until `stopTimers()` (called by close()).
7
+ * - Neither timer is cleared by `setOnline(false)`, `forceOffline(true)`, or
8
+ * sync failure state changes only flip flags. Each tick is defensive and
9
+ * no-ops when inapplicable.
10
+ * - `autoSyncTimer` tick: run `sync()` iff `online && !forcedOffline`.
11
+ * - `reconnectTimer` tick: call `tryGoOnline()` iff `!online && !forcedOffline`
12
+ * and no `tryGoOnline` is in flight.
13
+ * - `tryGoOnline()` pings the server; on success flips `online=true` (next
14
+ * auto-sync tick then runs). Historical `goOffline(reason)` method and
15
+ * `onForcedOffline` callback are removed — use `onSyncFailed` (logging-only
16
+ * callback fired from SyncEngine) or explicit `forceOffline(true)`.
9
17
  */
10
18
  import type { I_ConnectionManager, ConnectionManagerConfig } from "../types/managers";
11
19
  export declare class ConnectionManager implements I_ConnectionManager {
@@ -13,54 +21,48 @@ export declare class ConnectionManager implements I_ConnectionManager {
13
21
  private readonly restTimeoutMs;
14
22
  private readonly syncTimeoutMs;
15
23
  private readonly autoSyncIntervalMs?;
24
+ private readonly onlineRetryIntervalMs;
16
25
  private readonly callbacks;
17
26
  private readonly deps;
18
27
  private online;
19
28
  private forcedOffline;
20
29
  private autoSyncTimer?;
30
+ private reconnectTimer?;
31
+ private tryGoOnlineInFlight;
32
+ private closed;
21
33
  constructor(config: ConnectionManagerConfig);
22
- /**
23
- * Current online status (considering forcedOffline).
24
- */
34
+ /** Current online status (considering forcedOffline). */
25
35
  isOnline(): boolean;
26
- /**
27
- * Is forced offline mode active?
28
- */
29
36
  isForcedOffline(): boolean;
30
- /**
31
- * Can we sync with server?
32
- */
33
37
  canSync(): boolean;
34
- /**
35
- * Can we receive server updates?
36
- */
37
38
  canReceiveServerUpdates(): boolean;
38
39
  /**
39
- * Set online status.
40
- * Note: Going offline does NOT release leadership. With window-scoped locks,
41
- * a tab should remain leader within its window to process WebSocket notifications
42
- * even while offline.
40
+ * Set online status. Does NOT stop any timers.
41
+ *
42
+ * - `setOnline(true)` attempts `tryGoOnline` (ping flip state).
43
+ * - `setOnline(false)` flips state to offline and fires `onOnlineStatusChange`.
44
+ * The reconnect timer will continue attempting to come back online.
43
45
  */
44
46
  setOnline(online: boolean): Promise<void>;
45
47
  /**
46
- * Force offline mode.
48
+ * Force offline mode. Does NOT stop timers — reconnect timer will still
49
+ * check `forcedOffline` and skip while true. When released, `tryGoOnline`
50
+ * fires immediately to avoid waiting for the next tick.
47
51
  */
48
52
  forceOffline(forced: boolean): void;
49
53
  /**
50
- * Go offline with reason.
51
- * Note: This does NOT release leadership. With window-scoped locks,
52
- * a tab should remain leader within its window to process WebSocket notifications
53
- * even while offline due to network issues.
54
+ * Attempt to transition from offline to online.
55
+ * Idempotent, guards against concurrent calls and forcedOffline.
54
56
  */
55
- goOffline(reason: string): void;
57
+ tryGoOnline(): Promise<void>;
56
58
  /**
57
- * Start auto-sync timer.
59
+ * Start both timers. Idempotent. Called by SyncedDb.init().
58
60
  */
59
- startAutoSync(): void;
61
+ startTimers(): void;
60
62
  /**
61
- * Stop auto-sync timer.
63
+ * Stop both timers. Called by SyncedDb.close().
62
64
  */
63
- stopAutoSync(): void;
65
+ stopTimers(): void;
64
66
  /**
65
67
  * Ping server.
66
68
  */
@@ -77,6 +79,11 @@ export declare class ConnectionManager implements I_ConnectionManager {
77
79
  * Report infrastructure error.
78
80
  */
79
81
  reportInfrastructureError(type: string, message: string, error?: Error): void;
82
+ /**
83
+ * Notify consumers of a sync failure. Does not mutate state.
84
+ * Called from SyncEngine via deps.onSyncFailed wiring.
85
+ */
86
+ callOnSyncFailed(reason: string): void;
80
87
  /**
81
88
  * Call onWsConnect callback.
82
89
  */
@@ -101,5 +108,4 @@ export declare class ConnectionManager implements I_ConnectionManager {
101
108
  * Get onWsReconnect callback.
102
109
  */
103
110
  getOnWsReconnect(): ((attempt: number) => void) | undefined;
104
- private tryGoOnline;
105
111
  }
@@ -91,7 +91,10 @@ export interface I_CrossTabSyncManager {
91
91
  }
92
92
  export interface ConnectionCallbacks {
93
93
  onOnlineStatusChange?: (online: boolean) => void;
94
- onForcedOffline?: (reason: string) => void;
94
+ /**
95
+ * Fired on sync failure. Does NOT mutate online state. For logging only.
96
+ */
97
+ onSyncFailed?: (reason: string) => void;
95
98
  onWsConnect?: () => void;
96
99
  onWsDisconnect?: (reason: string) => void;
97
100
  onWsReconnect?: (attempt: number) => void;
@@ -112,6 +115,7 @@ export interface ConnectionManagerConfig {
112
115
  restTimeoutMs: number;
113
116
  syncTimeoutMs: number;
114
117
  autoSyncIntervalMs?: number;
118
+ onlineRetryIntervalMs?: number;
115
119
  callbacks: ConnectionCallbacks;
116
120
  deps: ConnectionManagerDeps;
117
121
  }
@@ -128,12 +132,15 @@ export interface I_ConnectionManager {
128
132
  setOnline(online: boolean): Promise<void>;
129
133
  /** Force offline mode. */
130
134
  forceOffline(forced: boolean): void;
131
- /** Go offline with reason. */
132
- goOffline(reason: string): void;
133
- /** Start auto-sync timer. */
134
- startAutoSync(): void;
135
- /** Stop auto-sync timer. */
136
- stopAutoSync(): void;
135
+ /**
136
+ * Attempt to transition from internal-offline to online (ping + start timers
137
+ * if successful). No-op if already online, forcedOffline, or a try is in flight.
138
+ */
139
+ tryGoOnline(): Promise<void>;
140
+ /** Start both auto-sync and reconnect timers (idempotent). */
141
+ startTimers(): void;
142
+ /** Stop both auto-sync and reconnect timers. Called from close(). */
143
+ stopTimers(): void;
137
144
  /** Ping server. */
138
145
  ping(timeoutMs?: number): Promise<boolean>;
139
146
  /** Wrap promise with sync timeout. */
@@ -256,7 +263,8 @@ export interface SyncEngineDeps {
256
263
  writeToInMemBatch: <T extends DbEntity>(collection: string, items: T[], operation: "upsert" | "delete") => void;
257
264
  getInMemById: <T extends DbEntity>(collection: string, id: Id) => T | undefined;
258
265
  withSyncTimeout: <T>(promise: Promise<T>, operation: string) => Promise<T>;
259
- goOffline: (reason: string) => void;
266
+ /** Notify consumers that a sync cycle failed. Does not mutate online state. */
267
+ onSyncFailed: (reason: string) => void;
260
268
  flushAllPendingChanges: () => Promise<void>;
261
269
  cancelRestUploadTimer: () => void;
262
270
  awaitRestUpload: () => Promise<void>;
@@ -317,8 +317,13 @@ export interface SyncedDbConfig {
317
317
  debounceDexieWritesMs?: number;
318
318
  /** Debounce čas za pošiljanje na REST v ms (default: 100) - po uspešnem zapisu v Dexie */
319
319
  debounceRestWritesMs?: number;
320
- /** Callback ki se pokliče, ko SyncedDb sam preide v offline stanje (npr. ob sync napaki) */
321
- onForcedOffline?: (reason: string) => void;
320
+ /**
321
+ * Callback fired on each sync failure. Unlike the removed `onForcedOffline`,
322
+ * this does NOT mutate online state — the library keeps trying on the next
323
+ * auto-sync tick. Use this callback for logging/telemetry only. To actually
324
+ * force the database offline, call {@link forceOffline}.
325
+ */
326
+ onSyncFailed?: (reason: string) => void;
322
327
  /** Callback fired once during init() when the IndexedDB database was created fresh (first ever open). */
323
328
  onDatabaseCreated?: () => void;
324
329
  /** Callback at the start of each sync cycle. initialSync=true if no full sync has completed yet. */
@@ -381,8 +386,19 @@ export interface SyncedDbConfig {
381
386
  * Če je podano, se sync() kliče avtomatsko na ta interval, ko je online.
382
387
  * Auto-sync se izvaja samo ko je online in ne bo interferiral z eksplicitnimi sync() klici
383
388
  * (uporablja isti syncLock mehanizem).
389
+ *
390
+ * Timer je zagnan v init() in ustavljen v close(). Če je offline ali
391
+ * forcedOffline, tick-i so no-op (ne ubije se timer, naslednji tick bo spet
392
+ * poskusil). Self-healing: sync napaka ne ustavi timer-ja.
384
393
  */
385
394
  autoSyncIntervalMs?: number;
395
+ /**
396
+ * Interval za periodično poskušanje preklopa iz offline v online, v ms.
397
+ * Vedno-živi timer: od init() do close(). Če smo offline in ne forcedOffline,
398
+ * vsak tick pokliče `tryGoOnline()` (ping → če uspešen, gremo online in naslednji
399
+ * auto-sync tick bo sinhroniziral). Default: 60000 (60 s). 0/undefined disable.
400
+ */
401
+ onlineRetryIntervalMs?: number;
386
402
  /** Callback when WebSocket connects */
387
403
  onWsConnect?: () => void;
388
404
  /** Callback when WebSocket disconnects */
@@ -391,7 +407,7 @@ export interface SyncedDbConfig {
391
407
  onWsReconnect?: (attempt: number) => void;
392
408
  /** Callback when a WebSocket notification is received */
393
409
  onWsNotification?: (info: WsNotificationInfo) => void;
394
- /** Callback when online status changes (after ping success/failure in tryGoOnline or goOffline) */
410
+ /** Callback when online status changes (after ping success/failure in tryGoOnline) */
395
411
  onOnlineStatusChange?: (online: boolean) => void;
396
412
  /** Debounce interval for cross-tab sync broadcasts in ms (default: 100) */
397
413
  crossTabSyncDebounceMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.139",
3
+ "version": "0.1.141",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "vitest": "^4.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "cry-db": "^2.4.28",
39
+ "cry-db": "^2.4.31",
40
40
  "cry-helpers": "^2.1.193",
41
41
  "msgpackr": "^1.11.9",
42
42
  "notepack": "^0.0.2",