cry-synced-db-client 0.1.176 → 0.1.177

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,6 +1,62 @@
1
1
  # Versions
2
2
 
3
- ## Unreleased
3
+ ## 0.1.177 (2026-05-13)
4
+
5
+ Hot-path micro-optimizations from a ts-coding skill audit. Five small wins,
6
+ each verified independently against the actual control flow before applying.
7
+
8
+ ### `$regex` operand cached per pattern
9
+
10
+ `matchesOperator` for `$regex` used to call `new RegExp(operand)` on every
11
+ record × operator invocation. For in-mem `find` over a large collection
12
+ this recompiled the same pattern thousands of times. Now a module-level
13
+ `Map<string, RegExp>` caches compiled regexes by operand string with a
14
+ bounded FIFO eviction (128 entries) so dynamic patterns don't leak.
15
+ Implementation in `src/utils/localQuery.ts:compileRegex`.
16
+
17
+ ### `SyncedDb.close()` is idempotent
18
+
19
+ Added a `this.closed` guard that early-returns on the second call. Most
20
+ disposal calls inside `close()` were already idempotent (timer clears,
21
+ listener removes), but `crossTabSync.dispose()`, `wakeSync.dispose()`,
22
+ `networkStatus.dispose()`, and `serverUpdateNotifier.dispose?.()` are not
23
+ internally guarded — calling them twice could throw. Now safe.
24
+
25
+ ### Orphan dirty reconstruction batches into `saveMany`
26
+
27
+ `SyncEngine.uploadDirtyItems` reconstructs missing main rows when a dirty
28
+ change exists but the main entry was lost (e.g. debounced write didn't
29
+ flush before reload). Previously called `await dexieDb.save(...)` per
30
+ orphan inside the per-item loop. Now collects reconstructed entities and
31
+ issues one `saveMany` at the end. Rare path — minor improvement, no
32
+ behavior change.
33
+
34
+ ### `sentIds` warning path drops intermediate spreads
35
+
36
+ `SyncEngine.uploadDirtyItems` builds a `sentIds` Set for the
37
+ "unacknowledged items" warning. Previously used
38
+ `new Set([...batches.flat().filter(...).flatMap(...)])` (multiple array
39
+ allocations) and `[...sentIds].filter(...)` (one more spread). Now a
40
+ single for-of pass into a Set, plus a direct iteration to build `unacked`.
41
+
42
+ ### `ServerUpdateHandler` batch case now concurrent
43
+
44
+ WebSocket batch server-update notifications previously processed each
45
+ insert / update / delete with sequential `await` calls. Each per-item
46
+ handler operates on an independent `_id`; Dexie reads/writes on different
47
+ keys don't conflict, so replaced the loops with `Promise.all` over the
48
+ per-item handlers. On a batch of N items this turns N sequential IDB
49
+ round-trips into N concurrent ones — the dominant latency win for any
50
+ tab subscribed to a busy collection.
51
+
52
+ The deeper "single `getByIds` + `saveMany` per batch" refactor was
53
+ deferred: the per-item update handler has 5+ semantically distinct
54
+ branches (self-echo A/B/C, pending change merge, dirty merge, clean
55
+ meta-only) and duplicating that logic for the batch path carries
56
+ re-implementation risk that isn't justified by the additional latency
57
+ savings over `Promise.all`.
58
+
59
+ ## 0.1.176 (2026-05-13)
4
60
 
5
61
  ### Passive transport metrics on `findNewerManyStream` round-trip (rdb2)
6
62
 
@@ -104,6 +160,8 @@ Live test (localhost, warm, 20 samples): WS p50 0.30 ms, E2E p50
104
160
  4.11 ms. Mock-only unit tests in `test/measureRtt.test.ts` (7 cases —
105
161
  delegation, propagated rejection, no-notifier error, shape sanity).
106
162
 
163
+ ## 0.1.175 (2026-05-13)
164
+
107
165
  ### `save(coll, id, {field: {}})` clears existing nested children
108
166
 
109
167
  `computeDiffInto` for plain objects iterated only `Object.keys(update)`,
