cry-synced-db-client 0.1.143 → 0.1.145

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,40 @@
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
+
5
39
  ### `getDirtyMeta()` for lightweight dirty-state inspection
6
40
 
7
41
  - New `SyncedDb.getDirtyMeta()` returns dirty-entry meta (everything except the
@@ -74,6 +108,37 @@ Signature is identical. No other callback changes.
74
108
  - Noop when offline or on writeOnly collections.
75
109
  - Ignored on `find` / `findOne` (use `referToServer` there).
76
110
 
111
+ ## 0.1.145 (2026-04-25)
112
+
113
+ ### Fix: `onSyncProgress` back-track during initial sync
114
+
115
+ `onSyncProgress` is fired from two distinct phases that can run **concurrently**
116
+ during initial sync — Dexie → in-mem hydration (`SyncedDb.loadCollectionsToInMem`)
117
+ and server → Dexie download (`SyncEngine.findNewerManyStream`). Each phase
118
+ carries its own `loaded`/`total`, so consumers wiring the callback into a single
119
+ progress bar saw the percentage back-track every time a tick from the other
120
+ phase arrived (e.g. dexie 9/58 → server 1/62 → dexie 10/58 → server 2/62 …).
121
+
122
+ The payload now carries a `phase: 'dexie' | 'server'` discriminator so consumers
123
+ can attribute each tick to its source and either filter or render the two
124
+ streams separately. **Non-breaking**: consumers that destructure
125
+ `{ collection, loaded, total }` and ignore `phase` keep working unchanged.
126
+
127
+ Type change in `I_SyncedDb.SyncedDbConfig` and internal `SyncEngineCallbacks`:
128
+ ```ts
129
+ onSyncProgress?: (info: {
130
+ phase: 'dexie' | 'server';
131
+ collection: string;
132
+ loaded: number;
133
+ total: number;
134
+ items: number;
135
+ }) => void;
136
+ ```
137
+
138
+ The JSDoc on `onSyncProgress` previously claimed "Fires only during
139
+ init/setSyncOnlyTheseCollections" — that was wrong; it also fires during server
140
+ sync. Doc corrected.
141
+
77
142
  ## 0.1.136 (2026-04-20)
78
143
 
79
144
  - `DexieDb.saveMany` is now fail-safe:
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
  }
