@warlock.js/cascade 5.20.0 → 5.21.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.
Files changed (168) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/cjs/index.cjs +2283 -1037
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/query-builder.contract.d.mts +30 -3
  5. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  6. package/esm/data-source/data-source-registry.d.mts +33 -1
  7. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  8. package/esm/data-source/data-source-registry.mjs +75 -5
  9. package/esm/data-source/data-source-registry.mjs.map +1 -1
  10. package/esm/database-dirty-tracker.d.mts +5 -0
  11. package/esm/database-dirty-tracker.d.mts.map +1 -1
  12. package/esm/database-dirty-tracker.mjs +16 -0
  13. package/esm/database-dirty-tracker.mjs.map +1 -1
  14. package/esm/drivers/mongodb/mongodb-driver.d.mts +12 -0
  15. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  16. package/esm/drivers/mongodb/mongodb-driver.mjs +47 -16
  17. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts +1 -1
  19. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts.map +1 -1
  20. package/esm/drivers/mongodb/mongodb-migration-driver.mjs +15 -18
  21. package/esm/drivers/mongodb/mongodb-migration-driver.mjs.map +1 -1
  22. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +13 -3
  23. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  24. package/esm/drivers/mongodb/mongodb-query-builder.mjs +72 -19
  25. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  26. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +6 -0
  27. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  28. package/esm/drivers/mongodb/mongodb-query-parser.mjs +128 -30
  29. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  30. package/esm/drivers/postgres/index.d.mts +2 -3
  31. package/esm/drivers/postgres/index.mjs +1 -2
  32. package/esm/drivers/postgres/postgres-blueprint.mjs +4 -4
  33. package/esm/drivers/postgres/postgres-blueprint.mjs.map +1 -1
  34. package/esm/drivers/postgres/postgres-dialect.d.mts +5 -1
  35. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  36. package/esm/drivers/postgres/postgres-dialect.mjs +38 -8
  37. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  38. package/esm/drivers/postgres/postgres-driver.d.mts +52 -29
  39. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  40. package/esm/drivers/postgres/postgres-driver.mjs +190 -57
  41. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  42. package/esm/drivers/postgres/postgres-migration-driver.d.mts +2 -1
  43. package/esm/drivers/postgres/postgres-migration-driver.d.mts.map +1 -1
  44. package/esm/drivers/postgres/postgres-migration-driver.mjs +8 -6
  45. package/esm/drivers/postgres/postgres-migration-driver.mjs.map +1 -1
  46. package/esm/drivers/postgres/postgres-query-builder.d.mts +18 -7
  47. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  48. package/esm/drivers/postgres/postgres-query-builder.mjs +146 -31
  49. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  50. package/esm/drivers/postgres/postgres-query-parser.d.mts +23 -1
  51. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  52. package/esm/drivers/postgres/postgres-query-parser.mjs +160 -43
  53. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  54. package/esm/drivers/postgres/postgres-sql-serializer.mjs +3 -2
  55. package/esm/drivers/postgres/postgres-sql-serializer.mjs.map +1 -1
  56. package/esm/drivers/postgres/postgres-type-converters.mjs +57 -0
  57. package/esm/drivers/postgres/postgres-type-converters.mjs.map +1 -0
  58. package/esm/events/model-events.d.mts +1 -1
  59. package/esm/events/model-events.d.mts.map +1 -1
  60. package/esm/events/model-events.mjs.map +1 -1
  61. package/esm/index.d.mts +5 -6
  62. package/esm/index.mjs +4 -5
  63. package/esm/migration/column-builder.d.mts.map +1 -1
  64. package/esm/migration/column-builder.mjs +2 -2
  65. package/esm/migration/column-builder.mjs.map +1 -1
  66. package/esm/migration/index.d.mts +2 -2
  67. package/esm/migration/index.mjs +1 -1
  68. package/esm/migration/migration-order.mjs +5 -2
  69. package/esm/migration/migration-order.mjs.map +1 -1
  70. package/esm/migration/migration-runner.d.mts +50 -5
  71. package/esm/migration/migration-runner.d.mts.map +1 -1
  72. package/esm/migration/migration-runner.mjs +369 -146
  73. package/esm/migration/migration-runner.mjs.map +1 -1
  74. package/esm/migration/migration.d.mts +36 -3
  75. package/esm/migration/migration.d.mts.map +1 -1
  76. package/esm/migration/migration.mjs +62 -5
  77. package/esm/migration/migration.mjs.map +1 -1
  78. package/esm/model/methods/accessor-methods.mjs +1 -3
  79. package/esm/model/methods/accessor-methods.mjs.map +1 -1
  80. package/esm/model/methods/delete-methods.mjs +6 -2
  81. package/esm/model/methods/delete-methods.mjs.map +1 -1
  82. package/esm/model/methods/hydration-methods.mjs +16 -2
  83. package/esm/model/methods/hydration-methods.mjs.map +1 -1
  84. package/esm/model/methods/instance-event-methods.mjs +2 -1
  85. package/esm/model/methods/instance-event-methods.mjs.map +1 -1
  86. package/esm/model/methods/meta-methods.mjs +53 -16
  87. package/esm/model/methods/meta-methods.mjs.map +1 -1
  88. package/esm/model/methods/query-methods.mjs +86 -15
  89. package/esm/model/methods/query-methods.mjs.map +1 -1
  90. package/esm/model/methods/static-event-methods.mjs +16 -1
  91. package/esm/model/methods/static-event-methods.mjs.map +1 -1
  92. package/esm/model/methods/write-methods.d.mts.map +1 -1
  93. package/esm/model/methods/write-methods.mjs +50 -50
  94. package/esm/model/methods/write-methods.mjs.map +1 -1
  95. package/esm/model/model.d.mts +16 -1
  96. package/esm/model/model.d.mts.map +1 -1
  97. package/esm/model/model.mjs +39 -5
  98. package/esm/model/model.mjs.map +1 -1
  99. package/esm/model/register-model.d.mts +6 -1
  100. package/esm/model/register-model.d.mts.map +1 -1
  101. package/esm/model/register-model.mjs +16 -1
  102. package/esm/model/register-model.mjs.map +1 -1
  103. package/esm/query-builder/query-builder.d.mts +21 -2
  104. package/esm/query-builder/query-builder.d.mts.map +1 -1
  105. package/esm/query-builder/query-builder.mjs +97 -20
  106. package/esm/query-builder/query-builder.mjs.map +1 -1
  107. package/esm/relations/key-conventions.mjs +13 -3
  108. package/esm/relations/key-conventions.mjs.map +1 -1
  109. package/esm/relations/pivot-operations.mjs +1 -1
  110. package/esm/relations/relation-loader.d.mts +15 -5
  111. package/esm/relations/relation-loader.d.mts.map +1 -1
  112. package/esm/relations/relation-loader.mjs +51 -16
  113. package/esm/relations/relation-loader.mjs.map +1 -1
  114. package/esm/remover/database-remover.d.mts.map +1 -1
  115. package/esm/remover/database-remover.mjs +21 -7
  116. package/esm/remover/database-remover.mjs.map +1 -1
  117. package/esm/restorer/database-restorer.d.mts +12 -2
  118. package/esm/restorer/database-restorer.d.mts.map +1 -1
  119. package/esm/restorer/database-restorer.mjs +43 -13
  120. package/esm/restorer/database-restorer.mjs.map +1 -1
  121. package/esm/sync/model-events.mjs +63 -3
  122. package/esm/sync/model-events.mjs.map +1 -1
  123. package/esm/sync/model-sync-operation.d.mts.map +1 -1
  124. package/esm/sync/model-sync-operation.mjs +3 -2
  125. package/esm/sync/model-sync-operation.mjs.map +1 -1
  126. package/esm/sync/sync-manager.d.mts +3 -42
  127. package/esm/sync/sync-manager.d.mts.map +1 -1
  128. package/esm/sync/sync-manager.mjs +37 -126
  129. package/esm/sync/sync-manager.mjs.map +1 -1
  130. package/esm/utils/connect-to-database.d.mts.map +1 -1
  131. package/esm/utils/connect-to-database.mjs +4 -4
  132. package/esm/utils/connect-to-database.mjs.map +1 -1
  133. package/esm/utils/database-writer.utils.d.mts +1 -1
  134. package/esm/utils/database-writer.utils.d.mts.map +1 -1
  135. package/esm/utils/database-writer.utils.mjs +1 -0
  136. package/esm/utils/database-writer.utils.mjs.map +1 -1
  137. package/esm/utils/define-model.d.mts.map +1 -1
  138. package/esm/utils/define-model.mjs +2 -10
  139. package/esm/utils/define-model.mjs.map +1 -1
  140. package/esm/utils/is-valid-date-value.mjs +3 -2
  141. package/esm/utils/is-valid-date-value.mjs.map +1 -1
  142. package/esm/utils/once-connected.d.mts.map +1 -1
  143. package/esm/utils/once-connected.mjs +12 -2
  144. package/esm/utils/once-connected.mjs.map +1 -1
  145. package/esm/validation/mutators/embed-mutator.mjs +19 -11
  146. package/esm/validation/mutators/embed-mutator.mjs.map +1 -1
  147. package/esm/validation/plugins/embed-validator-plugin.d.mts +2 -2
  148. package/esm/validation/plugins/embed-validator-plugin.d.mts.map +1 -1
  149. package/esm/validation/plugins/embed-validator-plugin.mjs +8 -2
  150. package/esm/validation/plugins/embed-validator-plugin.mjs.map +1 -1
  151. package/esm/validation/rules/database-model-rule.mjs +6 -5
  152. package/esm/validation/rules/database-model-rule.mjs.map +1 -1
  153. package/esm/validation/rules/exists-rule.mjs +6 -4
  154. package/esm/validation/rules/exists-rule.mjs.map +1 -1
  155. package/esm/validation/rules/unique-rule.mjs +5 -2
  156. package/esm/validation/rules/unique-rule.mjs.map +1 -1
  157. package/esm/validation/validators/embed-validator.d.mts.map +1 -1
  158. package/esm/validation/validators/embed-validator.mjs +1 -1
  159. package/esm/validation/validators/embed-validator.mjs.map +1 -1
  160. package/esm/writer/database-writer.d.mts +27 -0
  161. package/esm/writer/database-writer.d.mts.map +1 -1
  162. package/esm/writer/database-writer.mjs +84 -17
  163. package/esm/writer/database-writer.mjs.map +1 -1
  164. package/package.json +4 -4
  165. package/esm/drivers/postgres/postgres-sync-adapter.d.mts +0 -79
  166. package/esm/drivers/postgres/postgres-sync-adapter.d.mts.map +0 -1
  167. package/esm/drivers/postgres/postgres-sync-adapter.mjs +0 -162
  168. package/esm/drivers/postgres/postgres-sync-adapter.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
- import { getModelFromRegistry, resolveModelName } from "../model/register-model.mjs";
2
1
  import { inferBelongsToForeignKey, inferHasForeignKey, inferPivotKey, inferPivotTable } from "./key-conventions.mjs";
2
+ import { getModelFromRegistry, resolveModelName } from "../model/register-model.mjs";
3
3
  import { isLazy } from "@mongez/reinforcements";
4
4
 
