cry-synced-db-client 0.1.156 → 0.1.158
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
|
@@ -2723,16 +2723,32 @@ function isPlainObject4(value) {
|
|
|
2723
2723
|
function hasArrayIndexPath(key) {
|
|
2724
2724
|
return /\.\d+(\.|$)/.test(key);
|
|
2725
2725
|
}
|
|
2726
|
+
function pathTargetsArrayElement(key, arrayPathTokens) {
|
|
2727
|
+
const keyTokens = tokenizePath(key);
|
|
2728
|
+
if (keyTokens.length <= arrayPathTokens.length) return false;
|
|
2729
|
+
for (let i = 0; i < arrayPathTokens.length; i++) {
|
|
2730
|
+
if (keyTokens[i] !== arrayPathTokens[i]) return false;
|
|
2731
|
+
}
|
|
2732
|
+
const next = keyTokens[arrayPathTokens.length];
|
|
2733
|
+
return next.startsWith("[") || /^\d+$/.test(next);
|
|
2734
|
+
}
|
|
2726
2735
|
function fixDotnetArrays(changes, serverRev, baseRev) {
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
if (serverRev <= baseRev) {
|
|
2731
|
-
return changes;
|
|
2736
|
+
const arrayPathTokens = [];
|
|
2737
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
2738
|
+
if (Array.isArray(v)) arrayPathTokens.push(tokenizePath(k));
|
|
2732
2739
|
}
|
|
2740
|
+
const isStale = typeof serverRev === "number" && typeof baseRev === "number" && serverRev > baseRev;
|
|
2733
2741
|
const cleaned = {};
|
|
2734
2742
|
for (const [key, value] of Object.entries(changes)) {
|
|
2735
|
-
|
|
2743
|
+
let conflictsWithFullArray = false;
|
|
2744
|
+
for (const tokens of arrayPathTokens) {
|
|
2745
|
+
if (pathTargetsArrayElement(key, tokens)) {
|
|
2746
|
+
conflictsWithFullArray = true;
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
if (conflictsWithFullArray) continue;
|
|
2751
|
+
if (isStale && hasArrayIndexPath(key)) continue;
|
|
2736
2752
|
cleaned[key] = value;
|
|
2737
2753
|
}
|
|
2738
2754
|
return cleaned;
|
|
@@ -2786,6 +2802,42 @@ function translateKey(key, entity) {
|
|
|
2786
2802
|
return out.join(".");
|
|
2787
2803
|
}
|
|
2788
2804
|
|
|
2805
|
+
// src/utils/stripServerManaged.ts
|
|
2806
|
+
var SERVER_MANAGED_KEYS = /* @__PURE__ */ new Set(["_ts", "_rev", "_csq"]);
|
|
2807
|
+
function isObjectIdLike2(v) {
|
|
2808
|
+
return !!(v && typeof v === "object" && (v._bsontype === "ObjectId" || v._bsontype === "ObjectID"));
|
|
2809
|
+
}
|
|
2810
|
+
function isServerManagedPath(key) {
|
|
2811
|
+
for (const part of tokenizePath(key)) {
|
|
2812
|
+
if (part.startsWith("[")) continue;
|
|
2813
|
+
if (SERVER_MANAGED_KEYS.has(part)) return true;
|
|
2814
|
+
}
|
|
2815
|
+
return false;
|
|
2816
|
+
}
|
|
2817
|
+
function scrubServerManagedDeep(value) {
|
|
2818
|
+
if (value === null || value === void 0) return value;
|
|
2819
|
+
if (Array.isArray(value)) {
|
|
2820
|
+
return value.map((item) => scrubServerManagedDeep(item));
|
|
2821
|
+
}
|
|
2822
|
+
if (typeof value !== "object") return value;
|
|
2823
|
+
if (value instanceof Date) return value;
|
|
2824
|
+
if (isObjectIdLike2(value)) return value;
|
|
2825
|
+
const out = {};
|
|
2826
|
+
for (const k of Object.keys(value)) {
|
|
2827
|
+
if (SERVER_MANAGED_KEYS.has(k)) continue;
|
|
2828
|
+
out[k] = scrubServerManagedDeep(value[k]);
|
|
2829
|
+
}
|
|
2830
|
+
return out;
|
|
2831
|
+
}
|
|
2832
|
+
function stripServerManagedFromChanges(changes) {
|
|
2833
|
+
const out = {};
|
|
2834
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
2835
|
+
if (isServerManagedPath(key)) continue;
|
|
2836
|
+
out[key] = scrubServerManagedDeep(value);
|
|
2837
|
+
}
|
|
2838
|
+
return out;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2789
2841
|
// src/db/sync/SyncEngine.ts
|
|
2790
2842
|
var _SyncEngine = class _SyncEngine {
|
|
2791
2843
|
constructor(config) {
|
|
@@ -2989,6 +3041,7 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2989
3041
|
dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
|
|
2990
3042
|
}
|
|
2991
3043
|
const updates = [];
|
|
3044
|
+
const skipped = [];
|
|
2992
3045
|
const ids = dirtyChanges.map((dc) => dc._id);
|
|
2993
3046
|
const fullItems = await this.dexieDb.getByIds(collectionName, ids);
|
|
2994
3047
|
for (let i = 0; i < fullItems.length; i++) {
|
|
@@ -2999,6 +3052,8 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2999
3052
|
if (delta) {
|
|
3000
3053
|
const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
|
|
3001
3054
|
updates.push({ _id: fullItem._id, delta, currentServerRev, fullItem });
|
|
3055
|
+
} else {
|
|
3056
|
+
skipped.push({ _id: String(fullItem._id), reason: "no-delta-for-fullitem" });
|
|
3002
3057
|
}
|
|
3003
3058
|
} else if (id != null) {
|
|
3004
3059
|
const delta = dirtyChangesMap.get(String(id));
|
|
@@ -3006,29 +3061,62 @@ var _SyncEngine = class _SyncEngine {
|
|
|
3006
3061
|
const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
|
|
3007
3062
|
await this.dexieDb.save(collectionName, id, reconstructed);
|
|
3008
3063
|
updates.push({ _id: id, delta });
|
|
3064
|
+
} else {
|
|
3065
|
+
skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
|
|
3009
3066
|
}
|
|
3067
|
+
} else {
|
|
3068
|
+
skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
|
|
3010
3069
|
}
|
|
3011
3070
|
}
|
|
3012
3071
|
if (updates.length === 0) {
|
|
3013
3072
|
console.warn(
|
|
3014
|
-
`uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items
|
|
3073
|
+
`uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`,
|
|
3074
|
+
skipped
|
|
3015
3075
|
);
|
|
3076
|
+
if (this.callbacks.onUploadSkip) {
|
|
3077
|
+
try {
|
|
3078
|
+
this.callbacks.onUploadSkip({
|
|
3079
|
+
collection: collectionName,
|
|
3080
|
+
reason: "no-resolvable-items",
|
|
3081
|
+
dirtyCount: dirtyChanges.length,
|
|
3082
|
+
skippedIds: skipped.slice(0, 20).map((s) => s._id),
|
|
3083
|
+
skipReasons: skipped.slice(0, 20),
|
|
3084
|
+
calledFrom,
|
|
3085
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3086
|
+
});
|
|
3087
|
+
} catch (err) {
|
|
3088
|
+
console.error("onUploadSkip callback failed:", err);
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3016
3091
|
continue;
|
|
3017
3092
|
}
|
|
3093
|
+
if (skipped.length > 0 && this.callbacks.onUploadSkip) {
|
|
3094
|
+
try {
|
|
3095
|
+
this.callbacks.onUploadSkip({
|
|
3096
|
+
collection: collectionName,
|
|
3097
|
+
reason: "no-resolvable-items",
|
|
3098
|
+
dirtyCount: dirtyChanges.length,
|
|
3099
|
+
skippedIds: skipped.slice(0, 20).map((s) => s._id),
|
|
3100
|
+
skipReasons: skipped.slice(0, 20),
|
|
3101
|
+
calledFrom,
|
|
3102
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
3103
|
+
});
|
|
3104
|
+
} catch (err) {
|
|
3105
|
+
console.error("onUploadSkip callback failed:", err);
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3018
3108
|
collectionBatches.push([{
|
|
3019
3109
|
collection: collectionName,
|
|
3020
3110
|
batch: {
|
|
3021
3111
|
updates: updates.map((item) => {
|
|
3022
|
-
const
|
|
3023
|
-
const stripped =
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
stripped[k] = v;
|
|
3027
|
-
}
|
|
3112
|
+
const dirtyBaseRev = typeof item.delta._rev === "number" ? item.delta._rev : void 0;
|
|
3113
|
+
const stripped = stripServerManagedFromChanges(
|
|
3114
|
+
item.delta
|
|
3115
|
+
);
|
|
3028
3116
|
const fixed = fixDotnetArrays(
|
|
3029
3117
|
stripped,
|
|
3030
3118
|
item.currentServerRev,
|
|
3031
|
-
|
|
3119
|
+
dirtyBaseRev
|
|
3032
3120
|
);
|
|
3033
3121
|
const cleanedChanges = translateBracketPathsToIndex(fixed, item.fullItem);
|
|
3034
3122
|
return {
|
|
@@ -3932,6 +4020,8 @@ var _SyncedDb = class _SyncedDb {
|
|
|
3932
4020
|
this.onWakeSync = config.onWakeSync;
|
|
3933
4021
|
this.onEvictionStart = config.onEvictionStart;
|
|
3934
4022
|
this.onEviction = config.onEviction;
|
|
4023
|
+
this.onSaveIdMismatch = config.onSaveIdMismatch;
|
|
4024
|
+
this.onUploadSkip = config.onUploadSkip;
|
|
3935
4025
|
this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
|
|
3936
4026
|
this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
|
|
3937
4027
|
this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
|
|
@@ -4068,7 +4158,8 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4068
4158
|
onServerWriteRequest: config.onServerWriteRequest,
|
|
4069
4159
|
onServerWriteResult: config.onServerWriteResult,
|
|
4070
4160
|
onFindNewerManyCall: config.onFindNewerManyCall,
|
|
4071
|
-
onFindNewerManyResult: config.onFindNewerManyResult
|
|
4161
|
+
onFindNewerManyResult: config.onFindNewerManyResult,
|
|
4162
|
+
onUploadSkip: config.onUploadSkip
|
|
4072
4163
|
},
|
|
4073
4164
|
deps: {
|
|
4074
4165
|
getSyncMetaCache: () => this.syncMetaCache,
|
|
@@ -4679,6 +4770,29 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4679
4770
|
);
|
|
4680
4771
|
delete update._id;
|
|
4681
4772
|
}
|
|
4773
|
+
if ("_id" in update && update._id && String(update._id) !== String(id)) {
|
|
4774
|
+
const updateKeys = Object.keys(update);
|
|
4775
|
+
const stack = (() => {
|
|
4776
|
+
try {
|
|
4777
|
+
return new Error().stack;
|
|
4778
|
+
} catch (e) {
|
|
4779
|
+
return void 0;
|
|
4780
|
+
}
|
|
4781
|
+
})();
|
|
4782
|
+
console.error(
|
|
4783
|
+
`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(", ")}]`
|
|
4784
|
+
);
|
|
4785
|
+
this.safeCallback(this.onSaveIdMismatch, {
|
|
4786
|
+
collection,
|
|
4787
|
+
id: String(id),
|
|
4788
|
+
updateId: String(update._id),
|
|
4789
|
+
updateKeys,
|
|
4790
|
+
stack,
|
|
4791
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
4792
|
+
});
|
|
4793
|
+
delete update._id;
|
|
4794
|
+
}
|
|
4795
|
+
_SyncedDb.ensureNestedIds(update);
|
|
4682
4796
|
update = _SyncedDb.stringifyObjectIds(update);
|
|
4683
4797
|
const existing = await this.dexieDb.getById(collection, id);
|
|
4684
4798
|
if (!existing && !((_a = this.collections.get(collection)) == null ? void 0 : _a.writeOnly)) {
|
|
@@ -4709,6 +4823,7 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4709
4823
|
async upsert(collection, query, update) {
|
|
4710
4824
|
this.assertCollection(collection);
|
|
4711
4825
|
this.ensureId(update, "upsert", collection);
|
|
4826
|
+
_SyncedDb.ensureNestedIds(update);
|
|
4712
4827
|
query = _SyncedDb.stringifyObjectIds(query);
|
|
4713
4828
|
update = _SyncedDb.stringifyObjectIds(update);
|
|
4714
4829
|
const existing = await this.findOne(collection, query);
|
|
@@ -4722,6 +4837,7 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4722
4837
|
var _a;
|
|
4723
4838
|
this.assertCollection(collection);
|
|
4724
4839
|
this.ensureId(data, "insert", collection);
|
|
4840
|
+
_SyncedDb.ensureNestedIds(data);
|
|
4725
4841
|
data = _SyncedDb.stringifyObjectIds(data);
|
|
4726
4842
|
const id = String(data._id);
|
|
4727
4843
|
const existing = await this.dexieDb.getById(collection, id);
|
|
@@ -5823,6 +5939,48 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5823
5939
|
static isObjectIdLike(v) {
|
|
5824
5940
|
return !!(v && typeof v === "object" && (v._bsontype === "ObjectId" || typeof v.toHexString === "function"));
|
|
5825
5941
|
}
|
|
5942
|
+
/**
|
|
5943
|
+
* Recursively walk `value` and ensure every plain object that appears
|
|
5944
|
+
* as an element of an array carries an `_id`. Missing `_id`s are
|
|
5945
|
+
* generated as `new ObjectId().toHexString()` (string, not BSON instance).
|
|
5946
|
+
*
|
|
5947
|
+
* Mutates the input tree in place — caller sees the freshly-assigned ids
|
|
5948
|
+
* (matches the pattern of `ensureId`).
|
|
5949
|
+
*
|
|
5950
|
+
* Why: `computeDiff` falls back to a full-array replace whenever any
|
|
5951
|
+
* element of an array of objects lacks `_id` (see `allElementsHaveId`).
|
|
5952
|
+
* That defeats element-wise `arr[<_id>].field` paths and re-introduces
|
|
5953
|
+
* the stale-array-overwrite bug. Stamping ids upfront keeps every save
|
|
5954
|
+
* on the per-element bracket path.
|
|
5955
|
+
*
|
|
5956
|
+
* Skipped:
|
|
5957
|
+
* - primitives (numbers, strings, booleans, null, undefined)
|
|
5958
|
+
* - `Date`, `ObjectId`-like values
|
|
5959
|
+
* - top-level objects (only ARRAY ELEMENTS get an auto-id; the entity
|
|
5960
|
+
* itself is handled by `ensureId`)
|
|
5961
|
+
*/
|
|
5962
|
+
static ensureNestedIds(value) {
|
|
5963
|
+
if (value === null || value === void 0) return;
|
|
5964
|
+
if (typeof value !== "object") return;
|
|
5965
|
+
if (value instanceof Date) return;
|
|
5966
|
+
if (_SyncedDb.isObjectIdLike(value)) return;
|
|
5967
|
+
if (Array.isArray(value)) {
|
|
5968
|
+
for (const element of value) {
|
|
5969
|
+
if (element !== null && typeof element === "object" && !Array.isArray(element) && !(element instanceof Date) && !_SyncedDb.isObjectIdLike(element)) {
|
|
5970
|
+
if (element._id == null || element._id === "") {
|
|
5971
|
+
element._id = new ObjectId2().toHexString();
|
|
5972
|
+
} else if (_SyncedDb.isObjectIdLike(element._id)) {
|
|
5973
|
+
element._id = String(element._id);
|
|
5974
|
+
}
|
|
5975
|
+
}
|
|
5976
|
+
_SyncedDb.ensureNestedIds(element);
|
|
5977
|
+
}
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5980
|
+
for (const key of Object.keys(value)) {
|
|
5981
|
+
_SyncedDb.ensureNestedIds(value[key]);
|
|
5982
|
+
}
|
|
5983
|
+
}
|
|
5826
5984
|
/**
|
|
5827
5985
|
* Asserts write-only collection has online connectivity for reads.
|
|
5828
5986
|
* @throws Error if offline
|
|
@@ -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;
|
|
@@ -401,6 +403,27 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
401
403
|
*/
|
|
402
404
|
private static stringifyObjectIds;
|
|
403
405
|
private static isObjectIdLike;
|
|
406
|
+
/**
|
|
407
|
+
* Recursively walk `value` and ensure every plain object that appears
|
|
408
|
+
* as an element of an array carries an `_id`. Missing `_id`s are
|
|
409
|
+
* generated as `new ObjectId().toHexString()` (string, not BSON instance).
|
|
410
|
+
*
|
|
411
|
+
* Mutates the input tree in place — caller sees the freshly-assigned ids
|
|
412
|
+
* (matches the pattern of `ensureId`).
|
|
413
|
+
*
|
|
414
|
+
* Why: `computeDiff` falls back to a full-array replace whenever any
|
|
415
|
+
* element of an array of objects lacks `_id` (see `allElementsHaveId`).
|
|
416
|
+
* That defeats element-wise `arr[<_id>].field` paths and re-introduces
|
|
417
|
+
* the stale-array-overwrite bug. Stamping ids upfront keeps every save
|
|
418
|
+
* on the per-element bracket path.
|
|
419
|
+
*
|
|
420
|
+
* Skipped:
|
|
421
|
+
* - primitives (numbers, strings, booleans, null, undefined)
|
|
422
|
+
* - `Date`, `ObjectId`-like values
|
|
423
|
+
* - top-level objects (only ARRAY ELEMENTS get an auto-id; the entity
|
|
424
|
+
* itself is handled by `ensureId`)
|
|
425
|
+
*/
|
|
426
|
+
private static ensureNestedIds;
|
|
404
427
|
/**
|
|
405
428
|
* Asserts write-only collection has online connectivity for reads.
|
|
406
429
|
* @throws Error if offline
|
|
@@ -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
|
|
@@ -13,6 +13,28 @@
|
|
|
13
13
|
* "arr.10" → true (multi-digit index)
|
|
14
14
|
*/
|
|
15
15
|
export declare function hasArrayIndexPath(key: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* `true` if `key` targets an element of the array at `arrayPath`.
|
|
18
|
+
*
|
|
19
|
+
* Both inputs are tokenized via `tokenizePath` (so dot AND bracket-by-_id
|
|
20
|
+
* segments are normalized). The key targets an element iff:
|
|
21
|
+
* 1. its first N tokens equal `arrayPath`'s tokens (prefix match), AND
|
|
22
|
+
* 2. the very next token is a numeric index ("0", "1", …) OR a bracket
|
|
23
|
+
* `[<_id>]` segment.
|
|
24
|
+
*
|
|
25
|
+
* Examples (with arrayPath = "zaracunaj"):
|
|
26
|
+
* "zaracunaj" → false (same path, not a child)
|
|
27
|
+
* "zaracunaj.0.kolicina" → true (child via numeric index)
|
|
28
|
+
* "zaracunaj[<_id>].kolicina" → true (child via bracket _id)
|
|
29
|
+
* "zaracunajX" → false (different field)
|
|
30
|
+
* "zaracunaj.kolicina" → false (object field, not element)
|
|
31
|
+
*
|
|
32
|
+
* Examples (with arrayPath = "_redundanca.terapije"):
|
|
33
|
+
* "_redundanca.terapije[A].postavke[B].x" → true
|
|
34
|
+
* "_redundanca.terapije.0.x" → true
|
|
35
|
+
* "_redundanca.terapija" → false (different field)
|
|
36
|
+
*/
|
|
37
|
+
export declare function pathTargetsArrayElement(key: string, arrayPathTokens: readonly string[]): boolean;
|
|
16
38
|
/**
|
|
17
39
|
* TEMPORARY mitigation: when server has advanced past client's base _rev
|
|
18
40
|
* (i.e., the dirty was emitted against a stale snapshot of the record),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `true` if `key` (a dot/bracket-notation path) targets a server-managed
|
|
3
|
+
* metadata field anywhere along its tokenized path.
|
|
4
|
+
*
|
|
5
|
+
* Examples:
|
|
6
|
+
* "_ts" → true
|
|
7
|
+
* "_ts.t" → true (token `_ts`)
|
|
8
|
+
* "_rev" → true
|
|
9
|
+
* "field._ts" → true
|
|
10
|
+
* "arr[<_id>]._rev" → true
|
|
11
|
+
* "_redundanca.terapije[X]._ts.i" → true
|
|
12
|
+
* "stranka.gsm" → false
|
|
13
|
+
* "zaracunaj[<_id>].kolicina" → false
|
|
14
|
+
*/
|
|
15
|
+
export declare function isServerManagedPath(key: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Recursively scrub `_ts` / `_rev` / `_csq` from a value tree without
|
|
18
|
+
* mutating the input. Returns a new tree with the offending keys removed
|
|
19
|
+
* at every depth, including inside arrays.
|
|
20
|
+
*
|
|
21
|
+
* Primitives, Date, and ObjectId-like instances pass through by reference.
|
|
22
|
+
*/
|
|
23
|
+
export declare function scrubServerManagedDeep<T>(value: T): T;
|
|
24
|
+
/**
|
|
25
|
+
* Strip server-managed metadata from a dirty-changes payload at upload time.
|
|
26
|
+
*
|
|
27
|
+
* 1. Drop any path key whose tokenized path includes `_ts`, `_rev`, or `_csq`.
|
|
28
|
+
* 2. For surviving entries, recursively scrub the value (in case a value is
|
|
29
|
+
* a full object/array replace that contains nested `_ts` / `_rev`).
|
|
30
|
+
*
|
|
31
|
+
* Returns a new object — input is not mutated.
|
|
32
|
+
*/
|
|
33
|
+
export declare function stripServerManagedFromChanges(changes: Record<string, unknown>): Record<string, unknown>;
|