cry-synced-db-client 0.1.162 → 0.1.163

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 CHANGED
@@ -1,5 +1,192 @@
1
1
  # Versions
2
2
 
3
+ ## Unreleased
4
+
5
+ ### `onServerSyncWrite` callback
6
+
7
+ Single-shot callback that fires once per `restInterface.updateCollections`
8
+ round-trip with both the request payload and either the server response
9
+ or a thrown error in one payload. Convenience over correlating
10
+ `onServerWriteRequest` + `onServerWriteResult`:
11
+
12
+ ```typescript
13
+ const syncedDb = new SyncedDb({
14
+ // ...
15
+ onServerSyncWrite: (info) => {
16
+ if (info.error) {
17
+ console.error('upload failed', info.error.message, info.error.stack);
18
+ } else {
19
+ logToSyslog({ request: info.request, response: info.response });
20
+ }
21
+ },
22
+ });
23
+ ```
24
+
25
+ `ServerSyncWriteInfo`: `{ request, response?, error?, durationMs, calledFrom?, timestamp }`.
26
+ `response` is `undefined` on transport / timeout / network failure;
27
+ `error` is `{message, name?, stack?}` (serialized — safe for logging).
28
+
29
+ ### Local Dexie write uses diff-applied merged row (parity fix)
30
+
31
+ `SyncedDb.save()` now schedules the **mongo-symmetric `merged` row** to
32
+ Dexie via `pendingChanges`, not the raw partial `update`. Without this,
33
+ `Dexie.table.update(key, partialUpdate)` would replace top-level array
34
+ fields wholesale (postavke, koraki, etc.) and drop element sub-fields
35
+ the caller didn't mention — same data-loss class the in-mem path
36
+ already protected against. New regression test
37
+ `test/node-only/insertUpdateRacunPostavke.test.ts` (real cry-db) verifies
38
+ parity across **mongo + Dexie + in-mem** simultaneously after a partial
39
+ `save({ postavke: [{_id: "P1", kolicina: 2}] })` over an existing
40
+ `postavke[0] = {_id: "P1", opis: "postavka 1", kolicina: 1}`.
41
+
42
+ ## 0.1.162
43
+
44
+ ### Bracket-by-_id paths flow through server unchanged
45
+
46
+ `SyncEngine.uploadDirtyItems` no longer calls `translateBracketPathsToIndex`.
47
+ Bracket paths (`arr[<_id>].field`) leave the client as-is; cry-db ≥ 2.4.33
48
+ translates them server-side to mongo `arr.$[<filterId>].field` +
49
+ `arrayFilters` atomically against the **live document**.
50
+
51
+ This eliminates the race window where another writer's
52
+ reorder/insert/delete shifted the targeted index between the client's
53
+ read and the server's apply. Translation against a stale Dexie snapshot
54
+ (client-side) is replaced by atomic resolution against mongo's current
55
+ state (server-side).
56
+
57
+ ### `undefined` value = delete the field (cry-db `$unset` convention)
58
+
59
+ App code now signals "delete this field" by setting the value to
60
+ `undefined` in `update`. End-to-end behavior:
61
+
62
+ - `computeDiff` preserves `undefined` in the emitted diff (no longer
63
+ normalizes to `null`)
64
+ - Dirty change in Dexie carries `undefined` (Dexie's structured clone
65
+ preserves it)
66
+ - REST upload via `msgpackr` (`structuredClone: true`) preserves
67
+ `undefined` in the binary frame
68
+ - Server cry-db `_processUpdateObject` routes `key: undefined` to
69
+ `$unset[key] = true`; mongo physically removes the field
70
+ - Local in-mem and Dexie: `applyDiffLocally` detects `undefined`
71
+ values in the diff and uses `deleteByPath` (mongo-symmetric)
72
+
73
+ Caller convention: read with strict `racun.field === undefined` is
74
+ now safe (the field is genuinely absent, not stored as `null`).
75
+
76
+ ```typescript
77
+ // remove `navodilo` from the racun
78
+ await syncedDb.save("racuni", id, { navodilo: undefined });
79
+ ```
80
+
81
+ ### Diff-aware local apply (`applyDiffLocally`)
82
+
83
+ `SyncedDb.save()` no longer uses shallow `{ ...currentMem, ...update }`
84
+ spread that would replace top-level array/object fields wholesale and
85
+ drop nested fields the caller's `update` didn't mention.
86
+
87
+ Replaced with `applyDiffLocally(base, diff, id)`:
88
+ 1. Deep-clone `base` (currentMem ?? existing) via `safeDeepClone`
89
+ (handles Date and `ObjectId`-like values; avoids `structuredClone`
90
+ throwing on bson class instances)
91
+ 2. For each `(path, value)` in `diff`: `setByPath` if value is
92
+ defined, `deleteByPath` if value is `undefined`
93
+ 3. Result matches what server-side `$set` + `$unset` would produce
94
+
95
+ `deleteByPath` is now a sibling export of `setByPath` in `computeDiff.ts`.
96
+
97
+ ## 0.1.161
98
+
99
+ ### Don't auto-stamp `_id` on bracket-array elements
100
+
101
+ Reverted automatic `_id` stamping for objects appearing as array elements.
102
+ If an array of objects lacks `_id`, the caller's element shape is now
103
+ preserved. This allows callers to mix:
104
+ - Whole-element bracket replace: `update.postavke = [{...}]`
105
+ - Bracket-by-_id sub-field path: `update["postavke[<id>].field"] = value`
106
+ in the same payload without the client mutating element identity.
107
+
108
+ ## 0.1.160
109
+
110
+ ### Composition changes emit precise paths (not full-array replace)
111
+
112
+ `computeArrayDiff` strategy matrix is expanded:
113
+
114
+ | Composition | Strategy |
115
+ |---|---|
116
+ | Empty → empty | no diff |
117
+ | Same `_id` set, same order | element-wise: `arr[<id>].field` |
118
+ | Same `_id` set, different order | full replace `arr` |
119
+ | Different `_id` set | mixed: `$pull` + `$push` + sub-field |
120
+
121
+ For composition changes, `computeDiff` now emits:
122
+ - **Removed `_id`**: `arr[<id>] = undefined` (server: `$pull`)
123
+ - **Added `_id`**: `arr[<id>] = [element]` (server: `$concatArrays + $filter`)
124
+ - **Retained `_id`**: element-wise sub-field via `arr[<id>].field`
125
+
126
+ Pre-fix, composition change emitted full-array replace at `basePath`,
127
+ which `mergeDirtyPath` Case 2 then dropped pending sub-field paths on
128
+ the same parent — race-y data-loss strip pattern visible in production.
129
+
130
+ ## 0.1.159
131
+
132
+ ### Self-echo WS suppression for `_rev <= local._rev`
133
+
134
+ `ServerUpdateHandler` now ignores its own WS notifications when
135
+ `_lastUpdaterId === self.updaterId AND _rev <= local._rev`. Three sub-cases:
136
+
137
+ | `_rev` relation | Action |
138
+ |---|---|
139
+ | `=== local._rev + 1` | Bump meta to match server, clear dirty (existing loopback case) |
140
+ | `=== local._rev` | WS arrived AFTER post-upload writeback. Local is already at server's rev — drop the WS payload entirely. |
141
+ | `< local._rev` | Stale duplicate / out-of-order delivery. Never downgrade local. |
142
+
143
+ In B and C, `mergeLocalWithDelta` and `writeToInMemBatch` are skipped
144
+ entirely. Production data-loss case (2026-05-09): page re-mount loaded
145
+ older snapshot because a self-echo WS arrived after writeback and
146
+ overwrote in-mem with the server's `$set`-iterated copy of postavke
147
+ (missing freshly-set `pop` and `navodilo` fields). Now in-mem is preserved.
148
+
149
+ ## 0.1.158
150
+
151
+ Internal version bump consolidating 0.1.157 fixes for production publish.
152
+
153
+ ## 0.1.157
154
+
155
+ ### Recursive server-managed metadata strip at upload boundary
156
+
157
+ `stripServerManagedFromChanges(changes)` walks dirty payload paths AND
158
+ values, removing every `_ts` / `_rev` / `_csq` at every depth (including
159
+ inside nested arrays and objects). Replaces SyncEngine's prefix-only
160
+ filter (`startsWith('_ts.')`) which missed deep paths like
161
+ `_redundanca.terapije[<id>]._rev` and silent-rejected uploads on the
162
+ server.
163
+
164
+ ### `fixDotnetArrays` recognizes bracket-by-_id paths and nested top-level array keys
165
+
166
+ When a top-level full array key (e.g. `zaracunaj` or
167
+ `_redundanca.terapije`) coexists with element paths (`arr[<id>].field`
168
+ or `arr.0.field`), the element paths are dropped to prevent mongo
169
+ "path conflict" rejection (`Updating the path 'X' would create a
170
+ conflict at 'Y'`). Now also catches dot-key array names like
171
+ `_redundanca.terapije` and bracket-by-_id paths.
172
+
173
+ ### Auto-stamp `_id` on every plain object inside arrays (`SyncedDb.ensureNestedIds`)
174
+
175
+ Write operations (save / upsert / insert) recursively walk the data
176
+ tree and stamp `_id = new ObjectId().toHexString()` on every plain
177
+ object that appears as an array element but lacks one. This keeps
178
+ every save on the element-wise bracket-by-_id path
179
+ (`computeDiff`'s `allElementsHaveId` check).
180
+
181
+ (Note: 0.1.161 reverted this — see entry above.)
182
+
183
+ ### Production stuck-dirty regression test
184
+
185
+ `test/stuckDirtyDirectInject.test.ts` injects an exact production
186
+ stuck-dirty payload (mixing top-level full arrays with bracket paths)
187
+ into Dexie's `_dirty_changes` and asserts upload succeeds without
188
+ mongo path-conflict errors via a `MongoFaithfulRestInterface` mock.
189
+
3
190
  ## 0.1.156
4
191
 
5
192
  Three related fixes targeting **dirty-payload metadata leak** and
package/dist/index.js CHANGED
@@ -3218,6 +3218,7 @@ var _SyncEngine = class _SyncEngine {
3218
3218
  }
3219
3219
  this.callOnServerWriteRequest(collectionBatches, calledFrom);
3220
3220
  const writeStartTime = Date.now();
3221
+ const writeStartedAt = /* @__PURE__ */ new Date();
3221
3222
  let results;
3222
3223
  try {
3223
3224
  results = await this.deps.withSyncTimeout(
@@ -3225,8 +3226,24 @@ var _SyncEngine = class _SyncEngine {
3225
3226
  "updateCollections"
3226
3227
  );
3227
3228
  this.callOnServerWriteResult(results, writeStartTime, true, calledFrom);
3229
+ this.callOnServerSyncWrite(
3230
+ collectionBatches,
3231
+ results,
3232
+ void 0,
3233
+ writeStartTime,
3234
+ writeStartedAt,
3235
+ calledFrom
3236
+ );
3228
3237
  } catch (err) {
3229
3238
  this.callOnServerWriteResult([], writeStartTime, false, calledFrom, err);
3239
+ this.callOnServerSyncWrite(
3240
+ collectionBatches,
3241
+ void 0,
3242
+ err,
3243
+ writeStartTime,
3244
+ writeStartedAt,
3245
+ calledFrom
3246
+ );
3230
3247
  throw err;
3231
3248
  }
3232
3249
  let sentCount = 0;
@@ -3628,6 +3645,26 @@ var _SyncEngine = class _SyncEngine {
3628
3645
  }
3629
3646
  }
3630
3647
  }
