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 +64 -0
- package/dist/index.js +158 -11
- package/dist/src/db/DexieDb.d.ts +2 -1
- package/dist/src/db/SyncedDb.d.ts +58 -2
- package/dist/src/db/managers/CrossTabSyncManager.d.ts +6 -3
- package/dist/src/types/I_DexieDb.d.ts +8 -0
- package/dist/src/types/I_SyncedDb.d.ts +25 -4
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/utils/localQuery.d.ts +4 -2
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
|
|
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]);
|
package/dist/src/db/DexieDb.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
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
|
/**
|