@warlock.js/cascade 5.27.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 +7 -0
- package/cjs/index.cjs +134 -4
- package/cjs/index.cjs.map +1 -1
- package/esm/database-dirty-tracker.d.mts +38 -0
- package/esm/database-dirty-tracker.d.mts.map +1 -1
- package/esm/database-dirty-tracker.mjs +77 -2
- package/esm/database-dirty-tracker.mjs.map +1 -1
- package/esm/model/methods/accessor-methods.mjs +11 -0
- package/esm/model/methods/accessor-methods.mjs.map +1 -1
- package/esm/utils/prune-empty-parents.mjs +38 -0
- package/esm/utils/prune-empty-parents.mjs.map +1 -0
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +14 -2
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms.txt +15 -15
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ 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
|
+
|
|
7
14
|
## 5.27.0 - 2026-10-01
|
|
8
15
|
|
|
9
16
|
### Changed
|
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)
|
|
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")
|
|
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
|
|
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.
|
|
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
|
/**
|