3648
+ callOnServerSyncWrite(request, response, error, startTime, timestamp, calledFrom) {
3649
+ if (!this.callbacks.onServerSyncWrite) return;
3650
+ try {
3651
+ const errInfo = error ? {
3652
+ message: error instanceof Error ? error.message : String(error),
3653
+ name: error instanceof Error ? error.name : void 0,
3654
+ stack: error instanceof Error ? error.stack : void 0
3655
+ } : void 0;
3656
+ this.callbacks.onServerSyncWrite({
3657
+ request,
3658
+ response,
3659
+ error: errInfo,
3660
+ durationMs: Date.now() - startTime,
3661
+ calledFrom,
3662
+ timestamp
3663
+ });
3664
+ } catch (err) {
3665
+ console.error("onServerSyncWrite callback failed:", err);
3666
+ }
3667
+ }
3631
3668
  };
3632
3669
  // ============================================================
3633
3670
  // Private: Process Incoming Data
@@ -4248,6 +4285,7 @@ var _SyncedDb = class _SyncedDb {
4248
4285
  onConflictResolved: config.onConflictResolved,
4249
4286
  onServerWriteRequest: config.onServerWriteRequest,
4250
4287
  onServerWriteResult: config.onServerWriteResult,
4288
+ onServerSyncWrite: config.onServerSyncWrite,
4251
4289
  onFindNewerManyCall: config.onFindNewerManyCall,
4252
4290
  onFindNewerManyResult: config.onFindNewerManyResult,
4253
4291
  onUploadSkip: config.onUploadSkip
@@ -4883,7 +4921,6 @@ var _SyncedDb = class _SyncedDb {
4883
4921
  });