@@ -176,6 +234,37 @@ await syncedDb.replaceSyncCollection({
176
234
  });
177
235
  ```
178
236
 
237
+ ## 0.1.174 (2026-05-12)
238
+
239
+ ### Fix: overlay `_dirty_changes` onto in-mem on init
240
+
241
+ After Ctrl+R while a debounced Dexie main write was pending, the in-mem
242
+ cache showed stale Dexie main state instead of the merged dirty diff —
243
+ UI readers saw old field values until the next sync round-trip.
244
+ Production incident, klikvet 2026-05-12 with the server offline.
245
+
246
+ Root cause: `loadCollectionToInMem` (called from `init()`) read only the
247
+ Dexie main table; it never overlaid `_dirty_changes`.
248
+ `recoverPendingWrites` recovers from `localStorage`, but `localStorage`
249
+ may have been cleared by an earlier partial-debounce success — the
250
+ Dexie-only `_dirty_changes` table is the durable source for those
251
+ writes.
252
+
253
+ Fix: after loading Dexie main, walk `_dirty_changes` and apply each diff
254
+ to the matching main row via `applyDiffLocally`. Orphan dirty (no
255
+ matching main row) is included in in-mem with a `console.warn`. Dirty
256
+ entries marking `_deleted` / `_archived` remove the record from in-mem.
257
+
258
+ Scope: in-mem cache only. `findById` uses the in-mem fast path, so its
259
+ results now reflect dirty. `find()` reads Dexie main directly — that's
260
+ a separate Dexie-overlay gap, not addressed here.
261
+
262
+ Regression test: `test/dirtyOverlayOnInit.test.ts` (5 cases — production
263
+ scenario, orphan, soft-delete via dirty, no-dirty fast path, multiple
264
+ records). 708 pass / 0 fail.
265
+
266
+ ## 0.1.173 (2026-05-12)
267
+
179
268
  ### `preprocessDirtyItem` callback — per-item filter / transform before upload
180
269
 
181
270
  New optional config callback fired for **every** dirty item just before it
@@ -212,6 +301,8 @@ new SyncedDb({
212
301
  Useful for: per-tenant data sanitization, conditional upload gating,
213
302
  audit-trail injection.
214
303
 
304
+ ## 0.1.172 (2026-05-12)
305
+
215
306
  ### Nested-bracket terminal layering in `mergeDirtyPath` Case 2
216
307
 
217
308
  When a new terminal-bracket whole-element write arrives AFTER pending
@@ -250,6 +341,8 @@ alongside actual upload errors in observability pipelines.
250
341
  `SUPRESS_DB_WARNINGS` constant in `SyncEngine.ts` silences them when
251
342
  needed (e.g. during noisy migrations).
252
343
 
344
+ ## 0.1.171 (2026-05-11)
345
+
253
346
  ### Runtime collection registration (`addCollectionToSync`, `replaceSyncCollection`)
254
347
 
255
348
  Two methods to install / replace collection configs at runtime; both load the
@@ -346,6 +439,8 @@ const sinceMs = isLeader
346
439
  : Date.now() - syncedDb.followerSince()!.getTime();
347
440
  ```
348
441
 
442
+ ## 0.1.163 (2026-05-10)
443
+
349
444
  ### `onServerSyncWrite` callback
350
445
 
351
446
  Single-shot callback that fires once per `restInterface.updateCollections`
@@ -383,7 +478,7 @@ parity across **mongo + Dexie + in-mem** simultaneously after a partial
383
478
  `save({ postavke: [{_id: "P1", kolicina: 2}] })` over an existing
384
479
  `postavke[0] = {_id: "P1", opis: "postavka 1", kolicina: 1}`.
385
480
 
386
- ## 0.1.162
481
+ ## 0.1.162 (2026-05-10)
387
482
 
388
483
  ### Bracket-by-_id paths flow through server unchanged
389
484
 
@@ -438,7 +533,7 @@ Replaced with `applyDiffLocally(base, diff, id)`:
438
533
 
439
534
  `deleteByPath` is now a sibling export of `setByPath` in `computeDiff.ts`.
440
535
 
441
- ## 0.1.161
536
+ ## 0.1.161 (2026-05-10)
442
537
 
