@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.
@@ -53,6 +53,13 @@ declare class DatabaseDirtyTracker {
53
53
  * Set of column names (dot-notation paths) that existed initially but have been removed.
54
54
  */
55
55
  protected readonly removedColumns: Set<string>;
56
+ /**
57
+ * Top-level fields that were assigned or unset as a whole (`set("nested", obj)`,
58
+ * `unset("nested")`) since the last reset. Dotted-path writes
59
+ * (`set("nested.a", v)`) and `mergeChanges` are partial by intent and never
60
+ * land here. See {@link getReplacedColumns}.
61
+ */
62
+ protected readonly replacedRoots: Set<string>;
56
63
  constructor(data: Record<string, unknown>);
57
64
  /**
58
65
  * Returns the list of dirty columns using dot-notation.
@@ -68,6 +75,37 @@ declare class DatabaseDirtyTracker {
68
75
  * ```
69
76
  */
70
77
  getDirtyColumns(): string[];
78
+ /**
79
+ * Removed columns collapsed to the highest path that no longer exists.
80
+ *
81
+ * `getRemovedColumns()` reports leaves (`"a.b.c"`). Unsetting just that leaf
82
+ * would keep `a` and `a.b` in the stored document as empty objects whenever
83
+ * the unset emptied them (and `unset()` prunes those parents). This returns
84
+ * the path to actually `$unset`: the shortest prefix that is gone from the
85
+ * current data, which is the leaf itself when its parents still hold other
86
+ * keys.
87
+ *
88
+ * @returns Distinct paths to remove
89
+ */
90
+ getRemovedPaths(): string[];
91
+ /**
92
+ * Top-level fields whose nested object must be written as a WHOLE value.
93
+ *
94
+ * Per-leaf diffing cannot express "this object is now exactly this": it only
95
+ * reports the leaves that changed or vanished, so a key removed from a nested
96
+ * object leaves its (now empty) parents behind in the stored document. A field
97
+ * that was assigned or unset as a whole therefore reports here, and the writer
98
+ * sends one `$set` (or `$unset`) for it instead of per-leaf operations.
99
+ *
100
+ * A field is reported only when it was replaced via `set("field", value)` /
101
+ * `unset("field")`, either its old or new value is a plain object, and the
102
+ * value actually differs from the baseline. Dotted-path writes
103
+ * (`set("field.a", v)`) and `mergeChanges` stay per-leaf, so partial updates
104
+ * keep working. Scalars and arrays are already whole-value leaves in the diff.
105
+ *
106
+ * @returns Top-level column names to write as a whole
107
+ */
108
+ getReplacedColumns(): string[];
71
109
  /**
72
110
  * Determines whether there are any tracked changes.
73
111
  *
@@ -1 +1 @@
1
- {"version":3,"file":"database-dirty-tracker.d.mts","names":[],"sources":["../../../../../../cascade/src/database-dirty-tracker.ts"],"mappings":";;;;KA+CK,UAAA,GAAa,MAAM;;AAAA;AAAA;KAKnB,iBAAA;EAAsB,QAAA;EAAmB,QAAQ;AAAA;AAkBtD;;;;;;;;;;;;;;;;AAAA,cAAa,oBAAA;EAqUoB;;;;EAAA,UAhUrB,UAAA,EAAY,MAAA;EA4ZY;;;EAAA,UAvZxB,UAAA,EAAY,MAAA;EAAA;;;;EAAA,UAMZ,gBAAA,EAAkB,UAAA;EAUT;;;EAAA,UALT,gBAAA,EAAkB,UAAA;;;;qBAKT,YAAA,EAAY,GAAA;EAmDxB;;;EAAA,mBA9CY,cAAA,EAAc,GAAA;cAEd,IAAA,EAAM,MAAA;EA0FW;;;;;;;;;;;;;EAnE7B,eAAA;EAmMa;;;;;;;;;;;;;;;;;EA9Kb,UAAA;EAoVa;;;EA7Ub,OAAA,CAAQ,MAAA;EA6UmB;AAAA;;;;;;;;;;;;;;EA1T3B,iBAAA;;;;;;;;;;;;;;;;;EAoBA,yBAAA,IAA6B,MAAA,SAAe,iBAAA;;;;;;;;;;;;;;;;EA+B5C,kBAAA,CAAmB,IAAA,EAAM,MAAA;;;;;;;;;;;;;;;;;EAsBzB,YAAA,CAAa,OAAA,EAAS,MAAA;;;;;EAUtB,SAAA,CAAU,IAAA,UAAc,KAAA;;;;;;;;;;;;;;;;EAkCxB,KAAA,CAAM,OAAA;;;;;;;;;;;;;;;;;;;;;EA+BN,KAAA,CAAM,IAAA,GAAO,MAAA;;;;;YAgBV,WAAA,CAAY,IAAA,EAAM,MAAA,oBAA0B,UAAA;;;;;;;;;;YAa5C,gBAAA;;;;;;;;;;;;;;;;;;;;YA6CA,YAAA,CAAa,MAAA,EAAQ,MAAA,mBAAyB,MAAA,EAAQ,MAAA;;;;;;;;;;YAoBtD,aAAA,CAAc,IAAA;;;;;;;;;;;YAgDd,cAAA,CAAe,SAAA,WAAoB,OAAA;;;;;;;;YAwBnC,SAAA,IAAa,IAAA,EAAM,CAAA,GAAI,CAAA;AAAA"}
1
+ {"version":3,"file":"database-dirty-tracker.d.mts","names":[],"sources":["../../../../../../cascade/src/database-dirty-tracker.ts"],"mappings":";;;;KAgDK,UAAA,GAAa,MAAM;;AAAA;AAAA;KAKnB,iBAAA;EAAsB,QAAA;EAAmB,QAAQ;AAAA;AAkBtD;;;;;;;;;;;;;;;;AAAA,cAAa,oBAAA;EA2W2C;;;;EAAA,UAtW5C,UAAA,EAAY,MAAA;EAogBY;;;EAAA,UA/fxB,UAAA,EAAY,MAAA;EAAZ;;;;EAAA,UAMA,gBAAA,EAAkB,UAAA;EAKA;;;EAAA,UAAlB,gBAAA,EAAkB,UAAA;EAUK;;;EAAA,mBALd,YAAA,EAAY,GAAA;EAeN;;;EAAA,mBAVN,cAAA,EAAc,GAAA;EA+F1B;;;;;;EAAA,mBAvFY,aAAA,EAAa,GAAA;cAEb,IAAA,EAAM,MAAA;EAwMlB;;;;;;;;;;;;;EAjLA,eAAA;EA2SG;;;;;;;;;;;;EA3RH,eAAA;EAiakB;;;;;;;;AAwBS;;;;;;;;;EA3Y3B,kBAAA;;;;;;;;;;;;;;;;;;EAsCA,UAAA;;;;EAOA,OAAA,CAAQ,MAAA;;;;;;;;;;;;;;;;EAmBR,iBAAA;;;;;;;;;;;;;;;;;EAoBA,yBAAA,IAA6B,MAAA,SAAe,iBAAA;;;;;;;;;;;;;;;;EA+B5C,kBAAA,CAAmB,IAAA,EAAM,MAAA;;;;;;;;;;;;;;;;;EAsBzB,YAAA,CAAa,OAAA,EAAS,MAAA;;;;;EAUtB,SAAA,CAAU,IAAA,UAAc,KAAA;;;;;;;;;;;;;;;;EAsCxB,KAAA,CAAM,OAAA;;;;;;;;;;;;;;;;;;;;;EAmCN,KAAA,CAAM,IAAA,GAAO,MAAA;;;;;YAiBV,WAAA,CAAY,IAAA,EAAM,MAAA,oBAA0B,UAAA;;;;;;;;;;YAa5C,gBAAA;;;;;;;;;;;;;;;;;;;;YA6CA,YAAA,CAAa,MAAA,EAAQ,MAAA,mBAAyB,MAAA,EAAQ,MAAA;;;;;;;;;;YAoBtD,aAAA,CAAc,IAAA;;;;;;;;;;;YAwDd,cAAA,CAAe,SAAA,WAAoB,OAAA;;;;;;;;YAwBnC,SAAA,IAAa,IAAA,EAAM,CAAA,GAAI,CAAA;AAAA"}
@@ -1,3 +1,4 @@
1
+ import { pruneEmptyParents } from "./utils/prune-empty-parents.mjs";
1
2
  import { areEqual, clone } from "@mongez/reinforcements";
2
3
  import { isPlainObject } from "@mongez/supportive-is";
3
4
 
@@ -64,6 +65,13 @@ var DatabaseDirtyTracker = class {
64
65
  * Set of column names (dot-notation paths) that existed initially but have been removed.
65
66
  */
66
67
  removedColumns = /* @__PURE__ */ new Set();
68
+ /**
69
+ * Top-level fields that were assigned or unset as a whole (`set("nested", obj)`,
70
+ * `unset("nested")`) since the last reset. Dotted-path writes
71
+ * (`set("nested.a", v)`) and `mergeChanges` are partial by intent and never
72
+ * land here. See {@link getReplacedColumns}.
73
+ */
74
+ replacedRoots = /* @__PURE__ */ new Set();
67
75
  constructor(data) {
68
76
  this.initialRaw = this.cloneData(data);
69
77
  this.currentRaw = this.cloneData(data);
@@ -88,6 +96,64 @@ var DatabaseDirtyTracker = class {
88
96
  return Array.from(this.dirtyColumns);
89
97
  }
90
98
  /**
99
+ * Removed columns collapsed to the highest path that no longer exists.
100
+ *
101
+ * `getRemovedColumns()` reports leaves (`"a.b.c"`). Unsetting just that leaf
102
+ * would keep `a` and `a.b` in the stored document as empty objects whenever
103
+ * the unset emptied them (and `unset()` prunes those parents). This returns
104
+ * the path to actually `$unset`: the shortest prefix that is gone from the
105
+ * current data, which is the leaf itself when its parents still hold other
106
+ * keys.
107
+ *
108
+ * @returns Distinct paths to remove
109
+ */
110
+ getRemovedPaths() {
111
+ const paths = /* @__PURE__ */ new Set();
112
+ for (const column of this.removedColumns) {
113
+ const segments = column.split(".");
114
+ let container = this.currentRaw;
115
+ let path = column;
116
+ for (let index = 0; index < segments.length; index += 1) {
117
+ const segment = segments[index];
118
+ if (container === null || typeof container !== "object" || !(segment in container)) {
119
+ path = segments.slice(0, index + 1).join(".");
120
+ break;
121
+ }
122
+ container = container[segment];
123
+ }
124
+ paths.add(path);
125
+ }
126
+ return Array.from(paths);
127
+ }
128
+ /**
129
+ * Top-level fields whose nested object must be written as a WHOLE value.
130
+ *
131
+ * Per-leaf diffing cannot express "this object is now exactly this": it only
132
+ * reports the leaves that changed or vanished, so a key removed from a nested
133
+ * object leaves its (now empty) parents behind in the stored document. A field
134
+ * that was assigned or unset as a whole therefore reports here, and the writer
135
+ * sends one `$set` (or `$unset`) for it instead of per-leaf operations.
136
+ *
137
+ * A field is reported only when it was replaced via `set("field", value)` /
138
+ * `unset("field")`, either its old or new value is a plain object, and the
139
+ * value actually differs from the baseline. Dotted-path writes
140
+ * (`set("field.a", v)`) and `mergeChanges` stay per-leaf, so partial updates
141
+ * keep working. Scalars and arrays are already whole-value leaves in the diff.
142
+ *
143
+ * @returns Top-level column names to write as a whole
144
+ */
145
+ getReplacedColumns() {
146
+ const columns = [];
147
+ for (const root of this.replacedRoots) {
148
+ const initial = this.initialRaw[root];
149
+ const current = this.currentRaw[root];
150
+ if (!isPlainObject(initial) && !isPlainObject(current)) continue;
151
+ if (areEqual(initial, current)) continue;
152
+ columns.push(root);
153
+ }
154
+ return columns;
155
+ }
156
+ /**
91
157
  * Determines whether there are any tracked changes.
92
158
  *
93
159
  * Returns `true` if any columns have been modified or removed since the initial snapshot.
@@ -206,6 +272,7 @@ var DatabaseDirtyTracker = class {
206
272
  setAtPath(path, value) {
207
273
  const segments = path.split(".");
208
274
  let container = this.currentRaw;
275
+ if (segments.length === 1) this.replacedRoots.add(path);
209
276
  for (let index = 0; index < segments.length - 1; index += 1) {
210
277
  const segment = segments[index];
211
278
  if (!isPlainObject(container[segment]) && !Array.isArray(container[segment])) container[segment] = {};
@@ -232,7 +299,10 @@ var DatabaseDirtyTracker = class {
232
299
  */
233
300
  unset(columns) {
234
301
  const targets = Array.isArray(columns) ? columns : [columns];
235
- for (const path of targets) this.deleteFromRaw(path);
302
+ for (const path of targets) {
303
+ if (!path.includes(".")) this.replacedRoots.add(path);
304
+ this.deleteFromRaw(path);
305
+ }
236
306
  this.currentFlattened = this.flattenData(this.currentRaw);
237
307
  this.updateDirtyState();
238
308
  }
@@ -264,6 +334,7 @@ var DatabaseDirtyTracker = class {
264
334
  this.currentFlattened = this.flattenData(this.currentRaw);
265
335
  this.dirtyColumns.clear();
266
336
  this.removedColumns.clear();
337
+ this.replacedRoots.clear();
267
338
  }
268
339
  /**
269
340
  * Flattens the given data object.
@@ -347,7 +418,11 @@ var DatabaseDirtyTracker = class {
347
418
  if (!Number.isNaN(numericIndex)) container.splice(numericIndex, 1);
348
419
  return;
349
420
  }
350
- if (typeof container === "object") delete container[lastSegment];
421
+ if (typeof container === "object") {
422
+ const existed = lastSegment in container;
423
+ delete container[lastSegment];
424
+ if (existed) pruneEmptyParents(this.currentRaw, path);
425
+ }
351
426
  }
352
427
  /**
353
428
  * Resolves a single segment of a dot-notation path within a container.
@@ -1 +1 @@
1
- {"version":3,"file":"database-dirty-tracker.mjs","names":[],"sources":["../../../../../../cascade/src/database-dirty-tracker.ts"],"sourcesContent":["import { areEqual, clone } from \"@mongez/reinforcements\";\nimport { isPlainObject } from \"@mongez/supportive-is\";\n\nfunction canBeFlatten(object: unknown): boolean {\n return isPlainObject(object);\n}\n\n/**\n * A fix for flatten as non-plain object is being flatten as well which it should not be\n */\nfunction flatten(\n object: Record<string, unknown>,\n separator = \".\",\n keepNestedOriginalObject = false,\n parent?: string,\n root: Record<string, unknown> = {},\n) {\n if (canBeFlatten(object) === false) {\n return object;\n }\n // object = toPlainObject(object);\n for (const key of Object.keys(object)) {\n const value = object[key];\n const keyChain = parent ? parent + separator + key : key;\n if ((Array.isArray(value) && value.length === 0) || typeof value === \"function\") {\n root[keyChain] = value;\n } else if (canBeFlatten(value)) {\n if (keepNestedOriginalObject) {\n root[keyChain] = value;\n }\n flatten(\n value as Record<string, unknown>,\n separator,\n keepNestedOriginalObject,\n keyChain,\n root,\n );\n } else {\n root[keyChain] = value;\n }\n }\n return root;\n}\n\n/**\n * Flattened record type representing dot-notation paths mapped to their values.\n */\ntype FlatRecord = Record<string, unknown>;\n\n/**\n * Represents the old and new values of a dirty column.\n */\ntype DirtyColumnValues = { oldValue: unknown; newValue: unknown };\n\n/**\n * Tracks changes to model data by maintaining snapshots of initial and current state.\n *\n * The tracker stores both raw (nested) and flattened (dot-notation) versions of the data\n * to accurately detect modifications, additions, and removals at any nesting level.\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", age: 30 });\n * tracker.mergeChanges({ age: 31 });\n * console.log(tracker.hasChanges()); // true\n * console.log(tracker.getDirtyColumns()); // [\"age\"]\n * console.log(tracker.getDirtyColumnsWithValues());\n * // { age: { oldValue: 30, newValue: 31 } }\n * ```\n */\nexport class DatabaseDirtyTracker {\n /**\n * The initial raw data snapshot taken at construction or last reset.\n * Used as the baseline for comparison.\n */\n protected initialRaw: Record<string, unknown>;\n\n /**\n * The current raw data snapshot reflecting all changes made via merge/unset.\n */\n protected currentRaw: Record<string, unknown>;\n\n /**\n * Flattened version of the initial data using dot-notation keys.\n * Example: { \"address.city\": \"NYC\" }\n */\n protected initialFlattened: FlatRecord;\n\n /**\n * Flattened version of the current data using dot-notation keys.\n */\n protected currentFlattened: FlatRecord;\n\n /**\n * Set of column names (dot-notation paths) that have been modified.\n */\n protected readonly dirtyColumns = new Set<string>();\n\n /**\n * Set of column names (dot-notation paths) that existed initially but have been removed.\n */\n protected readonly removedColumns = new Set<string>();\n\n public constructor(data: Record<string, unknown>) {\n this.initialRaw = this.cloneData(data);\n this.currentRaw = this.cloneData(data);\n\n this.initialFlattened = this.flattenData(this.initialRaw);\n this.currentFlattened = { ...this.initialFlattened };\n\n this.updateDirtyState();\n }\n\n /**\n * Returns the list of dirty columns using dot-notation.\n *\n * A column is considered dirty if its value has changed compared to the initial snapshot.\n *\n * @returns An array of column names (dot-notation paths) that have been modified\n *\n * @example\n * ```typescript\n * tracker.mergeChanges({ name: \"Bob\", \"address.city\": \"LA\" });\n * tracker.getDirtyColumns(); // [\"name\", \"address.city\"]\n * ```\n */\n public getDirtyColumns(): string[] {\n return Array.from(this.dirtyColumns);\n }\n\n /**\n * Determines whether there are any tracked changes.\n *\n * Returns `true` if any columns have been modified or removed since the initial snapshot.\n *\n * @returns `true` if there are changes, `false` otherwise\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.hasChanges(); // false\n * tracker.mergeChanges({ name: \"Bob\" });\n * tracker.hasChanges(); // true\n * tracker.unset(\"name\");\n * tracker.hasChanges(); // true (removed column counts as a change)\n * ```\n */\n public hasChanges(): boolean {\n return this.dirtyColumns.size > 0 || this.removedColumns.size > 0;\n }\n\n /**\n * Check if the given column is dirty (changed)\n */\n public isDirty(column: string): boolean {\n return this.dirtyColumns.has(column);\n }\n\n /**\n * Returns the set of columns that have been removed compared to the baseline.\n *\n * A column is considered removed if it existed in the initial snapshot but has been\n * explicitly unset or deleted from the current data.\n *\n * @returns An array of column names (dot-notation paths) that have been removed\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", temp: \"value\" });\n * tracker.unset(\"temp\");\n * tracker.getRemovedColumns(); // [\"temp\"]\n * ```\n */\n public getRemovedColumns(): string[] {\n return Array.from(this.removedColumns);\n }\n\n /**\n * Provides a mapping of dirty columns to their previous and current values.\n *\n * This is useful for generating audit logs, building partial update payloads,\n * or displaying change summaries to users.\n *\n * @returns A record mapping each dirty column to an object containing oldValue and newValue\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", age: 30 });\n * tracker.mergeChanges({ age: 31 });\n * tracker.getDirtyColumnsWithValues();\n * // { age: { oldValue: 30, newValue: 31 } }\n * ```\n */\n public getDirtyColumnsWithValues(): Record<string, DirtyColumnValues> {\n const result: Record<string, DirtyColumnValues> = {};\n\n for (const column of this.dirtyColumns) {\n const hasCurrent =\n this.currentFlattened[column] !== undefined || column in this.currentFlattened;\n\n result[column] = {\n oldValue: this.initialFlattened[column],\n newValue: hasCurrent ? this.currentFlattened[column] : undefined,\n };\n }\n\n return result;\n }\n\n /**\n * Replaces the current data snapshot entirely and recomputes the diff.\n *\n * This is useful when you want to replace all current data with a new set,\n * while keeping the initial baseline for comparison.\n *\n * @param data - The new data to set as the current snapshot\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.replaceCurrentData({ name: \"Bob\", email: \"bob@example.com\" });\n * tracker.getDirtyColumns(); // [\"name\", \"email\"]\n * ```\n */\n public replaceCurrentData(data: Record<string, unknown>): void {\n this.currentRaw = this.cloneData(data);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Merges a partial payload into the current snapshot and recomputes the diff.\n *\n * This performs a deep merge, preserving existing nested structures while\n * updating only the specified fields.\n *\n * @param partial - Partial data to merge into the current snapshot\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", address: { city: \"NYC\" } });\n * tracker.mergeChanges({ address: { zip: \"10001\" } });\n * // Current data: { name: \"Alice\", address: { city: \"NYC\", zip: \"10001\" } }\n * tracker.getDirtyColumns(); // [\"address.zip\"]\n * ```\n */\n public mergeChanges(partial: Record<string, unknown>): void {\n this.mergeIntoRaw(this.currentRaw, partial);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Replaces the value at a dot-notation path (no deep merge), mirroring\n * `model.set()` so stale nested keys are detected as removed.\n */\n public setAtPath(path: string, value: unknown): void {\n const segments = path.split(\".\");\n let container = this.currentRaw as Record<string, unknown>;\n\n for (let index = 0; index < segments.length - 1; index += 1) {\n const segment = segments[index] as string;\n\n if (!isPlainObject(container[segment]) && !Array.isArray(container[segment])) {\n container[segment] = {};\n }\n\n container = container[segment] as Record<string, unknown>;\n }\n\n container[segments[segments.length - 1] as string] = this.cloneData(value);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Explicitly removes one or more columns from the current data.\n *\n * Supports both single column names and arrays of column names.\n * Columns can be specified using dot-notation for nested paths.\n *\n * @param columns - A single column name or an array of column names to remove\n *\n * @example\n * ```typescript\n * tracker.unset(\"tempField\");\n * tracker.unset([\"field1\", \"field2\", \"nested.field\"]);\n * tracker.getRemovedColumns(); // [\"tempField\", \"field1\", \"field2\", \"nested.field\"]\n * ```\n */\n public unset(columns: string | string[]): void {\n const targets = Array.isArray(columns) ? columns : [columns];\n\n for (const path of targets) {\n this.deleteFromRaw(path);\n }\n\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Resets both the initial and current snapshots to the provided data.\n *\n * If no data is provided, the current snapshot becomes the new baseline.\n * This clears all tracked changes and removed columns.\n *\n * @param data - Optional new data to use as the baseline. If omitted, uses current data.\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.mergeChanges({ name: \"Bob\" });\n * tracker.hasChanges(); // true\n * tracker.reset(); // Make current state the new baseline\n * tracker.hasChanges(); // false\n *\n * // Or reset to entirely new data:\n * tracker.reset({ name: \"Charlie\", age: 25 });\n * ```\n */\n public reset(data?: Record<string, unknown>): void {\n const source = data ?? this.currentRaw;\n this.initialRaw = this.cloneData(source);\n this.currentRaw = this.cloneData(source);\n\n this.initialFlattened = this.flattenData(this.initialRaw);\n this.currentFlattened = this.flattenData(this.currentRaw);\n\n this.dirtyColumns.clear();\n this.removedColumns.clear();\n }\n\n /**\n * Flattens the given data object.\n * Can be overridden by subclasses to change flattening behavior.\n */\n protected flattenData(data: Record<string, unknown>): FlatRecord {\n return flatten(data);\n }\n\n /**\n * Recomputes the dirty and removed column sets by comparing initial and current snapshots.\n *\n * This method is called internally after any operation that modifies the current data.\n * It iterates through all keys in both flattened snapshots and determines which columns\n * have been modified or removed.\n *\n * @protected\n */\n protected updateDirtyState(): void {\n this.dirtyColumns.clear();\n this.removedColumns.clear();\n\n const keys = new Set([\n ...Object.keys(this.initialFlattened),\n ...Object.keys(this.currentFlattened),\n ]);\n\n for (const key of keys) {\n const hasCurrent = this.currentFlattened[key] !== undefined || key in this.currentFlattened;\n const hasInitial = this.initialFlattened[key] !== undefined || key in this.initialFlattened;\n\n if (!hasCurrent && hasInitial) {\n this.removedColumns.add(key);\n }\n\n const initialValue = this.initialFlattened[key];\n const currentValue = hasCurrent ? this.currentFlattened[key] : undefined;\n\n if (!areEqual(initialValue, currentValue)) {\n this.dirtyColumns.add(key);\n }\n }\n }\n\n /**\n * Recursively merges source object into target object, performing a deep merge.\n *\n * Only **plain** objects are merged recursively. Everything else — arrays,\n * primitives, and class instances such as `Date` / `Map` / `Set` / `RegExp` —\n * replaces the target value. All values are cloned to prevent reference sharing.\n *\n * The plain-object guard is load-bearing, not tidiness. A bare\n * `typeof value === \"object\"` also matches a `Date`, and `Object.entries(date)`\n * is `[]` — so merging a `Date` over a column that already held a `Date`\n * recursed into it, copied nothing, and left the old value in the snapshot.\n * The column then never went dirty and `save()` returned\n * `{ success: true, modifiedCount: 0 }` without issuing an `UPDATE`. This is\n * the same lesson `canBeFlatten` above already encodes.\n *\n * @param target - The object to merge into\n * @param source - The object to merge from\n * @private\n */\n protected mergeIntoRaw(target: Record<string, unknown>, source: Record<string, unknown>): void {\n for (const [key, value] of Object.entries(source)) {\n if (isPlainObject(value) && isPlainObject(target[key])) {\n this.mergeIntoRaw(target[key] as Record<string, unknown>, value as Record<string, unknown>);\n continue;\n }\n\n target[key] = this.cloneData(value);\n }\n }\n\n /**\n * Deletes a field from the current raw data using a dot-notation path.\n *\n * Supports nested paths (e.g., \"address.city\") and array indices (e.g., \"items.0\").\n * If any segment in the path doesn't exist, the operation is a no-op.\n *\n * @param path - The dot-notation path to the field to delete\n * @private\n */\n protected deleteFromRaw(path: string): void {\n const segments = path.split(\".\");\n let container: unknown = this.currentRaw;\n\n for (let index = 0; index < segments.length - 1; index += 1) {\n if (container === undefined || container === null) {\n return;\n }\n\n const segment = segments[index] as string;\n if (segment === undefined) {\n return;\n }\n\n container = this.resolveSegment(container, segment);\n }\n\n if (container === undefined || container === null) {\n return;\n }\n\n const lastSegment = segments[segments.length - 1];\n if (lastSegment === undefined) {\n return;\n }\n if (Array.isArray(container)) {\n const numericIndex = Number(lastSegment);\n if (!Number.isNaN(numericIndex)) {\n container.splice(numericIndex, 1);\n }\n return;\n }\n\n if (typeof container === \"object\") {\n delete (container as Record<string, unknown>)[lastSegment];\n }\n }\n\n /**\n * Resolves a single segment of a dot-notation path within a container.\n *\n * Handles both object property access and array index access.\n *\n * @param container - The object or array to access\n * @param segment - The property name or array index as a string\n * @returns The value at the specified segment, or undefined if not found\n * @private\n */\n protected resolveSegment(container: unknown, segment: string): unknown {\n if (Array.isArray(container)) {\n const numericIndex = Number(segment);\n if (Number.isNaN(numericIndex)) {\n return undefined;\n }\n\n return container[numericIndex];\n }\n\n if (container && typeof container === \"object\") {\n return (container as Record<string, unknown>)[segment];\n }\n\n return undefined;\n }\n\n /**\n * Creates a deep clone of the provided data.\n *\n * @param data - The data to clone\n * @returns A deep clone of the data\n * @private\n */\n protected cloneData<T>(data: T): T {\n return clone(data);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database-dirty-tracker.mjs","names":[],"sources":["../../../../../../cascade/src/database-dirty-tracker.ts"],"sourcesContent":["import { areEqual, clone } from \"@mongez/reinforcements\";\nimport { isPlainObject } from \"@mongez/supportive-is\";\nimport { pruneEmptyParents } from \"./utils/prune-empty-parents\";\n\nfunction canBeFlatten(object: unknown): boolean {\n return isPlainObject(object);\n}\n\n/**\n * A fix for flatten as non-plain object is being flatten as well which it should not be\n */\nfunction flatten(\n object: Record<string, unknown>,\n separator = \".\",\n keepNestedOriginalObject = false,\n parent?: string,\n root: Record<string, unknown> = {},\n) {\n if (canBeFlatten(object) === false) {\n return object;\n }\n // object = toPlainObject(object);\n for (const key of Object.keys(object)) {\n const value = object[key];\n const keyChain = parent ? parent + separator + key : key;\n if ((Array.isArray(value) && value.length === 0) || typeof value === \"function\") {\n root[keyChain] = value;\n } else if (canBeFlatten(value)) {\n if (keepNestedOriginalObject) {\n root[keyChain] = value;\n }\n flatten(\n value as Record<string, unknown>,\n separator,\n keepNestedOriginalObject,\n keyChain,\n root,\n );\n } else {\n root[keyChain] = value;\n }\n }\n return root;\n}\n\n/**\n * Flattened record type representing dot-notation paths mapped to their values.\n */\ntype FlatRecord = Record<string, unknown>;\n\n/**\n * Represents the old and new values of a dirty column.\n */\ntype DirtyColumnValues = { oldValue: unknown; newValue: unknown };\n\n/**\n * Tracks changes to model data by maintaining snapshots of initial and current state.\n *\n * The tracker stores both raw (nested) and flattened (dot-notation) versions of the data\n * to accurately detect modifications, additions, and removals at any nesting level.\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", age: 30 });\n * tracker.mergeChanges({ age: 31 });\n * console.log(tracker.hasChanges()); // true\n * console.log(tracker.getDirtyColumns()); // [\"age\"]\n * console.log(tracker.getDirtyColumnsWithValues());\n * // { age: { oldValue: 30, newValue: 31 } }\n * ```\n */\nexport class DatabaseDirtyTracker {\n /**\n * The initial raw data snapshot taken at construction or last reset.\n * Used as the baseline for comparison.\n */\n protected initialRaw: Record<string, unknown>;\n\n /**\n * The current raw data snapshot reflecting all changes made via merge/unset.\n */\n protected currentRaw: Record<string, unknown>;\n\n /**\n * Flattened version of the initial data using dot-notation keys.\n * Example: { \"address.city\": \"NYC\" }\n */\n protected initialFlattened: FlatRecord;\n\n /**\n * Flattened version of the current data using dot-notation keys.\n */\n protected currentFlattened: FlatRecord;\n\n /**\n * Set of column names (dot-notation paths) that have been modified.\n */\n protected readonly dirtyColumns = new Set<string>();\n\n /**\n * Set of column names (dot-notation paths) that existed initially but have been removed.\n */\n protected readonly removedColumns = new Set<string>();\n\n /**\n * Top-level fields that were assigned or unset as a whole (`set(\"nested\", obj)`,\n * `unset(\"nested\")`) since the last reset. Dotted-path writes\n * (`set(\"nested.a\", v)`) and `mergeChanges` are partial by intent and never\n * land here. See {@link getReplacedColumns}.\n */\n protected readonly replacedRoots = new Set<string>();\n\n public constructor(data: Record<string, unknown>) {\n this.initialRaw = this.cloneData(data);\n this.currentRaw = this.cloneData(data);\n\n this.initialFlattened = this.flattenData(this.initialRaw);\n this.currentFlattened = { ...this.initialFlattened };\n\n this.updateDirtyState();\n }\n\n /**\n * Returns the list of dirty columns using dot-notation.\n *\n * A column is considered dirty if its value has changed compared to the initial snapshot.\n *\n * @returns An array of column names (dot-notation paths) that have been modified\n *\n * @example\n * ```typescript\n * tracker.mergeChanges({ name: \"Bob\", \"address.city\": \"LA\" });\n * tracker.getDirtyColumns(); // [\"name\", \"address.city\"]\n * ```\n */\n public getDirtyColumns(): string[] {\n return Array.from(this.dirtyColumns);\n }\n\n /**\n * Removed columns collapsed to the highest path that no longer exists.\n *\n * `getRemovedColumns()` reports leaves (`\"a.b.c\"`). Unsetting just that leaf\n * would keep `a` and `a.b` in the stored document as empty objects whenever\n * the unset emptied them (and `unset()` prunes those parents). This returns\n * the path to actually `$unset`: the shortest prefix that is gone from the\n * current data, which is the leaf itself when its parents still hold other\n * keys.\n *\n * @returns Distinct paths to remove\n */\n public getRemovedPaths(): string[] {\n const paths = new Set<string>();\n\n for (const column of this.removedColumns) {\n const segments = column.split(\".\");\n let container: unknown = this.currentRaw;\n let path = column;\n\n for (let index = 0; index < segments.length; index += 1) {\n const segment = segments[index] as string;\n\n if (\n container === null ||\n typeof container !== \"object\" ||\n !(segment in (container as Record<string, unknown>))\n ) {\n path = segments.slice(0, index + 1).join(\".\");\n break;\n }\n\n container = (container as Record<string, unknown>)[segment];\n }\n\n paths.add(path);\n }\n\n return Array.from(paths);\n }\n\n /**\n * Top-level fields whose nested object must be written as a WHOLE value.\n *\n * Per-leaf diffing cannot express \"this object is now exactly this\": it only\n * reports the leaves that changed or vanished, so a key removed from a nested\n * object leaves its (now empty) parents behind in the stored document. A field\n * that was assigned or unset as a whole therefore reports here, and the writer\n * sends one `$set` (or `$unset`) for it instead of per-leaf operations.\n *\n * A field is reported only when it was replaced via `set(\"field\", value)` /\n * `unset(\"field\")`, either its old or new value is a plain object, and the\n * value actually differs from the baseline. Dotted-path writes\n * (`set(\"field.a\", v)`) and `mergeChanges` stay per-leaf, so partial updates\n * keep working. Scalars and arrays are already whole-value leaves in the diff.\n *\n * @returns Top-level column names to write as a whole\n */\n public getReplacedColumns(): string[] {\n const columns: string[] = [];\n\n for (const root of this.replacedRoots) {\n const initial = this.initialRaw[root];\n const current = this.currentRaw[root];\n\n if (!isPlainObject(initial) && !isPlainObject(current)) {\n continue;\n }\n\n if (areEqual(initial, current)) {\n continue;\n }\n\n columns.push(root);\n }\n\n return columns;\n }\n\n /**\n * Determines whether there are any tracked changes.\n *\n * Returns `true` if any columns have been modified or removed since the initial snapshot.\n *\n * @returns `true` if there are changes, `false` otherwise\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.hasChanges(); // false\n * tracker.mergeChanges({ name: \"Bob\" });\n * tracker.hasChanges(); // true\n * tracker.unset(\"name\");\n * tracker.hasChanges(); // true (removed column counts as a change)\n * ```\n */\n public hasChanges(): boolean {\n return this.dirtyColumns.size > 0 || this.removedColumns.size > 0;\n }\n\n /**\n * Check if the given column is dirty (changed)\n */\n public isDirty(column: string): boolean {\n return this.dirtyColumns.has(column);\n }\n\n /**\n * Returns the set of columns that have been removed compared to the baseline.\n *\n * A column is considered removed if it existed in the initial snapshot but has been\n * explicitly unset or deleted from the current data.\n *\n * @returns An array of column names (dot-notation paths) that have been removed\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", temp: \"value\" });\n * tracker.unset(\"temp\");\n * tracker.getRemovedColumns(); // [\"temp\"]\n * ```\n */\n public getRemovedColumns(): string[] {\n return Array.from(this.removedColumns);\n }\n\n /**\n * Provides a mapping of dirty columns to their previous and current values.\n *\n * This is useful for generating audit logs, building partial update payloads,\n * or displaying change summaries to users.\n *\n * @returns A record mapping each dirty column to an object containing oldValue and newValue\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", age: 30 });\n * tracker.mergeChanges({ age: 31 });\n * tracker.getDirtyColumnsWithValues();\n * // { age: { oldValue: 30, newValue: 31 } }\n * ```\n */\n public getDirtyColumnsWithValues(): Record<string, DirtyColumnValues> {\n const result: Record<string, DirtyColumnValues> = {};\n\n for (const column of this.dirtyColumns) {\n const hasCurrent =\n this.currentFlattened[column] !== undefined || column in this.currentFlattened;\n\n result[column] = {\n oldValue: this.initialFlattened[column],\n newValue: hasCurrent ? this.currentFlattened[column] : undefined,\n };\n }\n\n return result;\n }\n\n /**\n * Replaces the current data snapshot entirely and recomputes the diff.\n *\n * This is useful when you want to replace all current data with a new set,\n * while keeping the initial baseline for comparison.\n *\n * @param data - The new data to set as the current snapshot\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.replaceCurrentData({ name: \"Bob\", email: \"bob@example.com\" });\n * tracker.getDirtyColumns(); // [\"name\", \"email\"]\n * ```\n */\n public replaceCurrentData(data: Record<string, unknown>): void {\n this.currentRaw = this.cloneData(data);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Merges a partial payload into the current snapshot and recomputes the diff.\n *\n * This performs a deep merge, preserving existing nested structures while\n * updating only the specified fields.\n *\n * @param partial - Partial data to merge into the current snapshot\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\", address: { city: \"NYC\" } });\n * tracker.mergeChanges({ address: { zip: \"10001\" } });\n * // Current data: { name: \"Alice\", address: { city: \"NYC\", zip: \"10001\" } }\n * tracker.getDirtyColumns(); // [\"address.zip\"]\n * ```\n */\n public mergeChanges(partial: Record<string, unknown>): void {\n this.mergeIntoRaw(this.currentRaw, partial);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Replaces the value at a dot-notation path (no deep merge), mirroring\n * `model.set()` so stale nested keys are detected as removed.\n */\n public setAtPath(path: string, value: unknown): void {\n const segments = path.split(\".\");\n let container = this.currentRaw as Record<string, unknown>;\n\n if (segments.length === 1) {\n this.replacedRoots.add(path);\n }\n\n for (let index = 0; index < segments.length - 1; index += 1) {\n const segment = segments[index] as string;\n\n if (!isPlainObject(container[segment]) && !Array.isArray(container[segment])) {\n container[segment] = {};\n }\n\n container = container[segment] as Record<string, unknown>;\n }\n\n container[segments[segments.length - 1] as string] = this.cloneData(value);\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Explicitly removes one or more columns from the current data.\n *\n * Supports both single column names and arrays of column names.\n * Columns can be specified using dot-notation for nested paths.\n *\n * @param columns - A single column name or an array of column names to remove\n *\n * @example\n * ```typescript\n * tracker.unset(\"tempField\");\n * tracker.unset([\"field1\", \"field2\", \"nested.field\"]);\n * tracker.getRemovedColumns(); // [\"tempField\", \"field1\", \"field2\", \"nested.field\"]\n * ```\n */\n public unset(columns: string | string[]): void {\n const targets = Array.isArray(columns) ? columns : [columns];\n\n for (const path of targets) {\n if (!path.includes(\".\")) {\n this.replacedRoots.add(path);\n }\n\n this.deleteFromRaw(path);\n }\n\n this.currentFlattened = this.flattenData(this.currentRaw);\n this.updateDirtyState();\n }\n\n /**\n * Resets both the initial and current snapshots to the provided data.\n *\n * If no data is provided, the current snapshot becomes the new baseline.\n * This clears all tracked changes and removed columns.\n *\n * @param data - Optional new data to use as the baseline. If omitted, uses current data.\n *\n * @example\n * ```typescript\n * const tracker = new DatabaseDirtyTracker({ name: \"Alice\" });\n * tracker.mergeChanges({ name: \"Bob\" });\n * tracker.hasChanges(); // true\n * tracker.reset(); // Make current state the new baseline\n * tracker.hasChanges(); // false\n *\n * // Or reset to entirely new data:\n * tracker.reset({ name: \"Charlie\", age: 25 });\n * ```\n */\n public reset(data?: Record<string, unknown>): void {\n const source = data ?? this.currentRaw;\n this.initialRaw = this.cloneData(source);\n this.currentRaw = this.cloneData(source);\n\n this.initialFlattened = this.flattenData(this.initialRaw);\n this.currentFlattened = this.flattenData(this.currentRaw);\n\n this.dirtyColumns.clear();\n this.removedColumns.clear();\n this.replacedRoots.clear();\n }\n\n /**\n * Flattens the given data object.\n * Can be overridden by subclasses to change flattening behavior.\n */\n protected flattenData(data: Record<string, unknown>): FlatRecord {\n return flatten(data);\n }\n\n /**\n * Recomputes the dirty and removed column sets by comparing initial and current snapshots.\n *\n * This method is called internally after any operation that modifies the current data.\n * It iterates through all keys in both flattened snapshots and determines which columns\n * have been modified or removed.\n *\n * @protected\n */\n protected updateDirtyState(): void {\n this.dirtyColumns.clear();\n this.removedColumns.clear();\n\n const keys = new Set([\n ...Object.keys(this.initialFlattened),\n ...Object.keys(this.currentFlattened),\n ]);\n\n for (const key of keys) {\n const hasCurrent = this.currentFlattened[key] !== undefined || key in this.currentFlattened;\n const hasInitial = this.initialFlattened[key] !== undefined || key in this.initialFlattened;\n\n if (!hasCurrent && hasInitial) {\n this.removedColumns.add(key);\n }\n\n const initialValue = this.initialFlattened[key];\n const currentValue = hasCurrent ? this.currentFlattened[key] : undefined;\n\n if (!areEqual(initialValue, currentValue)) {\n this.dirtyColumns.add(key);\n }\n }\n }\n\n /**\n * Recursively merges source object into target object, performing a deep merge.\n *\n * Only **plain** objects are merged recursively. Everything else — arrays,\n * primitives, and class instances such as `Date` / `Map` / `Set` / `RegExp` —\n * replaces the target value. All values are cloned to prevent reference sharing.\n *\n * The plain-object guard is load-bearing, not tidiness. A bare\n * `typeof value === \"object\"` also matches a `Date`, and `Object.entries(date)`\n * is `[]` — so merging a `Date` over a column that already held a `Date`\n * recursed into it, copied nothing, and left the old value in the snapshot.\n * The column then never went dirty and `save()` returned\n * `{ success: true, modifiedCount: 0 }` without issuing an `UPDATE`. This is\n * the same lesson `canBeFlatten` above already encodes.\n *\n * @param target - The object to merge into\n * @param source - The object to merge from\n * @private\n */\n protected mergeIntoRaw(target: Record<string, unknown>, source: Record<string, unknown>): void {\n for (const [key, value] of Object.entries(source)) {\n if (isPlainObject(value) && isPlainObject(target[key])) {\n this.mergeIntoRaw(target[key] as Record<string, unknown>, value as Record<string, unknown>);\n continue;\n }\n\n target[key] = this.cloneData(value);\n }\n }\n\n /**\n * Deletes a field from the current raw data using a dot-notation path.\n *\n * Supports nested paths (e.g., \"address.city\") and array indices (e.g., \"items.0\").\n * If any segment in the path doesn't exist, the operation is a no-op.\n *\n * @param path - The dot-notation path to the field to delete\n * @private\n */\n protected deleteFromRaw(path: string): void {\n const segments = path.split(\".\");\n let container: unknown = this.currentRaw;\n\n for (let index = 0; index < segments.length - 1; index += 1) {\n if (container === undefined || container === null) {\n return;\n }\n\n const segment = segments[index] as string;\n if (segment === undefined) {\n return;\n }\n\n container = this.resolveSegment(container, segment);\n }\n\n if (container === undefined || container === null) {\n return;\n }\n\n const lastSegment = segments[segments.length - 1];\n if (lastSegment === undefined) {\n return;\n }\n if (Array.isArray(container)) {\n const numericIndex = Number(lastSegment);\n if (!Number.isNaN(numericIndex)) {\n container.splice(numericIndex, 1);\n }\n return;\n }\n\n if (typeof container === \"object\") {\n const existed = lastSegment in (container as Record<string, unknown>);\n\n delete (container as Record<string, unknown>)[lastSegment];\n\n // Drop parents this unset just emptied; a parent that was already `{}` is\n // left alone because nothing was removed from it.\n if (existed) {\n pruneEmptyParents(this.currentRaw, path);\n }\n }\n }\n\n /**\n * Resolves a single segment of a dot-notation path within a container.\n *\n * Handles both object property access and array index access.\n *\n * @param container - The object or array to access\n * @param segment - The property name or array index as a string\n * @returns The value at the specified segment, or undefined if not found\n * @private\n */\n protected resolveSegment(container: unknown, segment: string): unknown {\n if (Array.isArray(container)) {\n const numericIndex = Number(segment);\n if (Number.isNaN(numericIndex)) {\n return undefined;\n }\n\n return container[numericIndex];\n }\n\n if (container && typeof container === \"object\") {\n return (container as Record<string, unknown>)[segment];\n }\n\n return undefined;\n }\n\n /**\n * Creates a deep clone of the provided data.\n *\n * @param data - The data to clone\n * @returns A deep clone of the data\n * @private\n */\n protected cloneData<T>(data: T): T {\n return clone(data);\n }\n}\n"],"mappings":";;;;;AAIA,SAAS,aAAa,QAA0B;CAC9C,OAAO,cAAc,MAAM;AAC7B;;;;AAKA,SAAS,QACP,QACA,YAAY,KACZ,2BAA2B,OAC3B,QACA,OAAgC,CAAC,GACjC;CACA,IAAI,aAAa,MAAM,MAAM,OAC3B,OAAO;CAGT,KAAK,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG;EACrC,MAAM,QAAQ,OAAO;EACrB,MAAM,WAAW,SAAS,SAAS,YAAY,MAAM;EACrD,IAAK,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,KAAM,OAAO,UAAU,YACnE,KAAK,YAAY;OACZ,IAAI,aAAa,KAAK,GAAG;GAC9B,IAAI,0BACF,KAAK,YAAY;GAEnB,QACE,OACA,WACA,0BACA,UACA,IACF;EACF,OACE,KAAK,YAAY;CAErB;CACA,OAAO;AACT;;;;;;;;;;;;;;;;;AA4BA,IAAa,uBAAb,MAAkC;;;;;CAKhC,AAAU;;;;CAKV,AAAU;;;;;CAMV,AAAU;;;;CAKV,AAAU;;;;CAKV,AAAmB,+BAAe,IAAI,IAAY;;;;CAKlD,AAAmB,iCAAiB,IAAI,IAAY;;;;;;;CAQpD,AAAmB,gCAAgB,IAAI,IAAY;CAEnD,AAAO,YAAY,MAA+B;EAChD,KAAK,aAAa,KAAK,UAAU,IAAI;EACrC,KAAK,aAAa,KAAK,UAAU,IAAI;EAErC,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,mBAAmB,EAAE,GAAG,KAAK,iBAAiB;EAEnD,KAAK,iBAAiB;CACxB;;;;;;;;;;;;;;CAeA,AAAO,kBAA4B;EACjC,OAAO,MAAM,KAAK,KAAK,YAAY;CACrC;;;;;;;;;;;;;CAcA,AAAO,kBAA4B;EACjC,MAAM,wBAAQ,IAAI,IAAY;EAE9B,KAAK,MAAM,UAAU,KAAK,gBAAgB;GACxC,MAAM,WAAW,OAAO,MAAM,GAAG;GACjC,IAAI,YAAqB,KAAK;GAC9B,IAAI,OAAO;GAEX,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,QAAQ,SAAS,GAAG;IACvD,MAAM,UAAU,SAAS;IAEzB,IACE,cAAc,QACd,OAAO,cAAc,YACrB,EAAE,WAAY,YACd;KACA,OAAO,SAAS,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,GAAG;KAC5C;IACF;IAEA,YAAa,UAAsC;GACrD;GAEA,MAAM,IAAI,IAAI;EAChB;EAEA,OAAO,MAAM,KAAK,KAAK;CACzB;;;;;;;;;;;;;;;;;;CAmBA,AAAO,qBAA+B;EACpC,MAAM,UAAoB,CAAC;EAE3B,KAAK,MAAM,QAAQ,KAAK,eAAe;GACrC,MAAM,UAAU,KAAK,WAAW;GAChC,MAAM,UAAU,KAAK,WAAW;GAEhC,IAAI,CAAC,cAAc,OAAO,KAAK,CAAC,cAAc,OAAO,GACnD;GAGF,IAAI,SAAS,SAAS,OAAO,GAC3B;GAGF,QAAQ,KAAK,IAAI;EACnB;EAEA,OAAO;CACT;;;;;;;;;;;;;;;;;;CAmBA,AAAO,aAAsB;EAC3B,OAAO,KAAK,aAAa,OAAO,KAAK,KAAK,eAAe,OAAO;CAClE;;;;CAKA,AAAO,QAAQ,QAAyB;EACtC,OAAO,KAAK,aAAa,IAAI,MAAM;CACrC;;;;;;;;;;;;;;;;CAiBA,AAAO,oBAA8B;EACnC,OAAO,MAAM,KAAK,KAAK,cAAc;CACvC;;;;;;;;;;;;;;;;;CAkBA,AAAO,4BAA+D;EACpE,MAAM,SAA4C,CAAC;EAEnD,KAAK,MAAM,UAAU,KAAK,cAAc;GACtC,MAAM,aACJ,KAAK,iBAAiB,YAAY,UAAa,UAAU,KAAK;GAEhE,OAAO,UAAU;IACf,UAAU,KAAK,iBAAiB;IAChC,UAAU,aAAa,KAAK,iBAAiB,UAAU;GACzD;EACF;EAEA,OAAO;CACT;;;;;;;;;;;;;;;;CAiBA,AAAO,mBAAmB,MAAqC;EAC7D,KAAK,aAAa,KAAK,UAAU,IAAI;EACrC,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,iBAAiB;CACxB;;;;;;;;;;;;;;;;;CAkBA,AAAO,aAAa,SAAwC;EAC1D,KAAK,aAAa,KAAK,YAAY,OAAO;EAC1C,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,iBAAiB;CACxB;;;;;CAMA,AAAO,UAAU,MAAc,OAAsB;EACnD,MAAM,WAAW,KAAK,MAAM,GAAG;EAC/B,IAAI,YAAY,KAAK;EAErB,IAAI,SAAS,WAAW,GACtB,KAAK,cAAc,IAAI,IAAI;EAG7B,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,SAAS,GAAG,SAAS,GAAG;GAC3D,MAAM,UAAU,SAAS;GAEzB,IAAI,CAAC,cAAc,UAAU,QAAQ,KAAK,CAAC,MAAM,QAAQ,UAAU,QAAQ,GACzE,UAAU,WAAW,CAAC;GAGxB,YAAY,UAAU;EACxB;EAEA,UAAU,SAAS,SAAS,SAAS,MAAgB,KAAK,UAAU,KAAK;EACzE,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,iBAAiB;CACxB;;;;;;;;;;;;;;;;CAiBA,AAAO,MAAM,SAAkC;EAC7C,MAAM,UAAU,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO;EAE3D,KAAK,MAAM,QAAQ,SAAS;GAC1B,IAAI,CAAC,KAAK,SAAS,GAAG,GACpB,KAAK,cAAc,IAAI,IAAI;GAG7B,KAAK,cAAc,IAAI;EACzB;EAEA,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,iBAAiB;CACxB;;;;;;;;;;;;;;;;;;;;;CAsBA,AAAO,MAAM,MAAsC;EACjD,MAAM,SAAS,QAAQ,KAAK;EAC5B,KAAK,aAAa,KAAK,UAAU,MAAM;EACvC,KAAK,aAAa,KAAK,UAAU,MAAM;EAEvC,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EACxD,KAAK,mBAAmB,KAAK,YAAY,KAAK,UAAU;EAExD,KAAK,aAAa,MAAM;EACxB,KAAK,eAAe,MAAM;EAC1B,KAAK,cAAc,MAAM;CAC3B;;;;;CAMA,AAAU,YAAY,MAA2C;EAC/D,OAAO,QAAQ,IAAI;CACrB;;;;;;;;;;CAWA,AAAU,mBAAyB;EACjC,KAAK,aAAa,MAAM;EACxB,KAAK,eAAe,MAAM;EAE1B,MAAM,OAAO,IAAI,IAAI,CACnB,GAAG,OAAO,KAAK,KAAK,gBAAgB,GACpC,GAAG,OAAO,KAAK,KAAK,gBAAgB,CACtC,CAAC;EAED,KAAK,MAAM,OAAO,MAAM;GACtB,MAAM,aAAa,KAAK,iBAAiB,SAAS,UAAa,OAAO,KAAK;GAC3E,MAAM,aAAa,KAAK,iBAAiB,SAAS,UAAa,OAAO,KAAK;GAE3E,IAAI,CAAC,cAAc,YACjB,KAAK,eAAe,IAAI,GAAG;GAG7B,MAAM,eAAe,KAAK,iBAAiB;GAG3C,IAAI,CAAC,SAAS,cAFO,aAAa,KAAK,iBAAiB,OAAO,MAEvB,GACtC,KAAK,aAAa,IAAI,GAAG;EAE7B;CACF;;;;;;;;;;;;;;;;;;;;CAqBA,AAAU,aAAa,QAAiC,QAAuC;EAC7F,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG;GACjD,IAAI,cAAc,KAAK,KAAK,cAAc,OAAO,IAAI,GAAG;IACtD,KAAK,aAAa,OAAO,MAAiC,KAAgC;IAC1F;GACF;GAEA,OAAO,OAAO,KAAK,UAAU,KAAK;EACpC;CACF;;;;;;;;;;CAWA,AAAU,cAAc,MAAoB;EAC1C,MAAM,WAAW,KAAK,MAAM,GAAG;EAC/B,IAAI,YAAqB,KAAK;EAE9B,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,SAAS,GAAG,SAAS,GAAG;GAC3D,IAAI,cAAc,UAAa,cAAc,MAC3C;GAGF,MAAM,UAAU,SAAS;GACzB,IAAI,YAAY,QACd;GAGF,YAAY,KAAK,eAAe,WAAW,OAAO;EACpD;EAEA,IAAI,cAAc,UAAa,cAAc,MAC3C;EAGF,MAAM,cAAc,SAAS,SAAS,SAAS;EAC/C,IAAI,gBAAgB,QAClB;EAEF,IAAI,MAAM,QAAQ,SAAS,GAAG;GAC5B,MAAM,eAAe,OAAO,WAAW;GACvC,IAAI,CAAC,OAAO,MAAM,YAAY,GAC5B,UAAU,OAAO,cAAc,CAAC;GAElC;EACF;EAEA,IAAI,OAAO,cAAc,UAAU;GACjC,MAAM,UAAU,eAAgB;GAEhC,OAAQ,UAAsC;GAI9C,IAAI,SACF,kBAAkB,KAAK,YAAY,IAAI;EAE3C;CACF;;;;;;;;;;;CAYA,AAAU,eAAe,WAAoB,SAA0B;EACrE,IAAI,MAAM,QAAQ,SAAS,GAAG;GAC5B,MAAM,eAAe,OAAO,OAAO;GACnC,IAAI,OAAO,MAAM,YAAY,GAC3B;GAGF,OAAO,UAAU;EACnB;EAEA,IAAI,aAAa,OAAO,cAAc,UACpC,OAAQ,UAAsC;CAIlD;;;;;;;;CASA,AAAU,UAAa,MAAY;EACjC,OAAO,MAAM,IAAI;CACnB;AACF"}
@@ -1,3 +1,4 @@
1
+ import { pruneEmptyParents } from "../../utils/prune-empty-parents.mjs";
1
2
  import { get, merge, only, set, unset } from "@mongez/reinforcements";
2
3
 
3
4
  //#region ../cascade/src/model/methods/accessor-methods.ts
@@ -24,8 +25,18 @@ function incrementField(model, field, amount) {
24
25
  function decrementField(model, field, amount) {
25
26
  return setFieldValue(model, field, getFieldValue(model, field, 0) - (amount ?? 1));
26
27
  }
28
+ /**
29
+ * Remove fields from the model data.
30
+ *
31
+ * Unsetting the last key of a nested object also removes the parents that this
32
+ * unset left empty (`unset("a.b")` on `{ a: { b: 1 } }` leaves `{}`, not
33
+ * `{ a: {} }`). A parent is pruned only when this unset emptied it: an object
34
+ * the user stored as `{}` is never touched, and arrays are never pruned.
35
+ */
27
36
  function unsetFields(model, ...fields) {
37
+ const existing = fields.filter((field) => hasField(model, field));
28
38
  model.data = unset(model.data, fields);
39
+ for (const field of existing) pruneEmptyParents(model.data, field);
29
40
  model.dirtyTracker.unset(fields);
30
41
  return model;
31
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"accessor-methods.mjs","names":[],"sources":["../../../../../../../../cascade/src/model/methods/accessor-methods.ts"],"sourcesContent":["import { get, merge, only, set, unset } from \"@mongez/reinforcements\";\nimport type { Model } from \"../model\";\n\n/**\n * Sentinel symbol to distinguish a genuinely missing field from a field\n * whose value is `undefined`. Encapsulated here — callers use `hasField()`.\n */\nconst MISSING_VALUE = Symbol(\"missing\");\n\nexport function getFieldValue(model: Model, field: string, defaultValue?: unknown): any {\n return get(model.data, field, defaultValue);\n}\n\nexport function setFieldValue(model: Model, field: string, value: unknown): Model {\n const path = String(field);\n set(model.data, path, value);\n\n model.dirtyTracker.setAtPath(path, value);\n\n return model;\n}\n\nexport function hasField(model: Model, field: string): boolean {\n return get(model.data, field, MISSING_VALUE as any) !== MISSING_VALUE;\n}\n\nexport function incrementField(model: Model, field: string, amount?: number): Model {\n const value = getFieldValue(model, field, 0) as number;\n const incrementedValue = value + (amount ?? 1);\n return setFieldValue(model, field, incrementedValue);\n}\n\nexport function decrementField(model: Model, field: string, amount?: number): Model {\n const value = getFieldValue(model, field, 0) as number;\n const decrementedValue = value - (amount ?? 1);\n return setFieldValue(model, field, decrementedValue);\n}\n\nexport function unsetFields(model: Model, ...fields: string[]): Model {\n model.data = unset(model.data, fields);\n model.dirtyTracker.unset(fields);\n\n return model;\n}\n\n/**\n * Identity columns that a mass-assignment payload may never carry into an\n * already-persisted model. `merge()` is the method request bodies reach\n * (`model.merge(req.body)` / `save({ merge })`), and the primary key is what\n * the UPDATE filter is built from — letting it through means a payload of\n * `{ id: \"<victim-id>\" }` rewrites which document the save targets. Both the\n * configured primary key and the two framework identity columns are dropped,\n * because `id` and `_id` are managed by the writer/driver either way.\n *\n * A NEW model is untouched: creating with an explicit id is a legitimate flow,\n * and the writer itself merges the driver-returned document (`_id`, defaults)\n * back onto the instance before it is marked persisted.\n */\nfunction stripIdentityColumns(\n model: Model,\n values: Record<string, unknown>,\n): Record<string, unknown> {\n if (model.isNew) return values;\n\n const identityColumns = new Set([model.getPrimaryKey(), \"id\", \"_id\"]);\n const blockedKeys = Object.keys(values).filter(key => identityColumns.has(key));\n\n if (blockedKeys.length === 0) return values;\n\n // Copy — the caller's object (often the request body itself) is not ours to mutate.\n const safeValues = { ...values };\n for (const key of blockedKeys) {\n delete safeValues[key];\n }\n\n return safeValues;\n}\n\nexport function mergeFields(model: Model, values: Record<string, unknown>): Model {\n return mergeDriverFields(model, stripIdentityColumns(model, values));\n}\n\n/**\n * Merge a document the DRIVER produced (generated `_id`, `RETURNING *`, DB\n * defaults) back onto the model, identity columns included.\n *\n * Framework-internal counterpart of {@link mergeFields}: the write pipeline is\n * the one caller allowed to set identity columns on an already-persisted\n * instance, because the values come from the database rather than from a\n * request. Never route caller-supplied data through this.\n */\nexport function mergeDriverFields(model: Model, values: Record<string, unknown>): Model {\n model.data = merge(model.data, values) as any;\n model.dirtyTracker.mergeChanges(values);\n return model;\n}\n\nexport function getOnlyFields(model: Model, fields: string[]): Record<string, unknown> {\n return only(model.data, fields);\n}\n\nexport function getStringField(model: Model, key: string, defaultValue?: string): string | undefined {\n return getFieldValue(model, key, defaultValue) as string | undefined;\n}\n\nexport function getNumberField(model: Model, key: string, defaultValue?: number): number | undefined {\n return getFieldValue(model, key, defaultValue) as number | undefined;\n}\n\nexport function getBooleanField(model: Model, key: string, defaultValue?: boolean): boolean | undefined {\n return getFieldValue(model, key, defaultValue) as boolean | undefined;\n}\n"],"mappings":";;;;;;;AAOA,MAAM,gBAAgB,OAAO,SAAS;AAEtC,SAAgB,cAAc,OAAc,OAAe,cAA6B;CACtF,OAAO,IAAI,MAAM,MAAM,OAAO,YAAY;AAC5C;AAEA,SAAgB,cAAc,OAAc,OAAe,OAAuB;CAChF,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,MAAM,MAAM,MAAM,KAAK;CAE3B,MAAM,aAAa,UAAU,MAAM,KAAK;CAExC,OAAO;AACT;AAEA,SAAgB,SAAS,OAAc,OAAwB;CAC7D,OAAO,IAAI,MAAM,MAAM,OAAO,aAAoB,MAAM;AAC1D;AAEA,SAAgB,eAAe,OAAc,OAAe,QAAwB;CAGlF,OAAO,cAAc,OAAO,OAFd,cAAc,OAAO,OAAO,CACb,KAAK,UAAU,EACO;AACrD;AAEA,SAAgB,eAAe,OAAc,OAAe,QAAwB;CAGlF,OAAO,cAAc,OAAO,OAFd,cAAc,OAAO,OAAO,CACb,KAAK,UAAU,EACO;AACrD;AAEA,SAAgB,YAAY,OAAc,GAAG,QAAyB;CACpE,MAAM,OAAO,MAAM,MAAM,MAAM,MAAM;CACrC,MAAM,aAAa,MAAM,MAAM;CAE/B,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAS,qBACP,OACA,QACyB;CACzB,IAAI,MAAM,OAAO,OAAO;CAExB,MAAM,kBAAkB,IAAI,IAAI;EAAC,MAAM,cAAc;EAAG;EAAM;CAAK,CAAC;CACpE,MAAM,cAAc,OAAO,KAAK,MAAM,CAAC,CAAC,QAAO,QAAO,gBAAgB,IAAI,GAAG,CAAC;CAE9E,IAAI,YAAY,WAAW,GAAG,OAAO;CAGrC,MAAM,aAAa,EAAE,GAAG,OAAO;CAC/B,KAAK,MAAM,OAAO,aAChB,OAAO,WAAW;CAGpB,OAAO;AACT;AAEA,SAAgB,YAAY,OAAc,QAAwC;CAChF,OAAO,kBAAkB,OAAO,qBAAqB,OAAO,MAAM,CAAC;AACrE;;;;;;;;;;AAWA,SAAgB,kBAAkB,OAAc,QAAwC;CACtF,MAAM,OAAO,MAAM,MAAM,MAAM,MAAM;CACrC,MAAM,aAAa,aAAa,MAAM;CACtC,OAAO;AACT;AAEA,SAAgB,cAAc,OAAc,QAA2C;CACrF,OAAO,KAAK,MAAM,MAAM,MAAM;AAChC;AAEA,SAAgB,eAAe,OAAc,KAAa,cAA2C;CACnG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C;AAEA,SAAgB,eAAe,OAAc,KAAa,cAA2C;CACnG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C;AAEA,SAAgB,gBAAgB,OAAc,KAAa,cAA6C;CACtG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C"}
1
+ {"version":3,"file":"accessor-methods.mjs","names":[],"sources":["../../../../../../../../cascade/src/model/methods/accessor-methods.ts"],"sourcesContent":["import { get, merge, only, set, unset } from \"@mongez/reinforcements\";\nimport { pruneEmptyParents } from \"../../utils/prune-empty-parents\";\nimport type { Model } from \"../model\";\n\n/**\n * Sentinel symbol to distinguish a genuinely missing field from a field\n * whose value is `undefined`. Encapsulated here — callers use `hasField()`.\n */\nconst MISSING_VALUE = Symbol(\"missing\");\n\nexport function getFieldValue(model: Model, field: string, defaultValue?: unknown): any {\n return get(model.data, field, defaultValue);\n}\n\nexport function setFieldValue(model: Model, field: string, value: unknown): Model {\n const path = String(field);\n set(model.data, path, value);\n\n model.dirtyTracker.setAtPath(path, value);\n\n return model;\n}\n\nexport function hasField(model: Model, field: string): boolean {\n return get(model.data, field, MISSING_VALUE as any) !== MISSING_VALUE;\n}\n\nexport function incrementField(model: Model, field: string, amount?: number): Model {\n const value = getFieldValue(model, field, 0) as number;\n const incrementedValue = value + (amount ?? 1);\n return setFieldValue(model, field, incrementedValue);\n}\n\nexport function decrementField(model: Model, field: string, amount?: number): Model {\n const value = getFieldValue(model, field, 0) as number;\n const decrementedValue = value - (amount ?? 1);\n return setFieldValue(model, field, decrementedValue);\n}\n\n/**\n * Remove fields from the model data.\n *\n * Unsetting the last key of a nested object also removes the parents that this\n * unset left empty (`unset(\"a.b\")` on `{ a: { b: 1 } }` leaves `{}`, not\n * `{ a: {} }`). A parent is pruned only when this unset emptied it: an object\n * the user stored as `{}` is never touched, and arrays are never pruned.\n */\nexport function unsetFields(model: Model, ...fields: string[]): Model {\n const existing = fields.filter(field => hasField(model, field));\n\n model.data = unset(model.data, fields);\n\n for (const field of existing) {\n pruneEmptyParents(model.data, field);\n }\n\n model.dirtyTracker.unset(fields);\n\n return model;\n}\n\n/**\n * Identity columns that a mass-assignment payload may never carry into an\n * already-persisted model. `merge()` is the method request bodies reach\n * (`model.merge(req.body)` / `save({ merge })`), and the primary key is what\n * the UPDATE filter is built from — letting it through means a payload of\n * `{ id: \"<victim-id>\" }` rewrites which document the save targets. Both the\n * configured primary key and the two framework identity columns are dropped,\n * because `id` and `_id` are managed by the writer/driver either way.\n *\n * A NEW model is untouched: creating with an explicit id is a legitimate flow,\n * and the writer itself merges the driver-returned document (`_id`, defaults)\n * back onto the instance before it is marked persisted.\n */\nfunction stripIdentityColumns(\n model: Model,\n values: Record<string, unknown>,\n): Record<string, unknown> {\n if (model.isNew) return values;\n\n const identityColumns = new Set([model.getPrimaryKey(), \"id\", \"_id\"]);\n const blockedKeys = Object.keys(values).filter(key => identityColumns.has(key));\n\n if (blockedKeys.length === 0) return values;\n\n // Copy — the caller's object (often the request body itself) is not ours to mutate.\n const safeValues = { ...values };\n for (const key of blockedKeys) {\n delete safeValues[key];\n }\n\n return safeValues;\n}\n\nexport function mergeFields(model: Model, values: Record<string, unknown>): Model {\n return mergeDriverFields(model, stripIdentityColumns(model, values));\n}\n\n/**\n * Merge a document the DRIVER produced (generated `_id`, `RETURNING *`, DB\n * defaults) back onto the model, identity columns included.\n *\n * Framework-internal counterpart of {@link mergeFields}: the write pipeline is\n * the one caller allowed to set identity columns on an already-persisted\n * instance, because the values come from the database rather than from a\n * request. Never route caller-supplied data through this.\n */\nexport function mergeDriverFields(model: Model, values: Record<string, unknown>): Model {\n model.data = merge(model.data, values) as any;\n model.dirtyTracker.mergeChanges(values);\n return model;\n}\n\nexport function getOnlyFields(model: Model, fields: string[]): Record<string, unknown> {\n return only(model.data, fields);\n}\n\nexport function getStringField(model: Model, key: string, defaultValue?: string): string | undefined {\n return getFieldValue(model, key, defaultValue) as string | undefined;\n}\n\nexport function getNumberField(model: Model, key: string, defaultValue?: number): number | undefined {\n return getFieldValue(model, key, defaultValue) as number | undefined;\n}\n\nexport function getBooleanField(model: Model, key: string, defaultValue?: boolean): boolean | undefined {\n return getFieldValue(model, key, defaultValue) as boolean | undefined;\n}\n"],"mappings":";;;;;;;;AAQA,MAAM,gBAAgB,OAAO,SAAS;AAEtC,SAAgB,cAAc,OAAc,OAAe,cAA6B;CACtF,OAAO,IAAI,MAAM,MAAM,OAAO,YAAY;AAC5C;AAEA,SAAgB,cAAc,OAAc,OAAe,OAAuB;CAChF,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,MAAM,MAAM,MAAM,KAAK;CAE3B,MAAM,aAAa,UAAU,MAAM,KAAK;CAExC,OAAO;AACT;AAEA,SAAgB,SAAS,OAAc,OAAwB;CAC7D,OAAO,IAAI,MAAM,MAAM,OAAO,aAAoB,MAAM;AAC1D;AAEA,SAAgB,eAAe,OAAc,OAAe,QAAwB;CAGlF,OAAO,cAAc,OAAO,OAFd,cAAc,OAAO,OAAO,CACb,KAAK,UAAU,EACO;AACrD;AAEA,SAAgB,eAAe,OAAc,OAAe,QAAwB;CAGlF,OAAO,cAAc,OAAO,OAFd,cAAc,OAAO,OAAO,CACb,KAAK,UAAU,EACO;AACrD;;;;;;;;;AAUA,SAAgB,YAAY,OAAc,GAAG,QAAyB;CACpE,MAAM,WAAW,OAAO,QAAO,UAAS,SAAS,OAAO,KAAK,CAAC;CAE9D,MAAM,OAAO,MAAM,MAAM,MAAM,MAAM;CAErC,KAAK,MAAM,SAAS,UAClB,kBAAkB,MAAM,MAAM,KAAK;CAGrC,MAAM,aAAa,MAAM,MAAM;CAE/B,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAS,qBACP,OACA,QACyB;CACzB,IAAI,MAAM,OAAO,OAAO;CAExB,MAAM,kBAAkB,IAAI,IAAI;EAAC,MAAM,cAAc;EAAG;EAAM;CAAK,CAAC;CACpE,MAAM,cAAc,OAAO,KAAK,MAAM,CAAC,CAAC,QAAO,QAAO,gBAAgB,IAAI,GAAG,CAAC;CAE9E,IAAI,YAAY,WAAW,GAAG,OAAO;CAGrC,MAAM,aAAa,EAAE,GAAG,OAAO;CAC/B,KAAK,MAAM,OAAO,aAChB,OAAO,WAAW;CAGpB,OAAO;AACT;AAEA,SAAgB,YAAY,OAAc,QAAwC;CAChF,OAAO,kBAAkB,OAAO,qBAAqB,OAAO,MAAM,CAAC;AACrE;;;;;;;;;;AAWA,SAAgB,kBAAkB,OAAc,QAAwC;CACtF,MAAM,OAAO,MAAM,MAAM,MAAM,MAAM;CACrC,MAAM,aAAa,aAAa,MAAM;CACtC,OAAO;AACT;AAEA,SAAgB,cAAc,OAAc,QAA2C;CACrF,OAAO,KAAK,MAAM,MAAM,MAAM;AAChC;AAEA,SAAgB,eAAe,OAAc,KAAa,cAA2C;CACnG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C;AAEA,SAAgB,eAAe,OAAc,KAAa,cAA2C;CACnG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C;AAEA,SAAgB,gBAAgB,OAAc,KAAa,cAA6C;CACtG,OAAO,cAAc,OAAO,KAAK,YAAY;AAC/C"}
@@ -0,0 +1,38 @@
1
+ import { isPlainObject } from "@mongez/supportive-is";
2
+
3
+ //#region ../cascade/src/utils/prune-empty-parents.ts
4
+ /**
5
+ * Remove the ancestors of `path` that became empty plain objects, walking from
6
+ * the leaf's parent up to (and including) the top-level field.
7
+ *
8
+ * Call it right AFTER removing the leaf at `path`, and only when that leaf
9
+ * actually existed. Gating on "the leaf existed" is what keeps an object the
10
+ * user deliberately stored as `{}` untouched: unsetting a key that is not there
11
+ * removes nothing, so nothing becomes empty because of the unset and nothing is
12
+ * pruned. An ancestor is only ever pruned because THIS unset emptied it.
13
+ *
14
+ * Only plain objects are pruned, and only while their own container is a plain
15
+ * object (or the root): arrays are never pruned and stop the walk, because
16
+ * deleting an index would shift its siblings.
17
+ *
18
+ * @param root - The data object the path is relative to (mutated in place)
19
+ * @param path - Dot-notation path of the leaf that was just removed
20
+ */
21
+ function pruneEmptyParents(root, path) {
22
+ const segments = path.split(".");
23
+ for (let depth = segments.length - 1; depth >= 1; depth -= 1) {
24
+ let container = root;
25
+ for (let index = 0; index < depth - 1; index += 1) {
26
+ container = container?.[segments[index]];
27
+ if (!isPlainObject(container)) return;
28
+ }
29
+ const key = segments[depth - 1];
30
+ const candidate = container[key];
31
+ if (!isPlainObject(candidate) || Object.keys(candidate).length > 0) return;
32
+ delete container[key];
33
+ }
34
+ }
35
+
36
+ //#endregion
37
+ export { pruneEmptyParents };
38
+ //# sourceMappingURL=prune-empty-parents.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prune-empty-parents.mjs","names":[],"sources":["../../../../../../../cascade/src/utils/prune-empty-parents.ts"],"sourcesContent":["import { isPlainObject } from \"@mongez/supportive-is\";\n\n/**\n * Remove the ancestors of `path` that became empty plain objects, walking from\n * the leaf's parent up to (and including) the top-level field.\n *\n * Call it right AFTER removing the leaf at `path`, and only when that leaf\n * actually existed. Gating on \"the leaf existed\" is what keeps an object the\n * user deliberately stored as `{}` untouched: unsetting a key that is not there\n * removes nothing, so nothing becomes empty because of the unset and nothing is\n * pruned. An ancestor is only ever pruned because THIS unset emptied it.\n *\n * Only plain objects are pruned, and only while their own container is a plain\n * object (or the root): arrays are never pruned and stop the walk, because\n * deleting an index would shift its siblings.\n *\n * @param root - The data object the path is relative to (mutated in place)\n * @param path - Dot-notation path of the leaf that was just removed\n */\nexport function pruneEmptyParents(root: Record<string, unknown>, path: string): void {\n const segments = path.split(\".\");\n\n // Walk upward: the ancestor at `segments.slice(0, depth)` is the candidate.\n for (let depth = segments.length - 1; depth >= 1; depth -= 1) {\n let container: unknown = root;\n\n for (let index = 0; index < depth - 1; index += 1) {\n container = (container as Record<string, unknown> | undefined)?.[segments[index] as string];\n\n if (!isPlainObject(container)) {\n return;\n }\n }\n\n const key = segments[depth - 1] as string;\n const candidate = (container as Record<string, unknown>)[key];\n\n if (!isPlainObject(candidate) || Object.keys(candidate as object).length > 0) {\n return;\n }\n\n delete (container as Record<string, unknown>)[key];\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,kBAAkB,MAA+B,MAAoB;CACnF,MAAM,WAAW,KAAK,MAAM,GAAG;CAG/B,KAAK,IAAI,QAAQ,SAAS,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;EAC5D,IAAI,YAAqB;EAEzB,KAAK,IAAI,QAAQ,GAAG,QAAQ,QAAQ,GAAG,SAAS,GAAG;GACjD,YAAa,YAAoD,SAAS;GAE1E,IAAI,CAAC,cAAc,SAAS,GAC1B;EAEJ;EAEA,MAAM,MAAM,SAAS,QAAQ;EAC7B,MAAM,YAAa,UAAsC;EAEzD,IAAI,CAAC,cAAc,SAAS,KAAK,OAAO,KAAK,SAAmB,CAAC,CAAC,SAAS,GACzE;EAGF,OAAQ,UAAsC;CAChD;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;AAyDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,cAAA;EA+HvB;EAAA,iBA7HG,KAAA;EAyPgB;EAAA,iBAtPhB,kBAAA;EAqSJ;EAAA,iBAlSI,IAAA;EAkSa;EAAA,iBA/Rb,UAAA;EA+RmD;EAAA,iBA5RnD,MAAA;EA4XT;EAAA,iBAzXS,KAAA;EAqbJ;EAAA,iBAlbI,UAAA;EAqdT;EAAA,iBAldS,MAAA;EAmgBE;EAAA,iBAhgBF,UAAA;;;;;;;;;;;;;cAcE,KAAA,EAAO,KAAA;;;;;;;;EAkBb,IAAA,CAAK,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;;UAqE1C,eAAA;;;;;;;EA4HD,gBAAA,IAAoB,OAAA;;;;;;;;UAYnB,aAAA;;;;;;;;;;;;EAmCD,gBAAA,CAAiB,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,MAAA;;;;;;;;UA4CtD,aAAA;;;;;;UAoDN,kBAAA;;;;;;;;;;;;UAmDA,qBAAA;;;;;;EASK,cAAA,IAAkB,OAAA;;;;;;;;;;;;;;;;;;;;;;UAmCvB,qBAAA;;;;;;;;;;UAiDA,WAAA;AAAA"}
1
+ {"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;AAyDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,cAAA;EA+HvB;EAAA,iBA7HG,KAAA;EAyPgB;EAAA,iBAtPhB,kBAAA;EAqSJ;EAAA,iBAlSI,IAAA;EAkSa;EAAA,iBA/Rb,UAAA;EA+RmD;EAAA,iBA5RnD,MAAA;EA4XT;EAAA,iBAzXS,KAAA;EAqbJ;EAAA,iBAlbI,UAAA;EAqdT;EAAA,iBAldS,MAAA;EAqiBE;EAAA,iBAliBF,UAAA;;;;;;;;;;;;;cAcE,KAAA,EAAO,KAAA;;;;;;;;EAkBb,IAAA,CAAK,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;;UAqE1C,eAAA;;;;;;;EA4HD,gBAAA,IAAoB,OAAA;;;;;;;;UAYnB,aAAA;;;;;;;;;;;;EAmCD,gBAAA,CAAiB,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,MAAA;;;;;;;;UA4CtD,aAAA;;;;;;UAoDN,kBAAA;;;;;;;;;;;;UAmDA,qBAAA;;;;;;EASK,cAAA,IAAkB,OAAA;;;;;;;;;;;;;;;;;;;;;;UAmCvB,qBAAA;;;;;;;;;;UAmFA,WAAA;AAAA"}
@@ -321,7 +321,9 @@ var DatabaseWriter = class {
321
321
  "id",
322
322
  "_id"
323
323
  ]);
324
- const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));
324
+ const replacedColumns = this.model.dirtyTracker.getReplacedColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));
325
+ const isUnderReplaced = (column) => replacedColumns.some((root) => column === root || column.startsWith(`${root}.`));
326
+ const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column) && !this.unvalidatedColumns.has(column) && !isUnderReplaced(column));
325
327
  if (dirtyColumns.length > 0) {
326
328
  operations.$set = {};
327
329
  for (const column of dirtyColumns) {
@@ -329,11 +331,21 @@ var DatabaseWriter = class {
329
331
  operations.$set[column] = this.model.get(column);
330
332
  }
331
333
  }
332
- const removedColumns = this.model.getRemovedColumns().filter((column) => !identityColumns.has(column));
334
+ const removedColumns = this.model.dirtyTracker.getRemovedPaths().filter((column) => !identityColumns.has(column) && !isUnderReplaced(column));
333
335
  if (removedColumns.length > 0) {
334
336
  operations.$unset = {};
335
337
  for (const column of removedColumns) operations.$unset[column] = 1;
336
338
  }
339
+ for (const column of replacedColumns) {
340
+ const value = this.model.get(column);
341
+ if (value === void 0) {
342
+ operations.$unset ??= {};
343
+ operations.$unset[column] = 1;
344
+ continue;
345
+ }
346
+ operations.$set ??= {};
347
+ operations.$set[column] = value;
348
+ }
337
349
  return operations;
338
350
  }
