@warlock.js/cascade 5.26.0 → 5.28.0

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
@@ -4,6 +4,19 @@ All notable changes to `@warlock.js/cascade` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.28.0 - 2026-10-01
8
+
9
+ ### Fixed
10
+
11
+ - Replacing a nested object on MongoDB (`model.set("nested", obj).save()`) now writes the whole new object with one `$set`, instead of diffing per leaf. Removing keys no longer leaves their parents behind as empty `{}` objects in the stored document. Dotted-path sets (`set("nested.a", v)`) and `merge()` stay partial per-leaf updates, so concurrent writes to other keys still survive. SQL drivers already replace the whole JSON column and are unchanged.
12
+ - `unset("nested.x")` now removes the parents it empties (`unset("a.b.c")` on `{ a: { b: { c: 1 } } }` stores no `a`), and the update `$unset`s that parent rather than leaving `{}`. A parent is pruned only when that unset emptied it: an object stored or set as `{}` is left alone, and arrays are never pruned. `unset("nested")` removes the whole object.
13
+
14
+ ## 5.27.0 - 2026-10-01
15
+
16
+ ### Changed
17
+
18
+ - Dependency: @mongez/supportive-is 3.0 (type-predicate checkers).
19
+
7
20
  ## 5.26.0 - 2026-09-30
8
21
 
9
22
  ### Added
package/cjs/index.cjs CHANGED
@@ -760,6 +760,40 @@ var UnsupportedUpdateOperationError = class UnsupportedUpdateOperationError exte
760
760
  }
761
761
  };
762
762
 
763
+ //#endregion
764
+ //#region ../cascade/src/utils/prune-empty-parents.ts
765
+ /**
766
+ * Remove the ancestors of `path` that became empty plain objects, walking from
767
+ * the leaf's parent up to (and including) the top-level field.
768
+ *
769
+ * Call it right AFTER removing the leaf at `path`, and only when that leaf
770
+ * actually existed. Gating on "the leaf existed" is what keeps an object the
771
+ * user deliberately stored as `{}` untouched: unsetting a key that is not there
772
+ * removes nothing, so nothing becomes empty because of the unset and nothing is
773
+ * pruned. An ancestor is only ever pruned because THIS unset emptied it.
774
+ *
775
+ * Only plain objects are pruned, and only while their own container is a plain
776
+ * object (or the root): arrays are never pruned and stop the walk, because
777
+ * deleting an index would shift its siblings.
778
+ *
779
+ * @param root - The data object the path is relative to (mutated in place)
780
+ * @param path - Dot-notation path of the leaf that was just removed
781
+ */
782
+ function pruneEmptyParents(root, path) {
783
+ const segments = path.split(".");
784
+ for (let depth = segments.length - 1; depth >= 1; depth -= 1) {
785
+ let container = root;
786
+ for (let index = 0; index < depth - 1; index += 1) {
787
+ container = container?.[segments[index]];
788
+ if (!(0, _mongez_supportive_is.isPlainObject)(container)) return;
789
+ }
790
+ const key = segments[depth - 1];
791
+ const candidate = container[key];
792
+ if (!(0, _mongez_supportive_is.isPlainObject)(candidate) || Object.keys(candidate).length > 0) return;
793
+ delete container[key];
794
+ }
795
+ }
796
+
763
797
  //#endregion
764
798
  //#region ../cascade/src/database-dirty-tracker.ts