5
5
  //#region ../cascade/src/relations/relation-loader.ts
@@ -101,7 +101,13 @@ var RelationLoader = class RelationLoader {
101
101
  */
102
102
  get relationDefaults() {
103
103
  try {
104
- return this.modelClass.getDataSource()?.relationDefaults;
104
+ const dataSource = this.modelClass.getDataSource();
105
+ if (!dataSource) return void 0;
106
+ const namingConvention = dataSource.modelDefaults?.namingConvention ?? dataSource.driver?.modelDefaults?.namingConvention;
107
+ return {
108
+ ...dataSource.relationDefaults,
109
+ namingConvention
110
+ };
105
111
  } catch {
106
112
  return;
107
113
  }
@@ -129,21 +135,38 @@ var RelationLoader = class RelationLoader {
129
135
  async load(relations, constraints) {
130
136
  if (this.models.length === 0) return;
131
137
  const relationNames = Array.isArray(relations) ? relations : [relations];
138
+ const nodes = /* @__PURE__ */ new Map();
132
139
  for (const relationName of relationNames) {
140
+ const [root, ...rest] = this.parseNestedRelation(relationName);
141
+ const key = root ?? relationName;
142
+ if (!nodes.has(key)) nodes.set(key, {
143
+ children: [],
144
+ childConstraints: {}
145
+ });
146
+ const node = nodes.get(key);
133
147
  const constraint = constraints?.[relationName];
134
148
  const callbackConstraint = typeof constraint === "function" ? constraint : void 0;
135
- await this.loadRelation(relationName, callbackConstraint);
149
+ if (rest.length === 0) {
150
+ if (callbackConstraint) node.constraint = callbackConstraint;
151
+ continue;
152
+ }
153
+ const childPath = rest.join(".");
154
+ if (!node.children.includes(childPath)) node.children.push(childPath);
155
+ if (callbackConstraint) node.childConstraints[childPath] = callbackConstraint;
136
156
  }
157
+ for (const [root, node] of nodes) await this.loadRelation(root, node.constraint, node.children, node.childConstraints);
137
158
  }
138
159
  /**
139
- * Loads a single relation, handling nested relations via dot notation.
160
+ * Loads a single (root) relation once, then its child paths onto the same
161
+ * loaded instances.
140
162
  *
141
- * @param name - The relation name, possibly with dot notation for nesting
142
- * @param constraint - Optional constraint callback
163
+ * @param name - The root relation name
164
+ * @param constraint - Optional constraint callback, applied to this relation only
165
+ * @param children - Remaining dot paths below this relation
166
+ * @param childConstraints - Constraints keyed by the remaining dot paths
143
167
  */
144
- async loadRelation(name, constraint) {
145
- const path = this.parseNestedRelation(name);
146
- const rootRelation = path[0];
168
+ async loadRelation(name, constraint, children = [], childConstraints = {}) {
169
+ const rootRelation = name || void 0;
147
170
  if (rootRelation === void 0) throw new Error(`Relation name "${name}" could not be parsed into a relation path on model "${this.modelClass.name}".`);
148
171
  const definition = this.getRelationDefinition(rootRelation);
149
172
  if (!definition) throw new Error(`Relation "${rootRelation}" is not defined on model "${this.modelClass.name}". Make sure to define it in the static 'relations' property.`);
@@ -161,7 +184,7 @@ var RelationLoader = class RelationLoader {
161
184
  await this.loadBelongsToMany(rootRelation, definition, constraint);
162
185
  break;
163
186
  }
164
- if (path.length > 1) await this.loadNestedRelations(rootRelation, path.slice(1), constraint);
187
+ if (children.length > 0) await this.loadNestedRelations(rootRelation, children, childConstraints);
165
188
  }
166
189
  /**
167
190
  * Loads a hasMany relation for all models.
@@ -180,6 +203,7 @@ var RelationLoader = class RelationLoader {
180
203
  return;
181
204
  }
182
205
  const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);
206
+ this.applySelect(query, definition, [foreignKey]);
183
207
  if (constraint) constraint(query);
184
208
  const relatedRecords = await query.get();
185
209
  const recordsByForeignKey = this.groupBy(relatedRecords, foreignKey);
@@ -205,6 +229,7 @@ var RelationLoader = class RelationLoader {
205
229
  return;
206
230
  }
207
231
  const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);
232
+ this.applySelect(query, definition, [foreignKey]);
208
233
  if (constraint) constraint(query);
209
234
  const relatedRecords = await query.get();
210
235
  const recordsByForeignKey = /* @__PURE__ */ new Map();
@@ -234,6 +259,7 @@ var RelationLoader = class RelationLoader {
234
259
  return;
235
260
  }
236
261
  const query = RelatedModel.query().whereIn(ownerKey, foreignKeyValues);
262
+ this.applySelect(query, definition, [ownerKey]);
237
263
  if (constraint) constraint(query);
238
264
  const relatedRecords = await query.get();
239
265
  const recordsByOwnerKey = /* @__PURE__ */ new Map();
@@ -273,6 +299,7 @@ var RelationLoader = class RelationLoader {
273
299
  }
274
300
  const relatedIds = [...new Set(pivotRecords.map((p) => p[pivotForeignKey]))];
275
301
  const relatedQuery = RelatedModel.query().whereIn(relatedKey, relatedIds);
302
+ this.applySelect(relatedQuery, definition, [relatedKey]);
276
303
  if (constraint) constraint(relatedQuery);
277
304
  const relatedRecords = await relatedQuery.get();
278
305
  const relatedById = /* @__PURE__ */ new Map();
@@ -296,10 +323,10 @@ var RelationLoader = class RelationLoader {
296
323
  * Loads nested relations recursively.
297
324
  *
298
325
  * @param parentRelation - The name of the parent relation
299
- * @param remainingPath - The remaining path segments to load
300
- * @param constraint - Optional constraint callback
326
+ * @param remainingPaths - The remaining dot paths to load below the parent
327
+ * @param constraints - Constraints keyed by those remaining paths
301
328
  */
302
- async loadNestedRelations(parentRelation, remainingPath, constraint) {
329
+ async loadNestedRelations(parentRelation, remainingPaths, constraints) {
303
330
  const relatedModels = [];
304
331
  for (const model of this.models) {
305
332
  const loaded = this.getLoadedRelation(model, parentRelation);
@@ -309,9 +336,7 @@ var RelationLoader = class RelationLoader {
309
336
  if (relatedModels.length === 0) return;
310
337
  const parentDefinition = this.getRelationDefinition(parentRelation);
311
338
  if (!parentDefinition) return;
312
- const nestedLoader = new RelationLoader(relatedModels, this.resolveModelClass(parentDefinition.model));
313
- const nextRelation = remainingPath.join(".");
314
- await nestedLoader.load(nextRelation, constraint ? { [nextRelation]: constraint } : void 0);
339
+ await new RelationLoader(relatedModels, this.resolveModelClass(parentDefinition.model)).load(remainingPaths, constraints);
315
340
  }
316
341
  /**
317
342
  * Parses a dot-notation relation path into segments.
@@ -340,6 +365,16 @@ var RelationLoader = class RelationLoader {
340
365
  return ModelClass;
341
366
  }
342
367
  /**
368
+ * Applies the relation definition's `select` columns to the related query.
369
+ * Runs before the constraint callback so an explicit constraint can still
370
+ * override it. The key columns used to match records back to their owners
371
+ * are always included.
372
+ */
373
+ applySelect(query, definition, requiredKeys) {
374
+ if (!definition.select || definition.select.length === 0) return;
375
+ query.select([...new Set([...definition.select, ...requiredKeys])]);
376
+ }
377
+ /**
343
378
  * Gets the relation definition from the model class.
344
379
  *
345
380
  * @param name - The relation name
@@ -1 +1 @@
1
- {"version":3,"file":"relation-loader.mjs","names":[],"sources":["../../../../../../../cascade/src/relations/relation-loader.ts"],"sourcesContent":["/**\n * @fileoverview Core relation loading logic for the Cascade ORM.\n *\n * The RelationLoader is responsible for efficiently loading related models\n * while preventing N+1 query problems through batch loading strategies.\n *\n * @module @warlock.js/cascade/relations/relation-loader\n */\n\nimport { isLazy, type Lazy } from \"@mongez/reinforcements\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { getModelFromRegistry, resolveModelName } from \"../model/register-model\";\nimport {\n inferBelongsToForeignKey,\n inferHasForeignKey,\n inferPivotKey,\n inferPivotTable,\n} from \"./key-conventions\";\nimport type {\n LoadedRelationResult,\n RelationConstraintCallback,\n RelationConstraints,\n RelationDefinition,\n} from \"./types\";\n\n// ============================================================================\n// SHARED HELPER — SINGLE SOURCE OF TRUTH FOR LOADED RELATIONS\n// ============================================================================\n\n/**\n * Attach a loaded relation onto a model instance, keeping the\n * `loadedRelations` Map and the direct property access in sync.\n *\n * The property is installed as a `defineProperty` getter/setter façade over\n * the Map — `model.posts` reads from `loadedRelations.get(\"posts\")`, and\n * assigning `model.posts = newPosts` writes back to the Map. Eliminates the\n * historical drift between the two storage slots: any mutation visible via\n * one path is visible via the other.\n *\n * Used by both `RelationLoader` (the `with()` path) and the Postgres\n * driver's `attachJoinedRelations` (the `joinWith()` path).\n *\n * @example\n * attachLoadedRelation(user, \"posts\", postsArray);\n * user.posts === user.loadedRelations.get(\"posts\"); // always true\n */\nexport function attachLoadedRelation(\n model: object,\n name: string,\n value: LoadedRelationResult,\n): void {\n const modelWithRelations = model as {\n loadedRelations?: Map<string, LoadedRelationResult>;\n };\n\n if (!modelWithRelations.loadedRelations) {\n modelWithRelations.loadedRelations = new Map();\n }\n\n const relations = modelWithRelations.loadedRelations;\n relations.set(name, value);\n\n // Install a getter/setter on the instance so direct property access reads\n // from (and writes to) the Map. `configurable: true` lets a later reload\n // re-define the property; `enumerable: true` so JSON.stringify and\n // Object.keys see the relation.\n Object.defineProperty(model, name, {\n configurable: true,\n enumerable: true,\n get(): LoadedRelationResult | undefined {\n return relations.get(name);\n },\n set(next: LoadedRelationResult): void {\n relations.set(name, next);\n },\n });\n}\n\n// ============================================================================\n// RELATION LOADER CLASS\n// ============================================================================\n\n/**\n * Efficiently loads relationships for one or more model instances.\n *\n * The RelationLoader handles:\n * - Batch loading to prevent N+1 query problems\n * - Nested relation loading via dot notation\n * - Constrained loading with query callbacks\n * - All relation types (hasOne, hasMany, belongsTo, belongsToMany)\n *\n * @template TModel - The type of model being loaded\n *\n * @example\n * ```typescript\n * const users = await User.query().get();\n * const loader = new RelationLoader(users, User);\n *\n * // Load single relation\n * await loader.load(\"posts\");\n *\n * // Load multiple relations\n * await loader.load([\"posts\", \"organization\"]);\n *\n * // Load nested relations\n * await loader.load(\"posts.comments.author\");\n *\n * // Load with constraints\n * await loader.load(\"posts\", {\n * posts: (query) => query.where(\"isPublished\", true),\n * });\n * ```\n */\nexport class RelationLoader<TModel extends Model = Model> {\n // ==========================================================================\n // PRIVATE PROPERTIES\n // ==========================================================================\n\n /**\n * The model instances to load relations for.\n */\n private readonly models: TModel[];\n\n /**\n * The model class constructor.\n */\n private readonly modelClass: ChildModel<TModel>;\n\n // ==========================================================================\n // CONSTRUCTOR\n // ==========================================================================\n\n /**\n * Creates a new RelationLoader instance.\n *\n * @param models - The model instances to load relations for\n * @param modelClass - The model class constructor\n */\n public constructor(models: TModel[], modelClass: ChildModel<TModel>) {\n this.models = models;\n this.modelClass = modelClass;\n }\n\n /**\n * Read the configured relation conventions from this model's data\n * source. Returns `undefined` when no overrides are set — the inference\n * helpers fall back to framework defaults in that case.\n */\n private get relationDefaults() {\n try {\n return this.modelClass.getDataSource()?.relationDefaults;\n } catch {\n return undefined;\n }\n }\n\n // ==========================================================================\n // PUBLIC METHODS\n // ==========================================================================\n\n /**\n * Loads one or more relations for all model instances.\n *\n * @param relations - A single relation name, array of names, or constraints object\n * @param constraints - Optional constraints to apply to the loading query\n *\n * @example\n * ```typescript\n * // Single relation\n * await loader.load(\"posts\");\n *\n * // Multiple relations\n * await loader.load([\"posts\", \"organization\"]);\n *\n * // With constraints\n * await loader.load(\"posts\", {\n * posts: (query) => query.where(\"status\", \"active\"),\n * });\n * ```\n */\n public async load(\n relations: string | string[],\n constraints?: RelationConstraints,\n ): Promise<void> {\n // If no models, nothing to load\n if (this.models.length === 0) {\n return;\n }\n\n // Normalize to array\n const relationNames = Array.isArray(relations) ? relations : [relations];\n\n // Load each relation\n for (const relationName of relationNames) {\n const constraint = constraints?.[relationName];\n const callbackConstraint = typeof constraint === \"function\" ? constraint : undefined;\n\n await this.loadRelation(relationName, callbackConstraint);\n }\n }\n\n // ==========================================================================\n // PRIVATE METHODS - RELATION LOADING\n // ==========================================================================\n\n /**\n * Loads a single relation, handling nested relations via dot notation.\n *\n * @param name - The relation name, possibly with dot notation for nesting\n * @param constraint - Optional constraint callback\n */\n private async loadRelation(name: string, constraint?: RelationConstraintCallback): Promise<void> {\n const path = this.parseNestedRelation(name);\n const rootRelation = path[0];\n\n // An empty parse would otherwise reach `getRelationDefinition(undefined)`\n // and fail as `Relation \"undefined\" is not defined on model \"X\"` — a\n // message that sends the reader looking for a relation they never wrote,\n // instead of at the name they passed. Same failure, named correctly.\n if (rootRelation === undefined) {\n throw new Error(\n `Relation name \"${name}\" could not be parsed into a relation path on model ` +\n `\"${this.modelClass.name}\".`,\n );\n }\n\n // Get the relation definition from the model class\n const definition = this.getRelationDefinition(rootRelation);\n\n if (!definition) {\n throw new Error(\n `Relation \"${rootRelation}\" is not defined on model \"${this.modelClass.name}\". ` +\n `Make sure to define it in the static 'relations' property.`,\n );\n }\n\n // Load based on relation type\n switch (definition.type) {\n case \"hasMany\":\n await this.loadHasMany(rootRelation, definition, constraint);\n break;\n\n case \"hasOne\":\n await this.loadHasOne(rootRelation, definition, constraint);\n break;\n\n case \"belongsTo\":\n await this.loadBelongsTo(rootRelation, definition, constraint);\n break;\n\n case \"belongsToMany\":\n await this.loadBelongsToMany(rootRelation, definition, constraint);\n break;\n }\n\n // If there are nested relations, load them recursively\n if (path.length > 1) {\n await this.loadNestedRelations(rootRelation, path.slice(1), constraint);\n }\n }\n\n /**\n * Loads a hasMany relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadHasMany(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const localKey = definition.localKey ?? this.modelClass.primaryKey ?? \"id\";\n const foreignKey = definition.foreignKey ?? inferHasForeignKey(this.modelClass.name, this.relationDefaults);\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n // No values to query, set empty arrays\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Group by foreign key and assign to models\n const recordsByForeignKey = this.groupBy(relatedRecords as Model[], foreignKey);\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return recordsByForeignKey.get(keyValue) ?? [];\n });\n }\n\n /**\n * Loads a hasOne relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadHasOne(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const localKey = definition.localKey ?? this.modelClass.primaryKey ?? \"id\";\n const foreignKey = definition.foreignKey ?? inferHasForeignKey(this.modelClass.name, this.relationDefaults);\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n this.setRelationOnModels(name, () => null);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Index by foreign key\n const recordsByForeignKey = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n const fkValue = record.get(foreignKey);\n // For hasOne, take the first match only\n if (!recordsByForeignKey.has(fkValue)) {\n recordsByForeignKey.set(fkValue, record);\n }\n }\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return recordsByForeignKey.get(keyValue) ?? null;\n });\n }\n\n /**\n * Loads a belongsTo relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadBelongsTo(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const foreignKey = definition.foreignKey ?? inferBelongsToForeignKey(name, this.relationDefaults);\n const ownerKey = definition.localKey ?? RelatedModel.primaryKey ?? \"id\";\n\n // Collect all foreign key values from the models\n const foreignKeyValues = this.collectKeyValues(foreignKey);\n\n if (foreignKeyValues.length === 0) {\n this.setRelationOnModels(name, () => null);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(ownerKey, foreignKeyValues);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Index by owner key\n const recordsByOwnerKey = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n const keyValue = record.get(ownerKey);\n recordsByOwnerKey.set(keyValue, record);\n }\n\n this.setRelationOnModels(name, (model) => {\n const fkValue = model.get(foreignKey);\n return recordsByOwnerKey.get(fkValue) ?? null;\n });\n }\n\n /**\n * Loads a belongsToMany relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadBelongsToMany(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const relatedModelName = resolveModelName(definition.model);\n const pivotTable =\n definition.pivot ??\n inferPivotTable(this.modelClass.name, relatedModelName, this.relationDefaults);\n const localKey = definition.pivotLocalKey ?? this.modelClass.primaryKey ?? \"id\";\n const pivotLocalKey =\n definition.localKey ?? inferPivotKey(this.modelClass.name, this.relationDefaults);\n const pivotForeignKey =\n definition.foreignKey ?? inferPivotKey(relatedModelName, this.relationDefaults);\n const relatedKey = definition.pivotForeignKey ?? RelatedModel.primaryKey ?? \"id\";\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Step 1: Query the pivot table to get the relationships\n const dataSource = this.modelClass.getDataSource();\n const pivotQuery = dataSource.driver\n .queryBuilder(pivotTable)\n .whereIn(pivotLocalKey, localKeyValues);\n\n const pivotRecords = (await pivotQuery.get()) as Record<string, unknown>[];\n\n if (pivotRecords.length === 0) {\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Step 2: Collect related model IDs from pivot\n const relatedIds = [...new Set(pivotRecords.map((p) => p[pivotForeignKey]))];\n\n // Step 3: Query the related model\n const relatedQuery = RelatedModel.query().whereIn(relatedKey, relatedIds);\n\n if (constraint) {\n constraint(relatedQuery);\n }\n\n const relatedRecords = await relatedQuery.get();\n\n // Step 4: Index related records by their key\n const relatedById = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n relatedById.set(record.get(relatedKey), record);\n }\n\n // Step 5: Build the relationship map from pivot data\n const relationshipMap = new Map<unknown, Model[]>();\n\n for (const pivot of pivotRecords) {\n const localValue = pivot[pivotLocalKey];\n const foreignValue = pivot[pivotForeignKey];\n const relatedRecord = relatedById.get(foreignValue);\n\n if (relatedRecord) {\n if (!relationshipMap.has(localValue)) {\n relationshipMap.set(localValue, []);\n }\n relationshipMap.get(localValue)!.push(relatedRecord);\n }\n }\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return relationshipMap.get(keyValue) ?? [];\n });\n }\n\n // ==========================================================================\n // PRIVATE METHODS - NESTED RELATIONS\n // ==========================================================================\n\n /**\n * Loads nested relations recursively.\n *\n * @param parentRelation - The name of the parent relation\n * @param remainingPath - The remaining path segments to load\n * @param constraint - Optional constraint callback\n */\n private async loadNestedRelations(\n parentRelation: string,\n remainingPath: string[],\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n // Collect all loaded related models from the parent relation\n const relatedModels: Model[] = [];\n\n for (const model of this.models) {\n const loaded = this.getLoadedRelation(model, parentRelation);\n\n if (Array.isArray(loaded)) {\n relatedModels.push(...loaded);\n } else if (loaded) {\n relatedModels.push(loaded);\n }\n }\n\n if (relatedModels.length === 0) {\n return;\n }\n\n // Get the related model class\n const parentDefinition = this.getRelationDefinition(parentRelation);\n if (!parentDefinition) return;\n\n const RelatedModelClass = this.resolveModelClass(parentDefinition.model);\n\n // Create a new loader for the nested relation\n const nestedLoader = new RelationLoader(relatedModels, RelatedModelClass as ChildModel<Model>);\n\n // Load the next level\n const nextRelation = remainingPath.join(\".\");\n await nestedLoader.load(nextRelation, constraint ? { [nextRelation]: constraint } : undefined);\n }\n\n // ==========================================================================\n // PRIVATE METHODS - UTILITIES\n // ==========================================================================\n\n /**\n * Parses a dot-notation relation path into segments.\n *\n * @param path - The relation path (e.g., \"posts.comments.author\")\n * @returns An array of relation names\n */\n private parseNestedRelation(path: string): string[] {\n return path.split(\".\");\n }\n\n /**\n * Resolves a model class from the registry by name.\n *\n * @param name - The registered model name\n * @returns The model class constructor\n * @throws Error if the model is not found in the registry\n */\n private resolveModelClass(\n model: string | ChildModel<Model> | Lazy<unknown>,\n ): ChildModel<Model> {\n if (typeof model === \"function\") return model;\n if (isLazy(model)) return model.resolve() as ChildModel<Model>;\n\n const ModelClass = getModelFromRegistry(model);\n\n if (!ModelClass) {\n const callerName = this.modelClass.name || \"unknown\";\n throw new Error(\n `Cannot resolve relation target — model \"${model}\" is not registered.\\n` +\n ` Caller: ${callerName} (relation target reference)\\n` +\n ` Common causes:\\n` +\n ` - The target model is missing the @RegisterModel() decorator\\n` +\n ` - The target model's module is not imported anywhere at startup\\n` +\n ` - Circular import between the caller and target — one of them sees\\n` +\n ` the other as undefined during module load\\n` +\n ` Fix: add an explicit \\`import \"<path-to-${model}-model>\";\\` to your app's\\n` +\n ` entry point so the decorator runs before any query consults this relation.\\n` +\n ` Alternative: declare the relation with \\`lazy(() => ${model})\\` instead of a string\\n` +\n ` to bind directly to the class (no registry lookup needed).`,\n );\n }\n\n return ModelClass;\n }\n\n /**\n * Gets the relation definition from the model class.\n *\n * @param name - The relation name\n * @returns The relation definition or undefined\n */\n private getRelationDefinition(name: string): RelationDefinition | undefined {\n const relations = (\n this.modelClass as unknown as { relations?: Record<string, RelationDefinition> }\n ).relations;\n return relations?.[name];\n }\n\n /**\n * Collects unique key values from all models.\n *\n * @param key - The key to collect values for\n * @returns An array of unique key values\n */\n private collectKeyValues(key: string): unknown[] {\n const values = new Set<unknown>();\n\n for (const model of this.models) {\n const value = model.get(key);\n if (value !== undefined && value !== null) {\n values.add(value);\n }\n }\n\n return [...values];\n }\n\n /**\n * Groups records by a key value.\n *\n * @param records - The records to group\n * @param key - The key to group by\n * @returns A map of key values to records\n */\n private groupBy(records: Model[], key: string): Map<unknown, Model[]> {\n const groups = new Map<unknown, Model[]>();\n\n for (const record of records) {\n const keyValue = record.get(key);\n\n if (!groups.has(keyValue)) {\n groups.set(keyValue, []);\n }\n\n groups.get(keyValue)!.push(record);\n }\n\n return groups;\n }\n\n /**\n * Sets a relation value on all models using a getter function.\n *\n * @param name - The relation name\n * @param getter - Function that returns the relation value for each model\n */\n private setRelationOnModels(name: string, getter: (model: TModel) => LoadedRelationResult): void {\n for (const model of this.models) {\n const value = getter(model);\n this.setLoadedRelation(model, name, value);\n }\n }\n\n /**\n * Gets a loaded relation from a model instance.\n *\n * @param model - The model instance\n * @param name - The relation name\n * @returns The loaded relation value or undefined\n */\n private getLoadedRelation(model: TModel, name: string): LoadedRelationResult | undefined {\n const loadedRelations = (\n model as unknown as { loadedRelations?: Map<string, LoadedRelationResult> }\n ).loadedRelations;\n return loadedRelations?.get(name);\n }\n\n /**\n * Sets a loaded relation on a model instance.\n *\n * @param model - The model instance\n * @param name - The relation name\n * @param value - The relation value\n */\n private setLoadedRelation(model: TModel, name: string, value: LoadedRelationResult): void {\n attachLoadedRelation(model as object, name, value);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"relation-loader.mjs","names":[],"sources":["../../../../../../../cascade/src/relations/relation-loader.ts"],"sourcesContent":["/**\n * @fileoverview Core relation loading logic for the Cascade ORM.\n *\n * The RelationLoader is responsible for efficiently loading related models\n * while preventing N+1 query problems through batch loading strategies.\n *\n * @module @warlock.js/cascade/relations/relation-loader\n */\n\nimport { isLazy, type Lazy } from \"@mongez/reinforcements\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { getModelFromRegistry, resolveModelName } from \"../model/register-model\";\nimport {\n inferBelongsToForeignKey,\n inferHasForeignKey,\n inferPivotKey,\n inferPivotTable,\n} from \"./key-conventions\";\nimport type {\n LoadedRelationResult,\n RelationConstraintCallback,\n RelationConstraints,\n RelationDefinition,\n} from \"./types\";\n\n// ============================================================================\n// SHARED HELPER — SINGLE SOURCE OF TRUTH FOR LOADED RELATIONS\n// ============================================================================\n\n/**\n * Attach a loaded relation onto a model instance, keeping the\n * `loadedRelations` Map and the direct property access in sync.\n *\n * The property is installed as a `defineProperty` getter/setter façade over\n * the Map — `model.posts` reads from `loadedRelations.get(\"posts\")`, and\n * assigning `model.posts = newPosts` writes back to the Map. Eliminates the\n * historical drift between the two storage slots: any mutation visible via\n * one path is visible via the other.\n *\n * Used by both `RelationLoader` (the `with()` path) and the Postgres\n * driver's `attachJoinedRelations` (the `joinWith()` path).\n *\n * @example\n * attachLoadedRelation(user, \"posts\", postsArray);\n * user.posts === user.loadedRelations.get(\"posts\"); // always true\n */\nexport function attachLoadedRelation(\n model: object,\n name: string,\n value: LoadedRelationResult,\n): void {\n const modelWithRelations = model as {\n loadedRelations?: Map<string, LoadedRelationResult>;\n };\n\n if (!modelWithRelations.loadedRelations) {\n modelWithRelations.loadedRelations = new Map();\n }\n\n const relations = modelWithRelations.loadedRelations;\n relations.set(name, value);\n\n // Install a getter/setter on the instance so direct property access reads\n // from (and writes to) the Map. `configurable: true` lets a later reload\n // re-define the property; `enumerable: true` so JSON.stringify and\n // Object.keys see the relation.\n Object.defineProperty(model, name, {\n configurable: true,\n enumerable: true,\n get(): LoadedRelationResult | undefined {\n return relations.get(name);\n },\n set(next: LoadedRelationResult): void {\n relations.set(name, next);\n },\n });\n}\n\n// ============================================================================\n// RELATION LOADER CLASS\n// ============================================================================\n\n/**\n * Efficiently loads relationships for one or more model instances.\n *\n * The RelationLoader handles:\n * - Batch loading to prevent N+1 query problems\n * - Nested relation loading via dot notation\n * - Constrained loading with query callbacks\n * - All relation types (hasOne, hasMany, belongsTo, belongsToMany)\n *\n * @template TModel - The type of model being loaded\n *\n * @example\n * ```typescript\n * const users = await User.query().get();\n * const loader = new RelationLoader(users, User);\n *\n * // Load single relation\n * await loader.load(\"posts\");\n *\n * // Load multiple relations\n * await loader.load([\"posts\", \"organization\"]);\n *\n * // Load nested relations\n * await loader.load(\"posts.comments.author\");\n *\n * // Load with constraints\n * await loader.load(\"posts\", {\n * posts: (query) => query.where(\"isPublished\", true),\n * });\n * ```\n */\nexport class RelationLoader<TModel extends Model = Model> {\n // ==========================================================================\n // PRIVATE PROPERTIES\n // ==========================================================================\n\n /**\n * The model instances to load relations for.\n */\n private readonly models: TModel[];\n\n /**\n * The model class constructor.\n */\n private readonly modelClass: ChildModel<TModel>;\n\n // ==========================================================================\n // CONSTRUCTOR\n // ==========================================================================\n\n /**\n * Creates a new RelationLoader instance.\n *\n * @param models - The model instances to load relations for\n * @param modelClass - The model class constructor\n */\n public constructor(models: TModel[], modelClass: ChildModel<TModel>) {\n this.models = models;\n this.modelClass = modelClass;\n }\n\n /**\n * Read the configured relation conventions from this model's data\n * source. Returns `undefined` when no overrides are set — the inference\n * helpers fall back to framework defaults in that case.\n */\n private get relationDefaults() {\n try {\n const dataSource = this.modelClass.getDataSource();\n\n if (!dataSource) return undefined;\n\n const namingConvention =\n dataSource.modelDefaults?.namingConvention ??\n dataSource.driver?.modelDefaults?.namingConvention;\n\n return { ...dataSource.relationDefaults, namingConvention };\n } catch {\n return undefined;\n }\n }\n\n // ==========================================================================\n // PUBLIC METHODS\n // ==========================================================================\n\n /**\n * Loads one or more relations for all model instances.\n *\n * @param relations - A single relation name, array of names, or constraints object\n * @param constraints - Optional constraints to apply to the loading query\n *\n * @example\n * ```typescript\n * // Single relation\n * await loader.load(\"posts\");\n *\n * // Multiple relations\n * await loader.load([\"posts\", \"organization\"]);\n *\n * // With constraints\n * await loader.load(\"posts\", {\n * posts: (query) => query.where(\"status\", \"active\"),\n * });\n * ```\n */\n public async load(\n relations: string | string[],\n constraints?: RelationConstraints,\n ): Promise<void> {\n // If no models, nothing to load\n if (this.models.length === 0) {\n return;\n }\n\n // Normalize to array\n const relationNames = Array.isArray(relations) ? relations : [relations];\n\n // Group the paths into a tree keyed by root relation, so a shared prefix\n // (\"posts.comments\", \"posts.tags\") is loaded ONCE and every child path is\n // loaded onto those same instances instead of overwriting them.\n const nodes = new Map<\n string,\n {\n constraint?: RelationConstraintCallback;\n children: string[];\n childConstraints: RelationConstraints;\n }\n >();\n\n for (const relationName of relationNames) {\n const [root, ...rest] = this.parseNestedRelation(relationName);\n const key = root ?? relationName;\n\n if (!nodes.has(key)) {\n nodes.set(key, { children: [], childConstraints: {} });\n }\n\n const node = nodes.get(key)!;\n const constraint = constraints?.[relationName];\n const callbackConstraint = typeof constraint === \"function\" ? constraint : undefined;\n\n if (rest.length === 0) {\n // A constraint belongs to the LAST segment of its path only.\n if (callbackConstraint) node.constraint = callbackConstraint;\n continue;\n }\n\n const childPath = rest.join(\".\");\n\n if (!node.children.includes(childPath)) {\n node.children.push(childPath);\n }\n\n if (callbackConstraint) node.childConstraints[childPath] = callbackConstraint;\n }\n\n for (const [root, node] of nodes) {\n await this.loadRelation(root, node.constraint, node.children, node.childConstraints);\n }\n }\n\n // ==========================================================================\n // PRIVATE METHODS - RELATION LOADING\n // ==========================================================================\n\n /**\n * Loads a single (root) relation once, then its child paths onto the same\n * loaded instances.\n *\n * @param name - The root relation name\n * @param constraint - Optional constraint callback, applied to this relation only\n * @param children - Remaining dot paths below this relation\n * @param childConstraints - Constraints keyed by the remaining dot paths\n */\n private async loadRelation(\n name: string,\n constraint?: RelationConstraintCallback,\n children: string[] = [],\n childConstraints: RelationConstraints = {},\n ): Promise<void> {\n const rootRelation: string | undefined = name || undefined;\n\n // An empty parse would otherwise reach `getRelationDefinition(undefined)`\n // and fail as `Relation \"undefined\" is not defined on model \"X\"` — a\n // message that sends the reader looking for a relation they never wrote,\n // instead of at the name they passed. Same failure, named correctly.\n if (rootRelation === undefined) {\n throw new Error(\n `Relation name \"${name}\" could not be parsed into a relation path on model ` +\n `\"${this.modelClass.name}\".`,\n );\n }\n\n // Get the relation definition from the model class\n const definition = this.getRelationDefinition(rootRelation);\n\n if (!definition) {\n throw new Error(\n `Relation \"${rootRelation}\" is not defined on model \"${this.modelClass.name}\". ` +\n `Make sure to define it in the static 'relations' property.`,\n );\n }\n\n // Load based on relation type\n switch (definition.type) {\n case \"hasMany\":\n await this.loadHasMany(rootRelation, definition, constraint);\n break;\n\n case \"hasOne\":\n await this.loadHasOne(rootRelation, definition, constraint);\n break;\n\n case \"belongsTo\":\n await this.loadBelongsTo(rootRelation, definition, constraint);\n break;\n\n case \"belongsToMany\":\n await this.loadBelongsToMany(rootRelation, definition, constraint);\n break;\n }\n\n // If there are nested relations, load them recursively\n if (children.length > 0) {\n await this.loadNestedRelations(rootRelation, children, childConstraints);\n }\n }\n\n /**\n * Loads a hasMany relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadHasMany(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const localKey = definition.localKey ?? this.modelClass.primaryKey ?? \"id\";\n const foreignKey = definition.foreignKey ?? inferHasForeignKey(this.modelClass.name, this.relationDefaults);\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n // No values to query, set empty arrays\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);\n\n this.applySelect(query, definition, [foreignKey]);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Group by foreign key and assign to models\n const recordsByForeignKey = this.groupBy(relatedRecords as Model[], foreignKey);\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return recordsByForeignKey.get(keyValue) ?? [];\n });\n }\n\n /**\n * Loads a hasOne relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadHasOne(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const localKey = definition.localKey ?? this.modelClass.primaryKey ?? \"id\";\n const foreignKey = definition.foreignKey ?? inferHasForeignKey(this.modelClass.name, this.relationDefaults);\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n this.setRelationOnModels(name, () => null);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(foreignKey, localKeyValues);\n\n this.applySelect(query, definition, [foreignKey]);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Index by foreign key\n const recordsByForeignKey = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n const fkValue = record.get(foreignKey);\n // For hasOne, take the first match only\n if (!recordsByForeignKey.has(fkValue)) {\n recordsByForeignKey.set(fkValue, record);\n }\n }\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return recordsByForeignKey.get(keyValue) ?? null;\n });\n }\n\n /**\n * Loads a belongsTo relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadBelongsTo(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const foreignKey = definition.foreignKey ?? inferBelongsToForeignKey(name, this.relationDefaults);\n const ownerKey = definition.localKey ?? RelatedModel.primaryKey ?? \"id\";\n\n // Collect all foreign key values from the models\n const foreignKeyValues = this.collectKeyValues(foreignKey);\n\n if (foreignKeyValues.length === 0) {\n this.setRelationOnModels(name, () => null);\n return;\n }\n\n // Build and execute query\n const query = RelatedModel.query().whereIn(ownerKey, foreignKeyValues);\n\n this.applySelect(query, definition, [ownerKey]);\n\n if (constraint) {\n constraint(query);\n }\n\n const relatedRecords = await query.get();\n\n // Index by owner key\n const recordsByOwnerKey = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n const keyValue = record.get(ownerKey);\n recordsByOwnerKey.set(keyValue, record);\n }\n\n this.setRelationOnModels(name, (model) => {\n const fkValue = model.get(foreignKey);\n return recordsByOwnerKey.get(fkValue) ?? null;\n });\n }\n\n /**\n * Loads a belongsToMany relation for all models.\n *\n * @param name - The relation name\n * @param definition - The relation definition\n * @param constraint - Optional constraint callback\n */\n private async loadBelongsToMany(\n name: string,\n definition: RelationDefinition,\n constraint?: RelationConstraintCallback,\n ): Promise<void> {\n const RelatedModel = this.resolveModelClass(definition.model);\n const relatedModelName = resolveModelName(definition.model);\n const pivotTable =\n definition.pivot ??\n inferPivotTable(this.modelClass.name, relatedModelName, this.relationDefaults);\n const localKey = definition.pivotLocalKey ?? this.modelClass.primaryKey ?? \"id\";\n const pivotLocalKey =\n definition.localKey ?? inferPivotKey(this.modelClass.name, this.relationDefaults);\n const pivotForeignKey =\n definition.foreignKey ?? inferPivotKey(relatedModelName, this.relationDefaults);\n const relatedKey = definition.pivotForeignKey ?? RelatedModel.primaryKey ?? \"id\";\n\n // Collect all local key values\n const localKeyValues = this.collectKeyValues(localKey);\n\n if (localKeyValues.length === 0) {\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Step 1: Query the pivot table to get the relationships\n const dataSource = this.modelClass.getDataSource();\n const pivotQuery = dataSource.driver\n .queryBuilder(pivotTable)\n .whereIn(pivotLocalKey, localKeyValues);\n\n const pivotRecords = (await pivotQuery.get()) as Record<string, unknown>[];\n\n if (pivotRecords.length === 0) {\n this.setRelationOnModels(name, () => []);\n return;\n }\n\n // Step 2: Collect related model IDs from pivot\n const relatedIds = [...new Set(pivotRecords.map((p) => p[pivotForeignKey]))];\n\n // Step 3: Query the related model\n const relatedQuery = RelatedModel.query().whereIn(relatedKey, relatedIds);\n\n this.applySelect(relatedQuery, definition, [relatedKey]);\n\n if (constraint) {\n constraint(relatedQuery);\n }\n\n const relatedRecords = await relatedQuery.get();\n\n // Step 4: Index related records by their key\n const relatedById = new Map<unknown, Model>();\n for (const record of relatedRecords as Model[]) {\n relatedById.set(record.get(relatedKey), record);\n }\n\n // Step 5: Build the relationship map from pivot data\n const relationshipMap = new Map<unknown, Model[]>();\n\n for (const pivot of pivotRecords) {\n const localValue = pivot[pivotLocalKey];\n const foreignValue = pivot[pivotForeignKey];\n const relatedRecord = relatedById.get(foreignValue);\n\n if (relatedRecord) {\n if (!relationshipMap.has(localValue)) {\n relationshipMap.set(localValue, []);\n }\n relationshipMap.get(localValue)!.push(relatedRecord);\n }\n }\n\n this.setRelationOnModels(name, (model) => {\n const keyValue = model.get(localKey);\n return relationshipMap.get(keyValue) ?? [];\n });\n }\n\n // ==========================================================================\n // PRIVATE METHODS - NESTED RELATIONS\n // ==========================================================================\n\n /**\n * Loads nested relations recursively.\n *\n * @param parentRelation - The name of the parent relation\n * @param remainingPaths - The remaining dot paths to load below the parent\n * @param constraints - Constraints keyed by those remaining paths\n */\n private async loadNestedRelations(\n parentRelation: string,\n remainingPaths: string[],\n constraints: RelationConstraints,\n ): Promise<void> {\n // Collect all loaded related models from the parent relation\n const relatedModels: Model[] = [];\n\n for (const model of this.models) {\n const loaded = this.getLoadedRelation(model, parentRelation);\n\n if (Array.isArray(loaded)) {\n relatedModels.push(...loaded);\n } else if (loaded) {\n relatedModels.push(loaded);\n }\n }\n\n if (relatedModels.length === 0) {\n return;\n }\n\n // Get the related model class\n const parentDefinition = this.getRelationDefinition(parentRelation);\n if (!parentDefinition) return;\n\n const RelatedModelClass = this.resolveModelClass(parentDefinition.model);\n\n // Create a new loader for the nested relation\n const nestedLoader = new RelationLoader(relatedModels, RelatedModelClass as ChildModel<Model>);\n\n // Load the next level\n await nestedLoader.load(remainingPaths, constraints);\n }\n\n // ==========================================================================\n // PRIVATE METHODS - UTILITIES\n // ==========================================================================\n\n /**\n * Parses a dot-notation relation path into segments.\n *\n * @param path - The relation path (e.g., \"posts.comments.author\")\n * @returns An array of relation names\n */\n private parseNestedRelation(path: string): string[] {\n return path.split(\".\");\n }\n\n /**\n * Resolves a model class from the registry by name.\n *\n * @param name - The registered model name\n * @returns The model class constructor\n * @throws Error if the model is not found in the registry\n */\n private resolveModelClass(\n model: string | ChildModel<Model> | Lazy<unknown>,\n ): ChildModel<Model> {\n if (typeof model === \"function\") return model;\n if (isLazy(model)) return model.resolve() as ChildModel<Model>;\n\n const ModelClass = getModelFromRegistry(model);\n\n if (!ModelClass) {\n const callerName = this.modelClass.name || \"unknown\";\n throw new Error(\n `Cannot resolve relation target — model \"${model}\" is not registered.\\n` +\n ` Caller: ${callerName} (relation target reference)\\n` +\n ` Common causes:\\n` +\n ` - The target model is missing the @RegisterModel() decorator\\n` +\n ` - The target model's module is not imported anywhere at startup\\n` +\n ` - Circular import between the caller and target — one of them sees\\n` +\n ` the other as undefined during module load\\n` +\n ` Fix: add an explicit \\`import \"<path-to-${model}-model>\";\\` to your app's\\n` +\n ` entry point so the decorator runs before any query consults this relation.\\n` +\n ` Alternative: declare the relation with \\`lazy(() => ${model})\\` instead of a string\\n` +\n ` to bind directly to the class (no registry lookup needed).`,\n );\n }\n\n return ModelClass;\n }\n\n /**\n * Applies the relation definition's `select` columns to the related query.\n * Runs before the constraint callback so an explicit constraint can still\n * override it. The key columns used to match records back to their owners\n * are always included.\n */\n private applySelect(\n query: { select: (columns: string[]) => unknown },\n definition: RelationDefinition,\n requiredKeys: string[],\n ): void {\n if (!definition.select || definition.select.length === 0) return;\n\n query.select([...new Set([...definition.select, ...requiredKeys])]);\n }\n\n /**\n * Gets the relation definition from the model class.\n *\n * @param name - The relation name\n * @returns The relation definition or undefined\n */\n private getRelationDefinition(name: string): RelationDefinition | undefined {\n const relations = (\n this.modelClass as unknown as { relations?: Record<string, RelationDefinition> }\n ).relations;\n return relations?.[name];\n }\n\n /**\n * Collects unique key values from all models.\n *\n * @param key - The key to collect values for\n * @returns An array of unique key values\n */\n private collectKeyValues(key: string): unknown[] {\n const values = new Set<unknown>();\n\n for (const model of this.models) {\n const value = model.get(key);\n if (value !== undefined && value !== null) {\n values.add(value);\n }\n }\n\n return [...values];\n }\n\n /**\n * Groups records by a key value.\n *\n * @param records - The records to group\n * @param key - The key to group by\n * @returns A map of key values to records\n */\n private groupBy(records: Model[], key: string): Map<unknown, Model[]> {\n const groups = new Map<unknown, Model[]>();\n\n for (const record of records) {\n const keyValue = record.get(key);\n\n if (!groups.has(keyValue)) {\n groups.set(keyValue, []);\n }\n\n groups.get(keyValue)!.push(record);\n }\n\n return groups;\n }\n\n /**\n * Sets a relation value on all models using a getter function.\n *\n * @param name - The relation name\n * @param getter - Function that returns the relation value for each model\n */\n private setRelationOnModels(name: string, getter: (model: TModel) => LoadedRelationResult): void {\n for (const model of this.models) {\n const value = getter(model);\n this.setLoadedRelation(model, name, value);\n }\n }\n\n /**\n * Gets a loaded relation from a model instance.\n *\n * @param model - The model instance\n * @param name - The relation name\n * @returns The loaded relation value or undefined\n */\n private getLoadedRelation(model: TModel, name: string): LoadedRelationResult | undefined {\n const loadedRelations = (\n model as unknown as { loadedRelations?: Map<string, LoadedRelationResult> }\n ).loadedRelations;\n return loadedRelations?.get(name);\n }\n\n /**\n * Sets a loaded relation on a model instance.\n *\n * @param model - The model instance\n * @param name - The relation name\n * @param value - The relation value\n */\n private setLoadedRelation(model: TModel, name: string, value: LoadedRelationResult): void {\n attachLoadedRelation(model as object, name, value);\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"database-remover.d.mts","names":[],"sources":["../../../../../../../cascade/src/remover/database-remover.ts"],"mappings":";;;;;;AAmCA;;;;;;;;;;;;;;;;;;cAAa,eAAA,YAA2B,eAAA;EA+BnB;EAAA,iBA7BF,KAAA;EA6Ca;EAAA,iBA1Cb,IAAA;EA0CmC;EAAA,iBAvCnC,UAAA;EAsMT;EAAA,iBAnMS,MAAA;EA6OH;EAAA,iBA1OG,KAAA;EA0OQ;EAAA,iBAvOR,UAAA;;;;;;;;;;;;;cAcE,KAAA,EAAO,KAAA;;;;;;;;EAgBb,OAAA,CAAQ,OAAA,GAAS,cAAA,GAAsB,OAAA,CAAQ,aAAA;;;;;;;;;;;;;;;;;;;;;UA+JpD,kBAAA;;;;;;;;;;;;UAsBA,iBAAA;;;;;;;;;UAoBM,WAAA;AAAA"}
1
+ {"version":3,"file":"database-remover.d.mts","names":[],"sources":["../../../../../../../cascade/src/remover/database-remover.ts"],"mappings":";;;;;;AAoCA;;;;;;;;;;;;;;;;;;cAAa,eAAA,YAA2B,eAAA;EA+BnB;EAAA,iBA7BF,KAAA;EA6Ca;EAAA,iBA1Cb,IAAA;EA0CmC;EAAA,iBAvCnC,UAAA;EAoNT;EAAA,iBAjNS,MAAA;EA8PH;EAAA,iBA3PG,KAAA;EA2PQ;EAAA,iBAxPR,UAAA;;;;;;;;;;;;;cAcE,KAAA,EAAO,KAAA;;;;;;;;EAgBb,OAAA,CAAQ,OAAA,GAAS,cAAA,GAAsB,OAAA,CAAQ,aAAA;;;;;;;;;;;;;;;;;;;;;UA6KpD,kBAAA;;;;;;;;;;;;UAyBA,iBAAA;;;;;;;;;UAoBM,WAAA;AAAA"}
@@ -1,5 +1,6 @@
1
- import { getModelDeletedEvent } from "../sync/model-events.mjs";
2
- import events from "@mongez/events";
1
+ import { afterCommit } from "../transactions/after-commit.mjs";
2
+ import { triggerModelEvent } from "../sync/model-events.mjs";
3
+ import { log } from "@warlock.js/logger";
3
4
 
4
5
  //#region ../cascade/src/remover/database-remover.ts
5
6
  /**
@@ -98,8 +99,16 @@ var DatabaseRemover = class {
98
99
  if (deletedAtColumn === false || deletedAtColumn === void 0) throw new Error(`Cannot perform soft delete on ${this.ctor.name}: deletedAtColumn is not configured. Set a column name or use a different delete strategy.`);
99
100
  const deletedAt = /* @__PURE__ */ new Date();
100
101
  const updateOperations = { $set: { [deletedAtColumn]: deletedAt } };
101
- deletedCount = (await this.driver.update(this.table, filter, updateOperations)).modifiedCount > 0 ? 1 : 0;
102
- if (deletedCount > 0) this.model.set(deletedAtColumn, deletedAt);
102
+ const softFilter = {
103
+ ...filter,
104
+ [deletedAtColumn]: null
105
+ };
106
+ deletedCount = (await this.driver.update(this.table, softFilter, updateOperations)).modifiedCount > 0 ? 1 : 0;
107
+ if (deletedCount > 0) {
108
+ this.model.set(deletedAtColumn, deletedAt);
109
+ const dirty = this.model.dirtyTracker.getDirtyColumns();
110
+ if (dirty.length === 1 && dirty[0] === deletedAtColumn) this.model.dirtyTracker.reset(this.model.data);
111
+ }
103
112
  break;
104
113
  }
105
114
  }
@@ -107,7 +116,7 @@ var DatabaseRemover = class {
107
116
  context.deletedCount = deletedCount;
108
117
  if (strategy !== "soft") this.model.isNew = true;
109
118
  if (!options.skipEvents) await this.model.emitEvent("deleted", context);
110
- if (!options.skipSync) this.triggerSync();
119
+ if (!options.skipSync && strategy !== "soft") afterCommit(() => this.triggerSync());
111
120
  return {
112
121
  success: true,
113
122
  deletedCount,
@@ -136,9 +145,10 @@ var DatabaseRemover = class {
136
145
  * @private
137
146
  */
138
147
  prepareTrashRecord(documentData) {
148
+ const deletedAtColumn = this.ctor.deletedAtColumn;
139
149
  return {
140
150
  ...documentData,
141
- deletedAt: /* @__PURE__ */ new Date(),
151
+ [typeof deletedAtColumn === "string" ? deletedAtColumn : "deletedAt"]: /* @__PURE__ */ new Date(),
142
152
  originalTable: this.table
143
153
  };
144
154
  }
@@ -167,7 +177,11 @@ var DatabaseRemover = class {
167
177
  * @private
168
178
  */
169
179
  async triggerSync() {
170
- await events.triggerAll(getModelDeletedEvent(this.ctor), this.model);
180
+ try {
181
+ await triggerModelEvent(this.ctor, "deleted", this.model);
182
+ } catch (error) {
183
+ log.error("database", "sync.failed", `[cascade] sync failed for ${this.ctor.name}: ${error}`);
184
+ }
171
185
  }
172
186
  };
173
187
 
@@ -1 +1 @@
1
- {"version":3,"file":"database-remover.mjs","names":[],"sources":["../../../../../../../cascade/src/remover/database-remover.ts"],"sourcesContent":["import events from \"@mongez/events\";\nimport type {\n DriverContract,\n UpdateOperations,\n} from \"../contracts/database-driver.contract\";\nimport type {\n RemoverContract,\n RemoverOptions,\n RemoverResult,\n} from \"../contracts/database-remover.contract\";\nimport type { OnDeletedEventContext } from \"../events/model-events\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { getModelDeletedEvent } from \"../sync/model-events\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database remover service that orchestrates model deletion.\n *\n * Handles the complete deletion pipeline:\n * 1. Strategy resolution (options → model static → data source default)\n * 2. Validation (check if model is new, has primary key)\n * 3. Event emission (deleting, deleted)\n * 4. Driver execution (based on strategy: trash, permanent, or soft)\n * 5. Post-deletion cleanup (mark as new, reset state)\n *\n * @example\n * ```typescript\n * const user = await User.find(1);\n * const remover = new DatabaseRemover(user);\n * const result = await remover.destroy();\n *\n * console.log(result.success); // true\n * console.log(result.strategy); // \"trash\" | \"permanent\" | \"soft\"\n * ```\n */\nexport class DatabaseRemover implements RemoverContract {\n /** The model instance being deleted */\n private readonly model: Model;\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver */\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 /**\n * Create a new remover instance for a model.\n *\n * @param model - The model instance to delete\n *\n * @example\n * ```typescript\n * const user = await User.find(1);\n * const remover = new DatabaseRemover(user);\n * await remover.destroy();\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 }\n\n /**\n * Destroy (delete) the model instance from the database.\n *\n * @param options - Remover options\n * @returns Result containing success status, strategy used, and metadata\n * @throws {Error} If model is new (not saved) or if deletion fails\n */\n public async destroy(options: RemoverOptions = {}): Promise<RemoverResult> {\n // 1. Resolve strategy (options → model static → data source default → permanent)\n const strategy =\n options.strategy ??\n this.ctor.deleteStrategy ??\n this.dataSource.defaultDeleteStrategy ??\n \"permanent\";\n\n // 2. Validate model is not new and has primary key\n if (this.model.isNew) {\n throw new Error(\n `Cannot destroy ${this.ctor.name} instance that hasn't been saved to the database.`,\n );\n }\n\n // The value captured when the model became persisted, never the current\n // (mass-assignable) one — a delete must not be retargetable by a payload\n // carrying `{ id: \"<victim-id>\" }`. Same rule as the writer's update filter.\n const primaryKeyValue = this.model.trustedPrimaryKey;\n if (!primaryKeyValue) {\n throw new Error(\n `Cannot destroy ${this.ctor.name} instance: primary key (${this.primaryKey}) is missing.`,\n );\n }\n\n // 3. Emit deleting event (unless skipEvents)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"deleting\", {\n strategy,\n primaryKeyValue,\n primaryKey: this.primaryKey,\n });\n }\n\n // 4. Execute deletion based on strategy\n let deletedCount = 0;\n let trashRecord: Record<string, unknown> | undefined;\n\n const filter = { [this.primaryKey]: primaryKeyValue };\n\n const context: Partial<OnDeletedEventContext> = {\n strategy,\n primaryKeyValue,\n primaryKey: this.primaryKey,\n };\n\n switch (strategy) {\n case \"trash\": {\n // Move to trash table, then delete\n const trashTable = this.resolveTrashTable();\n const documentData = { ...this.model.data };\n\n // Prepare trash record with metadata and handle ID conflicts\n const trashData = this.prepareTrashRecord(documentData);\n\n // Insert into trash table\n const insertResult = await this.driver.insert(trashTable, trashData);\n trashRecord = insertResult.document as Record<string, unknown>;\n\n context.trashRecord = trashRecord;\n\n // Delete original\n const result = await this.driver.delete(this.table, filter);\n deletedCount = result > 0 ? 1 : 0;\n break;\n }\n\n case \"permanent\": {\n // Direct deletion\n const result = await this.driver.delete(this.table, filter);\n deletedCount = result > 0 ? 1 : 0;\n break;\n }\n\n case \"soft\": {\n // Set deletedAt timestamp (using resolved column name)\n const deletedAtColumn = this.ctor.deletedAtColumn;\n\n // Only proceed if deletedAtColumn is configured (not false or undefined)\n if (deletedAtColumn === false || deletedAtColumn === undefined) {\n throw new Error(\n `Cannot perform soft delete on ${this.ctor.name}: deletedAtColumn is not configured. ` +\n `Set a column name or use a different delete strategy.`,\n );\n }\n\n const deletedAt = new Date();\n const updateOperations: UpdateOperations = {\n $set: { [deletedAtColumn]: deletedAt },\n };\n const updateResult = await this.driver.update(\n this.table,\n filter,\n updateOperations,\n );\n deletedCount = updateResult.modifiedCount > 0 ? 1 : 0;\n\n // The row stays (unlike trash/permanent), so reflect the persisted\n // timestamp on the in-memory model — otherwise the instance is stale\n // and `model.get(deletedAtColumn)` stays undefined after destroy().\n if (deletedCount > 0) {\n this.model.set(deletedAtColumn, deletedAt);\n }\n break;\n }\n }\n\n if (deletedCount === 0) {\n throw new Error(\n `Failed to destroy ${this.ctor.name} instance: record not found.`,\n );\n }\n\n context.deletedCount = deletedCount;\n\n // 5. Post-deletion cleanup\n // Only mark as new for permanent and trash (soft delete keeps the record)\n if (strategy !== \"soft\") {\n this.model.isNew = true;\n }\n\n // 6. Emit deleted event (unless skipEvents)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"deleted\", context);\n }\n\n // 7. Trigger sync operations (fire-and-forget, non-blocking)\n if (!options.skipSync) {\n void this.triggerSync();\n }\n\n return {\n success: true,\n deletedCount,\n strategy,\n trashRecord,\n };\n }\n\n /**\n * Prepare the trash record by preserving all original fields and adding deletion metadata.\n *\n * Keeps all original fields intact for easy restoration and adds:\n * - `deletedAt`: Timestamp when the record was deleted\n * - `originalTable`: The table/collection the record came from (for filtering in restoreAll)\n *\n * **ID Handling:**\n * - MongoDB with `_id`: Keeps `_id` as-is (unique across database)\n * - MongoDB with auto-increment `id`: Keeps `id` as a regular field (not primary key)\n * - SQL: Keeps original `id` as a regular field (trash table uses its own auto-increment primary key)\n *\n * The trash table should use its own primary key structure:\n * - MongoDB: Uses `_id` (ObjectId) as primary key, original `id` is just a field\n * - SQL: Uses auto-increment `trashId` as primary key, original `id` is just a field\n *\n * @param documentData - The original document data\n * @returns Prepared trash record data with all original fields + deletedAt + originalTable\n * @private\n */\n private prepareTrashRecord(\n documentData: Record<string, unknown>,\n ): Record<string, unknown> {\n // Preserve all original fields and add deletion metadata\n return {\n ...documentData,\n deletedAt: new Date(),\n originalTable: this.table,\n };\n }\n\n /**\n * Resolve the trash table/collection name.\n *\n * Priority:\n * 1. Model.trashTable (if set)\n * 2. Data source defaultTrashTable (e.g., \"RecycleBin\" for MongoDB)\n * 3. Default pattern: `{table}Trash`\n *\n * @returns The trash table/collection name\n * @private\n */\n private resolveTrashTable(): string {\n if (this.ctor.trashTable) {\n return this.ctor.trashTable;\n }\n\n if (this.dataSource.defaultTrashTable) {\n return this.dataSource.defaultTrashTable;\n }\n\n return `${this.table}Trash`;\n }\n\n /**\n * Trigger sync operations after successful deletion.\n *\n * Emits a model.deleted event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @private\n */\n private async triggerSync(): Promise<void> {\n // Emit model.deleted event - ModelSyncOperation listens to these\n await events.triggerAll(getModelDeletedEvent(this.ctor), this.model);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database-remover.mjs","names":[],"sources":["../../../../../../../cascade/src/remover/database-remover.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport type {\n DriverContract,\n UpdateOperations,\n} from \"../contracts/database-driver.contract\";\nimport type {\n RemoverContract,\n RemoverOptions,\n RemoverResult,\n} from \"../contracts/database-remover.contract\";\nimport type { OnDeletedEventContext } from \"../events/model-events\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport { afterCommit } from \"../transactions/after-commit\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database remover service that orchestrates model deletion.\n *\n * Handles the complete deletion pipeline:\n * 1. Strategy resolution (options → model static → data source default)\n * 2. Validation (check if model is new, has primary key)\n * 3. Event emission (deleting, deleted)\n * 4. Driver execution (based on strategy: trash, permanent, or soft)\n * 5. Post-deletion cleanup (mark as new, reset state)\n *\n * @example\n * ```typescript\n * const user = await User.find(1);\n * const remover = new DatabaseRemover(user);\n * const result = await remover.destroy();\n *\n * console.log(result.success); // true\n * console.log(result.strategy); // \"trash\" | \"permanent\" | \"soft\"\n * ```\n */\nexport class DatabaseRemover implements RemoverContract {\n /** The model instance being deleted */\n private readonly model: Model;\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver */\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 /**\n * Create a new remover instance for a model.\n *\n * @param model - The model instance to delete\n *\n * @example\n * ```typescript\n * const user = await User.find(1);\n * const remover = new DatabaseRemover(user);\n * await remover.destroy();\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 }\n\n /**\n * Destroy (delete) the model instance from the database.\n *\n * @param options - Remover options\n * @returns Result containing success status, strategy used, and metadata\n * @throws {Error} If model is new (not saved) or if deletion fails\n */\n public async destroy(options: RemoverOptions = {}): Promise<RemoverResult> {\n // 1. Resolve strategy (options → model static → data source default → permanent)\n const strategy =\n options.strategy ??\n this.ctor.deleteStrategy ??\n this.dataSource.defaultDeleteStrategy ??\n \"permanent\";\n\n // 2. Validate model is not new and has primary key\n if (this.model.isNew) {\n throw new Error(\n `Cannot destroy ${this.ctor.name} instance that hasn't been saved to the database.`,\n );\n }\n\n // The value captured when the model became persisted, never the current\n // (mass-assignable) one — a delete must not be retargetable by a payload\n // carrying `{ id: \"<victim-id>\" }`. Same rule as the writer's update filter.\n const primaryKeyValue = this.model.trustedPrimaryKey;\n if (!primaryKeyValue) {\n throw new Error(\n `Cannot destroy ${this.ctor.name} instance: primary key (${this.primaryKey}) is missing.`,\n );\n }\n\n // 3. Emit deleting event (unless skipEvents)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"deleting\", {\n strategy,\n primaryKeyValue,\n primaryKey: this.primaryKey,\n });\n }\n\n // 4. Execute deletion based on strategy\n let deletedCount = 0;\n let trashRecord: Record<string, unknown> | undefined;\n\n const filter = { [this.primaryKey]: primaryKeyValue };\n\n const context: Partial<OnDeletedEventContext> = {\n strategy,\n primaryKeyValue,\n primaryKey: this.primaryKey,\n };\n\n switch (strategy) {\n case \"trash\": {\n // Move to trash table, then delete\n const trashTable = this.resolveTrashTable();\n const documentData = { ...this.model.data };\n\n // Prepare trash record with metadata and handle ID conflicts\n const trashData = this.prepareTrashRecord(documentData);\n\n // Insert into trash table\n const insertResult = await this.driver.insert(trashTable, trashData);\n trashRecord = insertResult.document as Record<string, unknown>;\n\n context.trashRecord = trashRecord;\n\n // Delete original\n const result = await this.driver.delete(this.table, filter);\n deletedCount = result > 0 ? 1 : 0;\n break;\n }\n\n case \"permanent\": {\n // Direct deletion\n const result = await this.driver.delete(this.table, filter);\n deletedCount = result > 0 ? 1 : 0;\n break;\n }\n\n case \"soft\": {\n // Set deletedAt timestamp (using resolved column name)\n const deletedAtColumn = this.ctor.deletedAtColumn;\n\n // Only proceed if deletedAtColumn is configured (not false or undefined)\n if (deletedAtColumn === false || deletedAtColumn === undefined) {\n throw new Error(\n `Cannot perform soft delete on ${this.ctor.name}: deletedAtColumn is not configured. ` +\n `Set a column name or use a different delete strategy.`,\n );\n }\n\n const deletedAt = new Date();\n const updateOperations: UpdateOperations = {\n $set: { [deletedAtColumn]: deletedAt },\n };\n // Guard: an already soft-deleted row must keep its original timestamp\n const softFilter = { ...filter, [deletedAtColumn]: null };\n const updateResult = await this.driver.update(\n this.table,\n softFilter,\n updateOperations,\n );\n deletedCount = updateResult.modifiedCount > 0 ? 1 : 0;\n\n // The row stays (unlike trash/permanent), so reflect the persisted\n // timestamp on the in-memory model — otherwise the instance is stale\n // and `model.get(deletedAtColumn)` stays undefined after destroy().\n if (deletedCount > 0) {\n this.model.set(deletedAtColumn, deletedAt);\n\n // The timestamp is already persisted, so it must not stay dirty.\n // Re-baseline only when it is the sole pending change, so unsaved\n // edits to other columns keep their dirty state.\n const dirty = this.model.dirtyTracker.getDirtyColumns();\n if (dirty.length === 1 && dirty[0] === deletedAtColumn) {\n this.model.dirtyTracker.reset(\n this.model.data as Record<string, unknown>,\n );\n }\n }\n break;\n }\n }\n\n if (deletedCount === 0) {\n throw new Error(\n `Failed to destroy ${this.ctor.name} instance: record not found.`,\n );\n }\n\n context.deletedCount = deletedCount;\n\n // 5. Post-deletion cleanup\n // Only mark as new for permanent and trash (soft delete keeps the record)\n if (strategy !== \"soft\") {\n this.model.isNew = true;\n }\n\n // 6. Emit deleted event (unless skipEvents)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"deleted\", context);\n }\n\n // 7. Trigger sync operations after COMMIT; failures are logged\n // A soft delete keeps the row, so embedded copies must stay: only permanent/trash fire it.\n // TODO(5.22, K2:B10): move this fan-out to a retry/queue-backed path.\n if (!options.skipSync && strategy !== \"soft\") {\n afterCommit(() => this.triggerSync());\n }\n\n return {\n success: true,\n deletedCount,\n strategy,\n trashRecord,\n };\n }\n\n /**\n * Prepare the trash record by preserving all original fields and adding deletion metadata.\n *\n * Keeps all original fields intact for easy restoration and adds:\n * - `deletedAt`: Timestamp when the record was deleted\n * - `originalTable`: The table/collection the record came from (for filtering in restoreAll)\n *\n * **ID Handling:**\n * - MongoDB with `_id`: Keeps `_id` as-is (unique across database)\n * - MongoDB with auto-increment `id`: Keeps `id` as a regular field (not primary key)\n * - SQL: Keeps original `id` as a regular field (trash table uses its own auto-increment primary key)\n *\n * The trash table should use its own primary key structure:\n * - MongoDB: Uses `_id` (ObjectId) as primary key, original `id` is just a field\n * - SQL: Uses auto-increment `trashId` as primary key, original `id` is just a field\n *\n * @param documentData - The original document data\n * @returns Prepared trash record data with all original fields + deletedAt + originalTable\n * @private\n */\n private prepareTrashRecord(\n documentData: Record<string, unknown>,\n ): Record<string, unknown> {\n // Preserve all original fields and add deletion metadata\n const deletedAtColumn = this.ctor.deletedAtColumn;\n\n return {\n ...documentData,\n [typeof deletedAtColumn === \"string\" ? deletedAtColumn : \"deletedAt\"]:\n new Date(),\n originalTable: this.table,\n };\n }\n\n /**\n * Resolve the trash table/collection name.\n *\n * Priority:\n * 1. Model.trashTable (if set)\n * 2. Data source defaultTrashTable (e.g., \"RecycleBin\" for MongoDB)\n * 3. Default pattern: `{table}Trash`\n *\n * @returns The trash table/collection name\n * @private\n */\n private resolveTrashTable(): string {\n if (this.ctor.trashTable) {\n return this.ctor.trashTable;\n }\n\n if (this.dataSource.defaultTrashTable) {\n return this.dataSource.defaultTrashTable;\n }\n\n return `${this.table}Trash`;\n }\n\n /**\n * Trigger sync operations after successful deletion.\n *\n * Emits a model.deleted event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @private\n */\n private async triggerSync(): Promise<void> {\n // Emit model.deleted event - ModelSyncOperation listens to these\n try {\n await triggerModelEvent(this.ctor, \"deleted\", this.model);\n } catch (error) {\n log.error(\"database\", \"sync.failed\", `[cascade] sync failed for ${this.ctor.name}: ${error}`);\n }\n }\n}\n"],"mappings":"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"}
@@ -109,8 +109,10 @@ declare class DatabaseRestorer implements RestorerContract {
109
109
  /**
110
110
  * Assign a new ID to the record data.
111
111
  *
112
- * For MongoDB: Generates new ObjectId for `_id`, keeps `id` if it exists
113
- * For SQL: Removes `id` to let database auto-increment
112
+ * For MongoDB: drops the old `_id` and `id`, then runs the record through the
113
+ * writer's insert preparation so the new `id` comes from the same generator
114
+ * a normal save uses (validation and events are skipped; this is a restore)
115
+ * For SQL: Removes the primary key to let database auto-increment
114
116
  *
115
117
  * @param recordData - The record data
116
118
  * @returns Record data with new ID assigned
@@ -129,6 +131,14 @@ declare class DatabaseRestorer implements RestorerContract {
129
131
  * @private
130
132
  */
131
133
  private resolveTrashTable;
134
+ /**
135
+ * Re-run sync for a restored record so embedded copies come back up to date.
136
+ * Failures are swallowed: the restore itself already succeeded.
137
+ * TODO(5.22, K2:B10): move this fan-out to a retry/queue-backed path.
138
+ *
139
+ * @private
140
+ */
141
+ private triggerSync;
132
142
  }
133
143
  //#endregion
134
144
  export { DatabaseRestorer };
@@ -1 +1 @@
1
- {"version":3,"file":"database-restorer.d.mts","names":[],"sources":["../../../../../../../cascade/src/restorer/database-restorer.ts"],"mappings":";;;;;;AA6BA;;;;;;;;;;;;;;;;;cAAa,gBAAA,YAA4B,gBAAA;EActB;EAAA,iBAZA,IAAA;EAyBqB;EAAA,iBAtBrB,UAAA;EAuCJ;EAAA,iBApCI,MAAA;EAsCN;EAAA,iBAnCM,KAAA;EAoCd;EAAA,iBAjCc,UAAA;EAoHJ;;;;;;;;;;;cAvGM,UAAA,SAAmB,KAAA;EAgcb;AAAA;;;;;;;;EA/aZ,OAAA,CACX,EAAA,mBACA,OAAA,GAAS,eAAA,GACR,OAAA,CAAQ,cAAA;;;;;;;EAmFE,UAAA,CAAW,OAAA,GAAS,eAAA,GAAuB,OAAA,CAAQ,cAAA;;;;;;;;;;UAiKxD,eAAA;;;;;;;;;UAiBM,qBAAA;;;;;;;;UA0CA,yBAAA;;;;;;;;;;UA6CA,gBAAA;;;;;;;;UA4BA,aAAA;;;;;;;;;;;UAoBA,WAAA;;;;;;;;;;;;UAgCN,iBAAA;AAAA"}
1
+ {"version":3,"file":"database-restorer.d.mts","names":[],"sources":["../../../../../../../cascade/src/restorer/database-restorer.ts"],"mappings":";;;;;;AA+BA;;;;;;;;;;;;;;;;;cAAa,gBAAA,YAA4B,gBAAA;EActB;EAAA,iBAZA,IAAA;EAyBqB;EAAA,iBAtBrB,UAAA;EAuCJ;EAAA,iBApCI,MAAA;EAsCN;EAAA,iBAnCM,KAAA;EAoCd;EAAA,iBAjCc,UAAA;EA4HJ;;;;;;;;;;;cA/GM,UAAA,SAAmB,KAAA;EAqexB;;AAAW;;;;;;;EApdZ,OAAA,CACX,EAAA,mBACA,OAAA,GAAS,eAAA,GACR,OAAA,CAAQ,cAAA;;;;;;;EA2FE,UAAA,CAAW,OAAA,GAAS,eAAA,GAAuB,OAAA,CAAQ,cAAA;;;;;;;;;;UAsKxD,eAAA;;;;;;;;;UAiBM,qBAAA;;;;;;;;UA2CA,yBAAA;;;;;;;;;;UA6CA,gBAAA;;;;;;;;UA4BA,aAAA;;;;;;;;;;;;;UAsBA,WAAA;;;;;;;;;;;;UAkCN,iBAAA;;;;;;;;UAmBM,WAAA;AAAA"}
@@ -1,3 +1,6 @@
1
+ import { triggerModelEvent } from "../sync/model-events.mjs";
2
+ import { DatabaseWriter } from "../writer/database-writer.mjs";
3
+
1
4
  //#region ../cascade/src/restorer/database-restorer.ts
2
5
  /**
3
6
  * Database restorer service that orchestrates model restoration.
@@ -66,13 +69,16 @@ var DatabaseRestorer = class {
66
69
  const restoredData = { ...recordData };
67
70
  delete restoredData.deletedAt;
68
71
  delete restoredData.originalTable;
69
- const finalData = await this.handleIdConflict(restoredData, id, onIdConflict);
72
+ const finalData = strategy === "trash" ? await this.handleIdConflict(restoredData, id, onIdConflict) : restoredData;
70
73
  const model = new this.ctor(finalData);
71
74
  if (!skipEvents) await model.emitEvent("restoring");
72
75
  if (strategy === "trash") {
73
76
  await this.driver.insert(this.table, finalData);
74
77
  model.isNew = false;
75
- await this.driver.delete(this.resolveTrashTable(), { [this.primaryKey]: id });
78
+ await this.driver.delete(this.resolveTrashTable(), {
79
+ [this.primaryKey]: id,
80
+ originalTable: this.table
81
+ });
76
82
  } else if (strategy === "soft") {
77
83
  const deletedAtColumn = this.ctor.deletedAtColumn ?? "deletedAt";
78
84
  if (deletedAtColumn) {
@@ -83,6 +89,7 @@ var DatabaseRestorer = class {
83
89
  }
84
90
  }
85
91
  if (!skipEvents) await model.emitEvent("restored");
92
+ await this.triggerSync(model);
86
93
  return {
87
94
  success: true,
88
95
  restoredCount: 1,
@@ -116,7 +123,7 @@ var DatabaseRestorer = class {
116
123
  const restoredData = { ...recordData };
117
124
  delete restoredData.deletedAt;
118
125
  delete restoredData.originalTable;
119
- if (await this.checkIdExists(id)) {
126
+ if (strategy === "trash" && await this.checkIdExists(id)) {
120
127
  if (onIdConflict === "fail") throw new Error(`Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: ID already exists in target table.`);
121
128
  const finalData = await this.assignNewId(restoredData);
122
129
  conflicts.push({
@@ -139,6 +146,7 @@ var DatabaseRestorer = class {
139
146
  }
140
147
  restoredRecords.push(model);
141
148
  if (!skipEvents) await model.emitEvent("restored");
149
+ await this.triggerSync(model);
142
150
  } else {
143
151
  const model = new this.ctor(restoredData);
144
152
  if (!skipEvents) await model.emitEvent("restoring");
@@ -156,10 +164,14 @@ var DatabaseRestorer = class {
156
164
  }
157
165
  restoredRecords.push(model);
158
166
  if (!skipEvents) await model.emitEvent("restored");
167
+ await this.triggerSync(model);
159
168
  }
160
169
  if (strategy === "trash") {
161
170
  const trashTable = this.resolveTrashTable();
162
- const trashFilter = { [this.primaryKey]: id };
171
+ const trashFilter = {
172
+ [this.primaryKey]: id,
173
+ originalTable: this.table
174
+ };
163
175
  await this.driver.delete(trashTable, trashFilter);
164
176
  }
165
177
  restoredCount++;
@@ -203,7 +215,7 @@ var DatabaseRestorer = class {
203
215
  if (strategy === "trash") {
204
216
  const trashTable = this.resolveTrashTable();
205
217
  try {
206
- return await this.driver.queryBuilder(trashTable).where(this.primaryKey, id).first();
218
+ return await this.driver.queryBuilder(trashTable).where(this.primaryKey, id).where("originalTable", this.table).first();
207
219
  } catch {
208
220
  return null;
209
221
  }
@@ -277,21 +289,27 @@ var DatabaseRestorer = class {
277
289
  /**
278
290
  * Assign a new ID to the record data.
279
291
  *
280
- * For MongoDB: Generates new ObjectId for `_id`, keeps `id` if it exists
281
- * For SQL: Removes `id` to let database auto-increment
292
+ * For MongoDB: drops the old `_id` and `id`, then runs the record through the
293
+ * writer's insert preparation so the new `id` comes from the same generator
294
+ * a normal save uses (validation and events are skipped; this is a restore)
295
+ * For SQL: Removes the primary key to let database auto-increment
282
296
  *
283
297
  * @param recordData - The record data
284
298
  * @returns Record data with new ID assigned
285
299
  * @private
286
300
  */
287
301
  async assignNewId(recordData) {
288
- const isMongoDb = this.driver.name === "mongodb";
289
302
  const newData = { ...recordData };
290
- if (isMongoDb) {
291
- if (this.primaryKey === "_id") delete newData._id;
292
- else if (this.primaryKey === "id") delete newData.id;
293
- } else delete newData[this.primaryKey];
294
- return newData;
303
+ if (this.driver.name !== "mongodb") {
304
+ delete newData[this.primaryKey];
305
+ return newData;
306
+ }
307
+ delete newData._id;
308
+ delete newData.id;
309
+ return await new DatabaseWriter(new this.ctor(newData)).prepareForInsert({
310
+ skipEvents: true,
311
+ skipValidation: true
312
+ });
295
313
  }
296
314
  /**
297
315
  * Resolve the trash table/collection name.
@@ -309,6 +327,18 @@ var DatabaseRestorer = class {
309
327
  if (this.dataSource.defaultTrashTable) return this.dataSource.defaultTrashTable;
310
328
  return `${this.table}Trash`;
311
329
  }
330
+ /**
331
+ * Re-run sync for a restored record so embedded copies come back up to date.
332
+ * Failures are swallowed: the restore itself already succeeded.
333
+ * TODO(5.22, K2:B10): move this fan-out to a retry/queue-backed path.
334
+ *
335
+ * @private
336
+ */
337
+ async triggerSync(model) {
338
+ try {
339
+ await triggerModelEvent(this.ctor, "updated", model, Object.keys(model.data));
340
+ } catch {}
341
+ }
312
342
  };
313
343
 
314
344
  //#endregion