339
351
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import { when } from \"@mongez/reinforcements\";\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\nimport type {\n DriverContract,\n InsertResult,\n UpdateOperations,\n UpdateResult,\n} from \"../contracts/database-driver.contract\";\nimport type {\n WriterContract,\n WriterOptions,\n WriterResult,\n} from \"../contracts/database-writer.contract\";\nimport { mergeDriverFields } from \"../model/methods/accessor-methods\";\nimport { generateModelId } from \"../model/generate-model-id\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { enqueueSyncFanout } from \"../sync/sync-fanout\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport type { StrictMode } from \"../types\";\nimport { DatabaseWriterValidationError } from \"../validation\";\nimport { afterCommit } from \"../transactions/after-commit\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database writer service that orchestrates model persistence.\n *\n * Handles the complete save pipeline:\n * 1. Check for changes (skip if no changes and not new)\n * 2. Emit `saving` event (for data enrichment)\n * 3. Emit `validating` event\n * 4. Validate and cast data via @warlock.js/seal schema\n * 5. Emit `validated` event\n * 6. Generate ID (for new NoSQL records)\n * 7. Emit `creating`/`updating` events\n * 8. Execute insert or update via driver\n * 9. Merge returned data into model\n * 10. Reset dirty tracker and update `isNew` flag\n * 11. Emit `saved` and `created`/`updated` events\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n *\n * console.log(user.get(\"id\")); // 1 (auto-generated)\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\n *\n * // Update existing record\n * user.set(\"name\", \"Alice Smith\");\n * await writer.save();\n * // Only updates the \"name\" field (partial update)\n *\n * // Silent save (no events)\n * await writer.save({ skipEvents: true });\n * ```\n */\nexport class DatabaseWriter implements WriterContract {\n /** The model instance being persisted */\n private readonly model: Model;\n\n /** Dirty columns an update validation dropped (strip mode); never written. */\n private readonly unvalidatedColumns = new Set<string>();\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver and ID generator */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /** Validation schema (if defined) */\n private readonly schema?: ObjectValidator;\n\n /** Strict mode configuration */\n private readonly strictMode: StrictMode;\n\n /**\n * Create a new writer instance for a model.\n *\n * @param model - The model instance to persist\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n * ```\n */\n public constructor(model: Model) {\n this.model = model;\n this.ctor = model.constructor as ChildModel<Model>;\n this.dataSource = this.ctor.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = this.ctor.table;\n this.primaryKey = this.ctor.primaryKey;\n this.schema = this.ctor.schema;\n this.strictMode = this.ctor.strictMode;\n }\n\n /**\n * Save the model instance to the database.\n *\n * @param options - Save options\n * @returns Result with success status, document, and metadata\n * @throws {ValidationError} If validation fails\n */\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\n const isInsert = this.model.isNew;\n\n // 1. Check if model has changes (skip if no changes and not new)\n if (!isInsert && !this.model.hasChanges()) {\n return {\n success: true,\n document: this.model.data,\n isNew: false,\n modifiedCount: 0,\n };\n }\n\n // 2. Emit saving event (before validation for data enrichment)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saving\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // 3-4. Execute insert or update (validation happens inside each path)\n let result: InsertResult | UpdateResult;\n\n if (isInsert) {\n result = await this.performInsert(options);\n } else {\n await this.validateAndCast(false, options);\n result = await this.performUpdate(options);\n }\n\n // 5. Reset dirty tracker and update isNew flag\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\n this.model.dirtyTracker.reset();\n this.model.isNew = false;\n\n // 6. Emit post-save events\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saved\");\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\n }\n\n // 7. Trigger sync operations after COMMIT (inside a transaction they are\n // queued, so a rolled-back write never fans out). Failures are logged.\n if (!options.skipSync && !isInsert) {\n afterCommit(() => this.triggerSync(changedFields));\n }\n\n return {\n success: true,\n document: this.model.data,\n isNew: isInsert,\n modifiedCount: isInsert\n ? undefined\n : (result as UpdateResult).modifiedCount,\n };\n }\n\n /**\n * Validate and cast model data using the schema.\n *\n * Updates the model's data in-place with validated/casted values.\n *\n * @param isInsert - Whether this is an insert operation\n * @param options - Save options\n * @throws {ValidationError} If validation fails\n * @private\n */\n private async validateAndCast(\n isInsert: boolean,\n options: WriterOptions,\n ): Promise<void> {\n // Emit validating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validating\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // Skip validation if requested or no schema defined\n if (options.skipValidation || !this.schema) {\n return;\n }\n\n // Whitelist the framework-managed system columns so a model carrying them\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\n // `when(...)` adds each timestamp/soft-delete column only when configured\n // (truthy) — the lazy factory means a disabled column (`false`) never even\n // builds a bogus schema key.\n //\n // The whitelist must apply to BOTH insert and update: on insert a caller may\n // supply a backdated createdAt (e.g. data migrations / imports), and without\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\n // writer, while \"fail\" rejects the whole insert.\n const systemColumns = {\n id: v.scalar().optional(),\n _id: v.any().optional(),\n ...when(this.ctor.createdAtColumn, () => ({\n [this.ctor.createdAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.updatedAtColumn, () => ({\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.deletedAtColumn, () => ({\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\n })),\n };\n\n // Clone full schema for insert, partial (dirty keys only) for updates.\n //\n // An update validates ONLY the dirty columns and merges back only those\n // keys: columns the schema does not list (migration-added, withCount, ...)\n // are never touched, so they can no longer be turned into `$unset`.\n const dataToValidate: Record<string, unknown> = isInsert\n ? this.model.data\n : Object.fromEntries(\n this.model\n .getDirtyColumns()\n .filter(column => column in this.model.data)\n .map(column => [column, this.model.data[column]]),\n );\n\n const validationSchema = isInsert\n ? this.schema.clone().extend(systemColumns)\n : this.schema.clone(Object.keys(dataToValidate)).extend(systemColumns);\n\n // Apply strict mode\n if (this.strictMode === \"strip\") {\n validationSchema.stripUnknown();\n } else if (this.strictMode === \"fail\") {\n validationSchema.allowUnknown(false);\n } else if (this.strictMode === \"allow\") {\n validationSchema.allowUnknown(true);\n }\n\n // Run validation\n const result = await v.validate(validationSchema, dataToValidate, {\n context: {\n model: this.model,\n },\n ...getSealConfig(),\n });\n\n if (!result.isValid) {\n const error = new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n result.errors,\n );\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result, error });\n }\n throw error;\n }\n\n // Update model data with validated/casted data\n if (isInsert) {\n this.model.replaceData(result.data);\n } else {\n const validated = result.data as Record<string, unknown>;\n\n for (const column of Object.keys(dataToValidate)) {\n if (column in validated) {\n this.model.set(column, validated[column]);\n } else if (dataToValidate[column] === null && column in validationSchema.schema) {\n // An explicit null on a KNOWN column that passed validation: an\n // optional, non-nullable leaf coalesces null to \"absent\" in seal, so\n // it is missing from the result. It is a deliberate \"clear this\n // column\", not an unknown one to strip (Real-Estate #18). A required\n // column never gets here: its rule rejects the null and validation throws.\n this.model.set(column, null);\n } else {\n // Dropped by strictMode \"strip\": never write an unvalidated column.\n this.unvalidatedColumns.add(column);\n }\n }\n }\n\n // Emit validated event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result });\n }\n }\n\n /**\n * Validate, cast and id-generate a not-yet-persisted model for `upsert()`.\n *\n * Runs the insert-mode pipeline (schema, casts, strictMode, Mongo `id`) but\n * does not write; the caller sends the resulting data through the driver.\n */\n public async prepareForUpsert(): Promise<void> {\n await this.validateAndCast(true, {});\n await this.generateNextId();\n }\n\n /**\n * Perform an insert operation.\n *\n * @param options - Save options\n * @returns Insert result\n * @private\n */\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\n const dataToInsert = await this.prepareForInsert(options);\n\n // INSERT: use full validated data\n let result: InsertResult;\n\n try {\n result = await this.driver.insert(this.table, dataToInsert);\n } catch (error) {\n throw this.mapUniqueViolation(error, true);\n }\n\n // Merge returned data (e.g., generated _id, timestamps)\n // Note: We use merge here because the result might not include all fields\n // (e.g., our generated 'id' field), and we don't want to lose them\n mergeDriverFields(this.model, result.document as Record<string, unknown>);\n\n // Reset dirty tracker immediately after merge to prevent\n // database-generated fields (like _id) from being marked as dirty\n this.model.dirtyTracker.reset();\n\n return result;\n }\n\n /**\n * Run the insert pipeline (validation, casting, id generation, timestamps,\n * `validating`/`validated`/`creating` hooks) WITHOUT writing.\n *\n * `save()` uses this for its insert path, so bulk inserts and single inserts\n * cannot drift. The caller is responsible for sending the returned document\n * to the driver.\n *\n * @param options - Save options (e.g. `skipEvents`, `skipValidation`)\n * @returns The prepared document to insert\n */\n public async prepareForInsert(options: WriterOptions = {}): Promise<Record<string, unknown>> {\n // Validate and cast data\n await this.validateAndCast(true, options);\n\n // Generate ID if needed (NoSQL only)\n await this.generateNextId();\n\n // Get data to insert (already validated and casted)\n const dataToInsert = this.model.data;\n\n // Add createdAt and updatedAt to the data (using resolved column names)\n // The column names are already resolved through the hierarchy:\n // Model static property > Database config > Driver defaults > undefined\n //\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\n // value (e.g. data migrations / imports) is honored instead of overwritten.\n // This mirrors the upsert path's guard. updatedAt is always stamped to\n // reflect the moment the record is persisted.\n const createdAtColumn = this.ctor.createdAtColumn;\n\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\n dataToInsert[createdAtColumn] = new Date();\n }\n\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n dataToInsert[updatedAtColumn] = new Date();\n }\n\n // Emit creating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"creating\");\n }\n\n return dataToInsert;\n }\n\n /**\n * Perform an update operation.\n *\n * @param options - Save options\n * @returns Update result\n * @private\n */\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\n // Emit updating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"updating\");\n }\n\n // Update the updatedAt timestamp (using resolved column name)\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n this.model.set(updatedAtColumn, new Date());\n }\n\n if (options.replace) {\n const document = await this.driver.replace(\n this.table,\n this.buildPrimaryKeyFilter(),\n this.model.data,\n );\n\n if (document) {\n this.model.replaceData(document as Record<string, unknown>);\n }\n\n return { modifiedCount: document ? 1 : 0 };\n }\n\n // Build operations from dirty tracker\n const operations = this.buildUpdateOperations();\n\n // Nothing left to write once identity columns are excluded — don't hand the\n // driver an empty update document (MongoDB rejects one).\n if (Object.keys(operations).length === 0) {\n return { modifiedCount: 0 };\n }\n\n // Execute update with operations\n try {\n return await this.driver.update(\n this.table,\n this.buildPrimaryKeyFilter(),\n operations,\n );\n } catch (error) {\n throw this.mapUniqueViolation(error, false);\n }\n }\n\n /**\n * Map a driver unique-constraint violation (Postgres 23505, MongoDB 11000)\n * to a field validation error, so a lost `unique` race never surfaces as a\n * 500. Any other error is returned untouched.\n */\n private mapUniqueViolation(error: unknown, isInsert: boolean): unknown {\n const err = error as\n | {\n code?: string | number;\n detail?: string;\n keyPattern?: Record<string, unknown>;\n message?: string;\n }\n | undefined;\n\n if (err?.code !== \"23505\" && err?.code !== 11000 && err?.code !== \"11000\") {\n return error;\n }\n\n let field: string | undefined;\n\n if (err.keyPattern) {\n field = Object.keys(err.keyPattern)[0];\n } else {\n // Postgres: \"Key (email)=(x) already exists.\"\n field = /Key \\(([^)]+)\\)=/.exec(err.detail ?? \"\")?.[1]?.split(\",\")[0]?.trim();\n }\n\n if (!field) {\n // Mongo: \"index: email_1 dup key\"\n field = /index: (\\S+?)_-?1\\b/.exec(err.message ?? \"\")?.[1];\n }\n\n return new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n [\n {\n type: \"unique\",\n error: `The ${field ?? \"value\"} must be unique`,\n input: field ?? \"id\",\n },\n ],\n );\n }\n\n /**\n * Build the filter that pins a write to the row this model was loaded from.\n *\n * It reads `model.trustedPrimaryKey` — the value captured when the instance\n * became persisted — NOT the current value in `model.data`. The current value\n * is reachable by mass assignment (`model.merge(req.body)`), so deriving the\n * filter from it let a request body redirect the UPDATE to another document.\n *\n * @returns Filter matching the originally loaded record\n * @private\n */\n private buildPrimaryKeyFilter(): Record<string, unknown> {\n return { [this.primaryKey]: this.model.trustedPrimaryKey };\n }\n\n /**\n * Generate ID for the model if auto-generation is enabled.\n *\n * @private\n */\n public async generateNextId(): Promise<void> {\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\n return;\n }\n\n const id = await generateModelId(this.ctor);\n\n if (id === undefined) {\n return;\n }\n\n this.model.set(\"id\", id);\n }\n\n /**\n * Build update operations from the model's dirty tracker.\n *\n * Handles both modified fields ($set) and removed fields ($unset).\n *\n * @returns Update operations for the driver\n * @private\n *\n * @example\n * ```typescript\n * // Model with changes\n * user.set(\"name\", \"Alice\");\n * user.unset(\"tempField\");\n *\n * const operations = this.buildUpdateOperations();\n * // {\n * // $set: { name: \"Alice\" },\n * // $unset: { tempField: 1 }\n * // }\n * ```\n */\n private buildUpdateOperations(): UpdateOperations {\n const operations: UpdateOperations = {};\n\n // Identity columns are never written by an update. The filter pins the row\n // by its captured primary key, so a dirty `id`/`_id` can only come from\n // mass assignment or an explicit set() — either way, rewriting the key of\n // an existing row corrupts identity (and `_id` is immutable in MongoDB).\n // Changing a primary key is a deliberate operation: use the atomic/raw APIs.\n const identityColumns = new Set([this.primaryKey, \"id\", \"_id\"]);\n\n // Get dirty columns (modified fields)\n const dirtyColumns = this.model\n .getDirtyColumns()\n .filter(column => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));\n\n if (dirtyColumns.length > 0) {\n operations.$set = {};\n for (const column of dirtyColumns) {\n const value = this.model.get(column);\n if (value === undefined) continue;\n\n operations.$set[column] = this.model.get(column);\n }\n }\n\n // Get removed columns\n const removedColumns = this.model\n .getRemovedColumns()\n .filter(column => !identityColumns.has(column));\n\n if (removedColumns.length > 0) {\n operations.$unset = {};\n for (const column of removedColumns) {\n operations.$unset[column] = 1;\n }\n }\n\n return operations;\n }\n\n /**\n * Trigger sync operations after successful save.\n *\n * Emits a model.updated event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @param changedFields - Fields that were changed (for filtering)\n * @private\n */\n private triggerSync(changedFields: string[]): void {\n enqueueSyncFanout({\n sourceModel: this.ctor.name,\n operation: \"update\",\n execute: () => triggerModelEvent(this.ctor, \"updated\", this.model, changedFields),\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import { when } from \"@mongez/reinforcements\";\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\nimport type {\n DriverContract,\n InsertResult,\n UpdateOperations,\n UpdateResult,\n} from \"../contracts/database-driver.contract\";\nimport type {\n WriterContract,\n WriterOptions,\n WriterResult,\n} from \"../contracts/database-writer.contract\";\nimport { mergeDriverFields } from \"../model/methods/accessor-methods\";\nimport { generateModelId } from \"../model/generate-model-id\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { enqueueSyncFanout } from \"../sync/sync-fanout\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport type { StrictMode } from \"../types\";\nimport { DatabaseWriterValidationError } from \"../validation\";\nimport { afterCommit } from \"../transactions/after-commit\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database writer service that orchestrates model persistence.\n *\n * Handles the complete save pipeline:\n * 1. Check for changes (skip if no changes and not new)\n * 2. Emit `saving` event (for data enrichment)\n * 3. Emit `validating` event\n * 4. Validate and cast data via @warlock.js/seal schema\n * 5. Emit `validated` event\n * 6. Generate ID (for new NoSQL records)\n * 7. Emit `creating`/`updating` events\n * 8. Execute insert or update via driver\n * 9. Merge returned data into model\n * 10. Reset dirty tracker and update `isNew` flag\n * 11. Emit `saved` and `created`/`updated` events\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n *\n * console.log(user.get(\"id\")); // 1 (auto-generated)\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\n *\n * // Update existing record\n * user.set(\"name\", \"Alice Smith\");\n * await writer.save();\n * // Only updates the \"name\" field (partial update)\n *\n * // Silent save (no events)\n * await writer.save({ skipEvents: true });\n * ```\n */\nexport class DatabaseWriter implements WriterContract {\n /** The model instance being persisted */\n private readonly model: Model;\n\n /** Dirty columns an update validation dropped (strip mode); never written. */\n private readonly unvalidatedColumns = new Set<string>();\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver and ID generator */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /** Validation schema (if defined) */\n private readonly schema?: ObjectValidator;\n\n /** Strict mode configuration */\n private readonly strictMode: StrictMode;\n\n /**\n * Create a new writer instance for a model.\n *\n * @param model - The model instance to persist\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n * ```\n */\n public constructor(model: Model) {\n this.model = model;\n this.ctor = model.constructor as ChildModel<Model>;\n this.dataSource = this.ctor.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = this.ctor.table;\n this.primaryKey = this.ctor.primaryKey;\n this.schema = this.ctor.schema;\n this.strictMode = this.ctor.strictMode;\n }\n\n /**\n * Save the model instance to the database.\n *\n * @param options - Save options\n * @returns Result with success status, document, and metadata\n * @throws {ValidationError} If validation fails\n */\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\n const isInsert = this.model.isNew;\n\n // 1. Check if model has changes (skip if no changes and not new)\n if (!isInsert && !this.model.hasChanges()) {\n return {\n success: true,\n document: this.model.data,\n isNew: false,\n modifiedCount: 0,\n };\n }\n\n // 2. Emit saving event (before validation for data enrichment)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saving\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // 3-4. Execute insert or update (validation happens inside each path)\n let result: InsertResult | UpdateResult;\n\n if (isInsert) {\n result = await this.performInsert(options);\n } else {\n await this.validateAndCast(false, options);\n result = await this.performUpdate(options);\n }\n\n // 5. Reset dirty tracker and update isNew flag\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\n this.model.dirtyTracker.reset();\n this.model.isNew = false;\n\n // 6. Emit post-save events\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saved\");\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\n }\n\n // 7. Trigger sync operations after COMMIT (inside a transaction they are\n // queued, so a rolled-back write never fans out). Failures are logged.\n if (!options.skipSync && !isInsert) {\n afterCommit(() => this.triggerSync(changedFields));\n }\n\n return {\n success: true,\n document: this.model.data,\n isNew: isInsert,\n modifiedCount: isInsert\n ? undefined\n : (result as UpdateResult).modifiedCount,\n };\n }\n\n /**\n * Validate and cast model data using the schema.\n *\n * Updates the model's data in-place with validated/casted values.\n *\n * @param isInsert - Whether this is an insert operation\n * @param options - Save options\n * @throws {ValidationError} If validation fails\n * @private\n */\n private async validateAndCast(\n isInsert: boolean,\n options: WriterOptions,\n ): Promise<void> {\n // Emit validating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validating\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // Skip validation if requested or no schema defined\n if (options.skipValidation || !this.schema) {\n return;\n }\n\n // Whitelist the framework-managed system columns so a model carrying them\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\n // `when(...)` adds each timestamp/soft-delete column only when configured\n // (truthy) — the lazy factory means a disabled column (`false`) never even\n // builds a bogus schema key.\n //\n // The whitelist must apply to BOTH insert and update: on insert a caller may\n // supply a backdated createdAt (e.g. data migrations / imports), and without\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\n // writer, while \"fail\" rejects the whole insert.\n const systemColumns = {\n id: v.scalar().optional(),\n _id: v.any().optional(),\n ...when(this.ctor.createdAtColumn, () => ({\n [this.ctor.createdAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.updatedAtColumn, () => ({\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.deletedAtColumn, () => ({\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\n })),\n };\n\n // Clone full schema for insert, partial (dirty keys only) for updates.\n //\n // An update validates ONLY the dirty columns and merges back only those\n // keys: columns the schema does not list (migration-added, withCount, ...)\n // are never touched, so they can no longer be turned into `$unset`.\n const dataToValidate: Record<string, unknown> = isInsert\n ? this.model.data\n : Object.fromEntries(\n this.model\n .getDirtyColumns()\n .filter(column => column in this.model.data)\n .map(column => [column, this.model.data[column]]),\n );\n\n const validationSchema = isInsert\n ? this.schema.clone().extend(systemColumns)\n : this.schema.clone(Object.keys(dataToValidate)).extend(systemColumns);\n\n // Apply strict mode\n if (this.strictMode === \"strip\") {\n validationSchema.stripUnknown();\n } else if (this.strictMode === \"fail\") {\n validationSchema.allowUnknown(false);\n } else if (this.strictMode === \"allow\") {\n validationSchema.allowUnknown(true);\n }\n\n // Run validation\n const result = await v.validate(validationSchema, dataToValidate, {\n context: {\n model: this.model,\n },\n ...getSealConfig(),\n });\n\n if (!result.isValid) {\n const error = new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n result.errors,\n );\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result, error });\n }\n throw error;\n }\n\n // Update model data with validated/casted data\n if (isInsert) {\n this.model.replaceData(result.data);\n } else {\n const validated = result.data as Record<string, unknown>;\n\n for (const column of Object.keys(dataToValidate)) {\n if (column in validated) {\n this.model.set(column, validated[column]);\n } else if (dataToValidate[column] === null && column in validationSchema.schema) {\n // An explicit null on a KNOWN column that passed validation: an\n // optional, non-nullable leaf coalesces null to \"absent\" in seal, so\n // it is missing from the result. It is a deliberate \"clear this\n // column\", not an unknown one to strip (Real-Estate #18). A required\n // column never gets here: its rule rejects the null and validation throws.\n this.model.set(column, null);\n } else {\n // Dropped by strictMode \"strip\": never write an unvalidated column.\n this.unvalidatedColumns.add(column);\n }\n }\n }\n\n // Emit validated event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result });\n }\n }\n\n /**\n * Validate, cast and id-generate a not-yet-persisted model for `upsert()`.\n *\n * Runs the insert-mode pipeline (schema, casts, strictMode, Mongo `id`) but\n * does not write; the caller sends the resulting data through the driver.\n */\n public async prepareForUpsert(): Promise<void> {\n await this.validateAndCast(true, {});\n await this.generateNextId();\n }\n\n /**\n * Perform an insert operation.\n *\n * @param options - Save options\n * @returns Insert result\n * @private\n */\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\n const dataToInsert = await this.prepareForInsert(options);\n\n // INSERT: use full validated data\n let result: InsertResult;\n\n try {\n result = await this.driver.insert(this.table, dataToInsert);\n } catch (error) {\n throw this.mapUniqueViolation(error, true);\n }\n\n // Merge returned data (e.g., generated _id, timestamps)\n // Note: We use merge here because the result might not include all fields\n // (e.g., our generated 'id' field), and we don't want to lose them\n mergeDriverFields(this.model, result.document as Record<string, unknown>);\n\n // Reset dirty tracker immediately after merge to prevent\n // database-generated fields (like _id) from being marked as dirty\n this.model.dirtyTracker.reset();\n\n return result;\n }\n\n /**\n * Run the insert pipeline (validation, casting, id generation, timestamps,\n * `validating`/`validated`/`creating` hooks) WITHOUT writing.\n *\n * `save()` uses this for its insert path, so bulk inserts and single inserts\n * cannot drift. The caller is responsible for sending the returned document\n * to the driver.\n *\n * @param options - Save options (e.g. `skipEvents`, `skipValidation`)\n * @returns The prepared document to insert\n */\n public async prepareForInsert(options: WriterOptions = {}): Promise<Record<string, unknown>> {\n // Validate and cast data\n await this.validateAndCast(true, options);\n\n // Generate ID if needed (NoSQL only)\n await this.generateNextId();\n\n // Get data to insert (already validated and casted)\n const dataToInsert = this.model.data;\n\n // Add createdAt and updatedAt to the data (using resolved column names)\n // The column names are already resolved through the hierarchy:\n // Model static property > Database config > Driver defaults > undefined\n //\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\n // value (e.g. data migrations / imports) is honored instead of overwritten.\n // This mirrors the upsert path's guard. updatedAt is always stamped to\n // reflect the moment the record is persisted.\n const createdAtColumn = this.ctor.createdAtColumn;\n\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\n dataToInsert[createdAtColumn] = new Date();\n }\n\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n dataToInsert[updatedAtColumn] = new Date();\n }\n\n // Emit creating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"creating\");\n }\n\n return dataToInsert;\n }\n\n /**\n * Perform an update operation.\n *\n * @param options - Save options\n * @returns Update result\n * @private\n */\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\n // Emit updating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"updating\");\n }\n\n // Update the updatedAt timestamp (using resolved column name)\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n this.model.set(updatedAtColumn, new Date());\n }\n\n if (options.replace) {\n const document = await this.driver.replace(\n this.table,\n this.buildPrimaryKeyFilter(),\n this.model.data,\n );\n\n if (document) {\n this.model.replaceData(document as Record<string, unknown>);\n }\n\n return { modifiedCount: document ? 1 : 0 };\n }\n\n // Build operations from dirty tracker\n const operations = this.buildUpdateOperations();\n\n // Nothing left to write once identity columns are excluded — don't hand the\n // driver an empty update document (MongoDB rejects one).\n if (Object.keys(operations).length === 0) {\n return { modifiedCount: 0 };\n }\n\n // Execute update with operations\n try {\n return await this.driver.update(\n this.table,\n this.buildPrimaryKeyFilter(),\n operations,\n );\n } catch (error) {\n throw this.mapUniqueViolation(error, false);\n }\n }\n\n /**\n * Map a driver unique-constraint violation (Postgres 23505, MongoDB 11000)\n * to a field validation error, so a lost `unique` race never surfaces as a\n * 500. Any other error is returned untouched.\n */\n private mapUniqueViolation(error: unknown, isInsert: boolean): unknown {\n const err = error as\n | {\n code?: string | number;\n detail?: string;\n keyPattern?: Record<string, unknown>;\n message?: string;\n }\n | undefined;\n\n if (err?.code !== \"23505\" && err?.code !== 11000 && err?.code !== \"11000\") {\n return error;\n }\n\n let field: string | undefined;\n\n if (err.keyPattern) {\n field = Object.keys(err.keyPattern)[0];\n } else {\n // Postgres: \"Key (email)=(x) already exists.\"\n field = /Key \\(([^)]+)\\)=/.exec(err.detail ?? \"\")?.[1]?.split(\",\")[0]?.trim();\n }\n\n if (!field) {\n // Mongo: \"index: email_1 dup key\"\n field = /index: (\\S+?)_-?1\\b/.exec(err.message ?? \"\")?.[1];\n }\n\n return new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n [\n {\n type: \"unique\",\n error: `The ${field ?? \"value\"} must be unique`,\n input: field ?? \"id\",\n },\n ],\n );\n }\n\n /**\n * Build the filter that pins a write to the row this model was loaded from.\n *\n * It reads `model.trustedPrimaryKey` — the value captured when the instance\n * became persisted — NOT the current value in `model.data`. The current value\n * is reachable by mass assignment (`model.merge(req.body)`), so deriving the\n * filter from it let a request body redirect the UPDATE to another document.\n *\n * @returns Filter matching the originally loaded record\n * @private\n */\n private buildPrimaryKeyFilter(): Record<string, unknown> {\n return { [this.primaryKey]: this.model.trustedPrimaryKey };\n }\n\n /**\n * Generate ID for the model if auto-generation is enabled.\n *\n * @private\n */\n public async generateNextId(): Promise<void> {\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\n return;\n }\n\n const id = await generateModelId(this.ctor);\n\n if (id === undefined) {\n return;\n }\n\n this.model.set(\"id\", id);\n }\n\n /**\n * Build update operations from the model's dirty tracker.\n *\n * Handles both modified fields ($set) and removed fields ($unset).\n *\n * @returns Update operations for the driver\n * @private\n *\n * @example\n * ```typescript\n * // Model with changes\n * user.set(\"name\", \"Alice\");\n * user.unset(\"tempField\");\n *\n * const operations = this.buildUpdateOperations();\n * // {\n * // $set: { name: \"Alice\" },\n * // $unset: { tempField: 1 }\n * // }\n * ```\n */\n private buildUpdateOperations(): UpdateOperations {\n const operations: UpdateOperations = {};\n\n // Identity columns are never written by an update. The filter pins the row\n // by its captured primary key, so a dirty `id`/`_id` can only come from\n // mass assignment or an explicit set() — either way, rewriting the key of\n // an existing row corrupts identity (and `_id` is immutable in MongoDB).\n // Changing a primary key is a deliberate operation: use the atomic/raw APIs.\n const identityColumns = new Set([this.primaryKey, \"id\", \"_id\"]);\n\n // Top-level fields assigned/unset as a whole (`set(\"nested\", obj)`,\n // `unset(\"nested\")`) are written as ONE value. Per-leaf operations can only\n // report the leaves that changed or vanished, so a nested object replaced with\n // one that has fewer keys would keep its removed keys' empty parents in the\n // stored document. Dotted-path sets (`set(\"nested.a\", v)`) and merges are\n // partial by intent and keep going through the per-leaf path below.\n const replacedColumns = this.model.dirtyTracker\n .getReplacedColumns()\n .filter(column => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));\n\n // A leaf under a whole-written field must not be sent too: MongoDB rejects\n // an update touching both `nested` and `nested.a`.\n const isUnderReplaced = (column: string): boolean =>\n replacedColumns.some(root => column === root || column.startsWith(`${root}.`));\n\n // Get dirty columns (modified fields)\n const dirtyColumns = this.model\n .getDirtyColumns()\n .filter(\n column =>\n !identityColumns.has(column) &&\n !this.unvalidatedColumns.has(column) &&\n !isUnderReplaced(column),\n );\n\n if (dirtyColumns.length > 0) {\n operations.$set = {};\n for (const column of dirtyColumns) {\n const value = this.model.get(column);\n if (value === undefined) continue;\n\n operations.$set[column] = this.model.get(column);\n }\n }\n\n // Get removed columns, collapsed to the highest path that is actually gone so\n // a parent emptied by an unset is removed too instead of left behind as `{}`.\n const removedColumns = this.model.dirtyTracker\n .getRemovedPaths()\n .filter(column => !identityColumns.has(column) && !isUnderReplaced(column));\n\n if (removedColumns.length > 0) {\n operations.$unset = {};\n for (const column of removedColumns) {\n operations.$unset[column] = 1;\n }\n }\n\n for (const column of replacedColumns) {\n const value = this.model.get(column);\n\n if (value === undefined) {\n operations.$unset ??= {};\n operations.$unset[column] = 1;\n continue;\n }\n\n operations.$set ??= {};\n operations.$set[column] = value;\n }\n\n return operations;\n }\n\n /**\n * Trigger sync operations after successful save.\n *\n * Emits a model.updated event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @param changedFields - Fields that were changed (for filtering)\n * @private\n */\n private triggerSync(changedFields: string[]): void {\n enqueueSyncFanout({\n sourceModel: this.ctor.name,\n operation: \"update\",\n execute: () => triggerModelEvent(this.ctor, \"updated\", this.model, changedFields),\n });\n }\n}\n"],"mappings":"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"}