cry-synced-db-client 0.1.188 → 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 +60 -0
- package/dist/index.js +82 -0
- package/dist/src/db/SyncedDb.d.ts +32 -0
- package/dist/src/types/I_SyncedDb.d.ts +82 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
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
|
+
|
|
3
63
|
## 0.1.188 (2026-05-15)
|
|
4
64
|
|
|
5
65
|
### `clearDirty()` — manual dirty drain + `onBeforeDirtyClearAll` callback
|
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;
|
|
@@ -4485,6 +4486,9 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4485
4486
|
this.onSaveIdMismatch = config.onSaveIdMismatch;
|
|
4486
4487
|
this.onUploadSkip = config.onUploadSkip;
|
|
4487
4488
|
this.onBeforeDirtyClearAll = config.onBeforeDirtyClearAll;
|
|
4489
|
+
this.forceFullResyncIfOlderThanDays = config.forceFullResyncIfOlderThanDays;
|
|
4490
|
+
this.forceFullResyncCheckEveryHrs = config.forceFullResyncCheckEveryHrs;
|
|
4491
|
+
this.onForceFullResyncTriggered = config.onForceFullResyncTriggered;
|
|
4488
4492
|
this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
|
|
4489
4493
|
this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
|
|
4490
4494
|
this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
|
|
@@ -4925,6 +4929,8 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4925
4929
|
};
|
|
4926
4930
|
document.addEventListener("visibilitychange", this.visibilityFlushHandler);
|
|
4927
4931
|
}
|
|
4932
|
+
this._checkForceFullResyncThreshold("init");
|
|
4933
|
+
this._startForceResyncCheckTimer();
|
|
4928
4934
|
this.initialized = true;
|
|
4929
4935
|
}
|
|
4930
4936
|
/**
|
|
@@ -5004,6 +5010,73 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5004
5010
|
this._lastInitialSyncDate = new Date(meta.lastSyncTs);
|
|
5005
5011
|
}
|
|
5006
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
|
+
}
|
|
5007
5080
|
async close() {
|
|
5008
5081
|
var _a, _b;
|
|
5009
5082
|
if (this.closed) return;
|
|
@@ -5011,6 +5084,10 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5011
5084
|
this.leaderElection.setClosing(true);
|
|
5012
5085
|
this.pendingChanges.cancelRestUploadTimer();
|
|
5013
5086
|
this.connectionManager.stopTimers();
|
|
5087
|
+
if (this._forceResyncCheckTimer) {
|
|
5088
|
+
clearInterval(this._forceResyncCheckTimer);
|
|
5089
|
+
this._forceResyncCheckTimer = void 0;
|
|
5090
|
+
}
|
|
5014
5091
|
await this.pendingChanges.flushAll();
|
|
5015
5092
|
(_a = this.networkStatus) == null ? void 0 : _a.dispose();
|
|
5016
5093
|
(_b = this.wakeSync) == null ? void 0 : _b.dispose();
|
|
@@ -5634,6 +5711,10 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5634
5711
|
this._applyScopeExitChunkToPlan(evictionPlan, specId, items);
|
|
5635
5712
|
}
|
|
5636
5713
|
} : void 0;
|
|
5714
|
+
const consumingPendingFullResync = this._pendingFullResync;
|
|
5715
|
+
if (consumingPendingFullResync) {
|
|
5716
|
+
await this._resetSyncCursors();
|
|
5717
|
+
}
|
|
5637
5718
|
try {
|
|
5638
5719
|
await this.syncEngine.sync(calledFrom, evictionExtras);
|
|
5639
5720
|
if (!this.syncOnlyCollections) {
|
|
@@ -5646,6 +5727,7 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5646
5727
|
this._setLastFullSync(now).catch((err) => {
|
|
5647
5728
|
console.error(`[SyncedDb] Failed to persist lastFullSync: ${err}`, err);
|
|
5648
5729
|
});
|
|
5730
|
+
if (consumingPendingFullResync) this._pendingFullResync = false;
|
|
5649
5731
|
}
|
|
5650
5732
|
} catch (err) {
|
|
5651
5733
|
if (evictionExtras) evictionServerFailed = true;
|
|
@@ -57,6 +57,11 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
57
57
|
private readonly onSaveIdMismatch?;
|
|
58
58
|
private readonly onUploadSkip?;
|
|
59
59
|
private readonly onBeforeDirtyClearAll?;
|
|
60
|
+
private readonly forceFullResyncIfOlderThanDays?;
|
|
61
|
+
private readonly forceFullResyncCheckEveryHrs?;
|
|
62
|
+
private readonly onForceFullResyncTriggered?;
|
|
63
|
+
private _pendingFullResync;
|
|
64
|
+
private _forceResyncCheckTimer?;
|
|
60
65
|
private readonly evictStaleRecordsEveryHrs;
|
|
61
66
|
private readonly scopeExitLookbehindMs;
|
|
62
67
|
private readonly evictOnWake;
|
|
@@ -168,6 +173,33 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
168
173
|
_setLastInitialSync(date: Date): Promise<void>;
|
|
169
174
|
/** @internal Load cached value from Dexie */
|
|
170
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;
|
|
171
203
|
close(): Promise<void>;
|
|
172
204
|
isOnline(): boolean;
|
|
173
205
|
forceOffline(forced: boolean): void;
|
|
@@ -113,6 +113,34 @@ export interface BeforeDirtyClearAllInfo {
|
|
|
113
113
|
/** Timestamp when the callback fires. */
|
|
114
114
|
timestamp: Date;
|
|
115
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
|
+
}
|
|
116
144
|
/**
|
|
117
145
|
* Callback payload for server write requests (before sending)
|
|
118
146
|
*/
|
|
@@ -682,6 +710,60 @@ export interface SyncedDbConfig {
|
|
|
682
710
|
* don't fire this callback.
|
|
683
711
|
*/
|
|
684
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;
|
|
685
767
|
/**
|
|
686
768
|
* Enable in-memory object metadata feature.
|
|
687
769
|
* When true, collections with hasMetadata=true will have their metadata callbacks invoked
|