@c9up/atlas 0.1.19 → 0.2.1

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 (250) hide show
  1. package/README.md +63 -14
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +66 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +205 -39
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +181 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +127 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +250 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +81 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1433 -387
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ConnectionManager.d.ts +106 -0
  24. package/dist/ConnectionManager.d.ts.map +1 -0
  25. package/dist/ConnectionManager.js +228 -0
  26. package/dist/ConnectionManager.js.map +1 -0
  27. package/dist/ModelQuery.d.ts +577 -41
  28. package/dist/ModelQuery.d.ts.map +1 -1
  29. package/dist/ModelQuery.js +2188 -291
  30. package/dist/ModelQuery.js.map +1 -1
  31. package/dist/Transaction.d.ts +71 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +138 -24
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/adapters/NapiDbAdapter.d.ts +44 -4
  36. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  37. package/dist/adapters/NapiDbAdapter.js +131 -13
  38. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  39. package/dist/console/contract.d.ts +62 -0
  40. package/dist/console/contract.d.ts.map +1 -0
  41. package/dist/console/contract.js +38 -0
  42. package/dist/console/contract.js.map +1 -0
  43. package/dist/console/factoryCommands.d.ts +23 -0
  44. package/dist/console/factoryCommands.d.ts.map +1 -0
  45. package/dist/console/factoryCommands.js +62 -0
  46. package/dist/console/factoryCommands.js.map +1 -0
  47. package/dist/console/migrationCommands.d.ts +78 -0
  48. package/dist/console/migrationCommands.d.ts.map +1 -0
  49. package/dist/console/migrationCommands.js +327 -0
  50. package/dist/console/migrationCommands.js.map +1 -0
  51. package/dist/console/schemaCheckCommand.d.ts +11 -17
  52. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  53. package/dist/console/schemaCheckCommand.js +21 -15
  54. package/dist/console/schemaCheckCommand.js.map +1 -1
  55. package/dist/console/schemaDumpCommand.d.ts +30 -0
  56. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  57. package/dist/console/schemaDumpCommand.js +69 -0
  58. package/dist/console/schemaDumpCommand.js.map +1 -0
  59. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  60. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  61. package/dist/console/schemaGenerateCommand.js +246 -0
  62. package/dist/console/schemaGenerateCommand.js.map +1 -0
  63. package/dist/console/seederCommands.d.ts +46 -0
  64. package/dist/console/seederCommands.d.ts.map +1 -0
  65. package/dist/console/seederCommands.js +136 -0
  66. package/dist/console/seederCommands.js.map +1 -0
  67. package/dist/decorators/entity.d.ts +50 -8
  68. package/dist/decorators/entity.d.ts.map +1 -1
  69. package/dist/decorators/entity.js +42 -2
  70. package/dist/decorators/entity.js.map +1 -1
  71. package/dist/decorators/hooks.d.ts +3 -3
  72. package/dist/decorators/hooks.d.ts.map +1 -1
  73. package/dist/decorators/hooks.js.map +1 -1
  74. package/dist/events.d.ts +69 -0
  75. package/dist/events.d.ts.map +1 -0
  76. package/dist/events.js +82 -0
  77. package/dist/events.js.map +1 -0
  78. package/dist/factories.d.ts +8 -0
  79. package/dist/factories.d.ts.map +1 -0
  80. package/dist/factories.js +8 -0
  81. package/dist/factories.js.map +1 -0
  82. package/dist/index.d.ts +15 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +14 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/lucid-schema.d.ts +15 -0
  87. package/dist/lucid-schema.d.ts.map +1 -0
  88. package/dist/lucid-schema.js +16 -0
  89. package/dist/lucid-schema.js.map +1 -0
  90. package/dist/metadata-keys.d.ts +3 -2
  91. package/dist/metadata-keys.d.ts.map +1 -1
  92. package/dist/naming/NamingStrategy.d.ts +7 -0
  93. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  94. package/dist/naming/NamingStrategy.js +16 -0
  95. package/dist/naming/NamingStrategy.js.map +1 -1
  96. package/dist/orm.d.ts +14 -0
  97. package/dist/orm.d.ts.map +1 -0
  98. package/dist/orm.js +14 -0
  99. package/dist/orm.js.map +1 -0
  100. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  101. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  102. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  103. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  104. package/dist/query/DmlBuilder.d.ts +62 -0
  105. package/dist/query/DmlBuilder.d.ts.map +1 -0
  106. package/dist/query/DmlBuilder.js +84 -0
  107. package/dist/query/DmlBuilder.js.map +1 -0
  108. package/dist/query/QueryBuilder.d.ts +5 -0
  109. package/dist/query/QueryBuilder.d.ts.map +1 -1
  110. package/dist/query/QueryBuilder.js +7 -0
  111. package/dist/query/QueryBuilder.js.map +1 -1
  112. package/dist/query/RawQueryBuilder.d.ts +47 -0
  113. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  114. package/dist/query/RawQueryBuilder.js +138 -0
  115. package/dist/query/RawQueryBuilder.js.map +1 -0
  116. package/dist/query/interpolate.d.ts +45 -0
  117. package/dist/query/interpolate.d.ts.map +1 -0
  118. package/dist/query/interpolate.js +51 -0
  119. package/dist/query/interpolate.js.map +1 -0
  120. package/dist/query/native.d.ts +8 -0
  121. package/dist/query/native.d.ts.map +1 -1
  122. package/dist/query/native.js +14 -0
  123. package/dist/query/native.js.map +1 -1
  124. package/dist/query/operators.d.ts +4 -0
  125. package/dist/query/operators.d.ts.map +1 -0
  126. package/dist/query/operators.js +27 -0
  127. package/dist/query/operators.js.map +1 -0
  128. package/dist/schema/Migration.d.ts +50 -3
  129. package/dist/schema/Migration.d.ts.map +1 -1
  130. package/dist/schema/Migration.js +60 -24
  131. package/dist/schema/Migration.js.map +1 -1
  132. package/dist/schema/MigrationRunner.d.ts +95 -34
  133. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  134. package/dist/schema/MigrationRunner.js +523 -68
  135. package/dist/schema/MigrationRunner.js.map +1 -1
  136. package/dist/schema/Schema.d.ts +114 -0
  137. package/dist/schema/Schema.d.ts.map +1 -1
  138. package/dist/schema/Schema.js +272 -3
  139. package/dist/schema/Schema.js.map +1 -1
  140. package/dist/schema/SchemaDumper.d.ts +121 -0
  141. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  142. package/dist/schema/SchemaDumper.js +365 -0
  143. package/dist/schema/SchemaDumper.js.map +1 -0
  144. package/dist/schema/Seeder.d.ts +13 -0
  145. package/dist/schema/Seeder.d.ts.map +1 -1
  146. package/dist/schema/Seeder.js +28 -5
  147. package/dist/schema/Seeder.js.map +1 -1
  148. package/dist/schema/TableBuilder.d.ts +274 -16
  149. package/dist/schema/TableBuilder.d.ts.map +1 -1
  150. package/dist/schema/TableBuilder.js +656 -52
  151. package/dist/schema/TableBuilder.js.map +1 -1
  152. package/dist/schema/catalog.d.ts +71 -0
  153. package/dist/schema/catalog.d.ts.map +1 -0
  154. package/dist/schema/catalog.js +158 -0
  155. package/dist/schema/catalog.js.map +1 -0
  156. package/dist/schema/introspect.d.ts +1 -1
  157. package/dist/schema/introspect.d.ts.map +1 -1
  158. package/dist/schema/introspect.js +26 -11
  159. package/dist/schema/introspect.js.map +1 -1
  160. package/dist/schema/types.d.ts +150 -1
  161. package/dist/schema/types.d.ts.map +1 -1
  162. package/dist/schema/types.js +11 -0
  163. package/dist/schema/types.js.map +1 -1
  164. package/dist/seeders.d.ts +7 -0
  165. package/dist/seeders.d.ts.map +1 -0
  166. package/dist/seeders.js +7 -0
  167. package/dist/seeders.js.map +1 -0
  168. package/dist/services/db.d.ts +122 -5
  169. package/dist/services/db.d.ts.map +1 -1
  170. package/dist/services/db.js +187 -14
  171. package/dist/services/db.js.map +1 -1
  172. package/dist/testing/DatabaseCleanup.d.ts +33 -7
  173. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  174. package/dist/testing/DatabaseCleanup.js +54 -24
  175. package/dist/testing/DatabaseCleanup.js.map +1 -1
  176. package/dist/testing/DbAssertions.d.ts +48 -0
  177. package/dist/testing/DbAssertions.d.ts.map +1 -0
  178. package/dist/testing/DbAssertions.js +80 -0
  179. package/dist/testing/DbAssertions.js.map +1 -0
  180. package/dist/testing/Factory.d.ts +166 -15
  181. package/dist/testing/Factory.d.ts.map +1 -1
  182. package/dist/testing/Factory.js +497 -23
  183. package/dist/testing/Factory.js.map +1 -1
  184. package/dist/testing/TestUtils.d.ts +49 -0
  185. package/dist/testing/TestUtils.d.ts.map +1 -0
  186. package/dist/testing/TestUtils.js +79 -0
  187. package/dist/testing/TestUtils.js.map +1 -0
  188. package/dist/testing/index.d.ts +3 -1
  189. package/dist/testing/index.d.ts.map +1 -1
  190. package/dist/testing/index.js +3 -1
  191. package/dist/testing/index.js.map +1 -1
  192. package/dist/types/relations.d.ts +31 -0
  193. package/dist/types/relations.d.ts.map +1 -0
  194. package/dist/types/relations.js +20 -0
  195. package/dist/types/relations.js.map +1 -0
  196. package/index.darwin-arm64.node +0 -0
  197. package/index.darwin-x64.node +0 -0
  198. package/index.linux-arm64-gnu.node +0 -0
  199. package/index.linux-x64-gnu.node +0 -0
  200. package/index.win32-x64-msvc.node +0 -0
  201. package/package.json +32 -2
  202. package/scripts/guard-publish.mjs +15 -0
  203. package/src/AtlasProvider.ts +305 -52
  204. package/src/BaseEntity.ts +460 -41
  205. package/src/BaseModel.ts +461 -0
  206. package/src/BaseRepository.ts +1710 -421
  207. package/src/ConnectionManager.ts +298 -0
  208. package/src/ModelQuery.ts +2998 -335
  209. package/src/Transaction.ts +227 -30
  210. package/src/adapters/NapiDbAdapter.ts +224 -17
  211. package/src/console/contract.ts +96 -0
  212. package/src/console/factoryCommands.ts +73 -0
  213. package/src/console/migrationCommands.ts +448 -0
  214. package/src/console/schemaCheckCommand.ts +26 -22
  215. package/src/console/schemaDumpCommand.ts +96 -0
  216. package/src/console/schemaGenerateCommand.ts +380 -0
  217. package/src/console/seederCommands.ts +174 -0
  218. package/src/decorators/entity.ts +71 -8
  219. package/src/decorators/hooks.ts +5 -3
  220. package/src/events.ts +117 -0
  221. package/src/factories.ts +10 -0
  222. package/src/index.ts +69 -3
  223. package/src/lucid-schema.ts +22 -0
  224. package/src/metadata-keys.ts +3 -2
  225. package/src/naming/NamingStrategy.ts +23 -0
  226. package/src/orm.ts +13 -0
  227. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  228. package/src/query/DmlBuilder.ts +131 -0
  229. package/src/query/QueryBuilder.ts +8 -0
  230. package/src/query/RawQueryBuilder.ts +173 -0
  231. package/src/query/interpolate.ts +73 -0
  232. package/src/query/native.ts +29 -0
  233. package/src/query/operators.ts +27 -0
  234. package/src/schema/Migration.ts +71 -3
  235. package/src/schema/MigrationRunner.ts +676 -69
  236. package/src/schema/Schema.ts +359 -3
  237. package/src/schema/SchemaDumper.ts +518 -0
  238. package/src/schema/Seeder.ts +47 -10
  239. package/src/schema/TableBuilder.ts +783 -52
  240. package/src/schema/catalog.ts +236 -0
  241. package/src/schema/introspect.ts +26 -9
  242. package/src/schema/types.ts +137 -2
  243. package/src/seeders.ts +16 -0
  244. package/src/services/db.ts +361 -21
  245. package/src/testing/DatabaseCleanup.ts +78 -25
  246. package/src/testing/DbAssertions.ts +127 -0
  247. package/src/testing/Factory.ts +837 -40
  248. package/src/testing/TestUtils.ts +123 -0
  249. package/src/testing/index.ts +12 -1
  250. package/src/types/relations.ts +43 -0
@@ -4,14 +4,18 @@
4
4
  * @implements FR29, FR31, FR35
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
+ import { DateTime } from "@c9up/chronos";
8
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
7
9
  import { REPO_REF } from "./BaseEntity.js";
