cry-synced-db-client 0.1.187 → 0.1.189

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
@@ -1,5 +1,149 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.189 (2026-05-15)
4
+
5
+ ### Periodic full-resync maintenance heartbeat
6
+
7
+ New `SyncedDbConfig` options that guarantee a full server re-read at a
8
+ configurable cadence — useful when long-running tabs accumulate
9
+ server-side state drift (eviction window misses, race-ridden
10
+ tombstones, missed WS-push notifications while the tab was throttled).
11
+
12
+ ```ts
13
+ new SyncedDb({
14
+ // ...
15
+ forceFullResyncIfOlderThanDays: 7, // threshold (off when undefined)
16
+ forceFullResyncCheckEveryHrs: 24, // continuous re-check (init-only when undefined)
17
+ onForceFullResyncTriggered: (info) => {
18
+ // info: { reason: "init" | "timer", lastFullSyncDate?, daysSinceLastFullSync?, thresholdDays, timestamp }
19
+ syslog.info("force-full-resync", info);
20
+ },
21
+ });
22
+ ```
23
+
24
+ **What it does** — when `lastSuccessfulServerSync()` is older than the
25
+ threshold (checked once at `init()` + continuously every
26
+ `forceFullResyncCheckEveryHrs`):
27
+
28
+ 1. `onForceFullResyncTriggered` fires (idempotent — flag-gated, no
29
+ re-fire if a previous trigger is still pending).
30
+ 2. Internal `_pendingFullResync` flag is set.
31
+ 3. If `canSync()`, fires `sync("force-full-resync:<reason>")`.
32
+ 4. The consuming sync slot resets **every** collection's
33
+ `syncMeta.lastSyncTs` (Dexie + cache) just before `syncEngine.sync`
34
+ runs. Server returns the full dataset.
35
+ 5. Conflict resolution overwrites local rows record-by-record as the
36
+ stream lands. Bundled eviction sweeps records the server no longer
37
+ reports.
38
+ 6. On success the flag is cleared. On failure (offline, server error,
39
+ abort) the flag persists; the next sync slot retries.
40
+
41
+ **Safety guarantees** — `_dirty_changes` and Dexie main rows are
42
+ **never deleted** by this flow. The trigger only mutates cursors;
43
+ conflict resolution + eviction handle row updates and out-of-scope
44
+ removal as part of normal sync. A failed resync leaves the local
45
+ cache exactly as it was. There is no window where Dexie is empty
46
+ and the server response hasn't yet landed — fundamentally different
47
+ from the legacy `refreshDatabaseFromServer()` which wipes Dexie
48
+ before re-downloading.
49
+
50
+ Implementation notes:
51
+
52
+ - The check (`_checkForceFullResyncThreshold`) is cheap (memory + Date
53
+ math). The continuous timer runs for the SyncedDb's lifetime
54
+ (`init() → close()`); ticks during an in-flight sync no-op via
55
+ `syncLock`. The flag is boolean — multiple triggers between sync
56
+ slots collapse to one.
57
+ - Records with `_lastFullSyncDate === undefined` (never synced) are
58
+ NOT considered stale — initial sync handles those.
59
+ - Subset mode (`syncOnlyCollections !== null`) never sets
60
+ `__lastFullSync`, so the trigger never fires in that mode.
61
+ - `dropDatabase()` clears `__lastFullSync`, restoring "never synced".
62
+
63
+ ## 0.1.188 (2026-05-15)
64
+
65
+ ### `clearDirty()` — manual dirty drain + `onBeforeDirtyClearAll` callback
66
+
67
+ Public counterpart to `getDirty` / `getDirtyMeta` for the "stuck dirty
68
+ drain" recovery flow. Use case: a known-bad dirty entry can't upload
69
+ (e.g. pre-fix `sestevki` parent+descendant producing repeated 500-loops
70
+ on klikvet tabs running pre-0.1.185 bundles) and the operator wants to
71
+ forfeit the pending local intent in favor of server state — without
72
+ calling the heavier `dropCollection` / `dropDatabase` which also wipes
73
+ the main row data.
74
+
75
+ ```typescript
76
+ clearDirty(
77
+ collection?: string,
78
+ ids?: Id[],
79
+ calledFrom?: string,
80
+ ): Promise<DirtyMeta[]>
81
+ ```
82
+
83
+ Returns the `DirtyMeta[]` of every entry that was actually removed, so
84
+ the caller can archive what was lost.
85
+
86
+ | Call shape | Effect | Fires `onBeforeDirtyClearAll` |
87
+ |---|---|---|
88
+ | `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection | ✅ once per collection that has dirty |
89
+ | `clearDirty(coll)` | Clear all dirty in one collection | ❌ |
90
+ | `clearDirty(coll, ids)` | Clear specific ids in that collection | ❌ |
91
+ | `clearDirty(undefined, ids)` | Throws — ambiguous | — |
92
+
93
+ The new config callback:
94
+
95
+ ```typescript
96
+ onBeforeDirtyClearAll?: (info: BeforeDirtyClearAllInfo) => void;
97
+
98
+ interface BeforeDirtyClearAllInfo {
99
+ reason: string; // `calledFrom` if provided, else "manual"
100
+ collection: string;
101
+ items: DirtyMeta[]; // every entry in that collection (no `changes` payload)
102
+ calledFrom?: string; // passthrough of the third arg
103
+ timestamp: Date;
104
+ }
105
+ ```
106
+
107
+ Fires **only** on the clear-all path, **before** the Dexie delete runs,
108
+ **once per collection** with dirty. Use to archive the dirty state to
109
+ syslog / audit trail before it disappears. Per-collection /
110
+ per-id `clearDirty` calls are intentionally silent — the caller already
111
+ knows what they're touching.
112
+
113
+ ```typescript
114
+ new SyncedDb({
115
+ // ...
116
+ onBeforeDirtyClearAll: (info) => {
117
+ syslog.warn("dirty cleared", {
118
+ collection: info.collection,
119
+ count: info.items.length,
120
+ reason: info.reason,
121
+ ids: info.items.map((m) => m.id),
122
+ });
123
+ },
124
+ });
125
+
126
+ // Nuke everything across collections, tagged for the audit log.
127
+ const cleared = await syncedDb.clearDirty(undefined, undefined, "stuck-drain");
128
+
129
+ // Targeted: drop two known-bad rows in `obiski` (silent — no callback).
130
+ await syncedDb.clearDirty("obiski", ["6a032a59...", "6a05a1ba..."]);
131
+
132
+ // Inspect first, then decide.
133
+ const meta = await syncedDb.getDirtyMeta();
134
+ for (const [coll, items] of Object.entries(meta)) {
135
+ if (items.length > 100) {
136
+ await syncedDb.clearDirty(coll, undefined, `over-threshold:${items.length}`);
137
+ }
138
+ }
139
+ ```
140
+
141
+ `clearDirty` does NOT await in-flight uploads — call `flushToServer()`
142
+ first if a last-chance upload is desired. It also does not touch the
143
+ main row data; in-mem and Dexie rows are preserved at whatever state
144
+ they're at (typically: the server's view if WS-push has landed
145
+ recently, otherwise the pre-rollback local intent).
146
+
3
147
  ## 0.1.187 (2026-05-16)
4
148
 
5
149
  ### Fix: `setByPath` auto-creates plain-object intermediates (root cause of `sestevki` parent+child conflict)
package/dist/index.js CHANGED
@@ -4456,6 +4456,7 @@ var _SyncedDb = class _SyncedDb {
4456
4456
  this.syncOnlyCollections = null;
4457
4457
  // Sync metadata cache
4458
4458
  this.syncMetaCache = /* @__PURE__ */ new Map();
4459
+ this._pendingFullResync = false;
4459
4460
  var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p;
4460
4461
  this.tenant = config.tenant;
4461
4462
  this.dexieDb = config.dexieDb;
@@ -4484,6 +4485,10 @@ var _SyncedDb = class _SyncedDb {
4484
4485
  this.onEviction = config.onEviction;
4485
4486
  this.onSaveIdMismatch = config.onSaveIdMismatch;
4486
4487
  this.onUploadSkip = config.onUploadSkip;
4488
+ this.onBeforeDirtyClearAll = config.onBeforeDirtyClearAll;
4489
+ this.forceFullResyncIfOlderThanDays = config.forceFullResyncIfOlderThanDays;
4490
+ this.forceFullResyncCheckEveryHrs = config.forceFullResyncCheckEveryHrs;
4491
+ this.onForceFullResyncTriggered = config.onForceFullResyncTriggered;
4487
4492
  this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
4488
4493
  this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
4489
4494
  this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
@@ -4924,6 +4929,8 @@ var _SyncedDb = class _SyncedDb {
4924
4929
  };
4925
4930
  document.addEventListener("visibilitychange", this.visibilityFlushHandler);
4926
4931
  }
4932
+ this._checkForceFullResyncThreshold("init");
4933
+ this._startForceResyncCheckTimer();
4927
4934
  this.initialized = true;
4928
4935
  }
4929
4936
  /**
@@ -5003,6 +5010,73 @@ var _SyncedDb = class _SyncedDb {
5003
5010
  this._lastInitialSyncDate = new Date(meta.lastSyncTs);
5004
5011
  }
5005
5012
  }
5013
+ /**
5014
+ * Trip the `_pendingFullResync` flag iff `_lastFullSyncDate` is older
5015
+ * than `forceFullResyncIfOlderThanDays`. Fires `onForceFullResyncTriggered`
5016
+ * exactly once per trigger event (idempotent — repeated calls while the
5017
+ * flag is already set are a no-op). Attempts to kick off a sync
5018
+ * immediately if `canSync()`; otherwise the flag waits for the next
5019
+ * online sync slot (auto-sync timer, reconnect, manual `sync()`, …).
5020
+ *
5021
+ * Never deletes Dexie data; the actual cursor reset happens inside
5022
+ * `sync()` just before `syncEngine.sync()` runs (see `_resetSyncCursors`).
5023
+ */
5024
+ _checkForceFullResyncThreshold(reason) {
5025
+ if (this._pendingFullResync) return;
5026
+ const days = this.forceFullResyncIfOlderThanDays;
5027
+ if (!days || days <= 0) return;
5028
+ const last = this._lastFullSyncDate;
5029
+ if (!last) return;
5030
+ const ageMs = Date.now() - last.getTime();
5031
+ const ageDays = ageMs / 864e5;
5032
+ if (ageDays < days) return;
5033
+ this._pendingFullResync = true;
5034
+ this.safeCallback(this.onForceFullResyncTriggered, {
5035
+ reason,
5036
+ lastFullSyncDate: last,
5037
+ daysSinceLastFullSync: ageDays,
5038
+ thresholdDays: days,
5039
+ timestamp: /* @__PURE__ */ new Date()
5040
+ });
5041
+ if (this.connectionManager.canSync()) {
5042
+ this.sync(`force-full-resync:${reason}`).catch((err) => {
5043
+ console.error(`[SyncedDb] force full resync sync() failed: ${err}`, err);
5044
+ });
5045
+ }
5046
+ }
5047
+ _startForceResyncCheckTimer() {
5048
+ const hrs = this.forceFullResyncCheckEveryHrs;
5049
+ if (!hrs || hrs <= 0) return;
5050
+ this._forceResyncCheckTimer = setInterval(() => {
5051
+ this._checkForceFullResyncThreshold("timer");
5052
+ }, hrs * 36e5);
5053
+ }
5054
+ /**
5055
+ * Cursor-reset path for the force-full-resync flow. Resets every
5056
+ * collection's `syncMeta.lastSyncTs` to undefined (Dexie + cache) so
5057
+ * the next `syncEngine.sync` call fetches from the beginning. Does
5058
+ * NOT delete Dexie main rows, in-mem rows, or `_dirty_changes`.
5059
+ * Conflict resolution + eviction (handled by the normal sync flow)
5060
+ * sweep what needs sweeping AFTER the server response arrives — so
5061
+ * a failed sync leaves local data intact.
5062
+ *
5063
+ * Per-collection deleteSyncMeta failures are logged and tolerated
5064
+ * (cache is cleared unconditionally → in-memory effect is fully
5065
+ * reset even if some Dexie rows lingered).
5066
+ */
5067
+ async _resetSyncCursors() {
5068
+ for (const [name] of this.collections) {
5069
+ try {
5070
+ await this.dexieDb.deleteSyncMeta(name);
5071
+ } catch (err) {
5072
+ console.error(
5073
+ `[SyncedDb] _resetSyncCursors: deleteSyncMeta(${name}) failed: ${err}`,
5074
+ err
5075
+ );
5076
+ }
5077
+ }
5078
+ this.syncMetaCache.clear();
5079
+ }
5006
5080
  async close() {
5007
5081
  var _a, _b;
5008
5082
  if (this.closed) return;
@@ -5010,6 +5084,10 @@ var _SyncedDb = class _SyncedDb {
5010
5084
  this.leaderElection.setClosing(true);
5011
5085
  this.pendingChanges.cancelRestUploadTimer();
5012
5086
  this.connectionManager.stopTimers();
5087
+ if (this._forceResyncCheckTimer) {
5088
+ clearInterval(this._forceResyncCheckTimer);
5089
+ this._forceResyncCheckTimer = void 0;
5090
+ }
5013
5091
  await this.pendingChanges.flushAll();
5014
5092
  (_a = this.networkStatus) == null ? void 0 : _a.dispose();
5015
5093
  (_b = this.wakeSync) == null ? void 0 : _b.dispose();
@@ -5633,6 +5711,10 @@ var _SyncedDb = class _SyncedDb {
5633
5711
  this._applyScopeExitChunkToPlan(evictionPlan, specId, items);
5634
5712
  }
5635
5713
  } : void 0;
5714
+ const consumingPendingFullResync = this._pendingFullResync;
5715
+ if (consumingPendingFullResync) {
5716
+ await this._resetSyncCursors();
5717
+ }
5636
5718
  try {
5637
5719
  await this.syncEngine.sync(calledFrom, evictionExtras);
5638
5720
  if (!this.syncOnlyCollections) {
@@ -5645,6 +5727,7 @@ var _SyncedDb = class _SyncedDb {
5645
5727
  this._setLastFullSync(now).catch((err) => {
5646
5728
  console.error(`[SyncedDb] Failed to persist lastFullSync: ${err}`, err);
5647
5729
  });
5730
+ if (consumingPendingFullResync) this._pendingFullResync = false;
5648
5731
  }
5649
5732
  } catch (err) {
5650
5733
  if (evictionExtras) evictionServerFailed = true;
@@ -5759,6 +5842,68 @@ var _SyncedDb = class _SyncedDb {
5759
5842
  }
5760
5843
  return result;
5761
5844
  }
5845
+ /**
5846
+ * Manually clear pending dirty changes WITHOUT touching the main
5847
+ * row data. Counterpart to `getDirty` / `getDirtyMeta` for the
5848
+ * "stuck dirty drain" recovery flow — when a known-bad dirty entry
5849
+ * can't be uploaded (e.g. pre-fix sestevki parent+descendant
5850
+ * conflict producing repeated 500s) and the operator wants to
5851
+ * forfeit the pending local intent in favor of server state.
5852
+ *
5853
+ * Three call shapes:
5854
+ *
5855
+ * | Call | Effect |
5856
+ * |---|---|
5857
+ * | `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection. Fires `onBeforeDirtyClearAll` once per collection that has dirty (BEFORE the Dexie delete). |
5858
+ * | `clearDirty(coll)` | Clear all dirty in one collection. No callback. |
5859
+ * | `clearDirty(coll, ids)` | Clear specific ids in that collection. No callback. |
5860
+ * | `clearDirty(undefined, ids)` | Throws — ambiguous (which collection do the ids belong to?). |
5861
+ *
5862
+ * Returns the `DirtyMeta[]` of every entry that was actually
5863
+ * removed so the caller can log / archive what was lost.
5864
+ *
5865
+ * Pairs naturally with `getDirtyMeta()` for the inspect-then-clear
5866
+ * pattern. Doesn't await in-flight uploads — caller's
5867
+ * responsibility to call `flushToServer()` first if a last-chance
5868
+ * upload is desired.
5869
+ */
5870
+ async clearDirty(collection, ids, calledFrom) {
5871
+ if (!collection && ids && ids.length > 0) {
5872
+ throw new Error(
5873
+ `[SyncedDb] clearDirty: 'ids' requires a 'collection' \u2014 ids alone are ambiguous across collections.`
5874
+ );
5875
+ }
5876
+ const cleared = [];
5877
+ if (!collection) {
5878
+ for (const [name] of this.collections) {
5879
+ const metas2 = await this.dexieDb.getDirtyMeta(name);
5880
+ if (metas2.length === 0) continue;
5881
+ this.safeCallback(this.onBeforeDirtyClearAll, {
5882
+ reason: calledFrom != null ? calledFrom : "manual",
5883
+ collection: name,
5884
+ items: metas2,
5885
+ calledFrom,
5886
+ timestamp: /* @__PURE__ */ new Date()
5887
+ });
5888
+ await this.dexieDb.clearDirtyChanges(name);
5889
+ for (const m of metas2) cleared.push(m);
5890
+ }
5891
+ return cleared;
5892
+ }
5893
+ this.assertCollection(collection);
5894
+ if (ids && ids.length > 0) {
5895
+ const idStrings = new Set(ids.map((id) => String(id)));
5896
+ const allMetas = await this.dexieDb.getDirtyMeta(collection);
5897
+ for (const m of allMetas) {
5898
+ if (idStrings.has(String(m.id))) cleared.push(m);
5899
+ }
5900
+ await this.dexieDb.clearDirtyChangesBatch(collection, ids);
5901
+ return cleared;
5902
+ }
5903
+ const metas = await this.dexieDb.getDirtyMeta(collection);
5904
+ await this.dexieDb.clearDirtyChanges(collection);
5905
+ return metas;
5906
+ }
5762
5907
  // ==================== Data Deletion ====================
