cry-synced-db-client 0.1.141 → 0.1.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,70 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Fix: filtered-sync tombstone (scope-exit from other writers)
6
+
7
+ When a collection has `syncConfig.query` (e.g. `{ status: { $ne: "obsolete" } }`)
8
+ and another device mutates a record out of scope while this client is offline,
9
+ the filtered delta feed (`findNewer` with the positive query) never returns the
10
+ mutated record — the server filters it out. The local copy stayed stale
11
+ indefinitely, even across reloads and full re-syncs (positive query still
12
+ excludes it).
13
+
14
+ `evictOutOfScopeRecords` now runs a second, server-assisted pass that fires the
15
+ **negated** query `{ $and: [{ $nor: [syncConfig.query] }, { _id: { $in: knownIds } }] }`
16
+ against the server (scoped by local IDs for both bandwidth and authz) and
17
+ evicts any returned records locally. Purely client-side — no server protocol
18
+ change. Sound only when `syncConfig.query` is the complete server-side
19
+ predicate (no implicit server policy).
20
+
21
+ - New `opts.serverAssisted` parameter on `evictOutOfScopeRecords(collection, opts?)`.
22
+ Defaults to `true` when online and not `writeOnly`. Set `false` for pure
23
+ local pass (previous behavior).
24
+ - New `opts.outOfWindowLookbehindMs` parameter restricts the server-assisted
25
+ query to records changed in the last N ms (translates to `findNewer`'s
26
+ seconds-resolution timestamp filter). When unspecified, the scope-exit
27
+ scan uses the collection's current `meta.lastSyncTs` — the same cursor
28
+ `sync()` passes to `findNewer`, so it covers the same window as a delta
29
+ sync. Pass `Date.now()` to scan full history. Note: when eviction runs
30
+ post-sync, `lastSyncTs` has advanced past the window sync just covered;
31
+ set a lookbehind to re-scan that window.
32
+ - Dirty records are still preserved across the combined eviction.
33
+ - Auto-eviction (`evictStaleRecordsEveryHrs`) picks up the new behavior
34
+ transparently — no config change needed.
35
+ - `matchesQuery` (`src/utils/localQuery.ts`) gained top-level `$and`, `$or`,
36
+ `$nor` support, required for the negated query to evaluate against the test
37
+ mock and for any client-side filtering that uses logical operators.
38
+
39
+ ### `getDirtyMeta()` for lightweight dirty-state inspection
40
+
41
+ - New `SyncedDb.getDirtyMeta()` returns dirty-entry meta (everything except the
42
+ `changes` payload) grouped per collection, only for collections with ≥1 dirty
43
+ record. Mirrors `getDirty()` shape but avoids loading change payloads —
44
+ useful for counts, timestamps, and indicator UIs.
45
+ - New `I_DexieDb.getDirtyMeta(collection)` returning `DirtyMeta[]`.
46
+ - New exported `DirtyMeta` type (`Omit<DirtyChange, "changes">`) and
47
+ `DirtyChange` type surfaced from the package entry.
48
+
49
+ ### Fix: multi-tab divergence when offline edits cross leader/follower
50
+
51
+ Fixes a bug where, after both tabs edited different records offline and came
52
+ online with leader-first, the leader ended up with stale record content
53
+ carrying the new server `_rev`. Because `resolveConflict` ignores server
54
+ echoes with equal-or-lower `_rev`, the divergence was permanent until page
55
+ reload. Follower-first came out clean; leader-first did not.
56
+
57
+ Two contributing causes, both fixed:
58
+
59
+ - `SyncEngine` post-upload in-mem patch no longer spreads stale `getInMemById`
60
+ result over server-returned `_rev`/`_ts`. In-mem is now fed the freshly
61
+ patched Dexie item (authoritative content + server meta), so the tab that
62
+ uploaded on behalf of another tab's dirty write ends up with matching
63
+ content and `_rev` in-mem.
64
+ - `CrossTabSyncManager.broadcastMetaUpdate` no longer gated by `isLeader()`.
65
+ Non-leader tabs now broadcast their local writes so the leader's in-mem
66
+ cache learns of them via the existing shared-Dexie reload path. Reload
67
+ broadcasts (post-full-sync) remain leader-only.
68
+
5
69
  ### BREAKING: Self-healing sync/reconnect lifecycle
