cry-synced-db-client 0.1.143 → 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,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
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
  }
@@ -4662,9 +4683,42 @@ var _SyncedDb = class _SyncedDb {
4662
4683
  * Remove records from Dexie and in-mem that no longer match the
4663
4684
  * collection's current syncConfig.query. Does NOT delete from server.
4664
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.
4665
4719
  */
4666
- async evictOutOfScopeRecords(collection) {
4667
- var _a;
4720
+ async evictOutOfScopeRecords(collection, opts) {
4721
+ var _a, _b, _c, _d;
4668
4722
  this.assertCollection(collection);
4669
4723
  const config = this.collections.get(collection);
4670
4724
  const syncQuery = (_a = config.syncConfig) == null ? void 0 : _a.query;
@@ -4675,9 +4729,11 @@ var _SyncedDb = class _SyncedDb {
4675
4729
  await this.pendingChanges.flushForCollection(collection);
4676
4730
  const dirtyItems = await this.dexieDb.getDirty(collection);
4677
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();
4678
4733
  let scannedCount = 0;
4679
4734
  let dirtySkipped = 0;
4680
4735
  const evictIds = [];
4736
+ const serverCandidateIds = [];
4681
4737
  await this.dexieDb.forEachBatch(
4682
4738
  collection,
4683
4739
  2e3,
@@ -4691,10 +4747,38 @@ var _SyncedDb = class _SyncedDb {
4691
4747
  }
4692
4748
  if (!matchesQuery(item, query)) {
4693
4749
  evictIds.push(id);
4750
+ } else if (serverAssisted) {
4751
+ serverCandidateIds.push(id);
4694
4752
  }
4695
4753
  }
4696
4754
  }
4697
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
+ }
4698
4782
  if (evictIds.length > 0) {
4699
4783
  await this.dexieDb.deleteMany(collection, evictIds);
4700
4784
  if (!config.writeOnly) {
@@ -4708,6 +4792,49 @@ var _SyncedDb = class _SyncedDb {
4708
4792
  }
4709
4793
  return { collection, evictedCount: evictIds.length, dirtySkipped, scannedCount };
4710
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
+ }
4711
4838
  /**
4712
4839
  * Evict out-of-scope records for all collections.
4713
4840
  * Skips writeOnly and collections without syncConfig.query.
@@ -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.
@@ -738,9 +738,25 @@ export interface I_SyncedDb {
738
738
  * Remove records from Dexie and in-mem that no longer match the
739
739
  * collection's current syncConfig.query. Does NOT delete from server.
740
740
  * Skips records with dirty changes (pending local writes).
741
- * Returns per-collection eviction stats.
742
- */
743
- 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>;
744
760
  /**
745
761
  * Same as evictOutOfScopeRecords but for all configured collections.
746
762
  * 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.144",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",