cry-synced-db-client 0.1.188 → 0.1.190

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,110 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.190 (2026-05-15)
4
+
5
+ ### `addCollectionsToSync(specs[])` — batch register with single-RTT download
6
+
7
+ Plural counterpart to `addCollectionToSync`. Per-spec semantics are
8
+ identical (permanent existing = skip; temporary or absent = install),
9
+ but the install work is decomposed into three phases optimized for
10
+ batching:
11
+
12
+ 1. **Filter + register (sync)** — every survivor's
13
+ `this.collections.set()` and `syncOnlyTheseCollections` extension
14
+ happen up front, so a sync triggered mid-install already sees the
15
+ new collections.
16
+ 2. **Hydrate (parallel)** — Dexie cursor reads + in-mem hydrations
17
+ fan out via `Promise.allSettled`. Per-collection failure is logged
18
+ and isolated; the collection stays installed and hydrates on the
19
+ next sync tick.
20
+ 3. **Download (one RTT)** — every newly-installed non-`writeOnly`
21
+ spec is bundled into a **single `findNewerMany` request**, then
22
+ each result is fed through `processCollectionServerData` for
23
+ conflict resolution. **N collections = 1 RTT** of wall-clock time.
24
+
25
+ ```ts
26
+ // Login flow: install a workspace's collections in one shot.
27
+ await syncedDb.addCollectionsToSync([
28
+ { name: "obravnave", syncConfig: { query: () => ({ lokacija_id }) } },
29
+ { name: "zivali", syncConfig: { query: () => ({ tenant }) } },
30
+ { name: "racuni", syncConfig: { query: () => ({ leto }) } },
31
+ { name: "audit_log", writeOnly: true }, // installed but excluded from RTT
32
+ ]);
33
+ // → 1 findNewerMany covering [obravnave, zivali, racuni].
34
+ ```
35
+
36
+ Skipped specs (existing permanent) consume zero install work. No-op
37
+ when `specs` is empty or every spec is skipped. Network failure on the
38
+ batched download leaves collections registered; next auto-sync tick
39
+ retries via the normal sync flow.
40
+
41
+ Compared to looping `addCollectionToSync(spec)` per item:
42
+ - N collections × per-collection RTT (sequential or `Promise.all`)
43
+ → 1 RTT for the whole batch.
44
+ - Hydration also parallelized.
45
+ - Compatible with `syncOnlyTheseCollections` filter (each addition
46
+ extends the active set).
47
+
48
+ ## 0.1.189 (2026-05-15)
49
+
50
+ ### Periodic full-resync maintenance heartbeat
51
+
52
+ New `SyncedDbConfig` options that guarantee a full server re-read at a
53
+ configurable cadence — useful when long-running tabs accumulate
54
+ server-side state drift (eviction window misses, race-ridden
55
+ tombstones, missed WS-push notifications while the tab was throttled).
56
+
57
+ ```ts
58
+ new SyncedDb({
59
+ // ...
60
+ forceFullResyncIfOlderThanDays: 7, // threshold (off when undefined)
61
+ forceFullResyncCheckEveryHrs: 24, // continuous re-check (init-only when undefined)
62
+ onForceFullResyncTriggered: (info) => {
63
+ // info: { reason: "init" | "timer", lastFullSyncDate?, daysSinceLastFullSync?, thresholdDays, timestamp }
64
+ syslog.info("force-full-resync", info);
65
+ },
66
+ });
67
+ ```
68
+
69
+ **What it does** — when `lastSuccessfulServerSync()` is older than the
70
+ threshold (checked once at `init()` + continuously every
71
+ `forceFullResyncCheckEveryHrs`):
72
+
73
+ 1. `onForceFullResyncTriggered` fires (idempotent — flag-gated, no
74
+ re-fire if a previous trigger is still pending).
75
+ 2. Internal `_pendingFullResync` flag is set.
76
+ 3. If `canSync()`, fires `sync("force-full-resync:<reason>")`.
77
+ 4. The consuming sync slot resets **every** collection's
78
+ `syncMeta.lastSyncTs` (Dexie + cache) just before `syncEngine.sync`
79
+ runs. Server returns the full dataset.
80
+ 5. Conflict resolution overwrites local rows record-by-record as the
81
+ stream lands. Bundled eviction sweeps records the server no longer
82
+ reports.
83
+ 6. On success the flag is cleared. On failure (offline, server error,
84
+ abort) the flag persists; the next sync slot retries.
85
+
86
+ **Safety guarantees** — `_dirty_changes` and Dexie main rows are
87
+ **never deleted** by this flow. The trigger only mutates cursors;
88
+ conflict resolution + eviction handle row updates and out-of-scope
89
+ removal as part of normal sync. A failed resync leaves the local
90
+ cache exactly as it was. There is no window where Dexie is empty
91
+ and the server response hasn't yet landed — fundamentally different
92
+ from the legacy `refreshDatabaseFromServer()` which wipes Dexie
93
+ before re-downloading.
94
+
95
+ Implementation notes:
96
+
97
+ - The check (`_checkForceFullResyncThreshold`) is cheap (memory + Date
98
+ math). The continuous timer runs for the SyncedDb's lifetime
99
+ (`init() → close()`); ticks during an in-flight sync no-op via
100
+ `syncLock`. The flag is boolean — multiple triggers between sync
101
+ slots collapse to one.
102
+ - Records with `_lastFullSyncDate === undefined` (never synced) are
103
+ NOT considered stale — initial sync handles those.
104
+ - Subset mode (`syncOnlyCollections !== null`) never sets
105
+ `__lastFullSync`, so the trigger never fires in that mode.
106
+ - `dropDatabase()` clears `__lastFullSync`, restoring "never synced".
107
+
3
108
  ## 0.1.188 (2026-05-15)
