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 +144 -0
- package/dist/index.js +145 -0
- package/dist/src/db/SyncedDb.d.ts +59 -0
- package/dist/src/types/I_SyncedDb.d.ts +115 -0
- package/package.json +1 -1
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
|