5763
5908
  async dropCollection(collection, force = false) {
5764
5909
  this.assertCollection(collection);
@@ -56,6 +56,12 @@ export declare class SyncedDb implements I_SyncedDb {
56
56
  private readonly onEviction?;
57
57
  private readonly onSaveIdMismatch?;
58
58
  private readonly onUploadSkip?;
59
+ private readonly onBeforeDirtyClearAll?;
60
+ private readonly forceFullResyncIfOlderThanDays?;
61
+ private readonly forceFullResyncCheckEveryHrs?;
62
+ private readonly onForceFullResyncTriggered?;
63
+ private _pendingFullResync;
64
+ private _forceResyncCheckTimer?;
59
65
  private readonly evictStaleRecordsEveryHrs;
60
66
  private readonly scopeExitLookbehindMs;
61
67
  private readonly evictOnWake;
@@ -167,6 +173,33 @@ export declare class SyncedDb implements I_SyncedDb {
167
173
  _setLastInitialSync(date: Date): Promise<void>;
168
174
  /** @internal Load cached value from Dexie */
169
175
  private _loadLastInitialSync;
176
+ /**
177
+ * Trip the `_pendingFullResync` flag iff `_lastFullSyncDate` is older
178
+ * than `forceFullResyncIfOlderThanDays`. Fires `onForceFullResyncTriggered`
179
+ * exactly once per trigger event (idempotent — repeated calls while the
180
+ * flag is already set are a no-op). Attempts to kick off a sync
181
+ * immediately if `canSync()`; otherwise the flag waits for the next
182
+ * online sync slot (auto-sync timer, reconnect, manual `sync()`, …).
183
+ *
184
+ * Never deletes Dexie data; the actual cursor reset happens inside
185
+ * `sync()` just before `syncEngine.sync()` runs (see `_resetSyncCursors`).
186
+ */
187
+ private _checkForceFullResyncThreshold;
188
+ private _startForceResyncCheckTimer;
189
+ /**
190
+ * Cursor-reset path for the force-full-resync flow. Resets every
191
+ * collection's `syncMeta.lastSyncTs` to undefined (Dexie + cache) so
192
+ * the next `syncEngine.sync` call fetches from the beginning. Does
193
+ * NOT delete Dexie main rows, in-mem rows, or `_dirty_changes`.
194
+ * Conflict resolution + eviction (handled by the normal sync flow)
195
+ * sweep what needs sweeping AFTER the server response arrives — so
196
+ * a failed sync leaves local data intact.
197
+ *
198
+ * Per-collection deleteSyncMeta failures are logged and tolerated
199
+ * (cache is cleared unconditionally → in-memory effect is fully
200
+ * reset even if some Dexie rows lingered).
201
+ */
202
+ private _resetSyncCursors;
170
203
  close(): Promise<void>;
171
204
  isOnline(): boolean;
172
205
  forceOffline(forced: boolean): void;
@@ -238,6 +271,32 @@ export declare class SyncedDb implements I_SyncedDb {
238
271
  getOnWakeSync(): ((info: import("./types/managers").WakeSyncInfo) => void) | undefined;
239
272
  getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
240
273
  getDirtyMeta(): Promise<Readonly<Record<string, readonly DirtyMeta[]>>>;
274
+ /**
275
+ * Manually clear pending dirty changes WITHOUT touching the main
276
+ * row data. Counterpart to `getDirty` / `getDirtyMeta` for the
277
+ * "stuck dirty drain" recovery flow — when a known-bad dirty entry
278
+ * can't be uploaded (e.g. pre-fix sestevki parent+descendant
279
+ * conflict producing repeated 500s) and the operator wants to
280
+ * forfeit the pending local intent in favor of server state.
281
+ *
282
+ * Three call shapes:
283
+ *
284
+ * | Call | Effect |
285
+ * |---|---|
286
+ * | `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection. Fires `onBeforeDirtyClearAll` once per collection that has dirty (BEFORE the Dexie delete). |
287
+ * | `clearDirty(coll)` | Clear all dirty in one collection. No callback. |
288
+ * | `clearDirty(coll, ids)` | Clear specific ids in that collection. No callback. |
289
+ * | `clearDirty(undefined, ids)` | Throws — ambiguous (which collection do the ids belong to?). |
290
+ *
291
+ * Returns the `DirtyMeta[]` of every entry that was actually
292
+ * removed so the caller can log / archive what was lost.
293
+ *
294
+ * Pairs naturally with `getDirtyMeta()` for the inspect-then-clear
295
+ * pattern. Doesn't await in-flight uploads — caller's
296
+ * responsibility to call `flushToServer()` first if a last-chance
297
+ * upload is desired.
298
+ */
299
+ clearDirty(collection?: string, ids?: Id[], calledFrom?: string): Promise<DirtyMeta[]>;
241
300
  dropCollection(collection: string, force?: boolean): Promise<void>;
242
301
  dropDatabase(force?: boolean): Promise<void>;
243
302
  /**
@@ -91,6 +91,56 @@ export interface SaveIdMismatchInfo {
91
91
  /** Timestamp when mismatch detected */
92
92
  timestamp: Date;
93
93
  }
94
+ /**
95
+ * Callback payload fired by `clearDirty()` when called WITHOUT a
96
+ * `collection` argument (clear-all path). One invocation per
97
+ * collection that has ≥1 dirty entry, fired BEFORE the underlying
98
+ * Dexie delete runs — caller can archive/inspect the records.
99
+ *
100
+ * Per-collection `clearDirty(coll, ...)` calls do NOT fire this
101
+ * callback (targeted operation; caller already knows what they're
102
+ * touching).
103
+ */
104
+ export interface BeforeDirtyClearAllInfo {
105
+ /** What triggered the clear-all (e.g. "manual", custom string from caller). */
106
+ reason: string;
107
+ /** Collection whose dirty entries are about to be deleted. */
108
+ collection: string;
109
+ /** Meta of every dirty entry in this collection (no `changes` payload). */
110
+ items: import("./I_DexieDb").DirtyMeta[];
111
+ /** Optional caller tag passed through `clearDirty(undefined, undefined, calledFrom)`. */
112
+ calledFrom?: string;
113
+ /** Timestamp when the callback fires. */
114
+ timestamp: Date;
115
+ }
116
+ /**
117
+ * Callback payload fired when the `forceFullResyncIfOlderThanDays`
118
+ * threshold trips — either on `init()` (`reason: "init"`) or on a
119
+ * `forceFullResyncCheckEveryHrs` timer tick (`reason: "timer"`).
120
+ *
121
+ * Fires BEFORE the cursor reset / sync. Use for telemetry, syslog,
122
+ * UI ("refreshing your data…"), or to gate UX during the resync.
123
+ *
124
+ * The trigger sets an internal `_pendingFullResync` flag and tries to
125
+ * initiate a sync immediately. If offline or `forcedOffline`, the
126
+ * flag persists in memory; the next online sync slot consumes it.
127
+ * On sync failure, the flag persists and the next slot retries.
128
+ * Dexie data is NEVER deleted as part of this flow — only cursors
129
+ * are reset; conflict resolution overwrites local rows record-by-
130
+ * record as the server response streams in.
131
+ */
132
+ export interface ForceFullResyncInfo {
133
+ /** What triggered the resync check. */
134
+ reason: "init" | "timer";
135
+ /** Last successful full sync timestamp; undefined if never synced. */
136
+ lastFullSyncDate?: Date;
137
+ /** Age of last full sync in days; undefined if never synced. */
138
+ daysSinceLastFullSync?: number;
139
+ /** Configured threshold (`forceFullResyncIfOlderThanDays`) in days. */
140
+ thresholdDays: number;
141
+ /** Wall-clock at trigger time. */
142
+ timestamp: Date;
143
+ }
94
144
  /**
95
145
  * Callback payload for server write requests (before sending)
96
146
  */
@@ -649,6 +699,71 @@ export interface SyncedDbConfig {
649
699
  * where `this._id` and `this.protokol._id` had drifted apart.
650
700
  */
651
701
  onSaveIdMismatch?: (info: SaveIdMismatchInfo) => void;
702
+ /**
703
+ * Fired by `clearDirty()` (no collection argument — clear-all path)
704
+ * BEFORE the Dexie delete runs, once per collection that has
705
+ * dirty entries. Use to archive the dirty state to syslog / audit
706
+ * trail before it's gone, or to abort by throwing (the throw
707
+ * aborts only the in-flight clear, doesn't propagate).
708
+ *
709
+ * Per-collection `clearDirty(coll, ...)` calls are SILENT — they
710
+ * don't fire this callback.
711
+ */
712
+ onBeforeDirtyClearAll?: (info: BeforeDirtyClearAllInfo) => void;
713
+ /**
714
+ * Periodic full-resync maintenance heartbeat.
715
+ *
716
+ * If `lastSuccessfulServerSync()` is older than this many days, the
717
+ * library schedules a **safe full resync**:
718
+ *
719
+ * 1. Reset every collection's `syncMeta.lastSyncTs` to 0 (Dexie + cache).
720
+ * 2. Run `sync()` — cursors at 0 force the server to return everything.
721
+ * 3. Each record applied via normal conflict resolution; eviction
722
+ * sweeps records the server no longer reports.
723
+ *
724
+ * Dexie main rows and `_dirty_changes` are **never deleted**. If the
725
+ * resync fails (offline, server error, timeout, tab closed), the
726
+ * local cache stays exactly as it was; cursors remain at 0 so the
727
+ * next online sync retries.
728
+ *
729
+ * Checked at `init()` (once, after Dexie sync metas have loaded) and
730
+ * on each `forceFullResyncCheckEveryHrs` timer tick. Records with
731
+ * `_lastFullSyncDate === undefined` (never synced) are NOT considered
732
+ * stale — the normal initial sync handles them.
733
+ *
734
+ * Default: `undefined` (disabled). Set to a positive integer to enable.
735
+ *
736
+ * @see forceFullResyncCheckEveryHrs for the timer interval.
737
+ * @see onForceFullResyncTriggered for the telemetry hook.
738
+ */
739
+ forceFullResyncIfOlderThanDays?: number;
740
+ /**
741
+ * Continuous timer interval (in hours) that re-checks the
742
+ * `forceFullResyncIfOlderThanDays` threshold. The timer keeps running
743
+ * for the lifetime of the SyncedDb (`init()` → `close()`) and never
744
+ * self-disables — every tick is a cheap memory + Date math check.
745
+ *
746
+ * Set to `undefined` (default) to only check at `init()`. Set to e.g.
747
+ * `24` to check every 24h. Independent of
748
+ * `forceFullResyncIfOlderThanDays`: the timer fires regardless, but
749
+ * does nothing if the threshold is unset or not exceeded.
750
+ *
751
+ * Multiple triggers between sync attempts are idempotent (the flag is
752
+ * boolean). A timer tick during an in-flight sync no-ops; the next
753
+ * sync slot picks up the pending flag.
754
+ */
755
+ forceFullResyncCheckEveryHrs?: number;
756
+ /**
757
+ * Fires when `forceFullResyncIfOlderThanDays` trips — BEFORE the
758
+ * cursor reset and sync. Use for telemetry / syslog / UI banners
759
+ * ("refreshing your data…").
760
+ *
761
+ * Fires at most once per trigger event. Even if the trigger sets the
762
+ * flag while a previous trigger's flag is still pending, this
763
+ * callback does NOT re-fire — the `_pendingFullResync` flag is
764
+ * boolean.
765
+ */
766
+ onForceFullResyncTriggered?: (info: ForceFullResyncInfo) => void;
652
767
  /**
653
768
  * Enable in-memory object metadata feature.
654
769
  * When true, collections with hasMetadata=true will have their metadata callbacks invoked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.187",
3
+ "version": "0.1.189",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",