8
- import { getColumnMetadata, getDateColumnConfig, getEntityMetadata, getPrimaryKey, getPrimaryKeyGenerator, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
10
+ import { ensureEntityMetadata, getColumnMetadata, getDateColumnConfig, getPrimaryKey, getPrimaryKeyGenerator, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
9
11
  import { fireHooks } from "./decorators/hooks.js";
10
12
  import { AtlasError, EntityNotFoundError } from "./errors.js";
11
- import { ModelQuery, runWithAtlasInternalBypass } from "./ModelQuery.js";
13
+ import { isAtlasStrictMode, ModelQuery } from "./ModelQuery.js";
14
+ import { DatabaseQueryBuilder } from "./query/DatabaseQueryBuilder.js";
12
15
  import { compileStatementNative, getAtlasDialect, registerColumnCast, registerTableCasts, } from "./query/native.js";
13
16
  import { transaction } from "./Transaction.js";
14
17
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
18
+ import { isTransactionClient } from "./utils/transactionBrand.js";
15
19
  /**
16
20
  * Coerce a `lastInsertRowid` to a JS number when it fits, leaving large
17
21
  * mysql/sqlite values as bigint so callers don't silently lose precision.
@@ -90,10 +94,16 @@ const POSTGRES_CAST_TYPES = new Set([
90
94
  */
91
95
  export function computeCastTypes(entityClass) {
92
96
  const out = {};
97
+ // Resolve each property to its real DB column, honouring `@Column({ columnName })`
98
+ // — the cast MUST key off the column name that actually appears in the SQL.
99
+ const dbNameOf = new Map();
100
+ for (const col of getColumnMetadata(entityClass)) {
101
+ dbNameOf.set(col.propertyKey, col.columnName ?? camelToSnake(col.propertyKey));
102
+ }
93
103
  for (const col of getColumnMetadata(entityClass)) {
94
104
  const t = col.type?.toLowerCase();
95
105
  if (t && POSTGRES_CAST_TYPES.has(t)) {
96
- out[camelToSnake(col.propertyKey)] = t;
106
+ out[dbNameOf.get(col.propertyKey) ?? camelToSnake(col.propertyKey)] = t;
97
107
  }
98
108
  }
99
109
  // `@column.date()` / `@column.dateTime()` columns are tracked in a SEPARATE
@@ -105,14 +115,21 @@ export function computeCastTypes(entityClass) {
105
115
  // expression is of type text`. An explicit recognized `col.type` already set
106
116
  // in the loop above wins via `??=`.
107
117
  for (const [prop, cfg] of Object.entries(getDateColumnConfig(entityClass))) {
108
- out[camelToSnake(prop)] ??= cfg.dateOnly ? "date" : "timestamp";
118
+ out[dbNameOf.get(prop) ?? camelToSnake(prop)] ??= cfg.dateOnly
119
+ ? "date"
120
+ : "timestamp";
109
121
  }
110
122
  // A uuid-strategy primary key is generated app-side as a string.
111
123
  if (getPrimaryKeyGenerator(entityClass) === "uuid") {
112
- out[camelToSnake(getPrimaryKey(entityClass) ?? "id")] ??= "uuid";
124
+ const pk = getPrimaryKey(entityClass) ?? "id";
125
+ out[dbNameOf.get(pk) ?? camelToSnake(pk)] ??= "uuid";
113
126
  }
114
127
  return out;
115
128
  }
129
+ /** Structural guard: does this entity expose `useTransaction` (i.e. is a BaseModel)? */
130
+ function isTransactionBindable(entity) {
131
+ return ("useTransaction" in entity && typeof entity.useTransaction === "function");
132
+ }
116
133
  export class BaseRepository {
117
134
  #entityClass;
118
135
  #tableName;
@@ -121,7 +138,8 @@ export class BaseRepository {
121
138
  #db;
122
139
  #softDeletes;
123
140
  #validColumns;
124
- #columnMap; // camelCasesnake_case (cached)
141
+ #columnMap; // property/db name resolved db column (cached)
142
+ #columnByDbName; // resolved db column → property (for hydrate)
125
143
  #dateColumns;
126
144
  /** Snake column → logical type for params needing a Postgres `::cast`. */
127
145
  #castTypes;
@@ -148,6 +166,14 @@ export class BaseRepository {
148
166
  #dialect;
149
167
  /** Callback to dispatch domain events (set by framework integration). */
150
168
  onDomainEvents;
169
+ /**
170
+ * The durable (non-transactional) repo a `useTransaction(trx)` copy was forked
171
+ * from. Lucid resets a model's `$trx` on commit AND rollback, so after a manual
172
+ * transaction ends every entity persisted through the trx-bound repo must have
173
+ * its REPO_REF re-pointed here — otherwise related()/refresh() run on a finished
174
+ * transaction. Undefined on a durable repo (it IS the durable parent).
175
+ */
176
+ #durableParent;
151
177
  constructor(entityClass, db, options) {
152
178
  this.#entityClass = entityClass;
153
179
  if (db == null) {
@@ -162,12 +188,10 @@ export class BaseRepository {
162
188
  // Dialect resolution order: explicit option > connection.dialect > process default.
163
189
  const connDialect = db.dialect;
164
190
  this.#dialect = options?.dialect ?? connDialect ?? getAtlasDialect();
165
- const meta = getEntityMetadata(entityClass);
166
- if (!meta) {
167
- throw new AtlasError("NOT_ENTITY", `Class '${entityClass.name}' is not decorated with @Entity()`, {
168
- hint: "Add @Entity('table_name') decorator to the class.",
169
- });
170
- }
191
+ // Infer the table name (naming strategy / `static table`) when @Entity is
192
+ // absent — AdonisJS Lucid parity, shared with BaseModel via one helper so
193
+ // the Data-Mapper and Active-Record paths agree on the convention.
194
+ const meta = ensureEntityMetadata(entityClass);
171
195
  this.#tableName = meta.tableName;
172
196
  this.#primaryKey = getPrimaryKey(entityClass) ?? "id";
173
197
  const columnsMeta = getColumnMetadata(entityClass);
@@ -194,16 +218,31 @@ export class BaseRepository {
194
218
  // time, before any repository for that entity is instantiated.
195
219
  this.#validColumns = new Set();
196
220
  this.#columnMap = new Map();
197
- for (const col of this.#columns) {
198
- const snake = camelToSnake(col);
199
- this.#validColumns.add(col);
200
- this.#validColumns.add(snake);
201
- this.#columnMap.set(col, snake);
202
- this.#columnMap.set(snake, snake);
221
+ this.#columnByDbName = new Map();
222
+ for (const col of columnsMeta) {
223
+ const prop = col.propertyKey;
224
+ // Explicit `@Column({ columnName })` wins over the snake_case convention.
225
+ const db = col.columnName ?? camelToSnake(prop);
226
+ this.#validColumns.add(prop);
227
+ this.#validColumns.add(db);
228
+ this.#columnMap.set(prop, db);
229
+ this.#columnMap.set(db, db);
230
+ // Reverse map for hydration — a DB row keyed by the real column name maps
231
+ // back to the TS property (covers explicit overrides AND the default,
232
+ // where `snakeToCamel(db)` would otherwise mis-resolve an override).
233
+ this.#columnByDbName.set(db, prop);
203
234
  }
235
+ // PK is registered as a column too (via @PrimaryKey → Column), so the loop
236
+ // above already mapped it, honouring any columnName. Fall back for the rare
237
+ // PK declared outside the column metadata.
204
238
  this.#validColumns.add(this.#primaryKey);
205
- this.#validColumns.add(camelToSnake(this.#primaryKey));
206
- this.#columnMap.set(this.#primaryKey, camelToSnake(this.#primaryKey));
239
+ if (!this.#columnMap.has(this.#primaryKey)) {
240
+ const pkDb = camelToSnake(this.#primaryKey);
241
+ this.#validColumns.add(pkDb);
242
+ this.#columnMap.set(this.#primaryKey, pkDb);
243
+ this.#columnMap.set(pkDb, pkDb);
244
+ this.#columnByDbName.set(pkDb, this.#primaryKey);
245
+ }
207
246
  // Postgres cast hints: sqlx binds JS strings as `text`, which Postgres
208
247
  // won't coerce to timestamp/uuid/date. See `computeCastTypes`.
209
248
  this.#castTypes = computeCastTypes(entityClass);
@@ -226,7 +265,9 @@ export class BaseRepository {
226
265
  // FK lives on THIS table, references the related (owner) PK.
227
266
  const fk = rel.foreignKey ?? `${camelToSnake(related.name)}_id`;
228
267
  const ownerKey = rel.ownerKey ?? getPrimaryKey(related) ?? "id";
229
- const cast = computeCastTypes(related)[camelToSnake(ownerKey)];
268
+ const ownerDb = getColumnMetadata(related).find((c) => c.propertyKey === ownerKey)
269
+ ?.columnName ?? camelToSnake(ownerKey);
270
+ const cast = computeCastTypes(related)[ownerDb];
230
271
  if (cast)
231
272
  registerColumnCast(this.#tableName, fk, cast);
232
273
  }
@@ -234,9 +275,13 @@ export class BaseRepository {
234
275
  // hasOne / hasMany: FK lives on the RELATED table, references THIS PK.
235
276
  const fk = rel.foreignKey ?? `${camelToSnake(entityClass.name)}_id`;
236
277
  const localKey = rel.localKey ?? this.#primaryKey;
237
- const cast = this.#castTypes[camelToSnake(localKey)];
238
- const relatedMeta = getEntityMetadata(related);
239
- if (cast && relatedMeta) {
278
+ const cast = this.#castTypes[this.#dbColumn(localKey)];
279
+ // Boot the related model on demand (Lucid lazy-boot): a related model
280
+ // with only `static table` (no @Entity yet) would otherwise miss its FK
281
+ // cast → a uuid FK relation query compiles without `::uuid` and breaks on
282
+ // Postgres. Mirrors relatedProxy / the preload paths.
283
+ const relatedMeta = ensureEntityMetadata(related);
284
+ if (cast) {
240
285
  registerColumnCast(relatedMeta.tableName, fk, cast);
241
286
  }
242
287
  }
@@ -255,9 +300,26 @@ export class BaseRepository {
255
300
  hint: `Valid columns: ${this.#columns.join(", ")}`,
256
301
  });
257
302
  }
303
+ /**
304
+ * Resolve a KNOWN property (from `this.#columns`) to its real DB column name,
305
+ * honouring `@Column({ columnName })`. Non-throwing — used on the write path
306
+ * where the column set is already trusted. Falls back to the snake convention.
307
+ */
308
+ #dbColumn(prop) {
309
+ return this.#columnMap.get(prop) ?? camelToSnake(prop);
310
+ }
311
+ /**
312
+ * Normalise a mass-assignment key to its TS property. A payload may key by the
313
+ * DB column name (incl. an explicit `columnName`); without this, `create({
314
+ * full_label: 'x' })` would set a `full_label` property that the INSERT (which
315
+ * reads declared properties) then drops silently.
316
+ */
317
+ #toProperty(key) {
318
+ return this.#columnByDbName.get(key) ?? key;
319
+ }
258
320
  // ─── Query builder ────────────────────────────────────────
259
321
  query() {
260
- return new ModelQuery(this.#tableName, this.#db, (row) => this.#hydrate(row), this.#entityClass, (col) => this.#resolveColumn(col), this.#softDeletes, this.#dialect);
322
+ return new ModelQuery(this.#tableName, this.#db, (row) => this.#hydrate(row), this.#entityClass, (col) => this.#resolveColumn(col), this.#softDeletes, this.#dialect, (prop, value) => this.#applyPrepare(prop, value), this.onDomainEvents);
261
323
  }
262
324
  // ─── Transaction ──────────────────────────────────────────
263
325
  useTransaction(trx) {
@@ -270,6 +332,9 @@ export class BaseRepository {
270
332
  dialect: this.#dialect,
271
333
  });
272
334
  repo.onDomainEvents = this.onDomainEvents;
335
+ // Chain back to the true durable root (a nested useTransaction forwards it)
336
+ // so post-transaction REPO_REF restoration always lands on a live connection.
337
+ repo.#durableParent = this.#durableParent ?? this;
273
338
  return repo;
274
339
  }
275
340
  // ─── Finders ──────────────────────────────────────────────
@@ -293,17 +358,27 @@ export class BaseRepository {
293
358
  }
294
359
  return entity;
295
360
  }
296
- async findBy(column, value) {
361
+ async findBy(columnOrClause, value) {
297
362
  // Through the builder for read-hook parity (see `find`).
298
- return this.query().where(column, value).first();
363
+ let q = this.query();
364
+ if (typeof columnOrClause === "string") {
365
+ q = q.where(columnOrClause, value);
366
+ }
367
+ else {
368
+ for (const [k, v] of Object.entries(columnOrClause))
369
+ q = q.where(k, v);
370
+ }
371
+ return q.first();
299
372
  }
300
- /** Find by a column or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
301
- async findByOrFail(column, value) {
302
- const entity = await this.findBy(column, value);
373
+ async findByOrFail(columnOrClause, value) {
374
+ const entity = typeof columnOrClause === "string"
375
+ ? await this.findBy(columnOrClause, value)
376
+ : await this.findBy(columnOrClause);
303
377
  if (!entity) {
304
- throw new EntityNotFoundError(this.#entityClass.name, {
305
- [column]: value,
306
- });
378
+ const criteria = typeof columnOrClause === "string"
379
+ ? { [columnOrClause]: value }
380
+ : columnOrClause;
381
+ throw new EntityNotFoundError(this.#entityClass.name, criteria);
307
382
  }
308
383
  return entity;
309
384
  }
@@ -316,11 +391,16 @@ export class BaseRepository {
316
391
  .orderBy(this.#primaryKey, "desc")
317
392
  .exec();
318
393
  }
319
- /** Find many rows by an arbitrary column (AdonisJS `findManyBy`). */
320
- async findManyBy(column, values) {
321
- if (values.length === 0)
322
- return [];
323
- return this.query().whereIn(column, values).exec();
394
+ async findManyBy(columnOrClause, values) {
395
+ if (typeof columnOrClause === "string") {
396
+ if (!values || values.length === 0)
397
+ return [];
398
+ return this.query().whereIn(columnOrClause, values).exec();
399
+ }
400
+ let q = this.query();
401
+ for (const [k, v] of Object.entries(columnOrClause))
402
+ q = q.where(k, v);
403
+ return q.exec();
324
404
  }
325
405
  async all() {
326
406
  // Through the builder so beforeFetch/afterFetch fire. The builder applies
@@ -328,6 +408,32 @@ export class BaseRepository {
328
408
  // Ordered PK desc for AdonisJS Lucid `all()` parity (newest first).
329
409
  return this.query().orderBy(this.#primaryKey, "desc").exec();
330
410
  }
411
+ /**
412
+ * Empty this model's table (AdonisJS Lucid `Model.truncate`). Postgres/MySQL
413
+ * issue `TRUNCATE TABLE` (fast, resets identity); SQLite has no TRUNCATE so it
414
+ * falls back to `DELETE FROM`. `cascade` is Postgres-only (truncates dependent
415
+ * FK tables). The table name comes from entity metadata — never user input.
416
+ */
417
+ async truncate(cascade = false) {
418
+ // Quote each dotted segment so a schema-qualified table (`reporting.events`)
419
+ // becomes `"reporting"."events"`, not one dotted identifier that TRUNCATEs
420
+ // the wrong (nonexistent) table. Validate each segment even though the table
421
+ // name is app metadata: a bulletproof ORM must never emit malformed/injectable
422
+ // raw SQL from a `static table = 'x"; DROP…'` slip (same policy as qTable).
423
+ const wrap = (seg) => {
424
+ if (!/^[A-Za-z0-9_]+$/.test(seg)) {
425
+ throw new Error(`Unsafe table identifier: '${seg}'`);
426
+ }
427
+ return this.#dialect === "mysql" ? `\`${seg}\`` : `"${seg}"`;
428
+ };
429
+ const quoted = this.#tableName.split(".").map(wrap).join(".");
430
+ if (this.#dialect === "sqlite") {
431
+ await this.#db.query(`DELETE FROM ${quoted}`, []);
432
+ return;
433
+ }
434
+ const suffix = cascade && this.#dialect === "postgres" ? " CASCADE" : "";
435
+ await this.#db.query(`TRUNCATE TABLE ${quoted}${suffix}`, []);
436
+ }
331
437
  async allWithTrashed() {
332
438
  return this.query().withTrashed().exec();
333
439
  }
@@ -337,40 +443,66 @@ export class BaseRepository {
337
443
  return this.query().onlyTrashed().exec();
338
444
  }
339
445
  async where(column, value) {
340
- // Order by the primary key (DESC = most recent insert first when the PK is
341
- // an auto-increment integer or a monotonic UUID). The ordering contract is
342
- // "most recent first by PK" for `repo.where(col, val)` as a convenience
343
- // finder. Through the builder for read-hook parity (see `find`).
344
- return this.query()
345
- .where(column, value)
346
- .orderBy(this.#primaryKey, "desc")
347
- .exec();
446
+ // No implicit ORDER BY the row order is left to the database, matching
447
+ // Lucid's query-builder `where` (only `all`/`findMany` order by PK desc,
448
+ // which Lucid itself does). Add `.orderBy()` explicitly when order matters.
449
+ // Through the builder for read-hook parity (see `find`).
450
+ return this.query().where(column, value).exec();
348
451
  }
349
452
  // ─── Create / Save / Delete ───────────────────────────────
350
453
  /**
351
- * Build an entity from a plain object and persist it. Fires `beforeSave` →
352
- * `beforeCreate` → INSERT → `afterCreate` → `afterSave`.
454
+ * Build an entity from a plain object and persist it. Fires `beforeCreate` →
455
+ * `beforeSave` → INSERT → `afterCreate` → `afterSave` (AdonisJS/Lucid order:
456
+ * the specific hook runs before the general `beforeSave`).
457
+ *
458
+ * An unknown key throws by default (Lucid); pass `{ allowExtraProperties: true }`
459
+ * to drop unknown keys instead. A bare boolean is the legacy `quiet` flag.
353
460
  */
354
- async create(data) {
461
+ async create(data, options = false) {
462
+ // Back-compat: a bare boolean is the legacy `quiet` flag.
463
+ const { quiet = false, allowExtraProperties = false } = typeof options === "boolean" ? { quiet: options } : options;
355
464
  const entity = new this.#entityClass();
356
465
  for (const [key, value] of Object.entries(data)) {
357
466
  if (this.#validColumns.has(key) ||
358
467
  this.#validColumns.has(camelToSnake(key))) {
359
- entity.setProp(key, value);
468
+ const prop = this.#toProperty(key);
469
+ entity.assertMassAssignable(prop);
470
+ entity.setProp(prop, value);
471
+ }
472
+ else if (key.startsWith("$")) {
473
+ // Framework-internal (`$extras`, `$trx`, …) — leaks in when an entity
474
+ // instance is passed as data. Not a user column and not a typo; skip.
360
475
  }
476
+ else if (!allowExtraProperties) {
477
+ // Adonis Lucid throws on an unknown property by default (a typo'd or
478
+ // stray key is a bug, not something to silently drop). Opt out with
479
+ // `create(data, { allowExtraProperties: true })`.
480
+ throw new AtlasError("E_UNKNOWN_COLUMN", `Cannot assign '${key}' — it is not a column on ${this.#entityClass.name}. ` +
481
+ "Pass { allowExtraProperties: true } to drop unknown keys instead.");
482
+ }
483
+ }
484
+ if (!quiet) {
485
+ await fireHooks(this.#entityClass, "beforeCreate", entity);
486
+ await fireHooks(this.#entityClass, "beforeSave", entity);
361
487
  }
362
- await fireHooks(this.#entityClass, "beforeSave", entity);
363
- await fireHooks(this.#entityClass, "beforeCreate", entity);
364
488
  await this.#insert(entity);
365
- await fireHooks(this.#entityClass, "afterCreate", entity);
366
- await fireHooks(this.#entityClass, "afterSave", entity);
367
- await this.#dispatchDomainEvents(entity);
489
+ this.#attachRepoRef(entity);
490
+ if (!quiet) {
491
+ await fireHooks(this.#entityClass, "afterCreate", entity);
492
+ await fireHooks(this.#entityClass, "afterSave", entity);
493
+ }
494
+ await this.#dispatchOrDefer(entity, true);
368
495
  return entity;
369
496
  }
497
+ /** {@link create} without firing lifecycle hooks (AdonisJS Lucid `createQuietly`). */
498
+ createQuietly(data) {
499
+ return this.create(data, true);
500
+ }
370
501
  /**
371
502
  * Persist an entity. Insert if PK is missing or row doesn't exist, update
372
- * otherwise. Fires `beforeSave` → (`beforeCreate` | `beforeUpdate`) → DB →
373
- * (`afterCreate` | `afterUpdate`) → `afterSave`, then dispatches
503
+ * otherwise. Fires (`beforeCreate` | `beforeUpdate`) → `beforeSave` → DB →
504
+ * (`afterCreate` | `afterUpdate`) → `afterSave` (AdonisJS/Lucid order: the
505
+ * specific hook runs before the general `beforeSave`), then dispatches
374
506
  * accumulated domain events through `onDomainEvents`.
375
507
  *
376
508
  * Race-safety: the `find(pk)` → branch decision has a TOCTOU window. If a
@@ -381,19 +513,53 @@ export class BaseRepository {
381
513
  * `beforeCreate` hooks to be idempotent or move side-effects into
382
514
  * `afterCreate` / `afterSave` where they only fire on commit.
383
515
  */
384
- async save(entity) {
516
+ async save(entity, quiet = false) {
517
+ // A deleted instance must not be resurrected (AdonisJS Lucid parity —
518
+ // `save()` throws once `$isDeleted` is set). Prevents recreating a row the
519
+ // caller believes is gone, or clobbering one deleted concurrently.
520
+ if (entity.$isDeleted) {
521
+ throw new AtlasError("E_MODEL_DELETED", `Cannot save a deleted ${this.#entityClass.name} instance.`, {
522
+ hint: "The instance was already deleted; re-fetch it before saving again.",
523
+ });
524
+ }
385
525
  const pk = entity[this.#primaryKey];
386
- // Treat a present PK (including `0` and `''`) as a candidate update —
387
- // `pk && ...` would route legitimate zero / empty-string keys through
388
- // INSERT and double-write the row.
389
- const isUpdate = isProvidedPk(pk) && (await this.find(pk)) !== null;
390
- await fireHooks(this.#entityClass, "beforeSave", entity);
526
+ // A DB-originated entity whose PK wasn't loaded (an aggregate/alias partial
527
+ // projection) must NOT be treated as new that would INSERT a duplicate.
528
+ // Fail loud: re-fetch it fully or use `.pojo()` for projections.
529
+ if (entity.$isPersisted && !isProvidedPk(pk)) {
530
+ throw new AtlasError("E_MISSING_PRIMARY_KEY", `Cannot save a ${this.#entityClass.name} loaded without its primary key ('${this.#primaryKey}').`, {
531
+ hint: "Select the primary key (plain-column projections auto-include it) or use query().pojo() for aggregate/alias projections.",
532
+ });
533
+ }
534
+ // Decide insert-vs-update from the in-memory `$isPersisted` flag, exactly as
535
+ // AdonisJS/Lucid does — NOT a `find(pk)` SELECT probe. The old probe fired the
536
+ // `beforeFind`/`afterFind` read hooks on every `save()` (a spurious side
537
+ // effect: `save()` isn't a find) and cost an extra round-trip. A brand-new
538
+ // entity whose manual PK collides with an existing row still resolves to an
539
+ // UPDATE via the unique-violation fallback below.
540
+ const isUpdate = entity.$isPersisted;
541
+ // Snapshot the domain-event queue BEFORE hooks/write add to it, so a rollback
542
+ // (see #dispatchOrDefer) drops only this save's events, not ones the caller
543
+ // queued earlier.
544
+ const eventFloor = entity.domainEventCount();
545
+ if (!quiet) {
546
+ // AdonisJS/Lucid order: the SPECIFIC before-hook fires first, then the
547
+ // general `beforeSave`, then the DB write.
548
+ await fireHooks(this.#entityClass, isUpdate ? "beforeUpdate" : "beforeCreate", entity);
549
+ await fireHooks(this.#entityClass, "beforeSave", entity);
550
+ }
551
+ // Whether THIS call inserted a brand-new row (vs updated an existing one).
552
+ // Drives the manual-transaction rollback restore: only a fresh INSERT's row
553
+ // vanishes on rollback, so only it reverts to not-persisted. The race-recovery
554
+ // fallback below stays false — the row pre-existed (a concurrent writer).
555
+ let didInsert = false;
391
556
  if (isUpdate) {
392
- await this.#runUpdateBranch(entity);
557
+ await this.#runUpdateBranch(entity, quiet);
393
558
  }
394
559
  else {
395
560
  try {
396
- await this.#runInsertBranch(entity);
561
+ await this.#runInsertBranch(entity, quiet);
562
+ didInsert = true;
397
563
  }
398
564
  catch (err) {
399
565
  // Race recovery: the row didn't exist when we checked, but a
@@ -401,15 +567,45 @@ export class BaseRepository {
401
567
  // was explicitly provided (auto-generated PK can't collide on
402
568
  // a fresh insert — DB generates a unique one per call).
403
569
  if (isProvidedPk(pk) && isUniqueKeyViolation(err)) {
404
- await this.#runUpdateBranch(entity);
570
+ // We already fired `beforeCreate`; fire `beforeUpdate` too so the
571
+ // update branch's contract holds (documented race quirk).
572
+ if (!quiet)
573
+ await fireHooks(this.#entityClass, "beforeUpdate", entity);
574
+ await this.#runUpdateBranch(entity, quiet);
405
575
  }
406
576
  else {
407
577
  throw err;
408
578
  }
409
579
  }
410
580
  }
411
- await fireHooks(this.#entityClass, "afterSave", entity);
412
- await this.#dispatchDomainEvents(entity);
581
+ this.#attachRepoRef(entity);
582
+ if (!quiet)
583
+ await fireHooks(this.#entityClass, "afterSave", entity);
584
+ await this.#dispatchOrDefer(entity, didInsert, eventFloor);
585
+ }
586
+ /** {@link save} without firing lifecycle hooks (AdonisJS Lucid `saveQuietly`). */
587
+ saveQuietly(entity) {
588
+ return this.save(entity, true);
589
+ }
590
+ /**
591
+ * When true (a repo bound to an atlas-managed transaction), `create`/`save`/
592
+ * `createMany` BUFFER domain events on the entity instead of dispatching them
593
+ * inline. The managed helper flushes them only AFTER the transaction commits, so
594
+ * a rollback never emits events for rows that were rolled back.
595
+ */
596
+ #deferDomainEvents = false;
597
+ /**
598
+ * When set (a trx-bound repo whose owner wants to undo fresh inserts on
599
+ * rollback), every successful fresh INSERT through this repo pushes its entity
600
+ * here. The owner (a managed batch or a relation write) then reverts exactly
601
+ * these entities — the ones whose row provably did not exist before — to $isNew
602
+ * if the transaction rolls back, without a DB probe or find-vs-create bookkeeping.
603
+ * Undefined on a durable repo (nothing to undo — its writes are their own commit).
604
+ */
605
+ #insertTracker;
606
+ /** Record a fresh INSERT so its owner can revert it on rollback (see {@link #insertTracker}). */
607
+ #trackInsert(entity) {
608
+ this.#insertTracker?.push(entity);
413
609
  }
414
610
  /**
415
611
  * Flush the entity's accumulated domain events through `onDomainEvents`.
@@ -431,15 +627,61 @@ export class BaseRepository {
431
627
  }
432
628
  }
433
629
  }
434
- async #runInsertBranch(entity) {
435
- await fireHooks(this.#entityClass, "beforeCreate", entity);
630
+ /**
631
+ * Dispatch an entity's domain events AND restore its in-memory state across a
632
+ * transaction boundary, honouring the post-commit contract in EVERY context
633
+ * (BaseEntity documents post-commit flush):
634
+ * - inside a MANAGED batch (`#inManagedTx` set `#deferDomainEvents`): skip —
635
+ * that helper flushes `collect(result)` on `trx.after('commit')`, and its
636
+ * callers (`#inManagedTx` re-attach / `saveMany` rollback catch) restore
637
+ * REPO_REF + $isPersisted + events themselves.
638
+ * - inside a MANUAL transaction (`repo.useTransaction(trx).create(...)`): the
639
+ * repo's `#db` IS the trx. Register post-transaction hooks:
640
+ * · commit → re-point REPO_REF at the durable repo (Lucid resets `$trx` on
641
+ * commit) then flush events (a rollback thus publishes NOTHING).
642
+ * · rollback → re-point REPO_REF at the durable repo (Lucid also resets
643
+ * `$trx` on rollback); revert a fresh INSERT to not-persisted — the row
644
+ * never existed, and keeping `$isPersisted` would let a later
645
+ * `entity.related('x').create()` skip the parent save and write a child
646
+ * with a phantom FK (named data-integrity deviation vs Lucid, same class
647
+ * as the saveMany rollback fix); and clear the queued domain events — they
648
+ * describe a write that didn't happen, so leaving them would double-publish
649
+ * on a re-save.
650
+ * - no transaction: dispatch immediately.
651
+ */
652
+ async #dispatchOrDefer(entity, wasInsert, eventFloor = 0) {
653
+ if (this.#deferDomainEvents)
654
+ return;
655
+ if (isTransactionClient(this.#db)) {
656
+ const durable = this.#durableParent ?? this;
657
+ this.#db.after("commit", async () => {
658
+ durable.#attachRepoRef(entity);
659
+ await this.#dispatchDomainEvents(entity);
660
+ });
661
+ this.#db.after("rollback", () => {
662
+ durable.#attachRepoRef(entity);
663
+ if (wasInsert)
664
+ entity.markAsNotPersisted();
665
+ // Drop only the events THIS write queued (from `eventFloor` on), not the
666
+ // ones the caller queued before entering the transaction — those describe
667
+ // work outside the rolled-back write and must survive.
668
+ entity.restoreDomainEventsTo(eventFloor);
669
+ });
670
+ return;
671
+ }
672
+ await this.#dispatchDomainEvents(entity);
673
+ }
674
+ // The specific `beforeCreate`/`beforeUpdate` hook is fired by `save()` BEFORE
675
+ // `beforeSave` (Lucid order), so these branches only do the write + after-hook.
676
+ async #runInsertBranch(entity, quiet = false) {
436
677
  await this.#insert(entity);
437
- await fireHooks(this.#entityClass, "afterCreate", entity);
678
+ if (!quiet)
679
+ await fireHooks(this.#entityClass, "afterCreate", entity);
438
680
  }
439
- async #runUpdateBranch(entity) {
440
- await fireHooks(this.#entityClass, "beforeUpdate", entity);
681
+ async #runUpdateBranch(entity, quiet = false) {
441
682
  await this.#update(entity);
442
- await fireHooks(this.#entityClass, "afterUpdate", entity);
683
+ if (!quiet)
684
+ await fireHooks(this.#entityClass, "afterUpdate", entity);
443
685
  }
444
686
  /**
445
687
  * Insert many rows in a single multi-row INSERT. Fires beforeSave/beforeCreate
@@ -449,26 +691,58 @@ export class BaseRepository {
449
691
  *
450
692
  * @implements Story 30.1 + 30.5
451
693
  */
452
- async createMany(rows) {
694
+ async createMany(rows, quiet = false) {
453
695
  if (rows.length === 0)
454
696
  return [];
455
697
  const entities = rows.map((r) => {
456
698
  const e = new this.#entityClass();
457
699
  for (const [k, v] of Object.entries(r)) {
458
700
  if (this.#validColumns.has(k) ||
459
- this.#validColumns.has(camelToSnake(k)))
460
- e.setProp(k, v);
701
+ this.#validColumns.has(camelToSnake(k))) {
702
+ const prop = this.#toProperty(k);
703
+ e.assertMassAssignable(prop);
704
+ e.setProp(prop, v);
705
+ }
461
706
  }
462
707
  return e;
463
708
  });
464
- for (const e of entities) {
465
- await fireHooks(this.#entityClass, "beforeSave", e);
466
- await fireHooks(this.#entityClass, "beforeCreate", e);
709
+ // All-or-nothing (Lucid parity, same as saveMany): run the batch INSERT *and*
710
+ // its afterCreate/afterSave hooks inside ONE managed transaction, so a hook that
711
+ // throws rolls the whole batch back. Previously #persistFreshBatch ran the insert
712
+ // then the after-hooks with no surrounding transaction, so a failing after-hook
713
+ // left the rows committed while createMany rejected. The built entities are
714
+ // internal (returned only on success), so — unlike saveMany, whose instances the
715
+ // caller keeps — no rollback-restore of caller state is needed; the nested-under-
716
+ // external case is already handled by #inManagedTx's tracker.
717
+ return this.#inManagedTx((repo) => repo.#persistFreshBatch(entities, quiet), (result) => result);
718
+ }
719
+ /**
720
+ * Persist a batch of NEW entity INSTANCES: fire create/save hooks, batch-INSERT
721
+ * (multi-row RETURNING; mysql falls back to N inserts in one managed tx), fire
722
+ * the after hooks, wire the repo ref, and dispatch domain events (unless
723
+ * deferred). Shared by `createMany` (which builds instances from rows) and
724
+ * `saveMany` (which passes the CALLER's own fresh instances) so hook mutations
725
+ * and hook-generated domain events always land on the exact objects the caller
726
+ * holds — never on discarded clones.
727
+ */
728
+ async #persistFreshBatch(entities, quiet) {
729
+ if (entities.length === 0)
730
+ return [];
731
+ if (!quiet) {
732
+ for (const e of entities) {
733
+ await fireHooks(this.#entityClass, "beforeCreate", e);
734
+ await fireHooks(this.#entityClass, "beforeSave", e);
735
+ }
467
736
  }
468
737
  if (this.#dialect === "mysql") {
469
- // mysql: loop single inserts (no RETURNING).
470
- for (const e of entities)
471
- await this.#insert(e);
738
+ // mysql has no multi-row RETURNING, so insert row-by-row — but inside a
739
+ // single managed transaction so the batch is all-or-nothing (Lucid parity;
740
+ // a mid-batch failure must not leave a partial insert committed).
741
+ await transaction(this.#db, async (trx) => {
742
+ const r = this.useTransaction(trx);
743
+ for (const e of entities)
744
+ await r.#insert(e);
745
+ });
472
746
  }
473
747
  else {
474
748
  const specRows = entities.map((e) => this.#entityToRowPairs(e));
@@ -478,27 +752,43 @@ export class BaseRepository {
478
752
  rows: specRows,
479
753
  casts: this.#castTypes,
480
754
  returning: [
481
- camelToSnake(this.#primaryKey),
482
- ...this.#columns.map((c) => camelToSnake(c)),
755
+ this.#dbColumn(this.#primaryKey),
756
+ ...this.#columns.map((c) => this.#dbColumn(c)),
483
757
  ],
484
758
  };
485
759
  const compiled = compileStatementNative(spec, this.#dialect);
486
760
  const returned = await this.#db.query(compiled.statements[0], compiled.params);
487
761
  returned.forEach((row, i) => {
488
- for (const [k, v] of Object.entries(row))
489
- entities[i].setProp(snakeToCamel(k), v);
762
+ for (const [k, v] of Object.entries(row)) {
763
+ const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
764
+ // Run the DB value through consume so date columns come back as
765
+ // Chronos DateTime (not the raw ISO string) — mirrors #hydrate.
766
+ entities[i].setProp(prop, this.#applyConsume(prop, v, entities[i]));
767
+ }
490
768
  entities[i].markAsPersisted();
491
769
  });
492
770
  }
493
- for (const e of entities) {
494
- await fireHooks(this.#entityClass, "afterCreate", e);
495
- await fireHooks(this.#entityClass, "afterSave", e);
496
- }
497
- for (const e of entities) {
498
- await this.#dispatchDomainEvents(e);
771
+ if (!quiet) {
772
+ for (const e of entities) {
773
+ await fireHooks(this.#entityClass, "afterCreate", e);
774
+ await fireHooks(this.#entityClass, "afterSave", e);
775
+ }
499
776
  }
777
+ for (const e of entities)
778
+ this.#attachRepoRef(e);
779
+ // Record the fresh inserts on THIS repo (the mysql path ran #insert on a nested
780
+ // trx repo, so track here uniformly for both dialects) so the owning managed
781
+ // batch can revert them on rollback.
782
+ for (const e of entities)
783
+ this.#trackInsert(e);
784
+ for (const e of entities)
785
+ await this.#dispatchOrDefer(e, true);
500
786
  return entities;
501
787
  }
788
+ /** {@link createMany} without firing lifecycle hooks (AdonisJS Lucid `createManyQuietly`). */
789
+ createManyQuietly(rows) {
790
+ return this.createMany(rows, true);
791
+ }
502
792
  /**
503
793
  * Persist many already-constructed entity instances. Same hooks + batching
504
794
  * as `createMany`, but accepts prebuilt entities so dirty tracking works.
@@ -508,36 +798,68 @@ export class BaseRepository {
508
798
  async saveMany(entities) {
509
799
  if (entities.length === 0)
510
800
  return [];
511
- // Split new vs already-persisted; for simplicity, persist new ones as a
512
- // batch and fall back to per-entity save for dirty ones.
801
+ // All-or-nothing, like Lucid: `createMany` and every batch helper run in a
802
+ // managed transaction, so a mid-batch failure rolls the WHOLE batch back
803
+ // (verified against the Lucid CRUD docs). Fresh inserts AND dirty updates
804
+ // commit together or not at all — previously the dirty ones were saved one
805
+ // by one OUTSIDE any transaction, leaving earlier rows persisted on a later
806
+ // failure. Events flush post-commit via #inManagedTx (deferred inside).
807
+ // #inManagedTx re-points each returned entity's REPO_REF at the durable repo
808
+ // after commit, so related()/refresh() work on the instances we hand back.
809
+ // Split BEFORE the batch so the rollback path still knows which were fresh
810
+ // (once #persistFreshBatch runs markAsPersisted, the flag flips). Classify by
811
+ // `$isPersisted`, NOT by an empty `$original`: an aggregate/alias PROJECTION is
812
+ // hydrated persisted but with `$original = {}`, so the old empty-$original test
813
+ // misrouted it into the fresh INSERT batch — bypassing save()'s
814
+ // E_MISSING_PRIMARY_KEY guard and turning a keyless projection into an INSERT.
815
+ // A persisted projection now lands in `dirty` → save() → the guard fires.
513
816
  const fresh = [];
514
817
  const dirty = [];
515
818
  for (const e of entities) {
516
- if (Object.keys(e.$original ?? {}).length === 0)
819
+ if (!e.$isPersisted)
517
820
  fresh.push(e);
518
821
  else
519
822
  dirty.push(e);
520
823
  }
521
- if (fresh.length > 0) {
522
- const rows = fresh.map((e) => {
523
- const r = {};
524
- for (const c of this.#columns) {
525
- const v = e[c];
526
- if (v !== undefined)
527
- r[c] = v;
528
- }
529
- return r;
530
- });
531
- const created = await this.createMany(rows);
532
- // Copy generated PKs back to the original instances.
533
- created.forEach((c, i) => {
534
- fresh[i].setProp(this.#primaryKey, c[this.#primaryKey]);
535
- fresh[i].markAsPersisted();
536
- });
824
+ // Snapshot each caller instance's domain-event floor BEFORE the batch, so a
825
+ // rollback drops only the events this batch queued, keeping any the caller
826
+ // queued earlier (#8).
827
+ const eventFloors = new Map();
828
+ for (const e of entities)
829
+ eventFloors.set(e, e.domainEventCount());
830
+ try {
831
+ return await this.#inManagedTx(async (repo) => {
832
+ // Persist the caller's OWN fresh instances (not clones): hook mutations
833
+ // and hook-generated domain events stay on the objects we return.
834
+ if (fresh.length > 0)
835
+ await repo.#persistFreshBatch(fresh, false);
836
+ for (const d of dirty)
837
+ await repo.save(d);
838
+ return entities;
839
+ }, (result) => result, eventFloors);
840
+ }
841
+ catch (err) {
842
+ // Rollback recovery. Re-point every instance's REPO_REF at the durable repo
843
+ // (it was stamped at the now-finished trx) — Lucid resets `$trx` the same
844
+ // way. And REVERT the FRESH instances to not-persisted: their INSERT was
845
+ // rolled back, so keeping `$isPersisted` (Lucid does) would let a later
846
+ // `fresh.related('x').create()` skip re-saving the parent and write a child
847
+ // with a phantom foreign key. Reverting only the FRESH ones (provably
848
+ // unpersisted before the batch) is a NAMED safety deviation; DIRTY rows
849
+ // keep `$isPersisted` — their row still exists with its rolled-back values.
850
+ for (const e of entities)
851
+ this.#attachRepoRef(e);
852
+ for (const e of fresh)
853
+ e.markAsNotPersisted();
854
+ // Drop the events THIS batch queued (from each instance's pre-batch floor):
855
+ // the whole batch rolled back, so those describe writes that didn't happen.
856
+ // Leaving them would double-publish when the caller re-saves the same
857
+ // instance (its hooks re-queue the event). Events queued BEFORE the batch
858
+ // survive (#8) — they describe work outside this rolled-back batch.
859
+ for (const e of entities)
860
+ e.restoreDomainEventsTo(eventFloors.get(e) ?? 0);
861
+ throw err;
537
862
  }
538
- for (const d of dirty)
539
- await this.save(d);
540
- return entities;
541
863
  }
542
864
  /**
543
865
  * Dialect-aware upsert. postgres + sqlite emit `ON CONFLICT DO UPDATE`; mysql
@@ -568,13 +890,89 @@ export class BaseRepository {
568
890
  async firstOrCreate(search, defaults = {}) {
569
891
  // Atomic (AdonisJS Lucid parity): find-under-lock then create inside one
570
892
  // transaction, so two concurrent callers can't both miss and both INSERT.
571
- return transaction(this.#db, async (trx) => {
572
- const repo = this.useTransaction(trx);
893
+ return this.#inManagedTx(async (repo) => {
573
894
  const existing = await repo.#findBySearch(search, true);
574
895
  if (existing)
575
896
  return existing;
576
897
  return repo.create({ ...search, ...defaults });
898
+ }, (r) => [r]);
899
+ }
900
+ /**
901
+ * Run `body` inside an atlas-managed transaction whose trx-bound repo DEFERS
902
+ * domain-event dispatch, then flush the collected entities' events AFTER the
903
+ * commit — so a rollback emits no events for rows that were rolled back
904
+ * (previously each create/save dispatched in-loop, before the batch committed).
905
+ * `collect` picks the entities whose events flush post-commit.
906
+ */
907
+ async #inManagedTx(body, collect, eventFloors) {
908
+ // Records every fresh INSERT the body performs through the trx-bound repo, so
909
+ // we can revert exactly those (not the found-and-updated rows) on rollback.
910
+ const freshInserts = [];
911
+ const result = await transaction(this.#db, async (trx) => {
912
+ const repo = this.useTransaction(trx);
913
+ repo.#deferDomainEvents = true;
914
+ repo.#insertTracker = freshInserts;
915
+ const r = await body(repo);
916
+ // Flush AFTER the transaction is durable. Registering on the trx (rather
917
+ // than awaiting after `transaction(...)` returns) is what makes this
918
+ // correct inside an EXTERNAL transaction: there `transaction()` only
919
+ // opens a SAVEPOINT, so a post-return flush would fire before the outer
920
+ // commit — and emit events for rows a later outer rollback discards.
921
+ trx.after("commit", async () => {
922
+ for (const e of collect(r))
923
+ await this.#dispatchDomainEvents(e);
924
+ });
925
+ return r;
577
926
  });
927
+ // Every entity produced here was created / hydrated through the trx-bound
928
+ // repo, so its REPO_REF points at the (now-finished inner) transaction. Re-point
929
+ // it at `this` so related()/refresh()/fresh() work on the returned instance —
930
+ // covers firstOrCreate/updateOrCreate/*Many/saveMany.
931
+ const produced = collect(result);
932
+ for (const e of produced)
933
+ this.#attachRepoRef(e);
934
+ // When we ran NESTED inside an external transaction, `this.#db` is that outer
935
+ // trx and the re-attach above pointed REPO_REF at the outer-trx repo (correct
936
+ // while still inside it). But the inner SAVEPOINT's RELEASE is NOT durable — the
937
+ // root can still roll back. Lucid resets `$trx` once the transaction it was bound
938
+ // to resolves, either way, so re-point REPO_REF at the durable repo on BOTH the
939
+ // outer commit and the outer rollback; otherwise the ref dangles on a finished
940
+ // transaction ("transaction already finished") on any later related()/refresh().
941
+ // AND on rollback, revert the rows that were FRESHLY INSERTED (the tracker proves
942
+ // exactly which — found-and-updated rows still exist and stay persisted): keeping
943
+ // $isPersisted on a row that no longer exists would let a later related().create()
944
+ // skip re-saving the parent and orphan the FK (named data-integrity deviation, now
945
+ // closed for the nested managed path too — freshness is proven, no longer at Lucid
946
+ // parity as in the initial R21 pass).
947
+ if (isTransactionClient(this.#db)) {
948
+ const durable = this.#durableParent ?? this;
949
+ this.#db.after("commit", () => {
950
+ for (const e of produced)
951
+ durable.#attachRepoRef(e);
952
+ });
953
+ this.#db.after("rollback", () => {
954
+ for (const e of produced) {
955
+ durable.#attachRepoRef(e);
956
+ // Every produced entity was written (inserted OR updated) in the
957
+ // rolled-back trx, so any event THIS batch queued describes a write that
958
+ // never committed — drop it (else a later re-save double-publishes: a
959
+ // beforeUpdate hook on a found+updated row is the canonical trigger).
960
+ // Restore to the caller's pre-batch floor so events queued BEFORE the
961
+ // batch (e.g. a caller's manual addDomainEvent) survive; absent a floor
962
+ // the entity was tx-internal (floor 0 = clear).
963
+ e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
964
+ }
965
+ // Fresh inserts additionally revert to $isNew — their row is gone.
966
+ // Found+updated rows keep $isPersisted (their row still exists).
967
+ // (restoreDomainEventsTo is idempotent — safe even if a fresh insert is not
968
+ // among `produced`, e.g. an internal side-write not returned by collect.)
969
+ for (const e of freshInserts) {
970
+ e.markAsNotPersisted();
971
+ e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
972
+ }
973
+ });
974
+ }
975
+ return result;
578
976
  }
579
977
  /** Find a row or build an in-memory instance without persisting. */
580
978
  async firstOrNew(search, defaults = {}) {
@@ -583,24 +981,105 @@ export class BaseRepository {
583
981
  return existing;
584
982
  const e = new this.#entityClass();
585
983
  for (const [k, v] of Object.entries({ ...search, ...defaults })) {
586
- if (this.#validColumns.has(k) || this.#validColumns.has(camelToSnake(k)))
587
- e.setProp(k, v);
984
+ if (this.#validColumns.has(k) ||
985
+ this.#validColumns.has(camelToSnake(k))) {
986
+ const prop = this.#toProperty(k);
987
+ e.assertMassAssignable(prop);
988
+ e.setProp(prop, v);
989
+ }
588
990
  }
589
991
  return e;
590
992
  }
591
993
  /** Atomic find-or-update-or-insert (AdonisJS Lucid parity — locked + transactional). */
592
994
  async updateOrCreate(search, values) {
593
- return transaction(this.#db, async (trx) => {
594
- const repo = this.useTransaction(trx);
995
+ return this.#inManagedTx(async (repo) => {
595
996
  const existing = await repo.#findBySearch(search, true);
596
997
  if (existing) {
597
- for (const [k, v] of Object.entries(values))
598
- existing.setProp(k, v);
998
+ for (const [k, v] of Object.entries(values)) {
999
+ const prop = this.#toProperty(k);
1000
+ existing.assertMassAssignable(prop);
1001
+ existing.setProp(prop, v);
1002
+ }
599
1003
  await repo.save(existing);
600
1004
  return existing;
601
1005
  }
602
1006
  return repo.create({ ...search, ...values });
603
- });
1007
+ }, (r) => [r]);
1008
+ }
1009
+ /** Extract the search clause (the unique key column(s)) from a row. */
1010
+ #pickKeys(row, key) {
1011
+ const keys = Array.isArray(key) ? key : [key];
1012
+ const search = {};
1013
+ for (const k of keys) {
1014
+ // The predicate key AND the row may each be a TS property or a DB column
1015
+ // name. Normalise both to the property so `updateOrCreateMany('label', [{
1016
+ // full_label: 'x' }])` matches — mirrors the create() key normalization.
1017
+ const prop = this.#toProperty(k);
1018
+ const dbName = this.#dbColumn(prop);
1019
+ let value;
1020
+ if (k in row)
1021
+ value = row[k];
1022
+ else if (prop in row)
1023
+ value = row[prop];
1024
+ else
1025
+ value = row[dbName];
1026
+ search[prop] = value;
1027
+ }
1028
+ return search;
1029
+ }
1030
+ /**
1031
+ * Bulk find-or-update-or-insert, keyed by a unique column (or columns), in ONE
1032
+ * transaction — all-or-nothing (AdonisJS Lucid `updateOrCreateMany`).
1033
+ */
1034
+ async updateOrCreateMany(key, rows) {
1035
+ if (rows.length === 0)
1036
+ return [];
1037
+ return this.#inManagedTx(async (repo) => {
1038
+ const out = [];
1039
+ for (const row of rows) {
1040
+ const existing = await repo.#findBySearch(this.#pickKeys(row, key), true);
1041
+ if (existing) {
1042
+ for (const [k, v] of Object.entries(row)) {
1043
+ const prop = this.#toProperty(k);
1044
+ existing.assertMassAssignable(prop);
1045
+ existing.setProp(prop, v);
1046
+ }
1047
+ await repo.save(existing);
1048
+ out.push(existing);
1049
+ }
1050
+ else {
1051
+ out.push(await repo.create(row));
1052
+ }
1053
+ }
1054
+ return out;
1055
+ }, (out) => out);
1056
+ }
1057
+ /**
1058
+ * Bulk find-or-create keyed by a unique column(s) — existing rows are returned
1059
+ * untouched — in one transaction (AdonisJS Lucid `fetchOrCreateMany`).
1060
+ */
1061
+ async fetchOrCreateMany(key, rows) {
1062
+ if (rows.length === 0)
1063
+ return [];
1064
+ return this.#inManagedTx(async (repo) => {
1065
+ const out = [];
1066
+ for (const row of rows) {
1067
+ const existing = await repo.#findBySearch(this.#pickKeys(row, key), true);
1068
+ out.push(existing ?? (await repo.create(row)));
1069
+ }
1070
+ return out;
1071
+ }, (out) => out);
1072
+ }
1073
+ /**
1074
+ * Bulk find-or-new keyed by a unique column(s): existing rows are returned,
1075
+ * misses become UNPERSISTED in-memory instances (AdonisJS `fetchOrNewUpMany`).
1076
+ */
1077
+ async fetchOrNewUpMany(key, rows) {
1078
+ const out = [];
1079
+ for (const row of rows) {
1080
+ out.push(await this.firstOrNew(this.#pickKeys(row, key), row));
1081
+ }
1082
+ return out;
604
1083
  }
605
1084
  async #findBySearch(search, lock = false) {
606
1085
  let q = this.query();
@@ -617,26 +1096,76 @@ export class BaseRepository {
617
1096
  * contract — callback receives the raw value (including null/undefined) and
618
1097
  * decides what to do with it.
619
1098
  */
620
- #applyPrepare(propertyKey, value) {
1099
+ #applyPrepare(key, value, model) {
1100
+ // Callers may pass a DB column name (e.g. updateWhere("starts_at", …) or a
1101
+ // `@Column({ columnName })` column) — prepare/dateColumns are keyed by the TS
1102
+ // property, so normalise via the reverse map first, else the adapter/date
1103
+ // conversion is silently skipped.
1104
+ const propertyKey = this.#columnByDbName.get(key) ?? key;
621
1105
  const prepare = this.#columnPrepares.get(propertyKey);
622
- if (!prepare)
623
- return value;
624
- let result;
625
- try {
626
- result = prepare(value);
1106
+ if (prepare) {
1107
+ let result;
1108
+ try {
1109
+ // Adonis Lucid signature: (value, attribute, model). `model` is
1110
+ // undefined on query-builder paths that carry no instance.
1111
+ result = prepare(value, propertyKey, model);
1112
+ }
1113
+ catch (err) {
1114
+ throw wrapAdapterError("prepare", propertyKey, err);
1115
+ }
1116
+ assertNotPromise("prepare", propertyKey, result);
1117
+ return result;
627
1118
  }
628
- catch (err) {
629
- throw wrapAdapterError("prepare", propertyKey, err);
1119
+ // No explicit `@Column({ prepare })`: lower a `@column.date()` /
1120
+ // `@column.dateTime()` value to its ISO 8601 string for the SQL bind.
1121
+ // Branch order mirrors Lucid's `prepareDateColumn` (strings pass through,
1122
+ // `DateTime` is formatted, anything else throws naming the column) — see
1123
+ // `#prepareDateString` for the one named deviation.
1124
+ if (this.#dateColumns[propertyKey] && value != null) {
1125
+ if (typeof value === "string") {
1126
+ return this.#prepareDateString(propertyKey, value);
1127
+ }
1128
+ // A raw JS `Date` is accepted where Lucid throws: `toISOString()` is
1129
+ // unambiguous UTC, so the strictness would buy nothing. Named deviation.
1130
+ if (value instanceof Date)
1131
+ return value.toISOString();
1132
+ // Otherwise the Chronos adapter's prepare serialises a `DateTime` — via
1133
+ // a STRUCTURAL check, so an instance from a duplicated `@c9up/chronos`
1134
+ // copy (another realm) round-trips instead of being passed raw to the
1135
+ // N-API bind.
1136
+ return dateTimeAtlasAdapter.prepare(value);
630
1137
  }
631
- assertNotPromise("prepare", propertyKey, result);
632
- return result;
1138
+ return value;
1139
+ }
1140
+ /**
1141
+ * Lower a string assigned to a `@column.date()` / `@column.dateTime()`.
1142
+ *
1143
+ * Lucid lets every string through untouched (`prepareDateColumn`, first
1144
+ * branch), and atlas does the same — with ONE named deviation: a *naive*
1145
+ * datetime (no `Z`, no offset) is rejected. Such a string has no instant
1146
+ * attached, so the chronos read path resolves it in the JS runtime's local
1147
+ * zone: the very same row then hydrates to a different instant on a
1148
+ * developer laptop in Europe/Zurich and on a CI host running UTC. Silently
1149
+ * storing a value that means two different things is the failure this guard
1150
+ * exists to prevent; date-only strings and offset-bearing ones are
1151
+ * unambiguous and pass exactly like Lucid.
1152
+ */
1153
+ #prepareDateString(propertyKey, value) {
1154
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
1155
+ const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value.trim());
1156
+ if (dateOnly || hasZone)
1157
+ return value;
1158
+ throw new AtlasError("INVALID_DATE_COLUMN_VALUE", `${this.#entityClass.name}.${propertyKey}: "${value}" is a naive datetime — it carries no timezone, so it would read back differently depending on the machine.`, {
1159
+ hint: 'Add an offset ("2026-08-10T12:00:00Z"), pass a chronos DateTime, or use a date-only string ("2026-08-10").',
1160
+ });
633
1161
  }
634
- #applyConsume(propertyKey, value) {
1162
+ #applyConsume(propertyKey, value, model) {
635
1163
  const consume = this.#columnConsumes.get(propertyKey);
636
1164
  if (consume) {
637
1165
  let result;
638
1166
  try {
639
- result = consume(value);
1167
+ // Adonis Lucid signature: (value, attribute, model).
1168
+ result = consume(value, propertyKey, model);
640
1169
  }
641
1170
  catch (err) {
642
1171
  throw wrapAdapterError("consume", propertyKey, err);
@@ -645,18 +1174,13 @@ export class BaseRepository {
645
1174
  return result;
646
1175
  }
647
1176
  // No explicit `@Column({ consume })`: a `@column.date()` / `@column.dateTime()`
648
- // column hydrates its DB value (an ISO string from the Rust decode) into a
649
- // JS `Date`, so `.getTime()` / date arithmetic work on read. Mirrors Adonis
650
- // Lucid hydrating date columns to a Luxon `DateTime` atlas standardises on
651
- // the native `Date` (no Luxon dependency). An unparseable string is left
652
- // untouched rather than turned into `Invalid Date`.
653
- if (this.#dateColumns[propertyKey] &&
654
- value != null &&
655
- !(value instanceof Date) &&
656
- (typeof value === "string" || typeof value === "number")) {
657
- const d = new Date(value);
658
- if (!Number.isNaN(d.getTime()))
659
- return d;
1177
+ // column hydrates its DB value into a Chronos `DateTime` mirroring Adonis
1178
+ // Lucid, which hydrates date columns to a Luxon `DateTime` (here the Ream
1179
+ // date engine `@c9up/chronos` plays Luxon's role). The Chronos adapter's
1180
+ // consume is idempotent and uses a structural check, so a `DateTime` from a
1181
+ // different realm (duplicated package copy) is recognised too.
1182
+ if (this.#dateColumns[propertyKey] && value != null) {
1183
+ return dateTimeAtlasAdapter.consume(value);
660
1184
  }
661
1185
  return value;
662
1186
  }
@@ -668,10 +1192,9 @@ export class BaseRepository {
668
1192
  // through because that's a meaningful SQL value.
669
1193
  if (v === undefined)
670
1194
  continue;
671
- // Prepare map is keyed by camelCase property name. The input bag may use
672
- // either camel or snake try the raw key first, else convert.
673
- const propKey = this.#columnPrepares.has(k) ? k : snakeToCamel(k);
674
- pairs.push([this.#resolveColumn(k), this.#applyPrepare(propKey, v)]);
1195
+ // `#applyPrepare` normalises the key (property / snake / columnName) via
1196
+ // the reverse map, so pass the raw key straight through.
1197
+ pairs.push([this.#resolveColumn(k), this.#applyPrepare(k, v)]);
675
1198
  }
676
1199
  return pairs;
677
1200
  }
@@ -680,17 +1203,22 @@ export class BaseRepository {
680
1203
  for (const col of this.#columns) {
681
1204
  const v = entity[col];
682
1205
  if (v !== undefined)
683
- pairs.push([camelToSnake(col), this.#applyPrepare(col, v)]);
1206
+ pairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
684
1207
  }
685
1208
  return pairs;
686
1209
  }
687
1210
  /** Delete the entity. Fires `beforeDelete` → DB → `afterDelete`. Soft-delete aware. */
688
- async delete(entity) {
689
- await fireHooks(this.#entityClass, "beforeDelete", entity);
1211
+ async delete(entity, quiet = false) {
1212
+ // Guard BEFORE hooks — a projection entity with no PK must not fire
1213
+ // beforeDelete against a phantom row, then delete WHERE pk IS NULL.
1214
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "delete()");
1215
+ if (!quiet)
1216
+ await fireHooks(this.#entityClass, "beforeDelete", entity);
690
1217
  const pk = entity[this.#primaryKey];
691
1218
  if (this.#softDeletes) {
692
- const now = new Date().toISOString();
693
- await this.#runUpdate([["deleted_at", now]], [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }]);
1219
+ const now = DateTime.now();
1220
+ await this.#runUpdate([[this.#dbColumn("deletedAt"), now.toISO()]], [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }]);
1221
+ // In-memory value is a Chronos DateTime, matching how date columns hydrate.
694
1222
  entity.setProp("deletedAt", now);
695
1223
  }
696
1224
  else {
@@ -698,10 +1226,17 @@ export class BaseRepository {
698
1226
  { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
699
1227
  ]);
700
1228
  }
701
- await fireHooks(this.#entityClass, "afterDelete", entity);
1229
+ entity.markAsDeleted();
1230
+ if (!quiet)
1231
+ await fireHooks(this.#entityClass, "afterDelete", entity);
1232
+ }
1233
+ /** {@link delete} without firing lifecycle hooks (AdonisJS Lucid `deleteQuietly`). */
1234
+ deleteQuietly(entity) {
1235
+ return this.delete(entity, true);
702
1236
  }
703
1237
  /** Permanently delete (bypasses soft delete). Fires `beforeDelete` / `afterDelete` hooks. */
704
1238
  async forceDelete(entity) {
1239
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "forceDelete()");
705
1240
  await fireHooks(this.#entityClass, "beforeDelete", entity);
706
1241
  await this.#runDelete([
707
1242
  {
@@ -711,12 +1246,14 @@ export class BaseRepository {
711
1246
  type: "and",
712
1247
  },
713
1248
  ]);
1249
+ entity.markAsDeleted();
714
1250
  await fireHooks(this.#entityClass, "afterDelete", entity);
715
1251
  }
716
1252
  async restore(entity) {
717
1253
  if (!this.#softDeletes)
718
1254
  return;
719
- await this.#runUpdate([["deleted_at", null]], [
1255
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "restore()");
1256
+ await this.#runUpdate([[this.#dbColumn("deletedAt"), null]], [
720
1257
  {
721
1258
  column: this.#primaryKey,
722
1259
  operator: "=",
@@ -737,7 +1274,14 @@ export class BaseRepository {
737
1274
  const whereCol = this.#resolveColumn(column);
738
1275
  const set = this.#buildSetPairs(data);
739
1276
  await this.#runUpdate(set, [
740
- { column: whereCol, operator: "=", value: columnValue, type: "and" },
1277
+ {
1278
+ column: whereCol,
1279
+ operator: "=",
1280
+ // Prepare the filter value like the query()/where() path (DateTime→ISO,
1281
+ // @Column adapters) so updateWhere matches query().where().update().
1282
+ value: this.#applyPrepare(column, columnValue),
1283
+ type: "and",
1284
+ },
741
1285
  ]);
742
1286
  }
743
1287
  async increment(id, columnOrMap, amount = 1) {
@@ -754,6 +1298,17 @@ export class BaseRepository {
754
1298
  }
755
1299
  // ─── Raw ──────────────────────────────────────────────────
756
1300
  async raw(sql, ...params) {
1301
+ // Strict mode hardens the repository's raw surfaces (parity with
1302
+ // whereRaw/joinRaw/havingRaw): `raw()` splices a whole hand-written SQL
1303
+ // statement into the typed repo and hydrates it, so it's the widest raw
1304
+ // entry point of all. Block it and point at the connection-level break-glass
1305
+ // (`db.query()`/`db.execute()`, explicitly parameterised) — that stays the
1306
+ // sanctioned, greppable escape hatch, never a silent bypass of strict mode.
1307
+ if (isAtlasStrictMode()) {
1308
+ throw new AtlasError("E_STRICT_MODE", `raw() is disabled in Atlas strict mode on ${this.#entityClass.name}.`, {
1309
+ hint: "Use the typed query() builder, or db.query()/db.execute() with bound params for a deliberate break-glass query. Call setAtlasStrictMode(false) at bootstrap if you truly need repo.raw().",
1310
+ });
1311
+ }
757
1312
  const rows = await this.#db.query(sql, params);
758
1313
  return rows.map((r) => this.#hydrate(r));
759
1314
  }
@@ -765,6 +1320,32 @@ export class BaseRepository {
765
1320
  return this.#primaryKey;
766
1321
  }
767
1322
  // ─── Private helpers ──────────────────────────────────────
1323
+ /**
1324
+ * Guard for an op PREMISED on an existing DB row (refresh/fresh/delete/
1325
+ * forceDelete/restore/load*). These require a genuine database row, so the
1326
+ * entity must be `$isPersisted` — a locally-built instance with a manual PK is
1327
+ * NOT a row: deleting/refreshing off it would silently hit an unrelated row (or
1328
+ * none) and fire hooks against a hollow object. Mirrors Lucid, whose `refresh()`
1329
+ * rejects a non-persisted instance and whose destructive ops always run on a
1330
+ * loaded model; the extra strictness on delete/restore is a named safety
1331
+ * deviation. A persisted-but-keyless entity (aggregate/alias projection) is also
1332
+ * rejected, with the projection diagnostic.
1333
+ */
1334
+ #assertPersistedRow(entity, key, op, keyName = this.#primaryKey) {
1335
+ if (!entity.$isPersisted) {
1336
+ throw new AtlasError("E_MODEL_NOT_PERSISTED", `Cannot ${op} a ${this.#entityClass.name} that is not persisted.`, {
1337
+ hint: "Load it from the database (find/query) first — a locally-built instance with a manual primary key is not a database row.",
1338
+ });
1339
+ }
1340
+ if (!isProvidedPk(key)) {
1341
+ // Name the ACTUAL missing key — a relation with a custom `localKey` isn't
1342
+ // missing its primary key, it's missing that local key ('code', …).
1343
+ const isPk = keyName === this.#primaryKey;
1344
+ throw new AtlasError("E_MISSING_PRIMARY_KEY", `Cannot ${op} a ${this.#entityClass.name} loaded without its ${isPk ? "primary key" : "key"} ('${keyName}').`, {
1345
+ hint: "This entity came from an aggregate/alias projection. Select the key or use query().pojo() for projections.",
1346
+ });
1347
+ }
1348
+ }
768
1349
  async #runDelete(wheres) {
769
1350
  const compiled = compileStatementNative({ kind: "delete", table: this.#tableName, wheres }, this.#dialect);
770
1351
  await this.#db.execute(compiled.statements[0], compiled.params);
@@ -804,8 +1385,8 @@ export class BaseRepository {
804
1385
  values,
805
1386
  casts: this.#castTypes,
806
1387
  returning: [
807
- camelToSnake(this.#primaryKey),
808
- ...this.#columns.map((c) => camelToSnake(c)),
1388
+ this.#dbColumn(this.#primaryKey),
1389
+ ...this.#columns.map((c) => this.#dbColumn(c)),
809
1390
  ],
810
1391
  }
811
1392
  : {
@@ -839,8 +1420,11 @@ export class BaseRepository {
839
1420
  // callers see them on the entity without an extra `find()`. Mirrors
840
1421
  // `createMany`, where the multi-row path already does this.
841
1422
  if (result.row) {
842
- for (const [k, v] of Object.entries(result.row))
843
- entity.setProp(snakeToCamel(k), v);
1423
+ for (const [k, v] of Object.entries(result.row)) {
1424
+ const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
1425
+ // Consume so date columns hydrate to Chronos DateTime, not raw ISO.
1426
+ entity.setProp(prop, this.#applyConsume(prop, v, entity));
1427
+ }
844
1428
  }
845
1429
  else if (result.lastInsertRowid !== undefined &&
846
1430
  !isProvidedPk(entity[this.#primaryKey])) {
@@ -849,6 +1433,7 @@ export class BaseRepository {
849
1433
  // After a successful INSERT, the entity is now persisted — snapshot
850
1434
  // its columns so subsequent dirty checks compare against the DB state.
851
1435
  entity.markAsPersisted();
1436
+ this.#trackInsert(entity);
852
1437
  }
853
1438
  /**
854
1439
  * UPDATE the entity — emits only the dirty columns (story 32.2).
@@ -857,15 +1442,21 @@ export class BaseRepository {
857
1442
  * `save()` is called defensively without any real mutation).
858
1443
  */
859
1444
  async #update(entity) {
860
- // Auto-bump @column.dateTime({ autoUpdate: true }) BEFORE computing $dirty
861
- // so the bumped column lands in the SET if anything else is dirty.
1445
+ const forced = entity.$consumeForceUpdate();
1446
+ const pk = entity[this.#primaryKey];
1447
+ // Compute the REAL dirt BEFORE stamping autoUpdate, so a genuinely-clean
1448
+ // save() is a no-op — no `updated_at` bump, no query (AdonisJS Lucid parity;
1449
+ // stamping first would make every save() on an autoUpdate model dirty).
1450
+ const preDirty = entity.$dirty;
1451
+ delete preDirty[this.#primaryKey];
1452
+ if (Object.keys(preDirty).length === 0 && !forced)
1453
+ return; // nothing changed
1454
+ // A real change (or a forced update) is happening — now stamp
1455
+ // @column.dateTime({ autoUpdate: true }) so it lands in the SET.
862
1456
  this.#applyAutoTimestamps(entity, "update");
863
1457
  const dirty = entity.$dirty;
864
- const pk = entity[this.#primaryKey];
865
1458
  // Primary key is never part of the SET — it's the WHERE.
866
1459
  delete dirty[this.#primaryKey];
867
- if (Object.keys(dirty).length === 0)
868
- return; // nothing changed
869
1460
  // Map dirty camelCase keys to snake_case DB columns. `$dirty` keys are
870
1461
  // already camelCase (they come from `entity.setProp` / direct assignment),
871
1462
  // so the prepare lookup uses `k` as-is. Skip explicit `undefined`
@@ -875,7 +1466,18 @@ export class BaseRepository {
875
1466
  for (const [k, v] of Object.entries(dirty)) {
876
1467
  if (v === undefined)
877
1468
  continue;
878
- setPairs.push([camelToSnake(k), this.#applyPrepare(k, v)]);
1469
+ setPairs.push([this.#dbColumn(k), this.#applyPrepare(k, v)]);
1470
+ }
1471
+ // enableForceUpdate() with nothing dirty: re-persist the current non-PK
1472
+ // column values so an UPDATE still runs (fires triggers / bumps autoUpdate).
1473
+ if (setPairs.length === 0 && forced) {
1474
+ for (const col of this.#columns) {
1475
+ if (col === this.#primaryKey)
1476
+ continue;
1477
+ const v = entity[col];
1478
+ if (v !== undefined)
1479
+ setPairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
1480
+ }
879
1481
  }
880
1482
  if (setPairs.length === 0) {
881
1483
  // All dirty entries were `undefined` (skipped above). Re-snapshot
@@ -911,7 +1513,9 @@ export class BaseRepository {
911
1513
  * on the entity before persistence. Called from `#insert` and `#update`.
912
1514
  */
913
1515
  #applyAutoTimestamps(entity, phase) {
914
- const now = new Date();
1516
+ // A Chronos `DateTime` (not a JS `Date`) so `autoCreate`/`autoUpdate` values
1517
+ // match the type `@column.dateTime` columns hydrate to — Adonis Lucid parity.
1518
+ const now = DateTime.now();
915
1519
  for (const [prop, cfg] of Object.entries(this.#dateColumns)) {
916
1520
  if (phase === "insert") {
917
1521
  if (cfg.autoCreate && entity[prop] === undefined) {
@@ -929,33 +1533,57 @@ export class BaseRepository {
929
1533
  #hydrate(row) {
930
1534
  const entity = new this.#entityClass();
931
1535
  for (const [key, value] of Object.entries(row)) {
932
- const camelKey = snakeToCamel(key);
933
1536
  // Resolve against declared column metadata, not `in entity` — fields
934
1537
  // using Adonis' `declare field: T` pattern are not own-properties of
935
- // a freshly constructed instance.
936
- const targetKey = this.#validColumns.has(camelKey)
937
- ? camelKey
938
- : this.#validColumns.has(key)
939
- ? key
940
- : null;
1538
+ // a freshly constructed instance. The reverse db→property map is
1539
+ // consulted first so an explicit `columnName` override resolves to the
1540
+ // right property (where `snakeToCamel` alone would not).
1541
+ const camelKey = snakeToCamel(key);
1542
+ const targetKey = this.#columnByDbName.get(key) ??
1543
+ (this.#validColumns.has(camelKey)
1544
+ ? camelKey
1545
+ : this.#validColumns.has(key)
1546
+ ? key
1547
+ : null);
941
1548
  if (!targetKey)
942
1549
  continue;
943
1550
  // Apply `@Column({ consume })` if declared on this property. Unlike the
944
1551
  // previous registry-based design, the callback receives every value
945
1552
  // including `null` / `undefined` — the user's `consume` is responsible
946
1553
  // for its own null-handling, matching Adonis Lucid's contract.
947
- entity.setProp(targetKey, this.#applyConsume(targetKey, value));
1554
+ entity.setProp(targetKey, this.#applyConsume(targetKey, value, entity));
948
1555
  }
949
1556
  // Freeze the original snapshot — from now on, only columns changed AFTER
950
1557
  // hydration are considered dirty by `entity.$dirty`.
951
1558
  entity.markAsPersisted();
952
- // Back-pointer so `entity.refresh()` / `entity.fresh()` can re-query.
1559
+ entity.markAsFromDatabase();
1560
+ this.#attachRepoRef(entity);
1561
+ return entity;
1562
+ }
1563
+ /**
1564
+ * Back-pointer so a persisted instance can `related()` / `refresh()` / `fresh()`
1565
+ * / `load*()` without being re-fetched — AdonisJS Lucid parity: a model returned
1566
+ * by find/query AND by create/save/createMany/saveMany carries its query client.
1567
+ * Non-enumerable so it never serializes; `configurable` so re-persisting the same
1568
+ * instance is idempotent.
1569
+ */
1570
+ #attachRepoRef(entity) {
953
1571
  Object.defineProperty(entity, REPO_REF, {
954
1572
  value: this,
955
1573
  enumerable: false,
956
1574
  configurable: true,
957
1575
  });
958
- return entity;
1576
+ // Adonis Lucid `$trx` propagation: an instance loaded or created THROUGH a
1577
+ // transaction-bound repo binds to that transaction, so its own
1578
+ // `save()`/`delete()` stay inside it (and reset when the trx settles, via
1579
+ // the release hook `useTransaction` registers). Previously only an explicit
1580
+ // `model.useTransaction(trx)` did this, so a row read with
1581
+ // `Model.query({ client: trx })` could later save OUTSIDE the transaction.
1582
+ // Only BaseModel exposes `useTransaction`; a plain BaseEntity persists via
1583
+ // the repo directly, so it needs no instance binding.
1584
+ if (this.#durableParent !== undefined && isTransactionBindable(entity)) {
1585
+ entity.useTransaction(this.#db);
1586
+ }
959
1587
  }
960
1588
  /**
961
1589
  * Re-read the entity's row from the database and mutate the instance in place.
@@ -965,11 +1593,7 @@ export class BaseRepository {
965
1593
  */
966
1594
  async refresh(entity) {
967
1595
  const pk = entity[this.#primaryKey];
968
- if (pk === undefined || pk === null) {
969
- throw new EntityNotFoundError(this.#entityClass.name, {
970
- [this.#primaryKey]: pk,
971
- });
972
- }
1596
+ this.#assertPersistedRow(entity, pk, "refresh()");
973
1597
  const fresh = await this.find(pk);
974
1598
  if (!fresh) {
975
1599
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -997,11 +1621,7 @@ export class BaseRepository {
997
1621
  */
998
1622
  async loadCount(entity, relationName, alias) {
999
1623
  const pk = entity[this.#primaryKey];
1000
- if (pk === undefined || pk === null) {
1001
- throw new EntityNotFoundError(this.#entityClass.name, {
1002
- [this.#primaryKey]: pk,
1003
- });
1004
- }
1624
+ this.#assertPersistedRow(entity, pk, "loadCount()");
1005
1625
  const finalAlias = alias ?? `${relationName}_count`;
1006
1626
  const q = this.query()
1007
1627
  .where(this.#primaryKey, pk)
@@ -1020,11 +1640,7 @@ export class BaseRepository {
1020
1640
  */
1021
1641
  async loadAggregate(entity, relationName, build) {
1022
1642
  const pk = entity[this.#primaryKey];
1023
- if (pk === undefined || pk === null) {
1024
- throw new EntityNotFoundError(this.#entityClass.name, {
1025
- [this.#primaryKey]: pk,
1026
- });
1027
- }
1643
+ this.#assertPersistedRow(entity, pk, "loadAggregate()");
1028
1644
  let capturedAlias;
1029
1645
  const q = this.query()
1030
1646
  .where(this.#primaryKey, pk)
@@ -1045,11 +1661,7 @@ export class BaseRepository {
1045
1661
  */
1046
1662
  async loadRelation(entity, relationName, callback) {
1047
1663
  const pk = entity[this.#primaryKey];
1048
- if (pk === undefined || pk === null) {
1049
- throw new EntityNotFoundError(this.#entityClass.name, {
1050
- [this.#primaryKey]: pk,
1051
- });
1052
- }
1664
+ this.#assertPersistedRow(entity, pk, "loadRelation()");
1053
1665
  const q = this.query().where(this.#primaryKey, pk);
1054
1666
  if (callback)
1055
1667
  q.preload(relationName, callback);
@@ -1075,12 +1687,19 @@ export class BaseRepository {
1075
1687
  if (!relation)
1076
1688
  throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
1077
1689
  const relatedClass = relation.target();
1078
- const relatedMeta = getEntityMetadata(relatedClass);
1079
- if (!relatedMeta)
1080
- throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
1081
- const relatedTable = relatedMeta.tableName;
1690
+ // Synthesize the related model's @Entity metadata on demand (static `table`
1691
+ // / naming strategy) — a related model referenced ONLY through this relation
1692
+ // may never have been instantiated, so `getEntityMetadata` alone would be
1693
+ // empty and related()/create-through would wrongly fail. Mirrors how the repo
1694
+ // constructor boots its own class (AdonisJS Lucid lazy-boots models).
1695
+ const relatedTable = ensureEntityMetadata(relatedClass).tableName;
1082
1696
  const parentPk = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1083
- const parentIdValue = entity[parentPk];
1697
+ // Read the parent's key LAZILY, at operation time — not once at proxy
1698
+ // creation. Lucid resolves the pivot value when the query runs, so mutating
1699
+ // the parent's (custom local) key between `user.related('roles')` and a later
1700
+ // `.attach()` must target the CURRENT key, never a captured stale one.
1701
+ const readParentId = () => entity[parentPk];
1702
+ const keyLabel = parentPk === this.#primaryKey ? "primary key" : `key '${parentPk}'`;
1084
1703
  const relatedRepo = new BaseRepository(relatedClass, this.#db, {
1085
1704
  dialect: this.#dialect,
1086
1705
  });
@@ -1095,41 +1714,153 @@ export class BaseRepository {
1095
1714
  ? `${camelToSnake(relatedClass.name)}_id`
1096
1715
  : `${camelToSnake(this.#entityClass.name)}_id`);
1097
1716
  const fkProp = snakeToCamel(fkCol);
1098
- const injectFk = (data) => ({
1717
+ const injectFk = (data, fkValue) => ({
1099
1718
  ...data,
1100
- [fkCol]: parentIdValue,
1101
- [fkProp]: parentIdValue,
1719
+ [fkCol]: fkValue,
1720
+ [fkProp]: fkValue,
1721
+ });
1722
+ /**
1723
+ * Lucid persists the parent FIRST (inside a managed transaction) so its key
1724
+ * is available, then sets the child FK and writes the child — atomic, rolled
1725
+ * back on any failure. An already-persisted parent skips the save; a
1726
+ * persisted-but-keyless projection is rejected loud. Runs `body` with the
1727
+ * parent's now-guaranteed key and a trx-bound related repo.
1728
+ */
1729
+ const flushEvents = async (entities) => {
1730
+ for (const e of entities)
1731
+ await this.#dispatchDomainEvents(e);
1732
+ };
1733
+ const withParentSaved = (body) => transaction(this.#db, async (trx) => {
1734
+ // Snapshot BEFORE the save flips the flag — the parent's events flush
1735
+ // ONLY if WE persisted it here. An already-persisted parent may carry
1736
+ // unrelated in-memory events that belong to whoever saves it; a child
1737
+ // mutation must not emit them as a side effect.
1738
+ const savedParentHere = !entity.$isPersisted;
1739
+ const parentDurable = this.#durableParent ?? this;
1740
+ if (savedParentHere) {
1741
+ // Floor the parent's event queue BEFORE we persist it, so rollback drops
1742
+ // only the events this write queues, keeping any the caller queued
1743
+ // earlier (#8).
1744
+ const parentEventFloor = entity.domainEventCount();
1745
+ // Persist the parent on the SAME trx. Build a BaseEntity-typed repo
1746
+ // for the parent class (mirrors `relatedRepo`) so `save(entity)`
1747
+ // accepts the generic `BaseEntity` without widening `this`.
1748
+ const parentRepoTx = new BaseRepository(this.#entityClass, trx, { dialect: this.#dialect });
1749
+ parentRepoTx.onDomainEvents = this.onDomainEvents;
1750
+ parentRepoTx.#deferDomainEvents = true;
1751
+ await parentRepoTx.save(entity);
1752
+ // Register the parent's rollback restore IMMEDIATELY after its insert —
1753
+ // the parentPk check just below can throw (a custom `localKey` left unset
1754
+ // after the save), and that throw must still revert the freshly-inserted
1755
+ // parent instead of leaving it lying $isPersisted (same gotcha as
1756
+ // associate(): register the restore before ANY later throwable line).
1757
+ trx.after("rollback", () => {
1758
+ parentDurable.#attachRepoRef(entity);
1759
+ entity.markAsNotPersisted();
1760
+ entity.restoreDomainEventsTo(parentEventFloor);
1761
+ });
1762
+ }
1763
+ const fkValue = entity[parentPk];
1764
+ if (!isProvidedPk(fkValue)) {
1765
+ throw new AtlasError("E_MISSING_PRIMARY_KEY", `Cannot use related('${relationName}') on a ${this.#entityClass.name} with no ${keyLabel}.`, {
1766
+ hint: "The parent is an aggregate/alias projection with no key. Select the key or use query().pojo().",
1767
+ });
1768
+ }
1769
+ const relTx = relatedRepo.useTransaction(trx);
1770
+ relTx.#deferDomainEvents = true;
1771
+ // Track related rows inserted DIRECTLY through relTx (single create/save;
1772
+ // the batch helpers route through #inManagedTx, which tracks + reverts them
1773
+ // itself on this same trx). A caller-passed related instance we insert here
1774
+ // must, on rollback, revert to $isNew — its row is gone, and keeping
1775
+ // $isPersisted would orphan a later relation write (a M2M pivot-insert
1776
+ // failure AFTER `rel.save(related)` is the canonical trigger) — and drop its
1777
+ // queued events. On commit, re-point its REPO_REF at the durable related repo
1778
+ // (it was bound to the now-finished trx, so refresh()/related() would
1779
+ // otherwise throw "transaction already finished").
1780
+ const relInserts = [];
1781
+ relTx.#insertTracker = relInserts;
1782
+ const relDurable = relatedRepo.#durableParent ?? relatedRepo;
1783
+ // Per-related event floor, populated by a caller-instance write (save):
1784
+ // a fresh child built by create() has floor 0 (clear), but a caller's own
1785
+ // instance passed to save() may carry events queued before the write (#8).
1786
+ const relatedFloors = new Map();
1787
+ trx.after("commit", () => {
1788
+ for (const r of relInserts)
1789
+ relDurable.#attachRepoRef(r);
1790
+ });
1791
+ trx.after("rollback", () => {
1792
+ for (const r of relInserts) {
1793
+ relDurable.#attachRepoRef(r);
1794
+ r.markAsNotPersisted();
1795
+ r.restoreDomainEventsTo(relatedFloors.get(r) ?? 0);
1796
+ }
1797
+ });
1798
+ // Parent COMMIT restore (its rollback restore is registered above, right
1799
+ // after the insert). ONLY if WE persisted it here (`parentRepoTx.save`
1800
+ // flipped it to $isPersisted with REPO_REF bound to the trx repo). Lucid
1801
+ // resets `$trx` on commit → re-point REPO_REF at the durable repo, then
1802
+ // flush the parent's events (a rollback thus publishes nothing). An
1803
+ // already-persisted parent is left untouched: its events belong to whoever
1804
+ // saves it, and its row already exists.
1805
+ if (savedParentHere) {
1806
+ trx.after("commit", () => {
1807
+ parentDurable.#attachRepoRef(entity);
1808
+ return this.#dispatchDomainEvents(entity);
1809
+ });
1810
+ }
1811
+ return body(fkValue, relTx, trx, relatedFloors);
1102
1812
  });
1103
1813
  // Shared "has" proxy methods (create/createMany/save/saveMany +
1104
- // firstOrCreate/updateOrCreate scoped to this parent's FK).
1814
+ // firstOrCreate/updateOrCreate scoped to this parent's FK). Each persists the
1815
+ // parent first (Lucid parity) and writes the child with the FK set, atomically,
1816
+ // then flushes the child's domain events AFTER the transaction commits.
1105
1817
  const hasOps = {
1106
- async create(data) {
1107
- return relatedRepo.create(injectFk(data));
1108
- },
1109
- async createMany(rows) {
1110
- return relatedRepo.createMany(rows.map(injectFk));
1111
- },
1112
- // Scope the search to the parent's FK column so the lookup only sees
1113
- // this parent's rows; inject the FK into the created/updated row. The
1114
- // related repo's firstOrCreate/updateOrCreate are atomic (txn + lock).
1115
- async firstOrCreate(search, defaults = {}) {
1116
- return relatedRepo.firstOrCreate({ ...search, [fkCol]: parentIdValue }, injectFk(defaults));
1117
- },
1118
- async updateOrCreate(search, values) {
1119
- return relatedRepo.updateOrCreate({ ...search, [fkCol]: parentIdValue }, injectFk(values));
1120
- },
1121
- async save(related) {
1122
- related.setProp(fkCol, parentIdValue);
1123
- related.setProp(fkProp, parentIdValue);
1124
- await relatedRepo.save(related);
1125
- },
1126
- async saveMany(related) {
1818
+ create: (data) => withParentSaved(async (fk, rel, trx) => {
1819
+ const child = await rel.create(injectFk(data, fk));
1820
+ trx.after("commit", () => flushEvents([child]));
1821
+ return child;
1822
+ }),
1823
+ createMany: (rows) => withParentSaved(async (fk, rel, _trx) => {
1824
+ // NO wrapper flush: since createMany now runs through #inManagedTx it
1825
+ // ALREADY dispatches the children's events post-commit (like
1826
+ // firstOrCreate/updateOrCreate/saveMany). A second flush would
1827
+ // re-dispatch events the first hook re-queued on a partial sink failure.
1828
+ return rel.createMany(rows.map((r) => injectFk(r, fk)));
1829
+ }),
1830
+ // Scope the search to the parent's FK column so the lookup only sees this
1831
+ // parent's rows; inject the FK into the created/updated row.
1832
+ firstOrCreate: (search, defaults = {}) => withParentSaved(async (fk, rel, _trx) => {
1833
+ // NO wrapper flush here: unlike create/save, rel.firstOrCreate goes
1834
+ // through #inManagedTx, which ALREADY registers its own post-commit
1835
+ // dispatch for the child. A second flush would re-dispatch events the
1836
+ // first hook re-queued on a partial sink failure → bus duplication.
1837
+ return rel.firstOrCreate({ ...search, [fkCol]: fk }, injectFk(defaults, fk));
1838
+ }),
1839
+ updateOrCreate: (search, values) => withParentSaved(async (fk, rel, _trx) => {
1840
+ // NO wrapper flush: rel.updateOrCreate goes through #inManagedTx which
1841
+ // already dispatches the child's events post-commit (see firstOrCreate).
1842
+ return rel.updateOrCreate({ ...search, [fkCol]: fk }, injectFk(values, fk));
1843
+ }),
1844
+ save: (related) => withParentSaved(async (fk, rel, trx, relatedFloors) => {
1845
+ related.setProp(fkCol, fk);
1846
+ related.setProp(fkProp, fk);
1847
+ // Floor BEFORE the write so a rollback keeps events the caller queued on
1848
+ // this instance earlier, dropping only what this save adds (#8).
1849
+ relatedFloors.set(related, related.domainEventCount());
1850
+ await rel.save(related);
1851
+ trx.after("commit", () => flushEvents([related]));
1852
+ }),
1853
+ saveMany: (related) => withParentSaved(async (fk, rel, _trx) => {
1127
1854
  for (const r of related) {
1128
- r.setProp(fkCol, parentIdValue);
1129
- r.setProp(fkProp, parentIdValue);
1855
+ r.setProp(fkCol, fk);
1856
+ r.setProp(fkProp, fk);
1130
1857
  }
1131
- return relatedRepo.saveMany(related);
1132
- },
1858
+ // NO wrapper flush: rel.saveMany now runs through #inManagedTx (it's
1859
+ // all-or-nothing), which ALREADY dispatches these instances' events
1860
+ // post-commit. A second flush would re-dispatch on a partial sink
1861
+ // failure (round-13 double-flush class).
1862
+ return rel.saveMany(related);
1863
+ }),
1133
1864
  };
1134
1865
  // Scoped query builder (Story 31.9) — pre-applies the FK predicate
1135
1866
  // (or pivot JOIN for m2m) so downstream filters/updates/deletes stay
@@ -1142,7 +1873,13 @@ export class BaseRepository {
1142
1873
  const pivot = relation.pivot;
1143
1874
  const pivotFk = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1144
1875
  const pivotOther = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1145
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1876
+ // Resolve the related PK to its DB column (multi-word / columnName),
1877
+ // mirroring the eager-preload fix — a raw property name here targets
1878
+ // the wrong column in the correlated EXISTS.
1879
+ // The related-side key the pivot's otherKey references — the related
1880
+ // PK unless `relatedKey` overrides it (Adonis Lucid `relatedKey`).
1881
+ const relatedPkProp = pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
1882
+ const relatedPk = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPkProp)?.columnName ?? camelToSnake(relatedPkProp);
1146
1883
  // Inline validated quote (same policy as the m2m branch below).
1147
1884
  const dialect = this.#dialect;
1148
1885
  const quote = (name) => {
@@ -1151,30 +1888,89 @@ export class BaseRepository {
1151
1888
  }
1152
1889
  return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1153
1890
  };
1891
+ // Table identifiers may be schema-qualified (`schema.table`, e.g. a
1892
+ // Postgres `public.users_roles`) — quote each dotted segment on its own
1893
+ // so it becomes `"schema"."table"`, while EVERY segment still passes the
1894
+ // strict single-identifier guard above (no injection surface). Columns
1895
+ // stay single-segment via `quote`.
1896
+ const quoteTable = (name) => name.split(".").map(quote).join(".");
1154
1897
  // The bound `?` carries the parent PK type (often uuid). A raw `?`
1155
1898
  // can't be cast by the structured `casts` mechanism, so emit the
1156
1899
  // `::uuid` inline — `whereRaw` rewrites `?`→`$N`, yielding `$N::uuid`.
1157
1900
  // Postgres-only; sqlite/mysql coerce. Without it: `pivotFk = $N` is
1158
1901
  // `uuid = text`.
1159
- const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
1902
+ // Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
1903
+ // PK — an m2m with a custom localKey binds `entity[localKey]` into the
1904
+ // pivot FK, so the `::cast` must match that column's type.
1905
+ const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
1160
1906
  const ph = dialect === "postgres" && parentPkCast ? `?::${parentPkCast}` : "?";
1161
- // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk)
1162
- // Framework-internal raw fragment (identifiers already validated by
1163
- // the `quote` helper above) bypass strict mode so this path still
1164
- // works when the user enables `setAtlasStrictMode(true)` on their app.
1165
- runWithAtlasInternalBypass(() => {
1166
- q.whereRaw(`EXISTS (SELECT 1 FROM ${quote(pivot.pivotTable)} ` +
1167
- `WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} = ${ph} ` +
1168
- `AND ${quote(pivot.pivotTable)}.${quote(pivotOther)} = ${quote(relatedTable)}.${quote(relatedPk)})`, [parentIdValue]);
1907
+ // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk
1908
+ // [AND pivot.col <op> ?]…)
1909
+ // Deferred (not an eager whereRaw): a `.wherePivot()` chained on the
1910
+ // query the proxy hands back must fold into THIS subquery, so we build it
1911
+ // at #buildSpec time with the pivot constraints known then. Identifiers
1912
+ // are validated by `quote`; values bind as params (no injection surface),
1913
+ // so this internal fragment needs no strict-mode bypass.
1914
+ const pivotTable = pivot.pivotTable;
1915
+ q.setPivotExistsBuilder((pivotWheres) => {
1916
+ const base = `EXISTS (SELECT 1 FROM ${quoteTable(pivotTable)} ` +
1917
+ `WHERE ${quoteTable(pivotTable)}.${quote(pivotFk)} = ${ph} ` +
1918
+ `AND ${quoteTable(pivotTable)}.${quote(pivotOther)} = ${quoteTable(relatedTable)}.${quote(relatedPk)}`;
1919
+ const bindings = [readParentId()];
1920
+ let extra = "";
1921
+ for (const w of pivotWheres) {
1922
+ const col = `${quoteTable(pivotTable)}.${quote(w.column)}`;
1923
+ if (w.operator === "IN" || w.operator === "NOT IN") {
1924
+ const vals = Array.isArray(w.value) ? w.value : [w.value];
1925
+ if (vals.length === 0) {
1926
+ // IN () matches nothing; NOT IN () matches everything.
1927
+ if (w.operator === "IN")
1928
+ extra += " AND 1 = 0";
1929
+ continue;
1930
+ }
1931
+ extra += ` AND ${col} ${w.operator} (${vals.map(() => "?").join(", ")})`;
1932
+ bindings.push(...vals);
1933
+ }
1934
+ else {
1935
+ extra += ` AND ${col} ${w.operator} ?`;
1936
+ bindings.push(w.value);
1937
+ }
1938
+ }
1939
+ return { sql: `${base}${extra})`, bindings };
1169
1940
  });
1170
1941
  }
1171
1942
  else if (relation.type === "belongsTo") {
1172
1943
  const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1173
1944
  q.where(ownerKey, entity[fkProp] ?? entity[fkCol]);
1174
1945
  }
1946
+ else if (relation.type === "hasOneThrough" ||
1947
+ relation.type === "hasManyThrough") {
1948
+ // Lucid's read-only two-hop traversal (verified): the related rows are
1949
+ // reached VIA the intermediate ("through") table, never a direct FK.
1950
+ // related WHERE secondKey IN
1951
+ // (SELECT secondLocal FROM through WHERE firstKey = parent[localKey])
1952
+ // Same key resolution as the eager `#resolveThrough` loader so lazy and
1953
+ // eager agree. Returns a chainable ModelQuery (`.orderBy().limit()` …).
1954
+ if (!relation.through) {
1955
+ throw new Error(`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`);
1956
+ }
1957
+ const throughClass = relation.through();
1958
+ const throughRepo = new BaseRepository(throughClass, db, {
1959
+ dialect: this.#dialect,
1960
+ });
1961
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
1962
+ const parentLocal = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1963
+ const firstKey = relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1964
+ const secondKey = relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
1965
+ const secondLocal = relation.secondLocalKey ?? throughPk;
1966
+ q.whereIn(secondKey, throughRepo
1967
+ .query()
1968
+ .select(secondLocal)
1969
+ .where(firstKey, entity[parentLocal]));
1970
+ }
1175
1971
  else {
1176
1972
  // hasOne / hasMany
1177
- q.where(fkCol, parentIdValue);
1973
+ q.where(fkCol, readParentId());
1178
1974
  }
1179
1975
  return q;
1180
1976
  };
@@ -1185,19 +1981,101 @@ export class BaseRepository {
1185
1981
  // the standard TS idiom for widening a generic `this` — safe because
1186
1982
  // `T extends BaseEntity`.
1187
1983
  const parentRepo = this;
1984
+ // create/save/createMany/saveMany are INVALID for a belongsTo: the FK is on
1985
+ // THIS model, so `...hasOps` would inject the FK into the owner table and
1986
+ // save the current model before it has an owner. Reject them (same throwing
1987
+ // pattern as @HasOne's bulk methods); the only writes are associate /
1988
+ // dissociate.
1989
+ const rejectWrite = async (op) => {
1990
+ throw new Error(`related('${relationName}').${op}() is not supported on @BelongsTo — ` +
1991
+ `the foreign key is on this model; use associate() / dissociate().`);
1992
+ };
1188
1993
  const proxy = {
1189
1994
  type: "belongsTo",
1190
- ...hasOps,
1191
1995
  query: scopedQuery,
1996
+ create: () => rejectWrite("create"),
1997
+ save: () => rejectWrite("save"),
1998
+ createMany: () => rejectWrite("createMany"),
1999
+ saveMany: () => rejectWrite("saveMany"),
1192
2000
  async associate(model) {
1193
2001
  if (model === null || model === undefined) {
1194
2002
  throw new Error(`related('${relationName}').associate() rejects null/undefined — use dissociate() instead`);
1195
2003
  }
1196
2004
  const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1197
- const fkValue = model[ownerKey];
1198
- entity.setProp(fkCol, fkValue);
1199
- entity.setProp(fkProp, fkValue);
1200
- await parentRepo.save(entity);
2005
+ // Lucid: persist an unsaved owner FIRST (so a generated key exists),
2006
+ // set the parent FK to the owner's key, then save the parent — all in
2007
+ // ONE transaction (atomic, rolled back on failure). Reject a keyless
2008
+ // owner instead of silently setting the FK to `undefined` (which the
2009
+ // UPDATE would skip → stale/absent association). Events flush post-commit.
2010
+ await transaction(db, async (trx) => {
2011
+ const ownerTx = relatedRepo.useTransaction(trx);
2012
+ ownerTx.#deferDomainEvents = true;
2013
+ // Snapshot BEFORE the save: associate() only persists an unsaved
2014
+ // owner, so an already-persisted owner's pending domain events are
2015
+ // NOT ours to flush (same side-effect fix as withParentSaved).
2016
+ const savedOwnerHere = !model.$isPersisted;
2017
+ const ownerDurable = relatedRepo.#durableParent ?? relatedRepo;
2018
+ // Floor the owner's events before we persist it (#8).
2019
+ const ownerEventFloor = model.domainEventCount();
2020
+ if (savedOwnerHere) {
2021
+ await ownerTx.save(model);
2022
+ // Register the owner's rollback restore IMMEDIATELY after its
2023
+ // insert. The ownerKey check just below AND the parent save later
2024
+ // can BOTH throw after this point, and either must still revert the
2025
+ // freshly-inserted owner (Lucid resets `$trx` on rollback): re-point
2026
+ // its REPO_REF at the durable repo, revert it to $isNew (its row
2027
+ // vanished), and drop its queued events. Registering it here (not
2028
+ // after the checks) is the fix — a throw between the save and a
2029
+ // later registration would leave the owner lying $isPersisted.
2030
+ trx.after("rollback", () => {
2031
+ ownerDurable.#attachRepoRef(model);
2032
+ model.markAsNotPersisted();
2033
+ model.restoreDomainEventsTo(ownerEventFloor);
2034
+ });
2035
+ }
2036
+ const fkValue = model[ownerKey];
2037
+ if (!isProvidedPk(fkValue)) {
2038
+ throw new AtlasError("E_MISSING_OWNER_KEY", `Cannot associate('${relationName}'): the owner ${relatedClass.name} has no ${ownerKey} to reference.`, {
2039
+ hint: "Pass an owner whose key is set — a keyless aggregate/alias projection can't be a foreign-key target.",
2040
+ });
2041
+ }
2042
+ entity.setProp(fkCol, fkValue);
2043
+ entity.setProp(fkProp, fkValue);
2044
+ const parentTx = parentRepo.useTransaction(trx);
2045
+ parentTx.#deferDomainEvents = true;
2046
+ // Snapshot BEFORE the save flips the flag: the parent reverts on
2047
+ // rollback ONLY if WE inserted it here (a fresh parent). An
2048
+ // already-persisted parent's row survives the rollback.
2049
+ const savedParentHere = !entity.$isPersisted;
2050
+ const parentDurable = parentRepo.#durableParent ?? parentRepo;
2051
+ // Floor the parent's events before its save (#8).
2052
+ const parentEventFloor = entity.domainEventCount();
2053
+ // Register the parent resolution hooks BEFORE the risky parent save —
2054
+ // that save can throw (a beforeUpdate hook, a constraint) AFTER the
2055
+ // owner was inserted, and a throw here must still restore state.
2056
+ trx.after("commit", async () => {
2057
+ // Lucid resets `$trx` on commit → re-point REPO_REF at the durable
2058
+ // repo (else a post-commit refresh() hits the finished trx), then
2059
+ // flush events. The parent is always saved here → always flush its
2060
+ // events. The owner's events flush only if WE saved the owner.
2061
+ parentDurable.#attachRepoRef(entity);
2062
+ if (savedOwnerHere) {
2063
+ ownerDurable.#attachRepoRef(model);
2064
+ await parentRepo.#dispatchDomainEvents(model);
2065
+ }
2066
+ await parentRepo.#dispatchDomainEvents(entity);
2067
+ });
2068
+ trx.after("rollback", () => {
2069
+ // Restore the PARENT (the owner's restore is registered above, right
2070
+ // after its insert). We do NOT revert the parent's FK value — Lucid
2071
+ // never reverts attribute values on rollback.
2072
+ parentDurable.#attachRepoRef(entity);
2073
+ if (savedParentHere)
2074
+ entity.markAsNotPersisted();
2075
+ entity.restoreDomainEventsTo(parentEventFloor);
2076
+ });
2077
+ await parentTx.save(entity);
2078
+ });
1201
2079
  },
1202
2080
  async dissociate() {
1203
2081
  entity.setProp(fkCol, null);
@@ -1223,14 +2101,20 @@ export class BaseRepository {
1223
2101
  // every pivot statement (sync's currentIds SELECT, detach DELETE, attach
1224
2102
  // INSERT) must carry these explicitly, else `pivotFk = $1` is `uuid = text`.
1225
2103
  const pivotKeyCasts = {};
1226
- const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
2104
+ // Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
2105
+ // PK — an m2m with a custom localKey binds `entity[localKey]` into the
2106
+ // pivot FK, so the `::cast` must match that column's type.
2107
+ const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
1227
2108
  if (parentPkCast)
1228
2109
  pivotKeyCasts[pivotFk] = parentPkCast;
1229
- const relatedPkCast = computeCastTypes(relatedClass)[camelToSnake(getPrimaryKey(relatedClass) ?? "id")];
2110
+ const relatedPk = pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
2111
+ const relatedPkDb = getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPk)
2112
+ ?.columnName ?? camelToSnake(relatedPk);
2113
+ const relatedPkCast = computeCastTypes(relatedClass)[relatedPkDb];
1230
2114
  if (relatedPkCast)
1231
2115
  pivotKeyCasts[pivotOther] = relatedPkCast;
1232
2116
  /**
1233
- * Resolve pivot timestamp column names from the decorator config.
2117
+ * Pivot timestamp column names, resolved once from the decorator config.
1234
2118
  *
1235
2119
  * Three forms supported:
1236
2120
  * - `pivotTimestamps: true` → { created_at, updated_at } default names
@@ -1240,54 +2124,108 @@ export class BaseRepository {
1240
2124
  * `false` opts a timestamp out; a string overrides the column name;
1241
2125
  * `undefined` falls back to the default name.
1242
2126
  */
1243
- const resolveTimestamps = () => {
2127
+ let createdCol = null;
2128
+ let updatedCol = null;
2129
+ if (tsConfig === true) {
2130
+ createdCol = "created_at";
2131
+ updatedCol = "updated_at";
2132
+ }
2133
+ else if (tsConfig) {
2134
+ createdCol =
2135
+ tsConfig.createdAt === false
2136
+ ? null
2137
+ : (tsConfig.createdAt ?? "created_at");
2138
+ updatedCol =
2139
+ tsConfig.updatedAt === false
2140
+ ? null
2141
+ : (tsConfig.updatedAt ?? "updated_at");
2142
+ }
2143
+ const tsColumnSet = new Set([createdCol, updatedCol].filter((c) => c !== null));
2144
+ // INSERT (attach) stamps both created_at + updated_at; UPDATE (sync's
2145
+ // attribute refresh) bumps only updated_at — Adonis Lucid pivot semantics.
2146
+ const timestampValues = (mode) => {
1244
2147
  if (!tsConfig)
1245
2148
  return {};
1246
2149
  const now = new Date().toISOString();
1247
- let createdCol;
1248
- let updatedCol;
1249
- if (tsConfig === true) {
1250
- createdCol = "created_at";
1251
- updatedCol = "updated_at";
1252
- }
1253
- else {
1254
- createdCol =
1255
- tsConfig.createdAt === false
1256
- ? null
1257
- : (tsConfig.createdAt ?? "created_at");
1258
- updatedCol =
1259
- tsConfig.updatedAt === false
1260
- ? null
1261
- : (tsConfig.updatedAt ?? "updated_at");
1262
- }
1263
2150
  const out = {};
1264
- if (createdCol)
2151
+ if (mode === "insert" && createdCol)
1265
2152
  out[createdCol] = now;
1266
2153
  if (updatedCol)
1267
2154
  out[updatedCol] = now;
1268
2155
  return out;
1269
2156
  };
2157
+ // Object literal keys are ALWAYS strings, so `sync({ 1: {…} })` /
2158
+ // `attach({ 1: {…} })` arrive with id "1", not 1. Bound as text, a numeric
2159
+ // pivot FK column fails on Postgres (`text` ≠ `integer`, no implicit cast)
2160
+ // and the sync diff mis-compares "1" against the numeric id the DB returns.
2161
+ // Coerce a *canonical* integer back to a number; the round-trip guard
2162
+ // leaves uuid / zero-padded / oversized string keys (`"01234"`, `"abc"`)
2163
+ // untouched so they still bind as text.
2164
+ const canonicalizeId = (id) => {
2165
+ if (typeof id === "number")
2166
+ return id;
2167
+ return /^-?\d+$/.test(id) &&
2168
+ Number.isSafeInteger(Number(id)) &&
2169
+ String(Number(id)) === id
2170
+ ? Number(id)
2171
+ : id;
2172
+ };
1270
2173
  const normalizeAttach = (arg) => {
1271
2174
  if (Array.isArray(arg))
1272
- return arg.map((id) => ({ id, extras: {} }));
1273
- return Object.entries(arg).map(([id, extras]) => ({ id, extras }));
2175
+ return arg.map((id) => ({ id: canonicalizeId(id), extras: {} }));
2176
+ return Object.entries(arg).map(([id, extras]) => ({
2177
+ id: canonicalizeId(id),
2178
+ extras,
2179
+ }));
2180
+ };
2181
+ // Apply a pivot column's `prepare` adapter (model → DB), shared by the
2182
+ // INSERT (attach) and UPDATE (sync) write paths.
2183
+ const encodeExtra = (k, raw) => {
2184
+ const prepare = pivotAdapters?.[k]?.prepare;
2185
+ if (!prepare)
2186
+ return raw;
2187
+ let encoded;
2188
+ try {
2189
+ // Adonis Lucid signature: (value, attribute, model). A pivot-row
2190
+ // write carries no single model instance.
2191
+ encoded = prepare(raw, k, undefined);
2192
+ }
2193
+ catch (err) {
2194
+ throw wrapAdapterError("prepare", k, err);
2195
+ }
2196
+ assertNotPromise("prepare", k, encoded);
2197
+ return encoded;
1274
2198
  };
1275
- // Current pivot rows compiled through the Rust SELECT path so the
1276
- // pivot identifiers go through `quote_identifier` (rejects anything
1277
- // outside `[A-Za-z0-9_]`), rather than through the ad-hoc `quote`
1278
- // helper that would blindly wrap a malicious metadata string.
1279
- //
1280
- // Now async every site in `sync()` is in an async closure.
1281
- const currentIds = async () => {
2199
+ // Reject an extras key colliding with a reserved pivot column. Without
2200
+ // this guard the FK case would silently override `parentIdValue`
2201
+ // (corrupting the join) and the timestamp case would duplicate the column
2202
+ // (driver-dependent failure or last-wins overwrite).
2203
+ const assertExtraKeyAllowed = (k) => {
2204
+ if (k === pivotFk || k === pivotOther) {
2205
+ throw new Error(`Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`);
2206
+ }
2207
+ if (tsColumnSet.has(k)) {
2208
+ throw new Error(`Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`);
2209
+ }
2210
+ };
2211
+ // Narrow an unknown pivot id to a bindable scalar without an `as` cast.
2212
+ const asId = (v) => typeof v === "number" ? v : String(v);
2213
+ // Current pivot rows for this parent — the other-key plus any attribute
2214
+ // columns the caller needs (so sync() can diff changed pivot rows).
2215
+ // Compiled through the Rust SELECT path so the pivot identifiers go
2216
+ // through `quote_identifier` (rejects anything outside `[A-Za-z0-9_]`),
2217
+ // never the ad-hoc `quote` helper. Runs on `conn` — a transaction inside
2218
+ // sync(), the pool otherwise.
2219
+ const currentPivotRows = async (attrCols, conn = db) => {
1282
2220
  const selectSpec = {
1283
2221
  kind: "select",
1284
2222
  table: pivotTable,
1285
- select: [pivotOther],
2223
+ select: [pivotOther, ...attrCols],
1286
2224
  wheres: [
1287
2225
  {
1288
2226
  column: pivotFk,
1289
2227
  operator: "=",
1290
- value: parentIdValue,
2228
+ value: readParentId(),
1291
2229
  type: "and",
1292
2230
  },
1293
2231
  ],
@@ -1305,15 +2243,20 @@ export class BaseRepository {
1305
2243
  casts: pivotKeyCasts,
1306
2244
  };
1307
2245
  const compiled = compileStatementNative(selectSpec, dialect);
1308
- const rows = await db.query(compiled.statements[0], compiled.params);
1309
- return rows.map((r) => r[pivotOther]);
2246
+ const rows = await conn.query(compiled.statements[0], compiled.params);
2247
+ return rows.map((r) => ({ id: asId(r[pivotOther]), row: r }));
1310
2248
  };
1311
2249
  // Delete via the Rust DELETE compiler so the pivot table + columns get
1312
2250
  // `quote_identifier` validation (rejects `"`, `;`, etc.) — safer than
1313
2251
  // the previous hand-built SQL with a dumb `"` wrapper.
1314
- const detach = async (ids) => {
2252
+ const detach = async (ids, conn = db) => {
1315
2253
  const wheres = [
1316
- { column: pivotFk, operator: "=", value: parentIdValue, type: "and" },
2254
+ {
2255
+ column: pivotFk,
2256
+ operator: "=",
2257
+ value: readParentId(),
2258
+ type: "and",
2259
+ },
1317
2260
  ];
1318
2261
  if (ids && ids.length > 0) {
1319
2262
  wheres.push({
@@ -1331,58 +2274,30 @@ export class BaseRepository {
1331
2274
  casts: pivotKeyCasts,
1332
2275
  };
1333
2276
  const compiled = compileStatementNative(spec, dialect);
1334
- await db.execute(compiled.statements[0], compiled.params);
2277
+ await conn.execute(compiled.statements[0], compiled.params);
1335
2278
  };
1336
- const attach = async (ids) => {
2279
+ const attach = async (ids, conn = db, parentFk = readParentId()) => {
1337
2280
  const entries = normalizeAttach(ids);
1338
2281
  if (entries.length === 0)
1339
2282
  return;
1340
- const ts = resolveTimestamps();
1341
- // Normalize heterogeneous extras: compute the union of extra keys
1342
- // across all entries and back-fill missing keys with `null`, so every
1343
- // row in the multi-insert shares the same column set (required by the
1344
- // Rust compiler's homogeneity check).
2283
+ const ts = timestampValues("insert");
2284
+ // Union of extra keys across all entries; back-fill missing keys with
2285
+ // `null` so every row in the multi-insert shares the same column set
2286
+ // (required by the Rust compiler's homogeneity check).
1345
2287
  const extraKeys = new Set();
1346
2288
  for (const e of entries) {
1347
2289
  for (const k of Object.keys(e.extras))
1348
2290
  extraKeys.add(k);
1349
2291
  }
1350
- // Reject extras keys that collide with reserved pivot columns. Without
1351
- // this guard, an extras entry named after the FK or a timestamp column
1352
- // would emit a duplicate column in the INSERT row pair: the FK case
1353
- // silently overrides `parentIdValue` (corrupting the join); the
1354
- // timestamp case duplicates the column entirely (driver-dependent
1355
- // failure or last-wins overwrite).
1356
- for (const k of extraKeys) {
1357
- if (k === pivotFk || k === pivotOther) {
1358
- throw new Error(`Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`);
1359
- }
1360
- if (Object.hasOwn(ts, k)) {
1361
- throw new Error(`Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`);
1362
- }
1363
- }
2292
+ for (const k of extraKeys)
2293
+ assertExtraKeyAllowed(k);
1364
2294
  const rowPairs = entries.map((e) => {
1365
2295
  const pairs = [
1366
- [pivotFk, parentIdValue],
2296
+ [pivotFk, parentFk],
1367
2297
  [pivotOther, e.id],
1368
2298
  ];
1369
- for (const k of extraKeys) {
1370
- const raw = e.extras[k] ?? null;
1371
- const prepare = pivotAdapters?.[k]?.prepare;
1372
- if (!prepare) {
1373
- pairs.push([k, raw]);
1374
- continue;
1375
- }
1376
- let encoded;
1377
- try {
1378
- encoded = prepare(raw);
1379
- }
1380
- catch (err) {
1381
- throw wrapAdapterError("prepare", k, err);
1382
- }
1383
- assertNotPromise("prepare", k, encoded);
1384
- pairs.push([k, encoded]);
1385
- }
2299
+ for (const k of extraKeys)
2300
+ pairs.push([k, encodeExtra(k, e.extras[k] ?? null)]);
1386
2301
  for (const [k, v] of Object.entries(ts))
1387
2302
  pairs.push([k, v]);
1388
2303
  return pairs;
@@ -1400,77 +2315,215 @@ export class BaseRepository {
1400
2315
  casts: pivotCasts,
1401
2316
  };
1402
2317
  const compiled = compileStatementNative(spec, dialect);
1403
- await db.execute(compiled.statements[0], compiled.params);
2318
+ await conn.execute(compiled.statements[0], compiled.params);
2319
+ };
2320
+ // Refresh one already-attached pivot row's attributes (sync's update arm,
2321
+ // Adonis Lucid parity): set the provided extras (adapter-encoded) and bump
2322
+ // only updated_at.
2323
+ const updatePivot = async (id, extras, conn = db) => {
2324
+ const ts = timestampValues("update");
2325
+ const set = [];
2326
+ for (const [k, raw] of Object.entries(extras)) {
2327
+ assertExtraKeyAllowed(k);
2328
+ set.push([k, encodeExtra(k, raw ?? null)]);
2329
+ }
2330
+ for (const [k, v] of Object.entries(ts))
2331
+ set.push([k, v]);
2332
+ if (set.length === 0)
2333
+ return;
2334
+ const casts = { ...pivotKeyCasts };
2335
+ for (const k of Object.keys(ts))
2336
+ casts[k] = "timestamp";
2337
+ const spec = {
2338
+ kind: "update",
2339
+ table: pivotTable,
2340
+ set,
2341
+ wheres: [
2342
+ {
2343
+ column: pivotFk,
2344
+ operator: "=",
2345
+ value: readParentId(),
2346
+ type: "and",
2347
+ },
2348
+ { column: pivotOther, operator: "=", value: id, type: "and" },
2349
+ ],
2350
+ returning: [],
2351
+ casts,
2352
+ };
2353
+ const compiled = compileStatementNative(spec, dialect);
2354
+ await conn.execute(compiled.statements[0], compiled.params);
1404
2355
  };
1405
2356
  /**
1406
- * Diff the current pivot state against a target set and apply the
1407
- * minimum attach/detach to converge.
1408
- *
1409
- * **NOT ATOMIC.** `sync` reads the pivot, computes the diff, then
1410
- * writes another process mutating the pivot between the read and
1411
- * the writes will cause divergence. Wrap the call in a transaction
1412
- * if you need strong consistency under concurrent writers.
1413
- *
1414
- * On SQLite this is typically fine because better-sqlite3 serializes
1415
- * writes per connection; on Postgres/MySQL use `useTransaction` first.
2357
+ * Diff the current pivot state against a target set and apply the minimum
2358
+ * insert / update / delete to converge (Adonis Lucid `sync`): rows missing
2359
+ * from the pivot are attached, already-attached rows whose pivot attributes
2360
+ * changed are updated, and rows absent from the target are detached (unless
2361
+ * `additive`). The read and all three writes run inside ONE managed
2362
+ * transaction atomic and rolled back on any failure, so a concurrent
2363
+ * writer can't wedge the pivot into a half-synced state.
1416
2364
  */
1417
2365
  const sync = async (target, additive = false) => {
1418
- const current = new Set(await currentIds());
1419
2366
  const entries = normalizeAttach(target);
1420
- const desired = new Set(entries.map((e) => e.id));
1421
- const toAttach = entries.filter((e) => !current.has(e.id));
1422
- const toDetach = additive
1423
- ? []
1424
- : [...current].filter((id) => !desired.has(id));
1425
- if (toDetach.length > 0)
1426
- await detach(toDetach);
1427
- if (toAttach.length > 0) {
1428
- const attachArg = {};
1429
- for (const e of toAttach)
1430
- attachArg[String(e.id)] = e.extras;
1431
- await attach(attachArg);
2367
+ // Attribute columns to read back so we can detect changed pivot rows.
2368
+ const attrCols = new Set();
2369
+ for (const e of entries) {
2370
+ for (const k of Object.keys(e.extras))
2371
+ attrCols.add(k);
1432
2372
  }
2373
+ const desiredIds = new Set(entries.map((e) => String(e.id)));
2374
+ await transaction(db, async (trx) => {
2375
+ const current = await currentPivotRows([...attrCols], trx);
2376
+ const currentById = new Map();
2377
+ for (const c of current)
2378
+ currentById.set(String(c.id), c);
2379
+ // Diff by String(id): the DB returns numeric ids for an integer
2380
+ // pivot column while object-form targets carry canonicalized ids —
2381
+ // stringifying both sides keeps the comparison type-agnostic.
2382
+ const toDetach = additive
2383
+ ? []
2384
+ : current
2385
+ .filter((c) => !desiredIds.has(String(c.id)))
2386
+ .map((c) => c.id);
2387
+ const toAttach = entries.filter((e) => !currentById.has(String(e.id)));
2388
+ const toUpdate = entries.filter((e) => {
2389
+ if (Object.keys(e.extras).length === 0)
2390
+ return false;
2391
+ const cur = currentById.get(String(e.id));
2392
+ if (!cur)
2393
+ return false;
2394
+ // Only rewrite when a provided attribute actually differs — a
2395
+ // no-op sync must not churn rows or bump updated_at. Compare
2396
+ // nullish and empty-string as DISTINCT (a `String(x ?? "")`
2397
+ // collapse would treat `null` and `""` as equal and miss a real
2398
+ // attribute change from one to the other).
2399
+ return Object.keys(e.extras).some((k) => {
2400
+ const stored = cur.row[k];
2401
+ const next = encodeExtra(k, e.extras[k] ?? null);
2402
+ const storedNull = stored === null || stored === undefined;
2403
+ const nextNull = next === null || next === undefined;
2404
+ if (storedNull || nextNull)
2405
+ return storedNull !== nextNull;
2406
+ return String(stored) !== String(next);
2407
+ });
2408
+ });
2409
+ if (toDetach.length > 0)
2410
+ await detach(toDetach, trx);
2411
+ for (const e of toUpdate)
2412
+ await updatePivot(e.id, e.extras, trx);
2413
+ if (toAttach.length > 0) {
2414
+ const attachArg = {};
2415
+ for (const e of toAttach)
2416
+ attachArg[String(e.id)] = e.extras;
2417
+ await attach(attachArg, trx);
2418
+ }
2419
+ });
1433
2420
  };
1434
2421
  // m2m create/save persist the related row THEN insert a pivot row —
1435
2422
  // NOT `hasOps.injectFk`, which would write a bogus `<parent>_id` column
1436
2423
  // onto the related table and never touch the pivot (silent corruption).
1437
- const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
1438
- const attachIds = (rows) => {
2424
+ // The whole chain (persist unsaved parent → write related → insert pivot)
2425
+ // runs in ONE transaction via `withParentSaved` (AdonisJS/Lucid parity):
2426
+ // atomic, rolled back on any failure (no orphan related row, no pivot to a
2427
+ // missing parent), with domain events flushed only after commit.
2428
+ const relatedPkProp = pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
2429
+ const attachRows = (rows, trx, fk, pivotFor) => {
1439
2430
  if (rows.length === 0)
1440
2431
  return Promise.resolve();
1441
2432
  const arg = {};
1442
- for (const r of rows)
1443
- arg[String(r[relatedPkProp])] = {};
1444
- return attach(arg);
2433
+ rows.forEach((r, i) => {
2434
+ arg[String(r[relatedPkProp])] = pivotFor?.(i) ?? {};
2435
+ });
2436
+ return attach(arg, trx, fk);
1445
2437
  };
2438
+ // create/save accept per-row pivot attributes (Adonis Lucid `create(values,
2439
+ // pivotAttributes)` / `save(related, pivotAttributes)`) — written onto the
2440
+ // pivot row alongside the FK/otherKey, in the same transaction.
1446
2441
  const m2mOps = {
1447
- async create(data) {
1448
- const created = await relatedRepo.create(data);
1449
- await attachIds([created]);
2442
+ create: (data, pivotAttributes) => withParentSaved(async (fk, rel, trx) => {
2443
+ const created = await rel.create(data);
2444
+ await attachRows([created], trx, fk, () => pivotAttributes ?? {});
2445
+ trx.after("commit", () => flushEvents([created]));
1450
2446
  return created;
1451
- },
1452
- async createMany(rows) {
1453
- const created = await relatedRepo.createMany(rows);
1454
- await attachIds(created);
2447
+ }),
2448
+ createMany: (rows, pivotAttributes) => withParentSaved(async (fk, rel, trx) => {
2449
+ const created = await rel.createMany(rows);
2450
+ await attachRows(created, trx, fk, (i) => pivotAttributes?.[i] ?? {});
2451
+ // NO wrapper flush: rel.createMany now self-dispatches via
2452
+ // #inManagedTx (like saveMany). The pivot rows carry no events; a
2453
+ // second flush would double the related rows' events on a partial
2454
+ // sink failure.
1455
2455
  return created;
1456
- },
1457
- async save(related) {
1458
- await relatedRepo.save(related);
1459
- await attachIds([related]);
1460
- },
1461
- async saveMany(related) {
1462
- const saved = await relatedRepo.saveMany(related);
1463
- await attachIds(saved);
2456
+ }),
2457
+ save: (related, pivotAttributes) => withParentSaved(async (fk, rel, trx) => {
2458
+ await rel.save(related);
2459
+ await attachRows([related], trx, fk, () => pivotAttributes ?? {});
2460
+ trx.after("commit", () => flushEvents([related]));
2461
+ }),
2462
+ saveMany: (related, pivotAttributes) => withParentSaved(async (fk, rel, trx) => {
2463
+ const saved = await rel.saveMany(related);
2464
+ await attachRows(saved, trx, fk, (i) => pivotAttributes?.[i] ?? {});
2465
+ // NO wrapper flush: rel.saveMany self-dispatches via #inManagedTx
2466
+ // (all-or-nothing). A second flush would double on partial failure.
1464
2467
  return saved;
1465
- },
2468
+ }),
1466
2469
  };
2470
+ // attach/detach/sync operate DIRECTLY on the pivot using the parent key —
2471
+ // unlike create/save they never persist the parent (there's no related row
2472
+ // to hang the transaction on). Lucid requires a persisted parent WITH a key
2473
+ // here (every doc example starts from `findOrFail`); without the guard a
2474
+ // keyless/unsaved parent would write a pivot row with a null FK or target a
2475
+ // nonexistent parent. Same seam as delete/refresh: E_MODEL_NOT_PERSISTED on
2476
+ // an unsaved instance, E_MISSING_PRIMARY_KEY on a keyless projection.
2477
+ const guardParent = (op) => this.#assertPersistedRow(entity, readParentId(), `related('${relationName}').${op}`,
2478
+ // The pivot FK references `parentPk` (localKey ?? PK) — name THAT key
2479
+ // in a missing-key diagnostic, not always 'id'.
2480
+ parentPk);
1467
2481
  const proxy = {
1468
2482
  type: "manyToMany",
1469
2483
  ...m2mOps,
1470
2484
  query: scopedQuery,
1471
- attach,
1472
- detach,
1473
- sync,
2485
+ // A query builder on the PIVOT table itself, scoped to this parent
2486
+ // (Adonis Lucid `pivotQuery`) — for reading/updating/deleting pivot
2487
+ // rows directly, beyond attach/detach/sync.
2488
+ pivotQuery: () => {
2489
+ guardParent("pivotQuery()");
2490
+ return new DatabaseQueryBuilder(this.#db, dialect, pivotTable).where(pivotFk, readParentId());
2491
+ },
2492
+ // async so the guard throw surfaces as a REJECTED promise — a method
2493
+ // typed `Promise<void>` must never throw synchronously.
2494
+ attach: async (ids) => {
2495
+ guardParent("attach()");
2496
+ return attach(ids);
2497
+ },
2498
+ detach: async (ids) => {
2499
+ guardParent("detach()");
2500
+ return detach(ids);
2501
+ },
2502
+ sync: async (target, additive) => {
2503
+ guardParent("sync()");
2504
+ return sync(target, additive);
2505
+ },
2506
+ };
2507
+ return proxy;
2508
+ }
2509
+ if (relation.type === "hasOneThrough" ||
2510
+ relation.type === "hasManyThrough") {
2511
+ // READ-ONLY (Lucid parity, verified): a through relation exposes only
2512
+ // query()/preload. Every write is rejected — the old code fell through to
2513
+ // the hasMany default and wrote to the WRONG table with a bogus direct FK.
2514
+ // To persist, the caller must go through the intermediate model.
2515
+ const rejectWrite = async (op) => {
2516
+ throw new Error(`related('${relationName}').${op}() is not supported on ` +
2517
+ `@HasManyThrough/@HasOneThrough — through relations are READ-ONLY ` +
2518
+ `(Lucid parity); persist via the intermediate model.`);
2519
+ };
2520
+ const proxy = {
2521
+ type: relation.type,
2522
+ query: scopedQuery,
2523
+ create: () => rejectWrite("create"),
2524
+ save: () => rejectWrite("save"),
2525
+ createMany: () => rejectWrite("createMany"),
2526
+ saveMany: () => rejectWrite("saveMany"),
1474
2527
  };
1475
2528
  return proxy;
1476
2529
  }
@@ -1506,11 +2559,7 @@ export class BaseRepository {
1506
2559
  }
1507
2560
  async fresh(entity) {
1508
2561
  const pk = entity[this.#primaryKey];
1509
- if (pk === undefined || pk === null) {
1510
- throw new EntityNotFoundError(this.#entityClass.name, {
1511
- [this.#primaryKey]: pk,
1512
- });
1513
- }
2562
+ this.#assertPersistedRow(entity, pk, "fresh()");
1514
2563
  const found = await this.find(pk);
1515
2564
  if (!found) {
1516
2565
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -1529,7 +2578,7 @@ export class BaseRepository {
1529
2578
  for (const col of this.#columns) {
1530
2579
  const value = entity[col];
1531
2580
  if (value !== undefined) {
1532
- row[camelToSnake(col)] = this.#applyPrepare(col, value);
2581
+ row[this.#dbColumn(col)] = this.#applyPrepare(col, value, entity);
1533
2582
  }
1534
2583
  }
1535
2584
  return row;
@@ -1540,11 +2589,8 @@ export class BaseRepository {
1540
2589
  // Mirror `#plainToRowPairs` — skip undefined so updates can't bind it.
1541
2590
  if (value === undefined)
1542
2591
  continue;
1543
- const propKey = this.#columnPrepares.has(key) ? key : snakeToCamel(key);
1544
- pairs.push([
1545
- this.#resolveColumn(key),
1546
- this.#applyPrepare(propKey, value),
1547
- ]);
2592
+ // `#applyPrepare` normalises the key (property / snake / columnName).
2593
+ pairs.push([this.#resolveColumn(key), this.#applyPrepare(key, value)]);
1548
2594
  }
1549
2595
  return pairs;
1550
2596
  }
@@ -1567,7 +2613,7 @@ export class BaseRepository {
1567
2613
  * adapter rejected — the dev has to bisect across every adapter-tagged
1568
2614
  * property to find the culprit.
1569
2615
  */
1570
- function wrapAdapterError(phase, propertyKey, err) {
2616
+ export function wrapAdapterError(phase, propertyKey, err) {
1571
2617
  const message = err instanceof Error ? err.message : String(err);
1572
2618
  // `cause: err` preserves the original error (and its stack) per ES2022
1573
2619
  // Error Cause. The wrapped Error keeps its own `stack` pointing at the
@@ -1584,7 +2630,7 @@ function wrapAdapterError(phase, propertyKey, err) {
1584
2630
  * gives the user a column-annotated error instead of an opaque "Invalid bind
1585
2631
  * value" downstream when the unawaited Promise hits the NAPI boundary.
1586
2632
  */
1587
- function assertNotPromise(phase, propertyKey, value) {
2633
+ export function assertNotPromise(phase, propertyKey, value) {
1588
2634
  if (value !== null &&
1589
2635
  typeof value === "object" &&
1590
2636
  "then" in value &&