443
538
  ### Don't auto-stamp `_id` on bracket-array elements
444
539
 
@@ -449,7 +544,7 @@ preserved. This allows callers to mix:
449
544
  - Bracket-by-_id sub-field path: `update["postavke[<id>].field"] = value`
450
545
  in the same payload without the client mutating element identity.
451
546
 
452
- ## 0.1.160
547
+ ## 0.1.160 (2026-05-09)
453
548
 
454
549
  ### Composition changes emit precise paths (not full-array replace)
455
550
 
@@ -471,7 +566,7 @@ Pre-fix, composition change emitted full-array replace at `basePath`,
471
566
  which `mergeDirtyPath` Case 2 then dropped pending sub-field paths on
472
567
  the same parent — race-y data-loss strip pattern visible in production.
473
568
 
474
- ## 0.1.159
569
+ ## 0.1.159 (2026-05-09)
475
570
 
476
571
  ### Self-echo WS suppression for `_rev <= local._rev`
477
572
 
@@ -490,11 +585,11 @@ older snapshot because a self-echo WS arrived after writeback and
490
585
  overwrote in-mem with the server's `$set`-iterated copy of postavke
491
586
  (missing freshly-set `pop` and `navodilo` fields). Now in-mem is preserved.
492
587
 
493
- ## 0.1.158
588
+ ## 0.1.158 (2026-05-09)
494
589
 
495
590
  Internal version bump consolidating 0.1.157 fixes for production publish.
496
591
 
497
- ## 0.1.157
592
+ ## 0.1.157 (2026-05-08)
498
593
 
499
594
  ### Recursive server-managed metadata strip at upload boundary
500
595
 
@@ -531,7 +626,7 @@ stuck-dirty payload (mixing top-level full arrays with bracket paths)
531
626
  into Dexie's `_dirty_changes` and asserts upload succeeds without
532
627
  mongo path-conflict errors via a `MongoFaithfulRestInterface` mock.
533
628
 
534
- ## 0.1.156
629
+ ## 0.1.156 (2026-05-08)
535
630
 
536
631
  Three related fixes targeting **dirty-payload metadata leak** and
537
632
  **concurrent array merge corruption** observed in production
@@ -607,7 +702,7 @@ Temporary upload-time scrubber dropping legacy position-based array
607
702
  paths (`field.<digit>(.…)?`) when `serverRev > baseRev`. Marked for
608
703
  removal after ~2026-05-15 once all clients have re-synced.
609
704
 
610
- ## 0.1.155
705
+ ## 0.1.155 (2026-05-08)
611
706
 
612
707
  Two new `SyncedDbConfig` fields targeting **cross-device scope-exit**
613
708
  detection: situations where one device modifies a record so it no longer
@@ -664,7 +759,7 @@ wake.
664
759
  - `_collectScopeExitPlan` and single-collection `evictOutOfScopeRecords`
665
760
  both route through the new helper.
666
761
 
667
- ## Unreleased
762
+ ## 0.1.149 (2026-04-27)
668
763
 
669
764
  ### `SyncSource` flag in `I_InMemDb.saveMany` / `deleteManyByIds`
670
765
 
@@ -702,6 +797,8 @@ Tests: `test/syncSource.test.ts` (9 cases) covers initial / incremental
702
797
  / refresh propagation across all public write paths. `MockInMemDb`
703
798
  exposes `recordedCalls: RecordedInMemCall[]` for assertion.
704
799
 
800
+ ## 0.1.148 (2026-04-26)
801
+
705
802
  ### `uploadDirtyItems` follow-up pass — drain in-sync writes immediately
706
803
 
707
804
  Writes that land **during** a sync iteration had their
@@ -732,6 +829,8 @@ first pass — a follow-up failure does not roll back the first pass's
732
829
  already-cleared dirty entries; affected items are caught at the next
733
830
  sync tick (same retry semantics as before).
734
831
 
832
+ ## 0.1.147 (2026-04-25)
833
+
735
834
  ### Auto-eviction co-located with sync — one round-trip total
736
835
 
737
836
  When `evictStaleRecordsEveryHrs > 0` and the interval has elapsed, the