4884
4922
  delete update._id;
4885
4923
  }
4886
- _SyncedDb.ensureNestedIds(update);
4887
4924
  update = _SyncedDb.stringifyObjectIds(update);
4888
4925
  const existing = await this.dexieDb.getById(collection, id);
4889
4926
  if (!existing && !((_a = this.collections.get(collection)) == null ? void 0 : _a.writeOnly)) {
@@ -4899,10 +4936,6 @@ var _SyncedDb = class _SyncedDb {
4899
4936
  diff,
4900
4937
  { _ts: existing == null ? void 0 : existing._ts, _rev: existing == null ? void 0 : existing._rev }
4901
4938
  );
4902
- const newData = __spreadProps(__spreadValues({}, update), {
4903
- _lastUpdaterId: this.updaterId
4904
- });
4905
- this.pendingChanges.schedule(collection, id, newData, 0, "save");
4906
4939
  const isWriteOnly = (_b = this.collections.get(collection)) == null ? void 0 : _b.writeOnly;
4907
4940
  const currentMem = isWriteOnly ? null : this.inMemDb.getById(collection, id);
4908
4941
  const merged = _SyncedDb.applyDiffLocally(
@@ -4910,6 +4943,7 @@ var _SyncedDb = class _SyncedDb {
4910
4943
  diff,
4911
4944
  id
4912
4945
  );
4946
+ this.pendingChanges.schedule(collection, id, merged, 0, "save");
4913
4947
  if (!isWriteOnly && !(existing == null ? void 0 : existing._deleted) && !(existing == null ? void 0 : existing._archived)) {
4914
4948
  this.inMemManager.writeBatch(collection, [merged], "upsert", { source: "incremental" });
4915
4949
  }
@@ -4918,7 +4952,6 @@ var _SyncedDb = class _SyncedDb {
4918
4952
  async upsert(collection, query, update) {
4919
4953
  this.assertCollection(collection);
4920
4954
  this.ensureId(update, "upsert", collection);
4921
- _SyncedDb.ensureNestedIds(update);
4922
4955
  query = _SyncedDb.stringifyObjectIds(query);
4923
4956
  update = _SyncedDb.stringifyObjectIds(update);
4924
4957
  const existing = await this.findOne(collection, query);
@@ -4932,7 +4965,6 @@ var _SyncedDb = class _SyncedDb {
4932
4965
  var _a;
4933
4966
  this.assertCollection(collection);
4934
4967
  this.ensureId(data, "insert", collection);
4935
- _SyncedDb.ensureNestedIds(data);
4936
4968
  data = _SyncedDb.stringifyObjectIds(data);
4937
4969
  const unsetPaths = collectUnsetPaths(data);
4938
4970
  const id = String(data._id);
@@ -6094,48 +6126,6 @@ var _SyncedDb = class _SyncedDb {
6094
6126
  }
6095
6127
  return out;
6096
6128
  }
6097
- /**
6098
- * Recursively walk `value` and ensure every plain object that appears
6099
- * as an element of an array carries an `_id`. Missing `_id`s are
6100
- * generated as `new ObjectId().toHexString()` (string, not BSON instance).
6101
- *
6102
- * Mutates the input tree in place — caller sees the freshly-assigned ids
6103
- * (matches the pattern of `ensureId`).
6104
- *
6105
- * Why: `computeDiff` falls back to a full-array replace whenever any
6106
- * element of an array of objects lacks `_id` (see `allElementsHaveId`).
6107
- * That defeats element-wise `arr[<_id>].field` paths and re-introduces
6108
- * the stale-array-overwrite bug. Stamping ids upfront keeps every save
6109
- * on the per-element bracket path.
6110
- *
6111
- * Skipped:
6112
- * - primitives (numbers, strings, booleans, null, undefined)
6113
- * - `Date`, `ObjectId`-like values
6114
- * - top-level objects (only ARRAY ELEMENTS get an auto-id; the entity
6115
- * itself is handled by `ensureId`)
6116
- */
6117
- static ensureNestedIds(value) {
6118
- if (value === null || value === void 0) return;
6119
- if (typeof value !== "object") return;
6120
- if (value instanceof Date) return;
6121
- if (_SyncedDb.isObjectIdLike(value)) return;
6122
- if (Array.isArray(value)) {
6123
- for (const element of value) {
6124
- if (element !== null && typeof element === "object" && !Array.isArray(element) && !(element instanceof Date) && !_SyncedDb.isObjectIdLike(element)) {
6125
- if (element._id == null || element._id === "") {
6126
- element._id = new ObjectId2().toHexString();
6127
- } else if (_SyncedDb.isObjectIdLike(element._id)) {
6128
- element._id = String(element._id);
6129
- }
6130
- }
6131
- _SyncedDb.ensureNestedIds(element);
6132
- }
6133
- return;
6134
- }
6135
- for (const key of Object.keys(value)) {
6136
- _SyncedDb.ensureNestedIds(value[key]);
6137
- }
6138
- }
6139
6129
  /**
6140
6130
  * Asserts write-only collection has online connectivity for reads.
6141
6131
  * @throws Error if offline
@@ -427,27 +427,6 @@ export declare class SyncedDb implements I_SyncedDb {
427
427
  * instances like bson `ObjectId`.
428
428
  */
429
429
  private static safeDeepClone;
430
- /**
431
- * Recursively walk `value` and ensure every plain object that appears
432
- * as an element of an array carries an `_id`. Missing `_id`s are
433
- * generated as `new ObjectId().toHexString()` (string, not BSON instance).
434
- *
435
- * Mutates the input tree in place — caller sees the freshly-assigned ids
436
- * (matches the pattern of `ensureId`).
437
- *
438
- * Why: `computeDiff` falls back to a full-array replace whenever any
439
- * element of an array of objects lacks `_id` (see `allElementsHaveId`).
440
- * That defeats element-wise `arr[<_id>].field` paths and re-introduces
441
- * the stale-array-overwrite bug. Stamping ids upfront keeps every save
442
- * on the per-element bracket path.
443
- *
444
- * Skipped:
445
- * - primitives (numbers, strings, booleans, null, undefined)
446
- * - `Date`, `ObjectId`-like values
447
- * - top-level objects (only ARRAY ELEMENTS get an auto-id; the entity
448
- * itself is handled by `ensureId`)
449
- */
450
- private static ensureNestedIds;
451
430
  /**
452
431
  * Asserts write-only collection has online connectivity for reads.
453
432
  * @throws Error if offline
@@ -63,4 +63,5 @@ export declare class SyncEngine implements I_SyncEngine {
63
63
  private callOnFindNewerManyResult;
64
64
  private callOnServerWriteRequest;
65
65
  private callOnServerWriteResult;
66
+ private callOnServerSyncWrite;
66
67
  }
@@ -7,7 +7,7 @@ import type { I_DexieDb, SyncMeta, MetaUpdateBroadcast } from "../../types/I_Dex
7
7
  import type { I_InMemDb, SyncSource } from "../../types/I_InMemDb";
8
8
  import type { I_RestInterface } from "../../types/I_RestInterface";
9
9
  import type { PublishDataPayload } from "../../types/PublishRevsPayload";
10
- import type { CollectionConfig, SyncInfo, ConflictResolutionReport, ServerWriteRequestInfo, ServerWriteResultInfo, FindNewerManyCallInfo, FindNewerManyResultInfo, DexieWriteRequestInfo, DexieWriteResultInfo, LocalstorageWriteResultInfo, WsNotificationInfo, CrossTabSyncInfo } from "../../types/I_SyncedDb";
10
+ import type { CollectionConfig, SyncInfo, ConflictResolutionReport, ServerWriteRequestInfo, ServerWriteResultInfo, ServerSyncWriteInfo, FindNewerManyCallInfo, FindNewerManyResultInfo, DexieWriteRequestInfo, DexieWriteResultInfo, LocalstorageWriteResultInfo, WsNotificationInfo, CrossTabSyncInfo } from "../../types/I_SyncedDb";
11
11
  import type { PendingChange, UploadResult } from "./internal";
12
12
  export interface LeaderElectionCallbacks {
13
13
  onBecameLeader?: () => void;
@@ -259,6 +259,7 @@ export interface SyncEngineCallbacks {
259
259
  onConflictResolved?: (report: ConflictResolutionReport) => void;
260
260
  onServerWriteRequest?: (info: ServerWriteRequestInfo) => void;
261
261
  onServerWriteResult?: (info: ServerWriteResultInfo) => void;
262
+ onServerSyncWrite?: (info: ServerSyncWriteInfo) => void;
262
263
  onFindNewerManyCall?: (info: FindNewerManyCallInfo) => void;
263
264
  onFindNewerManyResult?: (info: FindNewerManyResultInfo) => void;
264
265
  onUploadSkip?: (info: import("../../types/I_SyncedDb").UploadSkipInfo) => void;
@@ -117,6 +117,41 @@ export interface ServerWriteResultInfo {
117
117
  /** Where sync was called from (for debugging) */
118
118
  calledFrom?: string;
119
119
  }
120
+ /**
121
+ * Callback payload for a single `updateCollections` round-trip — fires
122
+ * once per call with both the request and either the response OR the
123
+ * error description in a single payload.
124
+ *
125
+ * Convenience over `onServerWriteRequest` + `onServerWriteResult`: lets
126
+ * consumers route the entire request/response pair (or a thrown error)
127
+ * to syslog / observability with a single hook, without correlating
128
+ * across the two events.
129
+ */
130
+ export interface ServerSyncWriteInfo {
131
+ /** Batches sent to the server (the request payload). */
132
+ request: CollectionUpdateRequest<any>[];
133
+ /**
134
+ * Per-collection results from the server — present when the call
135
+ * succeeded (no transport / timeout / network error). Individual
136
+ * items inside the result may still report per-record `errors`.
137
+ */
138
+ response?: CollectionUpdateResult[];
139
+ /**
140
+ * Populated when `restInterface.updateCollections` threw or timed
141
+ * out. `response` is `undefined` in that case.
142
+ */
143
+ error?: {
144
+ message: string;
145
+ name?: string;
146
+ stack?: string;
147
+ };
148
+ /** Round-trip duration in ms. */
149
+ durationMs: number;
150
+ /** Where sync was called from (for debugging). */
151
+ calledFrom?: string;
152
+ /** Wall-clock time when the round-trip started. */
153
+ timestamp: Date;
154
+ }
120
155
  /**
121
156
  * Callback payload for findNewerMany calls
122
157
  */
@@ -459,6 +494,14 @@ export interface SyncedDbConfig {
459
494
  onServerWriteRequest?: (info: ServerWriteRequestInfo) => void;
460
495
  /** Callback after receiving result from server (updateCollections) */
461
496
  onServerWriteResult?: (info: ServerWriteResultInfo) => void;
497
+ /**
498
+ * Single-shot callback for each `updateCollections` round-trip — fires
499
+ * once with both the request payload AND either the server response or
500
+ * the thrown error (`error.message`/`name`/`stack`). Convenient when a
501
+ * consumer wants to log the full request/response pair (e.g. to syslog)
502
+ * without correlating across `onServerWriteRequest` + `onServerWriteResult`.
503
+ */
504
+ onServerSyncWrite?: (info: ServerSyncWriteInfo) => void;
462
505
  /** Callback when findNewerMany is called */
463
506
  onFindNewerManyCall?: (info: FindNewerManyCallInfo) => void;
464
507
  /** Callback when findNewerMany completes */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.162",
3
+ "version": "0.1.163",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",