@@ -2481,6 +2502,7 @@ var _SyncEngine = class _SyncEngine {
2481
2502
  if (!completedCollections.has(collection)) {
2482
2503
  completedCollections.add(collection);
2483
2504
  this.callbackSafe(this.callbacks.onSyncProgress, {
2505
+ phase: "server",
2484
2506
  collection,
2485
2507
  loaded: completedCollections.size,
2486
2508
  total: syncSpecs.length,
@@ -4662,9 +4684,42 @@ var _SyncedDb = class _SyncedDb {
4662
4684
  * Remove records from Dexie and in-mem that no longer match the
4663
4685
  * collection's current syncConfig.query. Does NOT delete from server.
4664
4686
  * Skips records with dirty changes (pending local writes).
4687
+ *
4688
+ * Runs two passes:
4689
+ * 1. Local: evicts items whose locally-cached state fails the query.
4690
+ * Covers self-inflicted scope exits (the client moved an item out).
4691
+ * 2. Server-assisted (default when online and not writeOnly): of the
4692
+ * items that still match locally, ask the server which now fail the
4693
+ * query server-side. Covers scope exits caused by other writers — the
4694
+ * filtered delta feed (`findNewer` with the positive query) never
4695
+ * ships those records, so the local cache would otherwise go stale
4696
+ * forever.
4697
+ *
4698
+ * The server-assisted query is `{ $and: [{ $nor: [query] }, { _id: { $in: chunk } }] }`
4699
+ * with `project: { _id: 1 }`. Scoped to known IDs to preserve both
4700
+ * bandwidth and authorization (server only reveals state of IDs the
4701
+ * client already knew about). Sound only if `syncConfig.query` is the
4702
+ * complete server-side predicate (no implicit server policy).
4703
+ *
4704
+ * @param opts.serverAssisted - Override auto-detection. Default: `true`
4705
+ * when online and not writeOnly. Set `false` for pure local eviction.
4706
+ * @param opts.outOfWindowLookbehindMs - Restrict the server-assisted
4707
+ * query to records changed in the last N ms (adds `_ts > now-N` via
4708
+ * `findNewer`'s timestamp filter). When unspecified, the collection's
4709
+ * current `meta.lastSyncTs` is used — the same cursor `sync()` passes
4710
+ * to `findNewer`, so scope-exit scan covers the same time window as a
4711
+ * regular delta sync. Pass `Date.now()` (or any value larger than
4712
+ * elapsed time) to scan full history.
4713
+ *
4714
+ * Note on post-sync timing: when this method runs AFTER `sync()`
4715
+ * (including via auto-eviction), `lastSyncTs` has already advanced
4716
+ * past the window sync just covered, so a record whose scope-exit
4717
+ * mutation landed in that window will not be re-examined on this
4718
+ * pass. Specify `outOfWindowLookbehindMs` (e.g., to cover since last
4719
+ * eviction) or call this before `sync()` to close that gap.
4665
4720
  */
4666
- async evictOutOfScopeRecords(collection) {
4667
- var _a;
4721
+ async evictOutOfScopeRecords(collection, opts) {
4722
+ var _a, _b, _c, _d;
4668
4723
  this.assertCollection(collection);
4669
4724
  const config = this.collections.get(collection);
4670
4725
  const syncQuery = (_a = config.syncConfig) == null ? void 0 : _a.query;
@@ -4675,9 +4730,11 @@ var _SyncedDb = class _SyncedDb {
4675
4730
  await this.pendingChanges.flushForCollection(collection);
4676
4731
  const dirtyItems = await this.dexieDb.getDirty(collection);
4677
4732
  const dirtyIds = new Set(dirtyItems.map((d) => String(d._id)));
4733
+ const serverAssisted = (_b = opts == null ? void 0 : opts.serverAssisted) != null ? _b : !config.writeOnly && this.connectionManager.isOnline();
4678
4734
  let scannedCount = 0;
4679
4735
  let dirtySkipped = 0;
4680
4736
  const evictIds = [];
4737
+ const serverCandidateIds = [];
4681
4738
  await this.dexieDb.forEachBatch(
4682
4739
  collection,
4683
4740
  2e3,
@@ -4691,10 +4748,38 @@ var _SyncedDb = class _SyncedDb {
4691
4748
  }
4692
4749
  if (!matchesQuery(item, query)) {
4693
4750
  evictIds.push(id);
4751
+ } else if (serverAssisted) {
4752
+ serverCandidateIds.push(id);
4694
4753
  }
4695
4754
  }
4696
4755
  }
4697
4756
  );
4757
+ if (serverAssisted && serverCandidateIds.length > 0) {
4758
+ let scopeExitTimestamp;
4759
+ const lookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
4760
+ if (lookbehindMs != null && lookbehindMs > 0) {
4761
+ scopeExitTimestamp = Math.max(
4762
+ 0,
4763
+ Math.floor((Date.now() - lookbehindMs) / 1e3)
4764
+ );
4765
+ } else {
4766
+ scopeExitTimestamp = (_d = (_c = this.syncMetaCache.get(collection)) == null ? void 0 : _c.lastSyncTs) != null ? _d : 0;
4767
+ }
4768
+ try {
4769
+ const serverExits = await this.findServerSideScopeExits(
4770
+ collection,
4771
+ query,
4772
+ serverCandidateIds,
4773
+ scopeExitTimestamp
4774
+ );
4775
+ for (const id of serverExits) evictIds.push(id);
4776
+ } catch (err) {
4777
+ console.error(
4778
+ `[evict] server-assisted pass failed for ${collection} (proceeding with local-only):`,
4779
+ err
4780
+ );
4781
+ }
4782
+ }
4698
4783
  if (evictIds.length > 0) {
4699
4784
  await this.dexieDb.deleteMany(collection, evictIds);
4700
4785
  if (!config.writeOnly) {
@@ -4708,6 +4793,49 @@ var _SyncedDb = class _SyncedDb {
4708
4793
  }
4709
4794
  return { collection, evictedCount: evictIds.length, dirtySkipped, scannedCount };
4710
4795
  }
4796
+ /**
4797
+ * Ask the server which of the given IDs no longer match the positive
4798
+ * query server-side. Used by `evictOutOfScopeRecords` server-assisted
4799
+ * pass to close the "filtered-delta tombstone" gap — records mutated
4800
+ * out of scope by other writers that the filtered delta feed would
4801
+ * never report.
4802
+ *
4803
+ * Chunks `candidateIds` into `$in`-sized batches to keep the request
4804
+ * payload bounded. Uses `project: { _id: 1 }` to keep the response
4805
+ * payload minimal.
4806
+ *
4807
+ * @param timestamp - `findNewer` timestamp cursor. Only records with
4808
+ * `_ts > timestamp` are examined. Caller decides the window (usually
4809
+ * the collection's `meta.lastSyncTs` to mirror the positive sync's
4810
+ * cursor, or a lookbehind-derived value for shorter windows).
4811
+ * @returns IDs the server reports as out-of-scope (i.e. matching
4812
+ * `NOT query`). These are candidates for local-only eviction.
4813
+ */
4814
+ async findServerSideScopeExits(collection, positiveQuery, candidateIds, timestamp) {
4815
+ const CHUNK_SIZE = 500;
4816
+ const scopeExits = [];
4817
+ const negated = { $nor: [positiveQuery] };
4818
+ for (let i = 0; i < candidateIds.length; i += CHUNK_SIZE) {
4819
+ const chunk = candidateIds.slice(i, i + CHUNK_SIZE);
4820
+ const scopedNegation = {
4821
+ $and: [negated, { _id: { $in: chunk } }]
4822
+ };
4823
+ const results = await this.connectionManager.withRestTimeout(
4824
+ this.restInterface.findNewer(
4825
+ collection,
4826
+ timestamp,
4827
+ scopedNegation,
4828
+ { project: { _id: 1 } }
4829
+ ),
4830
+ "evictOutOfScopeRecords.serverAssisted"
4831
+ );
4832
+ for (const item of results) {
4833
+ const id = item._id;
4834
+ if (id !== void 0) scopeExits.push(String(id));
4835
+ }
4836
+ }
4837
+ return scopeExits;
4838
+ }
4711
4839
  /**
4712
4840
  * Evict out-of-scope records for all collections.
4713
4841
  * Skips writeOnly and collections without syncConfig.query.
@@ -4856,6 +4984,7 @@ var _SyncedDb = class _SyncedDb {
4856
4984
  totalItems += items;
4857
4985
  loaded++;
4858
4986
  this.safeCallback(this.onSyncProgress, {
4987
+ phase: "dexie",
4859
4988
  collection: name,
4860
4989
  loaded,
4861
4990
  total: names.length,
@@ -183,8 +183,63 @@ export declare class SyncedDb implements I_SyncedDb {
183
183
  * Remove records from Dexie and in-mem that no longer match the
184
184
  * collection's current syncConfig.query. Does NOT delete from server.
185
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.
186
241
  */
187
- evictOutOfScopeRecords(collection: string): Promise<EvictionCollectionInfo>;
242
+ private findServerSideScopeExits;
188
243
  /**
189
244
  * Evict out-of-scope records for all collections.
190
245
  * Skips writeOnly and collections without syncConfig.query.
@@ -235,6 +235,7 @@ export interface SyncEngineCallbacks {
235
235
  }) => void;
236
236
  onSyncEnd?: (info: SyncInfo) => void;
237
237
  onSyncProgress?: (info: {
238
+ phase: 'dexie' | 'server';
238
239
  collection: string;
239
240
  loaded: number;
240
241
  total: number;
@@ -345,8 +345,15 @@ export interface SyncedDbConfig {
345
345
  totalItems: number;
346
346
  durationMs: number;
347
347
  }) => void;
348
- /** Callback after each collection is loaded during full sync (Dexie→inMem). Fires only during init/setSyncOnlyTheseCollections. */
348
+ /**
349
+ * Callback after each collection completes loading. Fires from two distinct phases that can run
350
+ * concurrently — `phase` discriminates the source so consumers can render progress coherently:
351
+ * - `'dexie'` — Dexie → in-memory hydration during init/setSyncOnlyTheseCollections
352
+ * - `'server'` — server → Dexie download during full/initial sync (findNewerManyStream)
353
+ * `loaded`/`total` are scoped to the phase that emitted the event.
354
+ */
349
355
  onSyncProgress?: (info: {
356
+ phase: 'dexie' | 'server';
350
357
  collection: string;
351
358
  loaded: number;
352
359
  total: number;
@@ -738,9 +745,25 @@ export interface I_SyncedDb {
738
745
  * Remove records from Dexie and in-mem that no longer match the
739
746
  * collection's current syncConfig.query. Does NOT delete from server.
740
747
  * Skips records with dirty changes (pending local writes).
741
- * Returns per-collection eviction stats.
742
- */
743
- evictOutOfScopeRecords(collection: string): Promise<EvictionCollectionInfo>;
748
+ *
749
+ * When `serverAssisted` is true (default when online and not writeOnly),
750
+ * also asks the server which of the locally-matching records no longer
751
+ * match the query server-side. This catches scope-exits caused by other
752
+ * writers — records the filtered delta feed would never report because
753
+ * the server-side state no longer matches the positive query.
754
+ *
755
+ * @param opts.serverAssisted - Override default auto-detection. Set
756
+ * `false` to skip the server round-trip (local-only pass).
757
+ * @param opts.outOfWindowLookbehindMs - Restrict the server-assisted
758
+ * query to records changed in the last N ms. When unspecified, uses
759
+ * the collection's current `meta.lastSyncTs` — the same cursor
760
+ * `sync()` passes to `findNewer`. Pass `Date.now()` to scan full
761
+ * history.
762
+ */
763
+ evictOutOfScopeRecords(collection: string, opts?: {
764
+ serverAssisted?: boolean;
765
+ outOfWindowLookbehindMs?: number;
766
+ }): Promise<EvictionCollectionInfo>;
744
767
  /**
745
768
  * Same as evictOutOfScopeRecords but for all configured collections.
746
769
  * Skips writeOnly collections and collections without syncConfig.query.
@@ -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.143",
3
+ "version": "0.1.145",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",