cry-synced-db-client 0.1.156 → 0.1.157
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/dist/index.js
CHANGED
|
@@ -2724,15 +2724,18 @@ function hasArrayIndexPath(key) {
|
|
|
2724
2724
|
return /\.\d+(\.|$)/.test(key);
|
|
2725
2725
|
}
|
|
2726
2726
|
function fixDotnetArrays(changes, serverRev, baseRev) {
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
if (serverRev <= baseRev) {
|
|
2731
|
-
return changes;
|
|
2727
|
+
const topLevelArrays = /* @__PURE__ */ new Set();
|
|
2728
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
2729
|
+
if (Array.isArray(v)) topLevelArrays.add(k);
|
|
2732
2730
|
}
|
|
2731
|
+
const isStale = typeof serverRev === "number" && typeof baseRev === "number" && serverRev > baseRev;
|
|
2733
2732
|
const cleaned = {};
|
|
2734
2733
|
for (const [key, value] of Object.entries(changes)) {
|
|
2735
|
-
if (hasArrayIndexPath(key))
|
|
2734
|
+
if (hasArrayIndexPath(key)) {
|
|
2735
|
+
const arrayField = key.split(".")[0];
|
|
2736
|
+
if (arrayField && topLevelArrays.has(arrayField)) continue;
|
|
2737
|
+
if (isStale) continue;
|
|
2738
|
+
}
|
|
2736
2739
|
cleaned[key] = value;
|
|
2737
2740
|
}
|
|
2738
2741
|
return cleaned;
|
|
@@ -2989,6 +2992,7 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2989
2992
|
dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
|
|
2990
2993
|
}
|
|
2991
2994
|
const updates = [];
|
|
2995
|
+
const skipped = [];
|
|
2992
2996
|
const ids = dirtyChanges.map((dc) => dc._id);
|
|
2993
2997
|
const fullItems = await this.dexieDb.getByIds(collectionName, ids);
|
|
2994
2998
|
for (let i = 0; i < fullItems.length; i++) {
|
|
@@ -2999,6 +3003,8 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2999
3003
|
if (delta) {
|
|
3000
3004
|
const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
|
|
3001
3005
|
updates.push({ _id: fullItem._id, delta, currentServerRev, fullItem });
|
|
3006
|
+
} else {
|
|
3007
|
+
skipped.push({ _id: String(fullItem._id), reason: "no-delta-for-fullitem" });
|
|
3002
3008
|
}
|
|
3003
3009
|
} else if (id != null) {
|
|
3004
3010
|
const delta = dirtyChangesMap.get(String(id));
|
|
@@ -3006,15 +3012,50 @@ var _SyncEngine = class _SyncEngine {
|
|
|
3006
3012
|
const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
|
|
3007
3013
|
await this.dexieDb.save(collectionName, id, reconstructed);
|
|
3008
3014
|
updates.push({ _id: id, delta });
|
|
3015
|
+
} else {
|
|
3016
|
+
skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
|
|
3009
3017
|
}
|
|
3018
|
+
} else {
|
|
3019
|
+
skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
|
|
3010
3020
|
}
|
|
3011
3021
|
}
|
|
3012
3022
|
if (updates.length === 0) {
|
|
3013
3023
|
console.warn(
|
|
3014
|
-
`uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items
|
|
3024
|
+
`uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`,
|
|
3025
|
+
skipped
|
|
3015
3026
|
);
|
|
3027
|
+
if (this.callbacks.onUploadSkip) {
|
|
3028
|
+
try {
|
|
3029
|
+
this.callbacks.onUploadSkip({
|
|
3030
|
+
collection: collectionName,
|
|
3031
|
+
reason: "no-resolvable-items",
|
|
3032
|
+
dirtyCount: dirtyChanges.length,
|
|
3033
|
+
skippedIds: skipped.slice(0, 20).map((s) => s._id),
|
|
3034
|
+
skipReasons: skipped.slice(0, 20),
|
|
3035
|
+
calledFrom,
|
|
3036
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3037
|
+
});
|
|
3038
|
+
} catch (err) {
|
|
3039
|
+
console.error("onUploadSkip callback failed:", err);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3016
3042
|
continue;
|
|
3017
3043
|
}
|
|
3044
|
+
if (skipped.length > 0 && this.callbacks.onUploadSkip) {
|
|
3045
|
+
try {
|
|
3046
|
+
this.callbacks.onUploadSkip({
|
|
3047
|
+
collection: collectionName,
|
|
3048
|
+
reason: "no-resolvable-items",
|
|
3049
|
+
dirtyCount: dirtyChanges.length,
|
|
3050
|
+
skippedIds: skipped.slice(0, 20).map((s) => s._id),
|
|
3051
|
+
skipReasons: skipped.slice(0, 20),
|
|
3052
|
+
calledFrom,
|
|
3053
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3054
|
+
});
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
console.error("onUploadSkip callback failed:", err);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3018
3059
|
collectionBatches.push([{
|
|
3019
3060
|
collection: collectionName,
|
|
3020
3061
|
batch: {
|
|
@@ -3932,6 +3973,8 @@ var _SyncedDb = class _SyncedDb {
|
|
|
3932
3973
|
this.onWakeSync = config.onWakeSync;
|
|
3933
3974
|
this.onEvictionStart = config.onEvictionStart;
|
|
3934
3975
|
this.onEviction = config.onEviction;
|
|
3976
|
+
this.onSaveIdMismatch = config.onSaveIdMismatch;
|
|
3977
|
+
this.onUploadSkip = config.onUploadSkip;
|
|
3935
3978
|
this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
|
|
3936
3979
|
this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
|
|
3937
3980
|
this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
|
|
@@ -4068,7 +4111,8 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4068
4111
|
onServerWriteRequest: config.onServerWriteRequest,
|
|
4069
4112
|
onServerWriteResult: config.onServerWriteResult,
|
|
4070
4113
|
onFindNewerManyCall: config.onFindNewerManyCall,
|
|
4071
|
-
onFindNewerManyResult: config.onFindNewerManyResult
|
|
4114
|
+
onFindNewerManyResult: config.onFindNewerManyResult,
|
|
4115
|
+
onUploadSkip: config.onUploadSkip
|
|
4072
4116
|
},
|
|
4073
4117
|
deps: {
|
|
4074
4118
|
getSyncMetaCache: () => this.syncMetaCache,
|
|
@@ -4679,6 +4723,28 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4679
4723
|
);
|
|
4680
4724
|
delete update._id;
|
|
4681
4725
|
}
|
|
4726
|
+
if ("_id" in update && update._id && String(update._id) !== String(id)) {
|
|
4727
|
+
const updateKeys = Object.keys(update);
|
|
4728
|
+
const stack = (() => {
|
|
4729
|
+
try {
|
|
4730
|
+
return new Error().stack;
|
|
4731
|
+
} catch (e) {
|
|
4732
|
+
return void 0;
|
|
4733
|
+
}
|
|
4734
|
+
})();
|
|
4735
|
+
console.error(
|
|
4736
|
+
`SyncedDb.save("${collection}", "${String(id)}"): update._id (${JSON.stringify(update._id)}) does NOT match id (${JSON.stringify(String(id))}). Stripped from update to prevent stuck-dirty bug. The caller likely passed a stale this._id while building update from a freshly-generated record. Data keys: [${updateKeys.join(", ")}]`
|
|
4737
|
+
);
|
|
4738
|
+
this.safeCallback(this.onSaveIdMismatch, {
|
|
4739
|
+
collection,
|
|
4740
|
+
id: String(id),
|
|
4741
|
+
updateId: String(update._id),
|
|
4742
|
+
updateKeys,
|
|
4743
|
+
stack,
|
|
4744
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
4745
|
+
});
|
|
4746
|
+
delete update._id;
|
|
4747
|
+
}
|
|
4682
4748
|
update = _SyncedDb.stringifyObjectIds(update);
|
|
4683
4749
|
const existing = await this.dexieDb.getById(collection, id);
|
|
4684
4750
|
if (!existing && !((_a = this.collections.get(collection)) == null ? void 0 : _a.writeOnly)) {
|
|
@@ -52,6 +52,8 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
52
52
|
private readonly onWakeSync?;
|
|
53
53
|
private readonly onEvictionStart?;
|
|
54
54
|
private readonly onEviction?;
|
|
55
|
+
private readonly onSaveIdMismatch?;
|
|
56
|
+
private readonly onUploadSkip?;
|
|
55
57
|
private readonly evictStaleRecordsEveryHrs;
|
|
56
58
|
private readonly scopeExitLookbehindMs;
|
|
57
59
|
private readonly evictOnWake;
|
|
@@ -261,6 +261,7 @@ export interface SyncEngineCallbacks {
|
|
|
261
261
|
onServerWriteResult?: (info: ServerWriteResultInfo) => void;
|
|
262
262
|
onFindNewerManyCall?: (info: FindNewerManyCallInfo) => void;
|
|
263
263
|
onFindNewerManyResult?: (info: FindNewerManyResultInfo) => void;
|
|
264
|
+
onUploadSkip?: (info: import("../../types/I_SyncedDb").UploadSkipInfo) => void;
|
|
264
265
|
}
|
|
265
266
|
export interface SyncEngineDeps {
|
|
266
267
|
getSyncMetaCache: () => Map<string, SyncMeta>;
|
|
@@ -35,6 +35,62 @@ export interface InfrastructureErrorInfo {
|
|
|
35
35
|
/** Timestamp when error occurred */
|
|
36
36
|
timestamp: Date;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Payload za upload-skip callback. Sproži se, ko `uploadDirtyItems` najde
|
|
40
|
+
* dirty zapise v Dexie `_dirty_changes`, ampak iz njih ne more zgraditi
|
|
41
|
+
* resolve-able update batch (npr. fullItem manjka in `id` je null/undefined,
|
|
42
|
+
* ali `dirtyChangesMap.get` vrne undefined zaradi key mismatch-a).
|
|
43
|
+
*
|
|
44
|
+
* Production incident 2026-05-08 (etv/prvak/studenci/tackakp racuni stuck):
|
|
45
|
+
* 14 dirty zapisov nikoli ne pride na server, brez napake na rdb2/errors,
|
|
46
|
+
* `lastDirtySyncAt: null` (sentCount=0). Ta callback razkrije za katere
|
|
47
|
+
* konkretne `_id`-je se to dogaja.
|
|
48
|
+
*/
|
|
49
|
+
export interface UploadSkipInfo {
|
|
50
|
+
/** Collection kjer je skip nastal */
|
|
51
|
+
collection: string;
|
|
52
|
+
/** Razlog za skip */
|
|
53
|
+
reason: "no-resolvable-items" | "no-batches";
|
|
54
|
+
/** Število dirty zapisov v Dexie pred skip-om */
|
|
55
|
+
dirtyCount: number;
|
|
56
|
+
/** Konkretni _id-ji, ki so šli skozi skip pot (do 20) */
|
|
57
|
+
skippedIds: string[];
|
|
58
|
+
/** Razlog za vsak skipped _id (do 20) */
|
|
59
|
+
skipReasons: {
|
|
60
|
+
_id: string;
|
|
61
|
+
reason: "no-fullitem-no-id" | "no-delta-for-fullitem" | "no-delta-for-orphan";
|
|
62
|
+
}[];
|
|
63
|
+
/** calledFrom iz sync flow-a */
|
|
64
|
+
calledFrom?: string;
|
|
65
|
+
/** Timestamp */
|
|
66
|
+
timestamp: Date;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Payload za save() id-mismatch callback. Sproži se, ko caller pokliče
|
|
70
|
+
* `save(collection, id, update)` z `update._id` ki se NE ujema z `id`.
|
|
71
|
+
*
|
|
72
|
+
* Brez popravka bi addDirtyChange shranil entry.id = id, entry.changes._id =
|
|
73
|
+
* update._id; pri uploadu bi server dobil update._id, vrnil ack pod update._id,
|
|
74
|
+
* client bi poskušal clear pod id → ne ujema → dirty stuck forever.
|
|
75
|
+
*
|
|
76
|
+
* Library v save() avtomatsko strip-a `_id` iz update preden pokliče
|
|
77
|
+
* addDirtyChange. Callback je samo za telemetry/syslog/alerting — caller bug
|
|
78
|
+
* mora biti popravljen v aplikaciji.
|
|
79
|
+
*/
|
|
80
|
+
export interface SaveIdMismatchInfo {
|
|
81
|
+
/** Collection name */
|
|
82
|
+
collection: string;
|
|
83
|
+
/** id parameter passed to save() */
|
|
84
|
+
id: string;
|
|
85
|
+
/** _id field present in update object (mismatched) */
|
|
86
|
+
updateId: string;
|
|
87
|
+
/** Top-level keys of the update object (no values, no PII) */
|
|
88
|
+
updateKeys: string[];
|
|
89
|
+
/** Stack trace at the call site */
|
|
90
|
+
stack?: string;
|
|
91
|
+
/** Timestamp when mismatch detected */
|
|
92
|
+
timestamp: Date;
|
|
93
|
+
}
|
|
38
94
|
/**
|
|
39
95
|
* Callback payload for server write requests (before sending)
|
|
40
96
|
*/
|
|
@@ -462,6 +518,29 @@ export interface SyncedDbConfig {
|
|
|
462
518
|
* Client code can use this to show warnings, log telemetry, or adjust behavior.
|
|
463
519
|
*/
|
|
464
520
|
onInfrastructureError?: (info: InfrastructureErrorInfo) => void;
|
|
521
|
+
/**
|
|
522
|
+
* Callback when `uploadDirtyItems` skips dirty entries (silent skip without
|
|
523
|
+
* sending to server). Used to diagnose stuck-dirty pattern where dirty
|
|
524
|
+
* exists but `sentCount === 0` (lastDirtySyncAt stays null).
|
|
525
|
+
*
|
|
526
|
+
* Production incident 2026-05-08 — 14 stuck racuni/protokoli on prod.
|
|
527
|
+
* Server has no record, no rdb2 error, dirty stays forever. This callback
|
|
528
|
+
* reveals which dirty entries hit which skip path.
|
|
529
|
+
*/
|
|
530
|
+
onUploadSkip?: (info: UploadSkipInfo) => void;
|
|
531
|
+
/**
|
|
532
|
+
* Callback when `save(collection, id, update)` is called with `update._id`
|
|
533
|
+
* that does NOT match `id`. Library auto-strips `_id` from update to prevent
|
|
534
|
+
* the stuck-dirty bug, but the caller bug should be fixed in the app code.
|
|
535
|
+
*
|
|
536
|
+
* Use this callback for telemetry/syslog/alerting — the mismatch is silent
|
|
537
|
+
* data corruption (dirty stays forever) without observability.
|
|
538
|
+
*
|
|
539
|
+
* Production incident 2026-05-08 (zvitorepka, 3 stuck protokoli) was caused
|
|
540
|
+
* by `pages/protokol.vue` calling `save("protokoli", this._id, this.protokol)`
|
|
541
|
+
* where `this._id` and `this.protokol._id` had drifted apart.
|
|
542
|
+
*/
|
|
543
|
+
onSaveIdMismatch?: (info: SaveIdMismatchInfo) => void;
|
|
465
544
|
/**
|
|
466
545
|
* Enable in-memory object metadata feature.
|
|
467
546
|
* When true, collections with hasMetadata=true will have their metadata callbacks invoked
|