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 +34 -0
- package/dist/index.js +129 -2
- package/dist/src/db/SyncedDb.d.ts +56 -1
- package/dist/src/types/I_SyncedDb.d.ts +19 -3
- package/dist/src/utils/localQuery.d.ts +4 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
*
|
|
742
|
-
|
|
743
|
-
|
|
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
|
|
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
|
/**
|