cry-synced-db-client 2.0.5 → 2.0.7

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
@@ -919,21 +919,23 @@ var InMemManager = class {
919
919
  const config = this.collections.get(collection);
920
920
  const source = (_a = opts == null ? void 0 : opts.source) != null ? _a : "incremental";
921
921
  if (operation === "upsert") {
922
- this.inMemDb.saveMany(collection, items, { source });
922
+ const itemsToWrite = source === "incremental" ? this.dropRevRegressions(collection, items) : items;
923
+ if (itemsToWrite.length === 0) return;
924
+ this.inMemDb.saveMany(collection, itemsToWrite, { source });
923
925
  if (this.useObjectMetadata && (config == null ? void 0 : config.hasMetadata)) {
924
926
  const ids = [];
925
- for (const item of items) ids.push(item._id);
927
+ for (const item of itemsToWrite) ids.push(item._id);
926
928
  let metadatas;
927
929
  if (config.onObjectsUpdated) {
928
930
  try {
929
- metadatas = config.onObjectsUpdated(items);
931
+ metadatas = config.onObjectsUpdated(itemsToWrite);
930
932
  } catch (err) {
931
933
  console.error(`[InMem] onObjectsUpdated callback failed: ${err}`, err);
932
934
  return;
933
935
  }
934
936
  } else if (config.onObjectUpdated) {
935
937
  try {
936
- metadatas = items.map((item) => config.onObjectUpdated(item));
938
+ metadatas = itemsToWrite.map((item) => config.onObjectUpdated(item));
937
939
  } catch (err) {
938
940
  console.error(`[InMem] onObjectUpdated callback failed: ${err}`, err);
939
941
  return;
@@ -952,6 +954,30 @@ var InMemManager = class {
952
954
  }
953
955
  }
954
956
  }
957
+ /**
958
+ * Filter out items whose `_rev` is lower than what in-mem already holds
959
+ * for that id — an incremental write with a stale rev is, by definition,
960
+ * confirming/echoing a write that a later one has already superseded.
961
+ * Items without a numeric `_rev` on either side are let through unchanged
962
+ * (legacy collections without rev tracking).
963
+ */
964
+ dropRevRegressions(collection, items) {
965
+ const result = [];
966
+ for (const item of items) {
967
+ const incomingRev = item._rev;
968
+ if (typeof incomingRev !== "number") {
969
+ result.push(item);
970
+ continue;
971
+ }
972
+ const current = this.inMemDb.getById(collection, item._id);
973
+ const currentRev = current == null ? void 0 : current._rev;
974
+ if (typeof currentRev === "number" && incomingRev < currentRev) {
975
+ continue;
976
+ }
977
+ result.push(item);
978
+ }
979
+ return result;
980
+ }
955
981
  /**
956
982
  * Initialize collection from Dexie data.
957
983
  * Called during SyncedDb.init().
@@ -3866,8 +3892,8 @@ var SyncEngine = class {
3866
3892
  collection,
3867
3893
  idsToCheck
3868
3894
  );
3869
- const dexieSaveBatch = [];
3870
- const inMemUpdateBatch = [];
3895
+ const saveIds = [];
3896
+ const revById = /* @__PURE__ */ new Map();
3871
3897
  const inMemDeleteIds = [];
3872
3898
  const dexieDeleteIds = [];
3873
3899
  for (let i = 0; i < insertedAndUpdated.length; i++) {
@@ -3875,17 +3901,29 @@ var SyncEngine = class {
3875
3901
  const dexieItem = dexieItems[i];
3876
3902
  if (mustRefreshIds.has(String(entity._id))) continue;
3877
3903
  if (dexieItem) {
3878
- dexieItem._rev = entity._rev;
3879
- dexieItem._ts = entity._ts;
3880
3904
  if (dexieItem._deleted) {
3881
3905
  inMemDeleteIds.push(entity._id);
3882
3906
  dexieDeleteIds.push(entity._id);
3883
3907
  } else {
3884
- dexieSaveBatch.push(dexieItem);
3885
- inMemUpdateBatch.push(dexieItem);
3908
+ saveIds.push(entity._id);
3909
+ revById.set(String(entity._id), { rev: entity._rev, ts: entity._ts });
3886
3910
  }
3887
3911
  }
3888
3912
  }
3913
+ const dexieSaveBatch = [];
3914
+ const inMemUpdateBatch = [];
3915
+ if (saveIds.length > 0) {
3916
+ const freshItems = await this.dexieDb.getByIds(collection, saveIds);
3917
+ for (const freshItem of freshItems) {
3918
+ if (!freshItem) continue;
3919
+ const revTs = revById.get(String(freshItem._id));
3920
+ if (!revTs) continue;
3921
+ freshItem._rev = revTs.rev;
3922
+ freshItem._ts = revTs.ts;
3923
+ dexieSaveBatch.push(freshItem);
3924
+ inMemUpdateBatch.push(freshItem);
3925
+ }
3926
+ }
3889
3927
  if (dexieSaveBatch.length > 0) {
3890
3928
  await this.dexieDb.saveMany(collection, dexieSaveBatch);
3891
3929
  }
@@ -4525,7 +4563,7 @@ var ServerUpdateHandler = class {
4525
4563
  return updatedIds;
4526
4564
  }
4527
4565
  async processBracketedItem(collection, item) {
4528
- var _a, _b, _c, _d, _e, _f, _g, _h;
4566
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
4529
4567
  const localItem = await this.dexieDb.getById(collection, item._id);
4530
4568
  if (!localItem) {
4531
4569
  if (item.operation === "insert" && item.bracketedData) {
@@ -4580,13 +4618,18 @@ var ServerUpdateHandler = class {
4580
4618
  case "apply":
4581
4619
  break;
4582
4620
  }
4621
+ const incomingUpdaterId = (_f = item._lastUpdaterId) != null ? _f : (_e = item.bracketedData) == null ? void 0 : _e._lastUpdaterId;
4583
4622
  const pendingChange = this.deps.getPendingChange(collection, item._id);
4584
4623
  if (pendingChange && item.operation === "update") {
4585
- const diff = this.stripMetaFromDiff(item.bracketedData);
4624
+ const diff = this.dropFieldsOwnedByPending(
4625
+ this.stripMetaFromDiff(item.bracketedData),
4626
+ pendingChange.data,
4627
+ incomingUpdaterId
4628
+ );
4586
4629
  const currentInMemState = Object.assign({}, localItem, pendingChange.data);
4587
4630
  const merged = applyObjDiff(currentInMemState, diff, item._id, "bracketed");
4588
- merged._rev = (_e = item._rev) != null ? _e : localItem._rev;
4589
- merged._ts = (_f = item._ts) != null ? _f : localItem._ts;
4631
+ merged._rev = (_g = item._rev) != null ? _g : localItem._rev;
4632
+ merged._ts = (_h = item._ts) != null ? _h : localItem._ts;
4590
4633
  if (!merged._deleted && !merged._archived) {
4591
4634
  this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", {
4592
4635
  source: "incremental"
@@ -4596,10 +4639,14 @@ var ServerUpdateHandler = class {
4596
4639
  }
4597
4640
  const dirtyChange = await this.dexieDb.getDirtyChange(collection, item._id);
4598
4641
  if (dirtyChange && item.operation === "update") {
4599
- const diff = this.stripMetaFromDiff(item.bracketedData);
4642
+ const diff = this.dropFieldsOwnedByPending(
4643
+ this.stripMetaFromDiff(item.bracketedData),
4644
+ dirtyChange,
4645
+ incomingUpdaterId
4646
+ );
4600
4647
  const merged = applyObjDiff(localItem, diff, item._id, "bracketed");
4601
- merged._rev = (_g = item._rev) != null ? _g : localItem._rev;
4602
- merged._ts = (_h = item._ts) != null ? _h : localItem._ts;
4648
+ merged._rev = (_i = item._rev) != null ? _i : localItem._rev;
4649
+ merged._ts = (_j = item._ts) != null ? _j : localItem._ts;
4603
4650
  if (!merged._deleted && !merged._archived) {
4604
4651
  await this.dexieDb.insert(collection, merged);
4605
4652
  this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", {
@@ -4883,7 +4930,12 @@ var ServerUpdateHandler = class {
4883
4930
  Object.assign(pendingChange.data, newFields);
4884
4931
  }
4885
4932
  const currentInMemState = Object.assign({}, localItem, pendingChange.data);
4886
- const merged = this.mergeLocalWithDelta(currentInMemState, serverDelta);
4933
+ const filteredDelta = this.dropFieldsOwnedByPending(
4934
+ serverDelta,
4935
+ pendingChange.data,
4936
+ serverDelta._lastUpdaterId
4937
+ );
4938
+ const merged = this.mergeLocalWithDelta(currentInMemState, filteredDelta);
4887
4939
  if (!merged._deleted && !merged._archived) {
4888
4940
  this.deps.writeToInMemBatch(
4889
4941
  collection,
@@ -4897,7 +4949,12 @@ var ServerUpdateHandler = class {
4897
4949
  const dirtyChange = await this.dexieDb.getDirtyChange(collection, serverDelta._id);
4898
4950
  const metaChanged = localItem._rev !== serverDelta._rev || !this.timestampsEqual(localItem._ts, serverDelta._ts);
4899
4951
  if (dirtyChange) {
4900
- const merged = this.mergeLocalWithDelta(localItem, serverDelta);
4952
+ const filteredDelta = this.dropFieldsOwnedByPending(
4953
+ serverDelta,
4954
+ dirtyChange,
4955
+ serverDelta._lastUpdaterId
4956
+ );
4957
+ const merged = this.mergeLocalWithDelta(localItem, filteredDelta);
4901
4958
  if (metaChanged) {
4902
4959
  await this.dexieDb.save(collection, serverDelta._id, merged);
4903
4960
  }
@@ -4975,6 +5032,50 @@ var ServerUpdateHandler = class {
4975
5032
  }
4976
5033
  return local;
4977
5034
  }
5035
+ /**
5036
+ * Drop fields from an incoming server delta/diff that our OWN in-flight
5037
+ * change (pending or already-dirty) already explicitly touches — but ONLY
5038
+ * when the incoming delta was itself authored by this same client
5039
+ * (`incomingUpdaterId === this.updaterId`). That is the one case where we
5040
+ * can safely assume our own newer local pending/dirty write supersedes the
5041
+ * incoming payload for that field: a slower/older echo of our OWN prior
5042
+ * write (e.g. a lock-write's confirmation arriving after we've already
5043
+ * locally reverted it, via a rev/timing gap the entry-level self-echo
5044
+ * check in `handleServerItemUpdate`/`checkRev` didn't catch) can otherwise
5045
+ * resurrect a field our own newer pending change had deliberately set or
5046
+ * cleared — both `mergeLocalWithDelta` and `applyObjDiff` apply whatever
5047
+ * the incoming payload says with no awareness of a competing, newer local
5048
+ * intent.
5049
+ *
5050
+ * When the incoming delta comes from a genuinely different actor (a true
5051
+ * second writer, or `incomingUpdaterId` is absent/unknown), this is a
5052
+ * skip-through no-op — that delta must always apply so real-time UX stays
5053
+ * live (see `test/secondActor.test.ts`): the field-name overlap alone does
5054
+ * NOT imply staleness for another writer's edit, only for our own.
5055
+ *
5056
+ * `ownedKeys` is checked via `hasOwnProperty` (not truthiness) because a
5057
+ * deliberately-cleared field is `undefined`, not absent, on the owning
5058
+ * diff/pendingChange.data.
5059
+ *
5060
+ * Path-based (bracketed) diff keys like `koraki[id].field` are matched by
5061
+ * their top-level segment — if `koraki` is owned, every path under it is
5062
+ * dropped, which is conservative but safe (reproduced with izdajRacun.vue's
5063
+ * sedajObdeluje lock/unlock race via the dbbracketed/ channel, 2026-07-14).
5064
+ */
5065
+ dropFieldsOwnedByPending(incoming, ownedKeys, incomingUpdaterId) {
5066
+ var _a;
5067
+ if (incomingUpdaterId === void 0 || incomingUpdaterId !== this.updaterId) {
5068
+ return incoming;
5069
+ }
5070
+ const result = {};
5071
+ for (const key in incoming) {
5072
+ if (!Object.prototype.hasOwnProperty.call(incoming, key)) continue;
5073
+ const topField = (_a = key.split(/[.[]/)[0]) != null ? _a : key;
5074
+ if (Object.prototype.hasOwnProperty.call(ownedKeys, topField)) continue;
5075
+ result[key] = incoming[key];
5076
+ }
5077
+ return result;
5078
+ }
4978
5079
  /**
4979
5080
  * Fields from an incoming server delta that our OWN in-flight pending
4980
5081
  * change doesn't already touch — safe to backfill into it so a later
@@ -28,6 +28,14 @@ export declare class InMemManager implements I_InMemManager {
28
28
  writeBatch<T extends DbEntity>(collection: string, items: T[], operation: "upsert" | "delete", opts?: {
29
29
  source?: SyncSource;
30
30
  }): void;
31
+ /**
32
+ * Filter out items whose `_rev` is lower than what in-mem already holds
33
+ * for that id — an incremental write with a stale rev is, by definition,
34
+ * confirming/echoing a write that a later one has already superseded.
35
+ * Items without a numeric `_rev` on either side are let through unchanged
36
+ * (legacy collections without rev tracking).
37
+ */
38
+ private dropRevRegressions;
31
39
  /**
32
40
  * Initialize collection from Dexie data.
33
41
  * Called during SyncedDb.init().
@@ -81,6 +81,37 @@ export declare class ServerUpdateHandler implements I_ServerUpdateHandler {
81
81
  private timestampsEqual;
82
82
  /** @mutates local — mutira vhodni objekt namesto kopiranja */
83
83
  private mergeLocalWithDelta;
84
+ /**
85
+ * Drop fields from an incoming server delta/diff that our OWN in-flight
86
+ * change (pending or already-dirty) already explicitly touches — but ONLY
87
+ * when the incoming delta was itself authored by this same client
88
+ * (`incomingUpdaterId === this.updaterId`). That is the one case where we
89
+ * can safely assume our own newer local pending/dirty write supersedes the
90
+ * incoming payload for that field: a slower/older echo of our OWN prior
91
+ * write (e.g. a lock-write's confirmation arriving after we've already
92
+ * locally reverted it, via a rev/timing gap the entry-level self-echo
93
+ * check in `handleServerItemUpdate`/`checkRev` didn't catch) can otherwise
94
+ * resurrect a field our own newer pending change had deliberately set or
95
+ * cleared — both `mergeLocalWithDelta` and `applyObjDiff` apply whatever
96
+ * the incoming payload says with no awareness of a competing, newer local
97
+ * intent.
98
+ *
99
+ * When the incoming delta comes from a genuinely different actor (a true
100
+ * second writer, or `incomingUpdaterId` is absent/unknown), this is a
101
+ * skip-through no-op — that delta must always apply so real-time UX stays
102
+ * live (see `test/secondActor.test.ts`): the field-name overlap alone does
103
+ * NOT imply staleness for another writer's edit, only for our own.
104
+ *
105
+ * `ownedKeys` is checked via `hasOwnProperty` (not truthiness) because a
106
+ * deliberately-cleared field is `undefined`, not absent, on the owning
107
+ * diff/pendingChange.data.
108
+ *
109
+ * Path-based (bracketed) diff keys like `koraki[id].field` are matched by
110
+ * their top-level segment — if `koraki` is owned, every path under it is
111
+ * dropped, which is conservative but safe (reproduced with izdajRacun.vue's
112
+ * sedajObdeluje lock/unlock race via the dbbracketed/ channel, 2026-07-14).
113
+ */
114
+ private dropFieldsOwnedByPending;
84
115
  /**
85
116
  * Fields from an incoming server delta that our OWN in-flight pending
86
117
  * change doesn't already touch — safe to backfill into it so a later
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -20,8 +20,9 @@
20
20
  "build": "bun run clean && bun run build:js && bun run build:types",
21
21
  "build:js": "esbuild ./src/index.ts --bundle --outdir=./dist --target=es2017 --format=esm --platform=browser --external:dexie --external:bson --external:cry-helpers",
22
22
  "build:types": "tsc --emitDeclarationOnly --outDir dist",
23
- "test": "bun test --preload ./test/preload-bun.cjs test/*.test.ts test/restProxy/*.test.ts; vitest run",
24
- "test:bun": "bun test test/*.test.ts test/restProxy/*.test.ts",
23
+ "test": "bash test/run-all.sh",
24
+ "test:ci": "SKIP_REAL_MONGO=1 bash test/run-all.sh",
25
+ "test:bun": "bun test --preload ./test/preload-bun.cjs test/*.test.ts test/restProxy/*.test.ts test/node-only-bun/*.test.ts",
25
26
  "test:node-only": "vitest run",
26
27
  "test:real-mongo": "USE_REAL_MONGO=1 npx vitest run test/node-only/",
27
28
  "prepublishOnly": "bun run build"