@@ -803,6 +902,8 @@ request carry multiple specs against the same collection without
803
902
  library always populates them. Downstream code that constructs mock
804
903
  literals (e.g. tests) needs the new fields.
805
904
 
905
+ ## 0.1.144 (2026-04-24)
906
+
806
907
  ### Fix: filtered-sync tombstone (scope-exit from other writers)
807
908
 
808
909
  When a collection has `syncConfig.query` (e.g. `{ status: { $ne: "obsolete" } }`)
@@ -837,6 +938,8 @@ predicate (no implicit server policy).
837
938
  `$nor` support, required for the negated query to evaluate against the test
838
939
  mock and for any client-side filtering that uses logical operators.
839
940
 
941
+ ## 0.1.142 (2026-04-21)
942
+
840
943
  ### `getDirtyMeta()` for lightweight dirty-state inspection
841
944
 
842
945
  - New `SyncedDb.getDirtyMeta()` returns dirty-entry meta (everything except the
@@ -867,6 +970,8 @@ Two contributing causes, both fixed:
867
970
  cache learns of them via the existing shared-Dexie reload path. Reload
868
971
  broadcasts (post-full-sync) remain leader-only.
869
972
 
973
+ ## 0.1.141 (2026-04-21)
974
+
870
975
  ### BREAKING: Self-healing sync/reconnect lifecycle
871
976
 
872
977
  Fixes a class of bugs where the 60s auto-sync scheduler silently died after a
@@ -897,17 +1002,21 @@ tenants, 62–296 min of dead scheduler with dirty items accumulating.
897
1002
  `onForcedOffline: (reason) => log(reason)` → `onSyncFailed: (reason) => log(reason)`.
898
1003
  Signature is identical. No other callback changes.
899
1004
 
900
- - Add `refreshInBackground` `QueryOpts` option for `findById` / `findByIds`
901
- - Stale-while-revalidate: cache-hit returns local result immediately and
902
- triggers a background fetch that updates Dexie + in-mem through conflict
903
- resolution (`processCollectionServerData`).
904
- - Orthogonal to `referToServer` does not change miss behaviour. With
905
- defaults (`referToServer: true`) misses are still awaited; with
906
- `referToServer: false` misses return `null` and bg fetch loads them async.
907
- - Dedupes against `referToServer`: IDs fetched blockingly are NOT re-fetched
908
- in the background (no double-round-trip).
909
- - Noop when offline or on writeOnly collections.
910
- - Ignored on `find` / `findOne` (use `referToServer` there).
1005
+ ## 0.1.139 (2026-04-21)
1006
+
1007
+ ### `refreshInBackground` `QueryOpts` option for `findById` / `findByIds`
1008
+
1009
+ Stale-while-revalidate: cache-hit returns the local result immediately and
1010
+ triggers a background fetch that updates Dexie + in-mem through conflict
1011
+ resolution (`processCollectionServerData`).
1012
+
1013
+ - Orthogonal to `referToServer` — does not change miss behaviour. With
1014
+ defaults (`referToServer: true`) misses are still awaited; with
1015
+ `referToServer: false` misses return `null` and bg fetch loads them async.
1016
+ - Dedupes against `referToServer`: IDs fetched blockingly are NOT re-fetched
1017
+ in the background (no double-round-trip).
1018
+ - Noop when offline or on writeOnly collections.
1019
+ - Ignored on `find` / `findOne` (use `referToServer` there).
911
1020
 
912
1021
  ## 0.1.146 (2026-04-25)
913
1022
 
package/dist/index.js CHANGED
@@ -37,6 +37,21 @@ import Dexie2 from "dexie";
37
37
  import { ObjectId as ObjectId2 } from "bson";
38
38
 
39
39
  // src/utils/localQuery.ts
40
+ var regexCache = /* @__PURE__ */ new Map();
41
+ var REGEX_CACHE_MAX = 128;
42
+ function compileRegex(operand) {
43
+ if (operand instanceof RegExp) return operand;
44
+ const key = String(operand);
45
+ let r = regexCache.get(key);
46
+ if (r) return r;
47
+ r = new RegExp(key);
48
+ if (regexCache.size >= REGEX_CACHE_MAX) {
49
+ const oldest = regexCache.keys().next().value;
50
+ if (oldest !== void 0) regexCache.delete(oldest);
51
+ }
52
+ regexCache.set(key, r);
53
+ return r;
54
+ }
40
55
  function matchesQuery(item, query) {
41
56
  for (const [key, condition] of Object.entries(query)) {
42
57
  if (key === "$and") {
@@ -107,7 +122,7 @@ function matchesOperator(value, operator, operand) {
107
122
  case "$exists":
108
123
  return operand ? value !== void 0 : value === void 0;
109
124
  case "$regex": {
110
- const regex = operand instanceof RegExp ? operand : new RegExp(operand);
125
+ const regex = compileRegex(operand);
111
126
  if (typeof value === "string") return regex.test(value);
112
127
  if (Array.isArray(value)) {
113
128
  return value.some((v) => typeof v === "string" && regex.test(v));
@@ -3253,6 +3268,7 @@ var _SyncEngine = class _SyncEngine {
3253
3268
  const skipped = [];
3254
3269
  const ids = dirtyChanges.map((dc) => dc._id);
3255
3270
  const fullItems = await this.dexieDb.getByIds(collectionName, ids);
3271
+ const orphanReconstructed = [];
3256
3272
  for (let i = 0; i < fullItems.length; i++) {
3257
3273
  const fullItem = fullItems[i];
3258
3274
  const id = ids[i];
@@ -3268,7 +3284,7 @@ var _SyncEngine = class _SyncEngine {
3268
3284
  const delta = dirtyChangesMap.get(String(id));
3269
3285
  if (delta) {
3270
3286
  const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
3271
- await this.dexieDb.save(collectionName, id, reconstructed);
3287
+ orphanReconstructed.push(reconstructed);
3272
3288
  updates.push({ _id: id, delta });
3273
3289
  } else {
3274
3290
  skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
@@ -3277,6 +3293,9 @@ var _SyncEngine = class _SyncEngine {
3277
3293
  skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
3278
3294
  }
3279
3295
  }
3296
+ if (orphanReconstructed.length > 0) {
3297
+ await this.dexieDb.saveMany(collectionName, orphanReconstructed);
3298
+ }
3280
3299
  if (updates.length === 0) {
3281
3300
  console.warn(
3282
3301
  `[SyncEngine] uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`,
@@ -3536,14 +3555,20 @@ var _SyncEngine = class _SyncEngine {
3536
3555
  );
3537
3556
  }
3538
3557
  }
3539
- const sentIds = /* @__PURE__ */ new Set([
3540
- ...collectionBatches.flat().filter((b) => b.collection === collection).flatMap((b) => [
3541
- ...b.batch.updates.map((u) => String(u._id)),
3542
- ...b.batch.deletes.map((d) => String(d._id))
3543
- ])
3544
- ]);
3545
- const ackIds = new Set(allSuccessIds.map(String));
3546
- const unacked = [...sentIds].filter((id) => !ackIds.has(id));
3558
+ const sentIds = /* @__PURE__ */ new Set();
3559
+ for (const batch of collectionBatches) {
3560
+ for (const b of batch) {
3561
+ if (b.collection !== collection) continue;
3562
+ for (const u of b.batch.updates) sentIds.add(String(u._id));
3563
+ for (const d of b.batch.deletes) sentIds.add(String(d._id));
3564
+ }
3565
+ }
3566
+ const ackIds = /* @__PURE__ */ new Set();
3567
+ for (const id of allSuccessIds) ackIds.add(String(id));
3568
+ const unacked = [];
3569
+ for (const id of sentIds) {
3570
+ if (!ackIds.has(id)) unacked.push(id);
3571
+ }
3547
3572
  if (unacked.length > 0) {
3548
3573
  console.warn(
3549
3574
  `[SyncEngine] uploadDirtyItems: ${collection}: ${unacked.length} items sent but not acknowledged:`,
@@ -3977,39 +4002,57 @@ var ServerUpdateHandler = class {
3977
4002
  deletes.push(item.data);
3978
4003
  }
3979
4004
  }
3980
- for (const serverItem of inserts) {
3981
- await this.handleServerItemInsert(collectionName, serverItem);
3982
- updatedIds.push(String(serverItem._id));
4005
+ if (inserts.length > 0) {
4006
+ await Promise.all(
4007
+ inserts.map(
4008
+ (serverItem) => this.handleServerItemInsert(collectionName, serverItem)
4009
+ )
4010
+ );
4011
+ for (const serverItem of inserts) {
4012
+ updatedIds.push(String(serverItem._id));
4013
+ }
3983
4014
  }
3984
4015
  if (updates.length > 0) {
3985
4016
  const updateIds = updates.map((u) => u._id);
3986
4017
  const localItems = await this.dexieDb.getByIds(collectionName, updateIds);
3987
4018
  const missingIds = [];
4019
+ const updatePromises = [];
3988
4020
  for (let i = 0; i < updates.length; i++) {
3989
4021
  const deltaData = updates[i];
3990
4022
  const localItem = localItems[i];
3991
4023
  if (localItem) {
3992
- await this.handleServerItemUpdate(collectionName, localItem, deltaData);
4024
+ updatePromises.push(
4025
+ this.handleServerItemUpdate(collectionName, localItem, deltaData)
4026
+ );
3993
4027
  updatedIds.push(String(deltaData._id));
3994
4028
  } else {
3995
4029
  missingIds.push(deltaData._id);
3996
4030
  }
3997
4031
  }
4032
+ if (updatePromises.length > 0) await Promise.all(updatePromises);
3998
4033
  if (missingIds.length > 0) {
3999
4034
  const fullItems = await this.restInterface.findByIds(
4000
4035
  collectionName,
4001
4036
  missingIds
4002
4037
  );
4038
+ const insertPromises = [];
4003
4039
  for (const fullItem of fullItems) {
4004
4040
  if (!fullItem) continue;
4005
- await this.handleServerItemInsert(collectionName, fullItem);
4041
+ insertPromises.push(
4042
+ this.handleServerItemInsert(collectionName, fullItem)
4043
+ );
4006
4044
  updatedIds.push(String(fullItem._id));
4007
4045
  }
4046
+ if (insertPromises.length > 0) await Promise.all(insertPromises);
4008
4047
  }
4009
4048
  }
4010
- for (const deleteData of deletes) {
4011
- await this.handleServerItemDelete(collectionName, deleteData._id);
4012
- updatedIds.push(String(deleteData._id));
4049
+ if (deletes.length > 0) {
4050
+ await Promise.all(
4051
+ deletes.map((d) => this.handleServerItemDelete(collectionName, d._id))
4052
+ );
4053
+ for (const deleteData of deletes) {
4054
+ updatedIds.push(String(deleteData._id));
4055
+ }
4013
4056
  }
4014
4057
  break;
4015
4058
  }
@@ -4346,6 +4389,7 @@ var _SyncedDb = class _SyncedDb {
4346
4389
  this.collections = /* @__PURE__ */ new Map();
4347
4390
  // State
4348
4391
  this.initialized = false;
4392
+ this.closed = false;
4349
4393
  this.syncing = false;
4350
4394
  this.syncLock = false;
4351
4395
  this.wsUpdateQueue = [];
@@ -4884,6 +4928,8 @@ var _SyncedDb = class _SyncedDb {
4884
4928
  }
4885
4929
  async close() {
4886
4930
  var _a, _b;
4931
+ if (this.closed) return;
4932
+ this.closed = true;
4887
4933
  this.leaderElection.setClosing(true);
4888
4934
  this.pendingChanges.cancelRestUploadTimer();
4889
4935
  this.connectionManager.stopTimers();
@@ -24,6 +24,7 @@ export declare class SyncedDb implements I_SyncedDb {
24
24
  private readonly wakeSync?;
25
25
  private readonly networkStatus?;
26
26
  private initialized;
27
+ private closed;
27
28
  private syncing;
28
29
  private syncLock;
29
30
  private wsUpdateQueue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.176",
3
+ "version": "0.1.177",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",