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
- if (typeof serverRev !== "number" || typeof baseRev !== "number") {
2728
- return changes;
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)) continue;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.156",
3
+ "version": "0.1.157",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",