6
70
 
7
71
  Fixes a class of bugs where the 60s auto-sync scheduler silently died after a
package/dist/index.js CHANGED
@@ -39,6 +39,27 @@ import { ObjectId as ObjectId2 } from "bson";
39
39
  // src/utils/localQuery.ts
40
40
  function matchesQuery(item, query) {
41
41
  for (const [key, condition] of Object.entries(query)) {
42
+ if (key === "$and") {
43
+ if (!Array.isArray(condition)) return false;
44
+ if (!condition.every((sub) => matchesQuery(item, sub))) {
45
+ return false;
46
+ }
47
+ continue;
48
+ }
49
+ if (key === "$or") {
50
+ if (!Array.isArray(condition) || condition.length === 0) return false;
51
+ if (!condition.some((sub) => matchesQuery(item, sub))) {
52
+ return false;
53
+ }
54
+ continue;
55
+ }
56
+ if (key === "$nor") {
57
+ if (!Array.isArray(condition) || condition.length === 0) return false;
58
+ if (condition.some((sub) => matchesQuery(item, sub))) {
59
+ return false;
60
+ }
61
+ continue;
62
+ }
42
63
  if (!matchesCondition(item, key, condition)) {
43
64
  return false;
44
65
  }
@@ -634,13 +655,14 @@ var CrossTabSyncManager = class {
634
655
  }
635
656
  /**
636
657
  * Broadcast updated IDs to other tabs (debounced).
637
- * Only the leader should broadcast.
658
+ * Any tab with local writes broadcasts so other tabs refresh their in-mem
659
+ * from shared Dexie. Otherwise non-leader writes stay invisible to the leader's
660
+ * in-mem cache, and a later upload patches new _rev onto stale content.
638
661
  * While a server sync is in progress, suppresses delta broadcasts and only
639
662
  * records which collections were affected (for the post-sync reload broadcast).
640
663
  */
641
664
  broadcastMetaUpdate(updates) {
642
665
  if (!this.metaUpdateChannel) return;
643
- if (!this.deps.isLeader()) return;
644
666
  if (this.serverSyncInProgress) {
645
667
  for (const collection of Object.keys(updates)) {
646
668
  this.syncAffectedCollections.add(collection);
@@ -2668,13 +2690,7 @@ var _SyncEngine = class _SyncEngine {
2668
2690
  dexieDeleteIds.push(entity._id);
2669
2691
  } else {
2670
2692
  dexieSaveBatch.push(dexieItem);
2671
- const inMemItem = this.deps.getInMemById(collection, entity._id);
2672
- if (inMemItem) {
2673
- inMemUpdateBatch.push(__spreadProps(__spreadValues({}, inMemItem), {
2674
- _rev: entity._rev,
2675
- _ts: entity._ts
2676
- }));
2677
- }
2693
+ inMemUpdateBatch.push(dexieItem);
2678
2694
  }
2679
2695
  }
2680
2696
  }
@@ -4550,6 +4566,16 @@ var _SyncedDb = class _SyncedDb {
4550
4566
  }
4551
4567
  return result;
4552
4568
  }
4569
+ async getDirtyMeta() {
4570
+ const result = {};
4571
+ for (const [collectionName] of this.collections) {
4572
+ const metas = await this.dexieDb.getDirtyMeta(collectionName);
4573
+ if (metas.length > 0) {
4574
+ result[collectionName] = metas;
4575
+ }
4576
+ }
4577
+ return result;
4578
+ }
4553
4579
  // ==================== Data Deletion ====================
4554
4580
  async dropCollection(collection, force = false) {
4555
4581
  this.assertCollection(collection);
@@ -4657,9 +4683,42 @@ var _SyncedDb = class _SyncedDb {
4657
4683
  * Remove records from Dexie and in-mem that no longer match the
4658
4684
  * collection's current syncConfig.query. Does NOT delete from server.
4659
4685
  * Skips records with dirty changes (pending local writes).
4686
+ *
4687
+ * Runs two passes:
4688
+ * 1. Local: evicts items whose locally-cached state fails the query.
4689
+ * Covers self-inflicted scope exits (the client moved an item out).
4690
+ * 2. Server-assisted (default when online and not writeOnly): of the
4691
+ * items that still match locally, ask the server which now fail the
4692
+ * query server-side. Covers scope exits caused by other writers — the
4693
+ * filtered delta feed (`findNewer` with the positive query) never
4694
+ * ships those records, so the local cache would otherwise go stale
4695
+ * forever.
4696
+ *
4697
+ * The server-assisted query is `{ $and: [{ $nor: [query] }, { _id: { $in: chunk } }] }`
4698
+ * with `project: { _id: 1 }`. Scoped to known IDs to preserve both
4699
+ * bandwidth and authorization (server only reveals state of IDs the
4700
+ * client already knew about). Sound only if `syncConfig.query` is the
4701
+ * complete server-side predicate (no implicit server policy).
4702
+ *
4703
+ * @param opts.serverAssisted - Override auto-detection. Default: `true`
4704
+ * when online and not writeOnly. Set `false` for pure local eviction.
4705
+ * @param opts.outOfWindowLookbehindMs - Restrict the server-assisted
4706
+ * query to records changed in the last N ms (adds `_ts > now-N` via
4707
+ * `findNewer`'s timestamp filter). When unspecified, the collection's
4708
+ * current `meta.lastSyncTs` is used — the same cursor `sync()` passes
4709
+ * to `findNewer`, so scope-exit scan covers the same time window as a
4710
+ * regular delta sync. Pass `Date.now()` (or any value larger than
4711
+ * elapsed time) to scan full history.
4712
+ *
4713
+ * Note on post-sync timing: when this method runs AFTER `sync()`
4714
+ * (including via auto-eviction), `lastSyncTs` has already advanced
4715
+ * past the window sync just covered, so a record whose scope-exit
4716
+ * mutation landed in that window will not be re-examined on this
4717
+ * pass. Specify `outOfWindowLookbehindMs` (e.g., to cover since last
4718
+ * eviction) or call this before `sync()` to close that gap.
4660
4719
  */
4661
- async evictOutOfScopeRecords(collection) {
4662
- var _a;
4720
+ async evictOutOfScopeRecords(collection, opts) {
4721
+ var _a, _b, _c, _d;
4663
4722
  this.assertCollection(collection);
4664
4723
  const config = this.collections.get(collection);
4665
4724
  const syncQuery = (_a = config.syncConfig) == null ? void 0 : _a.query;
@@ -4670,9 +4729,11 @@ var _SyncedDb = class _SyncedDb {
4670
4729
  await this.pendingChanges.flushForCollection(collection);
4671
4730
  const dirtyItems = await this.dexieDb.getDirty(collection);
4672
4731
  const dirtyIds = new Set(dirtyItems.map((d) => String(d._id)));
4732
+ const serverAssisted = (_b = opts == null ? void 0 : opts.serverAssisted) != null ? _b : !config.writeOnly && this.connectionManager.isOnline();
4673
4733
  let scannedCount = 0;
4674
4734
  let dirtySkipped = 0;
4675
4735
  const evictIds = [];
4736
+ const serverCandidateIds = [];
4676
4737
  await this.dexieDb.forEachBatch(
4677
4738
  collection,
4678
4739
  2e3,
@@ -4686,10 +4747,38 @@ var _SyncedDb = class _SyncedDb {
4686
4747
  }
4687
4748
  if (!matchesQuery(item, query)) {
4688
4749
  evictIds.push(id);
4750
+ } else if (serverAssisted) {
4751
+ serverCandidateIds.push(id);
4689
4752
  }
4690
4753
  }
4691
4754
  }
4692
4755
  );
4756
+ if (serverAssisted && serverCandidateIds.length > 0) {
4757
+ let scopeExitTimestamp;
4758
+ const lookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
4759
+ if (lookbehindMs != null && lookbehindMs > 0) {
4760
+ scopeExitTimestamp = Math.max(
4761
+ 0,
4762
+ Math.floor((Date.now() - lookbehindMs) / 1e3)
4763
+ );
4764
+ } else {
4765
+ scopeExitTimestamp = (_d = (_c = this.syncMetaCache.get(collection)) == null ? void 0 : _c.lastSyncTs) != null ? _d : 0;
4766
+ }
4767
+ try {
4768
+ const serverExits = await this.findServerSideScopeExits(
4769
+ collection,
4770
+ query,
4771
+ serverCandidateIds,
4772
+ scopeExitTimestamp
4773
+ );
4774
+ for (const id of serverExits) evictIds.push(id);
4775
+ } catch (err) {
4776
+ console.error(
4777
+ `[evict] server-assisted pass failed for ${collection} (proceeding with local-only):`,
4778
+ err
4779
+ );
4780
+ }
4781
+ }
4693
4782
  if (evictIds.length > 0) {
4694
4783
  await this.dexieDb.deleteMany(collection, evictIds);
4695
4784
  if (!config.writeOnly) {
@@ -4703,6 +4792,49 @@ var _SyncedDb = class _SyncedDb {
4703
4792
  }
4704
4793
  return { collection, evictedCount: evictIds.length, dirtySkipped, scannedCount };
4705
4794
  }
4795
+ /**
4796
+ * Ask the server which of the given IDs no longer match the positive
4797
+ * query server-side. Used by `evictOutOfScopeRecords` server-assisted
4798
+ * pass to close the "filtered-delta tombstone" gap — records mutated
4799
+ * out of scope by other writers that the filtered delta feed would
4800
+ * never report.
4801
+ *
4802
+ * Chunks `candidateIds` into `$in`-sized batches to keep the request
4803
+ * payload bounded. Uses `project: { _id: 1 }` to keep the response
4804
+ * payload minimal.
4805
+ *
4806
+ * @param timestamp - `findNewer` timestamp cursor. Only records with
4807
+ * `_ts > timestamp` are examined. Caller decides the window (usually
4808
+ * the collection's `meta.lastSyncTs` to mirror the positive sync's
4809
+ * cursor, or a lookbehind-derived value for shorter windows).
4810
+ * @returns IDs the server reports as out-of-scope (i.e. matching
4811
+ * `NOT query`). These are candidates for local-only eviction.
4812
+ */
4813
+ async findServerSideScopeExits(collection, positiveQuery, candidateIds, timestamp) {
4814
+ const CHUNK_SIZE = 500;
4815
+ const scopeExits = [];
4816
+ const negated = { $nor: [positiveQuery] };
4817
+ for (let i = 0; i < candidateIds.length; i += CHUNK_SIZE) {
4818
+ const chunk = candidateIds.slice(i, i + CHUNK_SIZE);
4819
+ const scopedNegation = {
4820
+ $and: [negated, { _id: { $in: chunk } }]
4821
+ };
4822
+ const results = await this.connectionManager.withRestTimeout(
4823
+ this.restInterface.findNewer(
4824
+ collection,
4825
+ timestamp,
4826
+ scopedNegation,
4827
+ { project: { _id: 1 } }
4828
+ ),
4829
+ "evictOutOfScopeRecords.serverAssisted"
4830
+ );
4831
+ for (const item of results) {
4832
+ const id = item._id;
4833
+ if (id !== void 0) scopeExits.push(String(id));
4834
+ }
4835
+ }
4836
+ return scopeExits;
4837
+ }
4706
4838
  /**
4707
4839
  * Evict out-of-scope records for all collections.
4708
4840
  * Skips writeOnly and collections without syncConfig.query.
@@ -5170,6 +5302,21 @@ var DexieDb = class extends Dexie {
5170
5302
  }
5171
5303
  return result;
5172
5304
  }
5305
+ async getDirtyMeta(collection) {
5306
+ const dirtyEntries = await this.dirtyChanges.where("[collection+id]").between([collection, Dexie.minKey], [collection, Dexie.maxKey]).toArray();
5307
+ const result = [];
5308
+ for (const entry of dirtyEntries) {
5309
+ result.push({
5310
+ collection: entry.collection,
5311
+ id: entry.id,
5312
+ baseTs: entry.baseTs,
5313
+ baseRev: entry.baseRev,
5314
+ createdAt: entry.createdAt,
5315
+ updatedAt: entry.updatedAt
5316
+ });
5317
+ }
5318
+ return result;
5319
+ }
5173
5320
  async addDirtyChange(collection, id, changes, baseMeta) {
5174
5321
  const stringId = this.idToString(id);
5175
5322
  const existing = await this.dirtyChanges.get([collection, stringId]);
@@ -1,5 +1,5 @@
1
1
  import Dexie from "dexie";
2
- import type { DirtyChange, I_DexieDb, SyncMeta } from "../types/I_DexieDb";
2
+ import type { DirtyChange, DirtyMeta, I_DexieDb, SyncMeta } from "../types/I_DexieDb";
3
3
  import type { CollectionConfig } from "../types/CollectionConfig";
4
4
  import type { Id, LocalDbEntity } from "../types/DbEntity";
5
5
  /**
@@ -31,6 +31,7 @@ export declare class DexieDb extends Dexie implements I_DexieDb {
31
31
  forEachBatch<T extends LocalDbEntity>(collection: string, batchSize: number, callback: (items: T[]) => Promise<void>): Promise<void>;
32
32
  count(collection: string): Promise<number>;
33
33
  getDirty<T extends LocalDbEntity>(collection: string): Promise<Partial<T>[]>;
34
+ getDirtyMeta(collection: string): Promise<DirtyMeta[]>;
34
35
  addDirtyChange(collection: string, id: Id, changes: Record<string, any>, baseMeta?: {
35
36
  _ts?: any;
36
37
  _rev?: number;
@@ -1,6 +1,6 @@
1
1
  import type { AggregateOptions } from "mongodb";
2
2
  import type { I_SyncedDb, SyncedDbConfig, WsNotificationInfo, EvictionInfo, EvictionCollectionInfo } from "../types/I_SyncedDb";
3
- import type { MetaUpdateBroadcast } from "../types/I_DexieDb";
3
+ import type { DirtyMeta, MetaUpdateBroadcast } from "../types/I_DexieDb";
4
4
  import type { QuerySpec, QueryOpts, UpdateSpec, InsertSpec, BatchSpec } from "../types/I_RestInterface";
5
5
  import type { Id, DbEntity } from "../types/DbEntity";
6
6
  /**
@@ -161,6 +161,7 @@ export declare class SyncedDb implements I_SyncedDb {
161
161
  getOnWsNotification(): ((info: WsNotificationInfo) => void) | undefined;
162
162
  getOnWakeSync(): ((info: import("./types/managers").WakeSyncInfo) => void) | undefined;
163
163
  getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
164
+ getDirtyMeta(): Promise<Readonly<Record<string, readonly DirtyMeta[]>>>;
164
165
  dropCollection(collection: string, force?: boolean): Promise<void>;
165
166
  dropDatabase(force?: boolean): Promise<void>;
166
167
  /**
@@ -182,8 +183,63 @@ export declare class SyncedDb implements I_SyncedDb {
182
183
  * Remove records from Dexie and in-mem that no longer match the
183
184
  * collection's current syncConfig.query. Does NOT delete from server.
184
185
  * Skips records with dirty changes (pending local writes).
186
+ *
187
+ * Runs two passes:
188
+ * 1. Local: evicts items whose locally-cached state fails the query.
189
+ * Covers self-inflicted scope exits (the client moved an item out).
190
+ * 2. Server-assisted (default when online and not writeOnly): of the
191
+ * items that still match locally, ask the server which now fail the
192
+ * query server-side. Covers scope exits caused by other writers — the
193
+ * filtered delta feed (`findNewer` with the positive query) never
194
+ * ships those records, so the local cache would otherwise go stale
195
+ * forever.
196
+ *
197
+ * The server-assisted query is `{ $and: [{ $nor: [query] }, { _id: { $in: chunk } }] }`
198
+ * with `project: { _id: 1 }`. Scoped to known IDs to preserve both
199
+ * bandwidth and authorization (server only reveals state of IDs the
200
+ * client already knew about). Sound only if `syncConfig.query` is the
201
+ * complete server-side predicate (no implicit server policy).
202
+ *
203
+ * @param opts.serverAssisted - Override auto-detection. Default: `true`
204
+ * when online and not writeOnly. Set `false` for pure local eviction.
205
+ * @param opts.outOfWindowLookbehindMs - Restrict the server-assisted
206
+ * query to records changed in the last N ms (adds `_ts > now-N` via
207
+ * `findNewer`'s timestamp filter). When unspecified, the collection's
208
+ * current `meta.lastSyncTs` is used — the same cursor `sync()` passes
209
+ * to `findNewer`, so scope-exit scan covers the same time window as a
210
+ * regular delta sync. Pass `Date.now()` (or any value larger than
211
+ * elapsed time) to scan full history.
212
+ *
213
+ * Note on post-sync timing: when this method runs AFTER `sync()`
214
+ * (including via auto-eviction), `lastSyncTs` has already advanced
215
+ * past the window sync just covered, so a record whose scope-exit
216
+ * mutation landed in that window will not be re-examined on this
217
+ * pass. Specify `outOfWindowLookbehindMs` (e.g., to cover since last
218
+ * eviction) or call this before `sync()` to close that gap.
219
+ */
220
+ evictOutOfScopeRecords(collection: string, opts?: {
221
+ serverAssisted?: boolean;
222
+ outOfWindowLookbehindMs?: number;
223
+ }): Promise<EvictionCollectionInfo>;
224
+ /**
225
+ * Ask the server which of the given IDs no longer match the positive
226
+ * query server-side. Used by `evictOutOfScopeRecords` server-assisted
227
+ * pass to close the "filtered-delta tombstone" gap — records mutated
228
+ * out of scope by other writers that the filtered delta feed would
229
+ * never report.
230
+ *
231
+ * Chunks `candidateIds` into `$in`-sized batches to keep the request
232
+ * payload bounded. Uses `project: { _id: 1 }` to keep the response
233
+ * payload minimal.
234
+ *
235
+ * @param timestamp - `findNewer` timestamp cursor. Only records with
236
+ * `_ts > timestamp` are examined. Caller decides the window (usually
237
+ * the collection's `meta.lastSyncTs` to mirror the positive sync's
238
+ * cursor, or a lookbehind-derived value for shorter windows).
239
+ * @returns IDs the server reports as out-of-scope (i.e. matching
240
+ * `NOT query`). These are candidates for local-only eviction.
185
241
  */
186
- evictOutOfScopeRecords(collection: string): Promise<EvictionCollectionInfo>;
242
+ private findServerSideScopeExits;
187
243
  /**
188
244
  * Evict out-of-scope records for all collections.
189
245
  * Skips writeOnly and collections without syncConfig.query.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * CrossTabSyncManager - Manages cross-tab synchronization via BroadcastChannel.
3
3
  *
4
- * When the leader tab syncs data or receives WebSocket updates, it broadcasts
5
- * the IDs of updated records so other tabs can refresh their in-memory state.
4
+ * Any tab with local writes (leader or follower) broadcasts the IDs of updated
5
+ * records so other tabs refresh their in-memory state from shared Dexie.
6
+ * Reload broadcasts (post-full-sync) remain leader-only.
6
7
  */
7
8
  import type { MetaUpdateBroadcast } from "../../types/I_DexieDb";
8
9
  import type { I_CrossTabSyncManager, CrossTabSyncConfig } from "../types/managers";
@@ -31,7 +32,9 @@ export declare class CrossTabSyncManager implements I_CrossTabSyncManager {
31
32
  init(): void;
32
33
  /**
33
34
  * Broadcast updated IDs to other tabs (debounced).
34
- * Only the leader should broadcast.
35
+ * Any tab with local writes broadcasts so other tabs refresh their in-mem
36
+ * from shared Dexie. Otherwise non-leader writes stay invisible to the leader's
37
+ * in-mem cache, and a later upload patches new _rev onto stale content.
35
38
  * While a server sync is in progress, suppresses delta broadcasts and only
36
39
  * records which collections were affected (for the post-sync reload broadcast).
37
40
  */
@@ -27,6 +27,12 @@ export interface DirtyChange {
27
27
  /** When last change was accumulated */
28
28
  updatedAt: number;
29
29
  }
30
+ /**
31
+ * Meta fields of a DirtyChange entry, without the `changes` payload.
32
+ * Used by `getDirtyMeta` for lightweight dirty-state inspection
33
+ * (counts, timestamps) without loading change payloads into memory.
34
+ */
35
+ export type DirtyMeta = Omit<DirtyChange, "changes">;
30
36
  /** Shared fields for all cross-tab broadcast messages */
31
37
  interface BroadcastBase {
32
38
  /** Unique ID of the SyncedDb instance that sent this broadcast */
@@ -84,6 +90,8 @@ export interface I_DexieDb {
84
90
  forEachBatch<T extends LocalDbEntity>(collection: string, batchSize: number, callback: (items: T[]) => Promise<void>): Promise<void>;
85
91
  /** Vrne vse dirty objekte (z lokalnimi spremembami) - returns only changed fields + _id + metadata */
86
92
  getDirty<T extends LocalDbEntity>(collection: string): Promise<Partial<T>[]>;
93
+ /** Vrne meta podatke vseh dirty vnosov za kolekcijo (brez `changes` payloada) */
94
+ getDirtyMeta(collection: string): Promise<DirtyMeta[]>;
87
95
  /** Add or accumulate changes for a record */
88
96
  addDirtyChange(collection: string, id: Id, changes: Record<string, any>, baseMeta?: {
89
97
  _ts?: any;
@@ -1,7 +1,7 @@
1
1
  import type { AggregateOptions } from "mongodb";
2
2
  import type { Id, DbEntity, LocalDbEntity } from "./DbEntity";
3
3
  import type { QuerySpec, QueryOpts, UpdateSpec, InsertSpec, BatchSpec, I_RestInterface, CollectionUpdateRequest, CollectionUpdateResult, GetNewerSpec } from "./I_RestInterface";
4
- import type { I_DexieDb } from "./I_DexieDb";
4
+ import type { DirtyMeta, I_DexieDb } from "./I_DexieDb";
5
5
  import type { I_InMemDb } from "./I_InMemDb";
6
6
  import type { I_ServerUpdateNotifier } from "./I_ServerUpdateNotifier";
7
7
  import type { WakeSyncInfo, NetworkStatusChangeInfo } from "../db/types/managers";
@@ -700,6 +700,11 @@ export interface I_SyncedDb {
700
700
  getDebounceRestWritesMs(): number;
701
701
  /** Vrne vse dirty objekte iz vseh kolekcij */
702
702
  getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
703
+ /**
704
+ * Vrne meta podatke dirty vnosov (brez `changes` payloada) po kolekcijah,
705
+ * ki imajo vsaj en dirty zapis. Kolekcije brez dirty vnosov niso vključene.
706
+ */
707
+ getDirtyMeta(): Promise<Readonly<Record<string, readonly DirtyMeta[]>>>;
703
708
  /**
704
709
  * Drops a collection, ensuring no data loss.
705
710
  * - Throws if offline or forcedOffline (unless force=true)
@@ -733,9 +738,25 @@ export interface I_SyncedDb {
733
738
  * Remove records from Dexie and in-mem that no longer match the
734
739
  * collection's current syncConfig.query. Does NOT delete from server.
735
740
  * Skips records with dirty changes (pending local writes).
736
- * Returns per-collection eviction stats.
737
- */
738
- evictOutOfScopeRecords(collection: string): Promise<EvictionCollectionInfo>;
741
+ *
742
+ * When `serverAssisted` is true (default when online and not writeOnly),
743
+ * also asks the server which of the locally-matching records no longer
744
+ * match the query server-side. This catches scope-exits caused by other
745
+ * writers — records the filtered delta feed would never report because
746
+ * the server-side state no longer matches the positive query.
747
+ *
748
+ * @param opts.serverAssisted - Override default auto-detection. Set
749
+ * `false` to skip the server round-trip (local-only pass).
750
+ * @param opts.outOfWindowLookbehindMs - Restrict the server-assisted
751
+ * query to records changed in the last N ms. When unspecified, uses
752
+ * the collection's current `meta.lastSyncTs` — the same cursor
753
+ * `sync()` passes to `findNewer`. Pass `Date.now()` to scan full
754
+ * history.
755
+ */
756
+ evictOutOfScopeRecords(collection: string, opts?: {
757
+ serverAssisted?: boolean;
758
+ outOfWindowLookbehindMs?: number;
759
+ }): Promise<EvictionCollectionInfo>;
739
760
  /**
740
761
  * Same as evictOutOfScopeRecords but for all configured collections.
741
762
  * Skips writeOnly collections and collections without syncConfig.query.
@@ -2,7 +2,7 @@ export type { Id, Entity, IdOrEntity, DbEntity, LocalDbEntity } from "./DbEntity
2
2
  export type { PublishableOperation, PublishRevsPayloadInsert, PublishRevsPayloadUpdate, PublishRevsPayloadDelete, PublishRevsPayloadUpdateMany, PublishRevsPayloadDeleteMany, PublishRevsPayloadBatchItem, PublishRevsPayloadBatch, PublishRevsPayload, PublishRevsSpec, PublishDataPayloadBase, PublishDataPayloadInsert, PublishDataPayloadUpdate, PublishDataPayloadDelete, PublishDataPayloadBatch, PublishDataPayload, PublishDataSpec, PublishSpec, } from "./PublishRevsPayload";
3
3
  export type { Obj, QuerySpec, Projection, QueryOpts, KeyOf, InsertKeyOf, InsertSpec, UpdateSpec, BatchSpec, UpsertOptions, GetNewerSpec, I_RestInterface as RestInterface, } from "./I_RestInterface";
4
4
  export type { I_InMemDb as InMemDb } from "./I_InMemDb";
5
- export type { I_DexieDb as DexieDb, SyncMeta } from "./I_DexieDb";
5
+ export type { I_DexieDb as DexieDb, SyncMeta, DirtyChange, DirtyMeta } from "./I_DexieDb";
6
6
  export type { I_ServerUpdateNotifier as ServerUpdateNotifier, ServerUpdateCallback, ServerUpdateNotifierCallbacks } from "./I_ServerUpdateNotifier";
7
7
  export type { I_SyncedDb as SyncedDb, SyncedDbConfig, CollectionConfig, CollectionSyncConfig, SyncInfo, ServerWriteRequestInfo, ServerWriteResultInfo, FindNewerManyCallInfo, FindNewerManyResultInfo, DexieWriteRequestInfo, DexieWriteResultInfo, LocalstorageWriteResultInfo, WsNotificationInfo, InfrastructureErrorType, InfrastructureErrorInfo, ConflictSource, ConflictResolutionReport, CrossTabSyncInfo, EvictionInfo, EvictionCollectionInfo, } from "./I_SyncedDb";
8
8
  export type { NetworkStatusChangeInfo } from "../db/types/managers";
@@ -1,8 +1,10 @@
1
1
  import type { QuerySpec, QueryOpts, Projection } from "../types/I_RestInterface";
2
2
  import type { DbEntity } from "../types/DbEntity";
3
3
  /**
4
- * Preveri, ali objekt ustreza MongoDB-style query specifikaciji
5
- * Podpira osnovne operatorje: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex
4
+ * Preveri, ali objekt ustreza MongoDB-style query specifikaciji.
5
+ * Podpira polje-operatorje: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin,
6
+ * $exists, $regex, $elemMatch, $size, $all — in logične operatorje
7
+ * $and, $or, $nor na top-levelu.
6
8
  */
7
9
  export declare function matchesQuery<T extends DbEntity>(item: T, query: QuerySpec<T>): boolean;
8
10
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.141",
3
+ "version": "0.1.144",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",