4
109
 
5
110
  ### `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;
@@ -4766,6 +4770,99 @@ var _SyncedDb = class _SyncedDb {
4766
4770
  }
4767
4771
  await this._installCollectionConfig(spec);
4768
4772
  }
4773
+ /**
4774
+ * Batch register. See `I_SyncedDb.addCollectionsToSync`.
4775
+ *
4776
+ * The work decomposes into three phases:
4777
+ *
4778
+ * 1. Filter + synchronously seed `this.collections` and the
4779
+ * `syncOnlyCollections` filter so any concurrent sync triggered
4780
+ * mid-install already sees the new collections.
4781
+ * 2. Parallel Dexie cursor load + in-mem hydration.
4782
+ * 3. Parallel per-collection one-shot download.
4783
+ *
4784
+ * Per-spec errors in phase 2/3 are isolated via `Promise.allSettled`:
4785
+ * one collection's hydration / download failure does not block the
4786
+ * others. Failures are logged and the spec is left installed (its
4787
+ * cache will hydrate on the next sync tick).
4788
+ */
4789
+ async addCollectionsToSync(specs) {
4790
+ var _a;
4791
+ if (specs.length === 0) return;
4792
+ const toInstall = [];
4793
+ for (const spec of specs) {
4794
+ const existing = this.collections.get(spec.name);
4795
+ if (existing && !existing.temporaryConfig) continue;
4796
+ this.collections.set(spec.name, spec);
4797
+ if (this.syncOnlyCollections) {
4798
+ this.syncOnlyCollections.add(spec.name);
4799
+ }
4800
+ toInstall.push(spec);
4801
+ }
4802
+ if (toInstall.length === 0) return;
4803
+ const hydrationResults = await Promise.allSettled(
4804
+ toInstall.map(async (spec) => {
4805
+ const meta = await this.dexieDb.getSyncMeta(spec.name);
4806
+ if (meta) this.syncMetaCache.set(spec.name, meta);
4807
+ if (!spec.writeOnly) {
4808
+ await this.loadCollectionToInMem(spec.name);
4809
+ }
4810
+ })
4811
+ );
4812
+ for (let i = 0; i < hydrationResults.length; i++) {
4813
+ const r = hydrationResults[i];
4814
+ if (r.status === "rejected") {
4815
+ console.error(
4816
+ `[SyncedDb] addCollectionsToSync: hydration failed for "${toInstall[i].name}":`,
4817
+ r.reason
4818
+ );
4819
+ }
4820
+ }
4821
+ if (!this.connectionManager.canSync()) return;
4822
+ const downloadable = toInstall.filter((s) => !s.writeOnly);
4823
+ if (downloadable.length === 0) return;
4824
+ const plans = [];
4825
+ const fetchSpecs = [];
4826
+ for (const spec of downloadable) {
4827
+ const rawQuery = (_a = spec.syncConfig) == null ? void 0 : _a.query;
4828
+ const query = typeof rawQuery === "function" ? rawQuery() : rawQuery;
4829
+ const meta = this.syncMetaCache.get(spec.name);
4830
+ const timestamp = (meta == null ? void 0 : meta.lastSyncTs) || 0;
4831
+ plans.push({ spec, wasFirstTime: !timestamp });
4832
+ fetchSpecs.push({
4833
+ collection: spec.name,
4834
+ timestamp,
4835
+ query,
4836
+ opts: { returnDeleted: true }
4837
+ });
4838
+ }
4839
+ let results;
4840
+ try {
4841
+ results = await this.connectionManager.withRestTimeout(
4842
+ this.restInterface.findNewerMany(fetchSpecs),
4843
+ "addCollectionsToSync"
4844
+ );
4845
+ } catch (err) {
4846
+ console.error(
4847
+ `[SyncedDb] addCollectionsToSync: batched findNewerMany failed:`,
4848
+ err
4849
+ );
4850
+ return;
4851
+ }
4852
+ for (const plan of plans) {
4853
+ const data = results[plan.spec.name];
4854
+ if (!data || data.length === 0) continue;
4855
+ const source = plan.wasFirstTime ? "initial" : "incremental";
4856
+ try {
4857
+ await this.syncEngine.processCollectionServerData(plan.spec.name, data, { source });
4858
+ } catch (err) {
4859
+ console.error(
4860
+ `[SyncedDb] addCollectionsToSync: processCollectionServerData failed for "${plan.spec.name}":`,
4861
+ err
4862
+ );
4863
+ }
4864
+ }
4865
+ }
4769
4866
  /**
4770
4867
  * Replace the collection config and re-sync. See `I_SyncedDb.replaceSyncCollection`.
4771
4868
  */