765
799
  function canBeFlatten(object) {
@@ -824,6 +858,13 @@ var DatabaseDirtyTracker = class {
824
858
  * Set of column names (dot-notation paths) that existed initially but have been removed.
825
859
  */
826
860
  removedColumns = /* @__PURE__ */ new Set();
861
+ /**
862
+ * Top-level fields that were assigned or unset as a whole (`set("nested", obj)`,
863
+ * `unset("nested")`) since the last reset. Dotted-path writes
864
+ * (`set("nested.a", v)`) and `mergeChanges` are partial by intent and never
865
+ * land here. See {@link getReplacedColumns}.
866
+ */
867
+ replacedRoots = /* @__PURE__ */ new Set();
827
868
  constructor(data) {
828
869
  this.initialRaw = this.cloneData(data);
829
870
  this.currentRaw = this.cloneData(data);
@@ -848,6 +889,64 @@ var DatabaseDirtyTracker = class {
848
889
  return Array.from(this.dirtyColumns);
849
890
  }
850
891
  /**
892
+ * Removed columns collapsed to the highest path that no longer exists.
893
+ *
894
+ * `getRemovedColumns()` reports leaves (`"a.b.c"`). Unsetting just that leaf
895
+ * would keep `a` and `a.b` in the stored document as empty objects whenever
896
+ * the unset emptied them (and `unset()` prunes those parents). This returns
897
+ * the path to actually `$unset`: the shortest prefix that is gone from the
898
+ * current data, which is the leaf itself when its parents still hold other
899
+ * keys.
900
+ *
901
+ * @returns Distinct paths to remove
902
+ */
903
+ getRemovedPaths() {
904
+ const paths = /* @__PURE__ */ new Set();
905
+ for (const column of this.removedColumns) {
906
+ const segments = column.split(".");
907
+ let container = this.currentRaw;
908
+ let path = column;
909
+ for (let index = 0; index < segments.length; index += 1) {
910
+ const segment = segments[index];
911
+ if (container === null || typeof container !== "object" || !(segment in container)) {
912
+ path = segments.slice(0, index + 1).join(".");
913
+ break;
914
+ }
915
+ container = container[segment];
916
+ }
917
+ paths.add(path);
918
+ }
919
+ return Array.from(paths);
920
+ }
921
+ /**
922
+ * Top-level fields whose nested object must be written as a WHOLE value.
923
+ *
924
+ * Per-leaf diffing cannot express "this object is now exactly this": it only
925
+ * reports the leaves that changed or vanished, so a key removed from a nested
926
+ * object leaves its (now empty) parents behind in the stored document. A field
927
+ * that was assigned or unset as a whole therefore reports here, and the writer
928
+ * sends one `$set` (or `$unset`) for it instead of per-leaf operations.
929
+ *
930
+ * A field is reported only when it was replaced via `set("field", value)` /
931
+ * `unset("field")`, either its old or new value is a plain object, and the
932
+ * value actually differs from the baseline. Dotted-path writes
933
+ * (`set("field.a", v)`) and `mergeChanges` stay per-leaf, so partial updates
934
+ * keep working. Scalars and arrays are already whole-value leaves in the diff.
935
+ *
936
+ * @returns Top-level column names to write as a whole
937
+ */
938
+ getReplacedColumns() {
939
+ const columns = [];
940
+ for (const root of this.replacedRoots) {
941
+ const initial = this.initialRaw[root];
942
+ const current = this.currentRaw[root];
943
+ if (!(0, _mongez_supportive_is.isPlainObject)(initial) && !(0, _mongez_supportive_is.isPlainObject)(current)) continue;
944
+ if ((0, _mongez_reinforcements.areEqual)(initial, current)) continue;
945
+ columns.push(root);
946
+ }
947
+ return columns;
948
+ }
949
+ /**
851
950
  * Determines whether there are any tracked changes.
852
951
  *
853
952
  * Returns `true` if any columns have been modified or removed since the initial snapshot.
@@ -966,6 +1065,7 @@ var DatabaseDirtyTracker = class {
966
1065
  setAtPath(path, value) {
967
1066
  const segments = path.split(".");
968
1067
  let container = this.currentRaw;
1068
+ if (segments.length === 1) this.replacedRoots.add(path);
969
1069
  for (let index = 0; index < segments.length - 1; index += 1) {
970
1070
  const segment = segments[index];
971
1071
  if (!(0, _mongez_supportive_is.isPlainObject)(container[segment]) && !Array.isArray(container[segment])) container[segment] = {};
@@ -992,7 +1092,10 @@ var DatabaseDirtyTracker = class {
992
1092
  */
993
1093
  unset(columns) {
994
1094
  const targets = Array.isArray(columns) ? columns : [columns];
995
- for (const path of targets) this.deleteFromRaw(path);
1095
+ for (const path of targets) {
1096
+ if (!path.includes(".")) this.replacedRoots.add(path);
1097
+ this.deleteFromRaw(path);
1098
+ }
996
1099
  this.currentFlattened = this.flattenData(this.currentRaw);
997
1100
  this.updateDirtyState();
998
1101
  }
@@ -1024,6 +1127,7 @@ var DatabaseDirtyTracker = class {
1024
1127
  this.currentFlattened = this.flattenData(this.currentRaw);
1025
1128
  this.dirtyColumns.clear();
1026
1129
  this.removedColumns.clear();
1130
+ this.replacedRoots.clear();
1027
1131
  }
1028
1132
  /**
1029
1133
  * Flattens the given data object.
@@ -1107,7 +1211,11 @@ var DatabaseDirtyTracker = class {
1107
1211
  if (!Number.isNaN(numericIndex)) container.splice(numericIndex, 1);
1108
1212
  return;
1109
1213
  }
1110
- if (typeof container === "object") delete container[lastSegment];
1214
+ if (typeof container === "object") {
1215
+ const existed = lastSegment in container;
1216
+ delete container[lastSegment];
1217
+ if (existed) pruneEmptyParents(this.currentRaw, path);
1218
+ }
1111
1219
  }
1112
1220
  /**
1113
1221
  * Resolves a single segment of a dot-notation path within a container.
@@ -3460,8 +3568,18 @@ function incrementField(model, field, amount) {
3460
3568
  function decrementField(model, field, amount) {
3461
3569
  return setFieldValue(model, field, getFieldValue(model, field, 0) - (amount ?? 1));
3462
3570
  }
3571
+ /**
3572
+ * Remove fields from the model data.
3573
+ *
3574
+ * Unsetting the last key of a nested object also removes the parents that this
3575
+ * unset left empty (`unset("a.b")` on `{ a: { b: 1 } }` leaves `{}`, not
3576
+ * `{ a: {} }`). A parent is pruned only when this unset emptied it: an object
3577
+ * the user stored as `{}` is never touched, and arrays are never pruned.
3578
+ */
3463
3579
  function unsetFields(model, ...fields) {
3580
+ const existing = fields.filter((field) => hasField(model, field));
3464
3581
  model.data = (0, _mongez_reinforcements.unset)(model.data, fields);
3582
+ for (const field of existing) pruneEmptyParents(model.data, field);
3465
3583
  model.dirtyTracker.unset(fields);
3466
3584
  return model;
3467
3585
  }
@@ -4781,7 +4899,9 @@ var DatabaseWriter = class {
4781
4899
  "id",
4782
4900
  "_id"
4783
4901
  ]);
4784
- const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));
4902
+ const replacedColumns = this.model.dirtyTracker.getReplacedColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));
4903
+ const isUnderReplaced = (column) => replacedColumns.some((root) => column === root || column.startsWith(`${root}.`));
4904
+ const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column) && !isUnderReplaced(column));
4785
4905
  if (dirtyColumns.length > 0) {
4786
4906
  operations.$set = {};
4787
4907
  for (const column of dirtyColumns) {
@@ -4789,11 +4909,21 @@ var DatabaseWriter = class {
4789
4909
  operations.$set[column] = this.model.get(column);
4790
4910
  }
4791
4911
  }
4792
- const removedColumns = this.model.getRemovedColumns().filter((column) => !identityColumns.has(column));
4912
+ const removedColumns = this.model.dirtyTracker.getRemovedPaths().filter((column) => !identityColumns.has(column) && !isUnderReplaced(column));
4793
4913
  if (removedColumns.length > 0) {
4794
4914
  operations.$unset = {};
4795
4915
  for (const column of removedColumns) operations.$unset[column] = 1;
4796
4916
  }
4917
+ for (const column of replacedColumns) {
4918
+ const value = this.model.get(column);
4919
+ if (value === void 0) {
4920
+ operations.$unset ??= {};
4921
+ operations.$unset[column] = 1;
4922
+ continue;
4923
+ }
4924
+ operations.$set ??= {};
4925
+ operations.$set[column] = value;
4926
+ }
4797
4927
  return operations;
4798
4928
  }
4799
4929
  /**