@@ -4925,6 +5022,8 @@ var _SyncedDb = class _SyncedDb {
4925
5022
  };
4926
5023
  document.addEventListener("visibilitychange", this.visibilityFlushHandler);
4927
5024
  }
5025
+ this._checkForceFullResyncThreshold("init");
5026
+ this._startForceResyncCheckTimer();
4928
5027
  this.initialized = true;
4929
5028
  }
4930
5029
  /**
@@ -5004,6 +5103,73 @@ var _SyncedDb = class _SyncedDb {
5004
5103
  this._lastInitialSyncDate = new Date(meta.lastSyncTs);
5005
5104
  }
5006
5105
  }
5106
+ /**
5107
+ * Trip the `_pendingFullResync` flag iff `_lastFullSyncDate` is older
5108
+ * than `forceFullResyncIfOlderThanDays`. Fires `onForceFullResyncTriggered`
5109
+ * exactly once per trigger event (idempotent — repeated calls while the
5110
+ * flag is already set are a no-op). Attempts to kick off a sync
5111
+ * immediately if `canSync()`; otherwise the flag waits for the next
5112
+ * online sync slot (auto-sync timer, reconnect, manual `sync()`, …).
5113
+ *
5114
+ * Never deletes Dexie data; the actual cursor reset happens inside
5115
+ * `sync()` just before `syncEngine.sync()` runs (see `_resetSyncCursors`).
5116
+ */
5117
+ _checkForceFullResyncThreshold(reason) {
5118
+ if (this._pendingFullResync) return;
5119
+ const days = this.forceFullResyncIfOlderThanDays;
5120
+ if (!days || days <= 0) return;
5121
+ const last = this._lastFullSyncDate;
5122
+ if (!last) return;
5123
+ const ageMs = Date.now() - last.getTime();
5124
+ const ageDays = ageMs / 864e5;
5125
+ if (ageDays < days) return;
5126
+ this._pendingFullResync = true;
5127
+ this.safeCallback(this.onForceFullResyncTriggered, {
5128
+ reason,
5129
+ lastFullSyncDate: last,
5130
+ daysSinceLastFullSync: ageDays,
5131
+ thresholdDays: days,
5132
+ timestamp: /* @__PURE__ */ new Date()
5133
+ });
5134
+ if (this.connectionManager.canSync()) {
5135
+ this.sync(`force-full-resync:${reason}`).catch((err) => {
5136
+ console.error(`[SyncedDb] force full resync sync() failed: ${err}`, err);
5137
+ });
5138
+ }
5139
+ }
5140
+ _startForceResyncCheckTimer() {
5141
+ const hrs = this.forceFullResyncCheckEveryHrs;
5142
+ if (!hrs || hrs <= 0) return;
5143
+ this._forceResyncCheckTimer = setInterval(() => {
5144
+ this._checkForceFullResyncThreshold("timer");
5145
+ }, hrs * 36e5);
5146
+ }
5147
+ /**
5148
+ * Cursor-reset path for the force-full-resync flow. Resets every
5149
+ * collection's `syncMeta.lastSyncTs` to undefined (Dexie + cache) so
5150
+ * the next `syncEngine.sync` call fetches from the beginning. Does
5151
+ * NOT delete Dexie main rows, in-mem rows, or `_dirty_changes`.
5152
+ * Conflict resolution + eviction (handled by the normal sync flow)
5153
+ * sweep what needs sweeping AFTER the server response arrives — so
5154
+ * a failed sync leaves local data intact.
5155
+ *
5156
+ * Per-collection deleteSyncMeta failures are logged and tolerated
5157
+ * (cache is cleared unconditionally → in-memory effect is fully
5158
+ * reset even if some Dexie rows lingered).
5159
+ */
5160
+ async _resetSyncCursors() {
5161
+ for (const [name] of this.collections) {
5162
+ try {
5163
+ await this.dexieDb.deleteSyncMeta(name);
5164
+ } catch (err) {
5165
+ console.error(
5166
+ `[SyncedDb] _resetSyncCursors: deleteSyncMeta(${name}) failed: ${err}`,
5167
+ err
5168
+ );
5169
+ }
5170
+ }
5171
+ this.syncMetaCache.clear();
5172
+ }
5007
5173
  async close() {
5008
5174
  var _a, _b;
5009
5175
  if (this.closed) return;
@@ -5011,6 +5177,10 @@ var _SyncedDb = class _SyncedDb {
5011
5177
  this.leaderElection.setClosing(true);
5012
5178
  this.pendingChanges.cancelRestUploadTimer();
5013
5179
  this.connectionManager.stopTimers();
5180
+ if (this._forceResyncCheckTimer) {
5181
+ clearInterval(this._forceResyncCheckTimer);
5182
+ this._forceResyncCheckTimer = void 0;
5183
+ }
5014
5184
  await this.pendingChanges.flushAll();
5015
5185
  (_a = this.networkStatus) == null ? void 0 : _a.dispose();
5016
5186
  (_b = this.wakeSync) == null ? void 0 : _b.dispose();
@@ -5634,6 +5804,10 @@ var _SyncedDb = class _SyncedDb {
5634
5804
  this._applyScopeExitChunkToPlan(evictionPlan, specId, items);
5635
5805
  }
5636
5806
  } : void 0;
5807
+ const consumingPendingFullResync = this._pendingFullResync;
5808
+ if (consumingPendingFullResync) {
5809
+ await this._resetSyncCursors();
5810
+ }
5637
5811
  try {
5638
5812
  await this.syncEngine.sync(calledFrom, evictionExtras);
5639
5813
  if (!this.syncOnlyCollections) {
@@ -5646,6 +5820,7 @@ var _SyncedDb = class _SyncedDb {
5646
5820
  this._setLastFullSync(now).catch((err) => {
5647
5821
  console.error(`[SyncedDb] Failed to persist lastFullSync: ${err}`, err);
5648
5822
  });
5823
+ if (consumingPendingFullResync) this._pendingFullResync = false;
5649
5824
  }
5650
5825
  } catch (err) {
5651
5826
  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;
@@ -95,6 +100,23 @@ export declare class SyncedDb implements I_SyncedDb {
95
100
  * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
96
101
  */
97
102
  addCollectionToSync(spec: CollectionConfig): Promise<void>;
103
+ /**
104
+ * Batch register. See `I_SyncedDb.addCollectionsToSync`.
105
+ *
106
+ * The work decomposes into three phases:
107
+ *
108
+ * 1. Filter + synchronously seed `this.collections` and the
109
+ * `syncOnlyCollections` filter so any concurrent sync triggered
110
+ * mid-install already sees the new collections.
111
+ * 2. Parallel Dexie cursor load + in-mem hydration.
112
+ * 3. Parallel per-collection one-shot download.
113
+ *
114
+ * Per-spec errors in phase 2/3 are isolated via `Promise.allSettled`:
115
+ * one collection's hydration / download failure does not block the
116
+ * others. Failures are logged and the spec is left installed (its
117
+ * cache will hydrate on the next sync tick).
118
+ */
119
+ addCollectionsToSync(specs: CollectionConfig[]): Promise<void>;
98
120
  /**
99
121
  * Replace the collection config and re-sync. See `I_SyncedDb.replaceSyncCollection`.
100
122
  */
@@ -168,6 +190,33 @@ export declare class SyncedDb implements I_SyncedDb {
168
190
  _setLastInitialSync(date: Date): Promise<void>;
169
191
  /** @internal Load cached value from Dexie */
170
192
  private _loadLastInitialSync;
193
+ /**
194
+ * Trip the `_pendingFullResync` flag iff `_lastFullSyncDate` is older
195
+ * than `forceFullResyncIfOlderThanDays`. Fires `onForceFullResyncTriggered`
196
+ * exactly once per trigger event (idempotent — repeated calls while the
197
+ * flag is already set are a no-op). Attempts to kick off a sync
198
+ * immediately if `canSync()`; otherwise the flag waits for the next
199
+ * online sync slot (auto-sync timer, reconnect, manual `sync()`, …).
200
+ *
201
+ * Never deletes Dexie data; the actual cursor reset happens inside
202
+ * `sync()` just before `syncEngine.sync()` runs (see `_resetSyncCursors`).
203
+ */
204
+ private _checkForceFullResyncThreshold;
205
+ private _startForceResyncCheckTimer;
206
+ /**
207
+ * Cursor-reset path for the force-full-resync flow. Resets every
208
+ * collection's `syncMeta.lastSyncTs` to undefined (Dexie + cache) so
209
+ * the next `syncEngine.sync` call fetches from the beginning. Does
210
+ * NOT delete Dexie main rows, in-mem rows, or `_dirty_changes`.
211
+ * Conflict resolution + eviction (handled by the normal sync flow)
212
+ * sweep what needs sweeping AFTER the server response arrives — so
213
+ * a failed sync leaves local data intact.
214
+ *
215
+ * Per-collection deleteSyncMeta failures are logged and tolerated
216
+ * (cache is cleared unconditionally → in-memory effect is fully
217
+ * reset even if some Dexie rows lingered).
218
+ */
219
+ private _resetSyncCursors;
171
220
  close(): Promise<void>;
172
221
  isOnline(): boolean;
173
222
  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
@@ -861,6 +943,34 @@ export interface I_SyncedDb {
861
943
  * @param spec Collection config to register
862
944
  */
863
945
  addCollectionToSync(spec: CollectionConfig): Promise<void>;
946
+ /**
947
+ * Batch variant of `addCollectionToSync`. Semantics are identical
948
+ * per-spec (permanent existing: no-op; temporary or absent: install).
949
+ *
950
+ * Three-phase install:
951
+ *
952
+ * 1. **Filter + register (sync)** — `this.collections.set()` and
953
+ * `syncOnlyTheseCollections` extensions happen up front so any
954
+ * concurrent sync triggered mid-install already sees the new
955
+ * collections.
956
+ * 2. **Hydrate (parallel)** — Dexie cursor reads + in-mem
957
+ * hydrations run via `Promise.allSettled`. Per-collection
958
+ * failures are logged and isolated; the collection stays
959
+ * installed and hydrates on the next sync tick.
960
+ * 3. **Download (one RTT)** — every newly-installed non-writeOnly
961
+ * collection is bundled into a SINGLE `findNewerMany` request,
962
+ * then each result is fed through `processCollectionServerData`
963
+ * for conflict resolution. N collections cost 1 RTT total
964
+ * instead of N parallel RTTs.
965
+ *
966
+ * Skipped specs (existing permanent) consume no install work. No-op
967
+ * when `specs` is empty or every spec is a no-op. Network failure on
968
+ * the batched download leaves the collections installed; the next
969
+ * auto-sync tick retries via the normal sync flow.
970
+ *
971
+ * @param specs Array of collection configs to register
972
+ */
973
+ addCollectionsToSync(specs: CollectionConfig[]): Promise<void>;
864
974
  /**
865
975
  * Replace the collection config and re-sync.
866
976
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.188",
3
+ "version": "0.1.190",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",