@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
@@ -5,12 +5,18 @@
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import type { TransactionOptions } from "./adapters/NapiDbAdapter.js";
8
+ import { DateTime } from "@c9up/chronos";
9
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
10
+ import type {
11
+ QueryMeta,
12
+ TransactionOptions,
13
+ } from "./adapters/NapiDbAdapter.js";
9
14
  import type {
10
15
  BaseEntity,
11
16
  BelongsToRelationProxy,
12
17
  DomainEvent,
13
18
  HasManyRelationProxy,
19
+ HasManyThroughRelationProxy,
14
20
  HasOneRelationProxy,
15
21
  ManyToManyRelationProxy,
16
22
  RelationProxy,
@@ -18,9 +24,9 @@ import type {
18
24
  import { REPO_REF } from "./BaseEntity.js";
19
25
  import {
20
26
  type DateColumnConfig,
27
+ ensureEntityMetadata,
21
28
  getColumnMetadata,
22
29
  getDateColumnConfig,
23
- getEntityMetadata,
24
30
  getPrimaryKey,
25
31
  getPrimaryKeyGenerator,
26
32
  getRelationMetadata,
@@ -29,7 +35,8 @@ import {
29
35
  } from "./decorators/entity.js";
30
36
  import { fireHooks } from "./decorators/hooks.js";
31
37
  import { AtlasError, EntityNotFoundError } from "./errors.js";
32
- import { ModelQuery, runWithAtlasInternalBypass } from "./ModelQuery.js";
38
+ import { isAtlasStrictMode, ModelQuery } from "./ModelQuery.js";
39
+ import { DatabaseQueryBuilder } from "./query/DatabaseQueryBuilder.js";
33
40
  import {
34
41
  type AtlasDialect,
35
42
  compileStatementNative,
@@ -39,6 +46,7 @@ import {
39
46
  } from "./query/native.js";
40
47
  import { type TransactionClient, transaction } from "./Transaction.js";
41
48
  import { camelToSnake, snakeToCamel } from "./utils/casing.js";
49
+ import { isTransactionClient } from "./utils/transactionBrand.js";
42
50
 
43
51
  type EntityConstructor<T extends BaseEntity> = new () => T;
44
52
 
@@ -115,11 +123,33 @@ function isUniqueKeyViolation(err: unknown): boolean {
115
123
  * Drivers backed by `AsyncDatabaseConnection` (`createNapiConnection`)
116
124
  * satisfy this interface out-of-the-box.
117
125
  */
126
+ /** Options for {@link BaseRepository.create} (Adonis Lucid `create` options). */
127
+ export interface CreateOptions {
128
+ /** Skip lifecycle hooks + event dispatch (atlas's legacy `quiet` flag). */
129
+ quiet?: boolean;
130
+ /** Silently drop keys that are not columns instead of throwing (Lucid default: throw). */
131
+ allowExtraProperties?: boolean;
132
+ }
133
+
118
134
  export interface DatabaseConnection {
119
- /** Run a write statement; returns rowsAffected. */
120
- execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
121
- /** Run a SELECT and return all rows. */
122
- query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
135
+ /**
136
+ * Run a write statement; returns rowsAffected.
137
+ *
138
+ * `meta` is optional context for the `db:query` event (model, method,
139
+ * per-query debug). A connection that ignores it — every test fake — is
140
+ * still a valid `DatabaseConnection`.
141
+ */
142
+ execute(
143
+ sql: string,
144
+ params?: unknown[],
145
+ meta?: QueryMeta,
146
+ ): Promise<{ rowsAffected: number }>;
147
+ /** Run a SELECT and return all rows. See {@link execute} for `meta`. */
148
+ query<T = Row>(
149
+ sql: string,
150
+ params?: unknown[],
151
+ meta?: QueryMeta,
152
+ ): Promise<T[]>;
123
153
  /**
124
154
  * Optional — open an interactive transaction pinned to ONE connection
125
155
  * (Lucid's `db.transaction`: manual without a callback, managed with one).
@@ -175,10 +205,19 @@ export function computeCastTypes(
175
205
  entityClass: Parameters<typeof getColumnMetadata>[0],
176
206
  ): Record<string, string> {
177
207
  const out: Record<string, string> = {};
208
+ // Resolve each property to its real DB column, honouring `@Column({ columnName })`
209
+ // — the cast MUST key off the column name that actually appears in the SQL.
210
+ const dbNameOf = new Map<string, string>();
211
+ for (const col of getColumnMetadata(entityClass)) {
212
+ dbNameOf.set(
213
+ col.propertyKey,
214
+ col.columnName ?? camelToSnake(col.propertyKey),
215
+ );
216
+ }
178
217
  for (const col of getColumnMetadata(entityClass)) {
179
218
  const t = col.type?.toLowerCase();
180
219
  if (t && POSTGRES_CAST_TYPES.has(t)) {
181
- out[camelToSnake(col.propertyKey)] = t;
220
+ out[dbNameOf.get(col.propertyKey) ?? camelToSnake(col.propertyKey)] = t;
182
221
  }
183
222
  }
184
223
  // `@column.date()` / `@column.dateTime()` columns are tracked in a SEPARATE
@@ -190,15 +229,30 @@ export function computeCastTypes(
190
229
  // expression is of type text`. An explicit recognized `col.type` already set
191
230
  // in the loop above wins via `??=`.
192
231
  for (const [prop, cfg] of Object.entries(getDateColumnConfig(entityClass))) {
193
- out[camelToSnake(prop)] ??= cfg.dateOnly ? "date" : "timestamp";
232
+ out[dbNameOf.get(prop) ?? camelToSnake(prop)] ??= cfg.dateOnly
233
+ ? "date"
234
+ : "timestamp";
194
235
  }
195
236
  // A uuid-strategy primary key is generated app-side as a string.
196
237
  if (getPrimaryKeyGenerator(entityClass) === "uuid") {
197
- out[camelToSnake(getPrimaryKey(entityClass) ?? "id")] ??= "uuid";
238
+ const pk = getPrimaryKey(entityClass) ?? "id";
239
+ out[dbNameOf.get(pk) ?? camelToSnake(pk)] ??= "uuid";
198
240
  }
199
241
  return out;
200
242
  }
201
243
 
244
+ /** A model instance that can bind to a transaction (BaseModel `useTransaction`). */
245
+ interface TransactionBindable {
246
+ useTransaction(trx: DatabaseConnection): unknown;
247
+ }
248
+
249
+ /** Structural guard: does this entity expose `useTransaction` (i.e. is a BaseModel)? */
250
+ function isTransactionBindable(entity: object): entity is TransactionBindable {
251
+ return (
252
+ "useTransaction" in entity && typeof entity.useTransaction === "function"
253
+ );
254
+ }
255
+
202
256
  export class BaseRepository<T extends BaseEntity> {
203
257
  #entityClass: EntityConstructor<T>;
204
258
  #tableName: string;
@@ -207,7 +261,8 @@ export class BaseRepository<T extends BaseEntity> {
207
261
  #db: DatabaseConnection;
208
262
  #softDeletes: boolean;
209
263
  #validColumns: Set<string>;
210
- #columnMap: Map<string, string>; // camelCasesnake_case (cached)
264
+ #columnMap: Map<string, string>; // property/db name resolved db column (cached)
265
+ #columnByDbName: Map<string, string>; // resolved db column → property (for hydrate)
211
266
  #dateColumns: Record<string, DateColumnConfig>;
212
267
  /** Snake column → logical type for params needing a Postgres `::cast`. */
213
268
  #castTypes: Record<string, string>;
@@ -216,13 +271,19 @@ export class BaseRepository<T extends BaseEntity> {
216
271
  * `@Column({ prepare })` metadata. Keyed by camelCase `propertyKey`.
217
272
  * Mirror of Adonis Lucid's `@column.prepare`. Story 35.10.
218
273
  */
219
- #columnPrepares: Map<string, (value: unknown) => unknown>;
274
+ #columnPrepares: Map<
275
+ string,
276
+ (value: unknown, attribute?: string, model?: unknown) => unknown
277
+ >;
220
278
  /**
221
279
  * Per-property `consume` (DB → model) callbacks lifted directly from
222
280
  * `@Column({ consume })` metadata. Keyed by camelCase `propertyKey`.
223
281
  * Mirror of Adonis Lucid's `@column.consume`. Story 35.10.
224
282
  */
225
- #columnConsumes: Map<string, (value: unknown) => unknown>;
283
+ #columnConsumes: Map<
284
+ string,
285
+ (value: unknown, attribute?: string, model?: unknown) => unknown
286
+ >;
226
287
  /**
227
288
  * SQL dialect used by this repository. Resolved at construction time from
228
289
  * the connection (if it exposes a `dialect` property) or from the explicit
@@ -236,6 +297,15 @@ export class BaseRepository<T extends BaseEntity> {
236
297
  /** Callback to dispatch domain events (set by framework integration). */
237
298
  onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
238
299
 
300
+ /**
301
+ * The durable (non-transactional) repo a `useTransaction(trx)` copy was forked
302
+ * from. Lucid resets a model's `$trx` on commit AND rollback, so after a manual
303
+ * transaction ends every entity persisted through the trx-bound repo must have
304
+ * its REPO_REF re-pointed here — otherwise related()/refresh() run on a finished
305
+ * transaction. Undefined on a durable repo (it IS the durable parent).
306
+ */
307
+ #durableParent?: BaseRepository<T>;
308
+
239
309
  constructor(
240
310
  entityClass: EntityConstructor<T>,
241
311
  db: DatabaseConnection,
@@ -260,16 +330,10 @@ export class BaseRepository<T extends BaseEntity> {
260
330
  const connDialect = (db as { dialect?: AtlasDialect }).dialect;
261
331
  this.#dialect = options?.dialect ?? connDialect ?? getAtlasDialect();
262
332
 
263
- const meta = getEntityMetadata(entityClass);
264
- if (!meta) {
265
- throw new AtlasError(
266
- "NOT_ENTITY",
267
- `Class '${entityClass.name}' is not decorated with @Entity()`,
268
- {
269
- hint: "Add @Entity('table_name') decorator to the class.",
270
- },
271
- );
272
- }
333
+ // Infer the table name (naming strategy / `static table`) when @Entity is
334
+ // absent — AdonisJS Lucid parity, shared with BaseModel via one helper so
335
+ // the Data-Mapper and Active-Record paths agree on the convention.
336
+ const meta = ensureEntityMetadata(entityClass);
273
337
 
274
338
  this.#tableName = meta.tableName;
275
339
  this.#primaryKey = getPrimaryKey(entityClass) ?? "id";
@@ -282,8 +346,14 @@ export class BaseRepository<T extends BaseEntity> {
282
346
  // No global registry, no late-registration concern: callbacks are baked
283
347
  // into the entity definition. Mirrors Adonis Lucid's `@column.prepare` /
284
348
  // `@column.consume` pattern.
285
- this.#columnPrepares = new Map<string, (value: unknown) => unknown>();
286
- this.#columnConsumes = new Map<string, (value: unknown) => unknown>();
349
+ this.#columnPrepares = new Map<
350
+ string,
351
+ (value: unknown, attribute?: string, model?: unknown) => unknown
352
+ >();
353
+ this.#columnConsumes = new Map<
354
+ string,
355
+ (value: unknown, attribute?: string, model?: unknown) => unknown
356
+ >();
287
357
  for (const col of columnsMeta) {
288
358
  if (col.prepare) this.#columnPrepares.set(col.propertyKey, col.prepare);
289
359
  if (col.consume) this.#columnConsumes.set(col.propertyKey, col.consume);
@@ -297,16 +367,31 @@ export class BaseRepository<T extends BaseEntity> {
297
367
  // time, before any repository for that entity is instantiated.
298
368
  this.#validColumns = new Set<string>();
299
369
  this.#columnMap = new Map<string, string>();
300
- for (const col of this.#columns) {
301
- const snake = camelToSnake(col);
302
- this.#validColumns.add(col);
303
- this.#validColumns.add(snake);
304
- this.#columnMap.set(col, snake);
305
- this.#columnMap.set(snake, snake);
370
+ this.#columnByDbName = new Map<string, string>();
371
+ for (const col of columnsMeta) {
372
+ const prop = col.propertyKey;
373
+ // Explicit `@Column({ columnName })` wins over the snake_case convention.
374
+ const db = col.columnName ?? camelToSnake(prop);
375
+ this.#validColumns.add(prop);
376
+ this.#validColumns.add(db);
377
+ this.#columnMap.set(prop, db);
378
+ this.#columnMap.set(db, db);
379
+ // Reverse map for hydration — a DB row keyed by the real column name maps
380
+ // back to the TS property (covers explicit overrides AND the default,
381
+ // where `snakeToCamel(db)` would otherwise mis-resolve an override).
382
+ this.#columnByDbName.set(db, prop);
306
383
  }
384
+ // PK is registered as a column too (via @PrimaryKey → Column), so the loop
385
+ // above already mapped it, honouring any columnName. Fall back for the rare
386
+ // PK declared outside the column metadata.
307
387
  this.#validColumns.add(this.#primaryKey);
308
- this.#validColumns.add(camelToSnake(this.#primaryKey));
309
- this.#columnMap.set(this.#primaryKey, camelToSnake(this.#primaryKey));
388
+ if (!this.#columnMap.has(this.#primaryKey)) {
389
+ const pkDb = camelToSnake(this.#primaryKey);
390
+ this.#validColumns.add(pkDb);
391
+ this.#columnMap.set(this.#primaryKey, pkDb);
392
+ this.#columnMap.set(pkDb, pkDb);
393
+ this.#columnByDbName.set(pkDb, this.#primaryKey);
394
+ }
310
395
 
311
396
  // Postgres cast hints: sqlx binds JS strings as `text`, which Postgres
312
397
  // won't coerce to timestamp/uuid/date. See `computeCastTypes`.
@@ -329,15 +414,22 @@ export class BaseRepository<T extends BaseEntity> {
329
414
  // FK lives on THIS table, references the related (owner) PK.
330
415
  const fk = rel.foreignKey ?? `${camelToSnake(related.name)}_id`;
331
416
  const ownerKey = rel.ownerKey ?? getPrimaryKey(related) ?? "id";
332
- const cast = computeCastTypes(related)[camelToSnake(ownerKey)];
417
+ const ownerDb =
418
+ getColumnMetadata(related).find((c) => c.propertyKey === ownerKey)
419
+ ?.columnName ?? camelToSnake(ownerKey);
420
+ const cast = computeCastTypes(related)[ownerDb];
333
421
  if (cast) registerColumnCast(this.#tableName, fk, cast);
334
422
  } else {
335
423
  // hasOne / hasMany: FK lives on the RELATED table, references THIS PK.
336
424
  const fk = rel.foreignKey ?? `${camelToSnake(entityClass.name)}_id`;
337
425
  const localKey = rel.localKey ?? this.#primaryKey;
338
- const cast = this.#castTypes[camelToSnake(localKey)];
339
- const relatedMeta = getEntityMetadata(related);
340
- if (cast && relatedMeta) {
426
+ const cast = this.#castTypes[this.#dbColumn(localKey)];
427
+ // Boot the related model on demand (Lucid lazy-boot): a related model
428
+ // with only `static table` (no @Entity yet) would otherwise miss its FK
429
+ // cast → a uuid FK relation query compiles without `::uuid` and breaks on
430
+ // Postgres. Mirrors relatedProxy / the preload paths.
431
+ const relatedMeta = ensureEntityMetadata(related);
432
+ if (cast) {
341
433
  registerColumnCast(relatedMeta.tableName, fk, cast);
342
434
  }
343
435
  }
@@ -363,6 +455,25 @@ export class BaseRepository<T extends BaseEntity> {
363
455
  );
364
456
  }
365
457
 
458
+ /**
459
+ * Resolve a KNOWN property (from `this.#columns`) to its real DB column name,
460
+ * honouring `@Column({ columnName })`. Non-throwing — used on the write path
461
+ * where the column set is already trusted. Falls back to the snake convention.
462
+ */
463
+ #dbColumn(prop: string): string {
464
+ return this.#columnMap.get(prop) ?? camelToSnake(prop);
465
+ }
466
+
467
+ /**
468
+ * Normalise a mass-assignment key to its TS property. A payload may key by the
469
+ * DB column name (incl. an explicit `columnName`); without this, `create({
470
+ * full_label: 'x' })` would set a `full_label` property that the INSERT (which
471
+ * reads declared properties) then drops silently.
472
+ */
473
+ #toProperty(key: string): string {
474
+ return this.#columnByDbName.get(key) ?? key;
475
+ }
476
+
366
477
  // ─── Query builder ────────────────────────────────────────
367
478
 
368
479
  query(): ModelQuery<T> {
@@ -374,6 +485,8 @@ export class BaseRepository<T extends BaseEntity> {
374
485
  (col) => this.#resolveColumn(col),
375
486
  this.#softDeletes,
376
487
  this.#dialect,
488
+ (prop, value) => this.#applyPrepare(prop, value),
489
+ this.onDomainEvents,
377
490
  );
378
491
  }
379
492
 
@@ -389,6 +502,9 @@ export class BaseRepository<T extends BaseEntity> {
389
502
  dialect: this.#dialect,
390
503
  });
391
504
  repo.onDomainEvents = this.onDomainEvents;
505
+ // Chain back to the true durable root (a nested useTransaction forwards it)
506
+ // so post-transaction REPO_REF restoration always lands on a live connection.
507
+ repo.#durableParent = this.#durableParent ?? this;
392
508
  return repo;
393
509
  }
394
510
 
@@ -419,18 +535,39 @@ export class BaseRepository<T extends BaseEntity> {
419
535
  return entity;
420
536
  }
421
537
 
422
- async findBy(column: string, value: unknown): Promise<T | null> {
538
+ async findBy(column: string, value: unknown): Promise<T | null>;
539
+ async findBy(clause: Record<string, unknown>): Promise<T | null>;
540
+ async findBy(
541
+ columnOrClause: string | Record<string, unknown>,
542
+ value?: unknown,
543
+ ): Promise<T | null> {
423
544
  // Through the builder for read-hook parity (see `find`).
424
- return this.query().where(column, value).first();
545
+ let q = this.query();
546
+ if (typeof columnOrClause === "string") {
547
+ q = q.where(columnOrClause, value);
548
+ } else {
549
+ for (const [k, v] of Object.entries(columnOrClause)) q = q.where(k, v);
550
+ }
551
+ return q.first();
425
552
  }
426
553
 
427
- /** Find by a column or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
428
- async findByOrFail(column: string, value: unknown): Promise<T> {
429
- const entity = await this.findBy(column, value);
554
+ /** Find by a column/clause or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
555
+ async findByOrFail(column: string, value: unknown): Promise<T>;
556
+ async findByOrFail(clause: Record<string, unknown>): Promise<T>;
557
+ async findByOrFail(
558
+ columnOrClause: string | Record<string, unknown>,
559
+ value?: unknown,
560
+ ): Promise<T> {
561
+ const entity =
562
+ typeof columnOrClause === "string"
563
+ ? await this.findBy(columnOrClause, value)
564
+ : await this.findBy(columnOrClause);
430
565
  if (!entity) {
431
- throw new EntityNotFoundError(this.#entityClass.name, {
432
- [column]: value,
433
- });
566
+ const criteria =
567
+ typeof columnOrClause === "string"
568
+ ? { [columnOrClause]: value }
569
+ : columnOrClause;
570
+ throw new EntityNotFoundError(this.#entityClass.name, criteria);
434
571
  }
435
572
  return entity;
436
573
  }
@@ -444,13 +581,23 @@ export class BaseRepository<T extends BaseEntity> {
444
581
  .exec();
445
582
  }
446
583
 
447
- /** Find many rows by an arbitrary column (AdonisJS `findManyBy`). */
584
+ /** Find many rows by a column IN values, or by an object clause (AdonisJS `findManyBy`). */
448
585
  async findManyBy(
449
586
  column: string,
450
587
  values: Array<string | number>,
588
+ ): Promise<T[]>;
589
+ async findManyBy(clause: Record<string, unknown>): Promise<T[]>;
590
+ async findManyBy(
591
+ columnOrClause: string | Record<string, unknown>,
592
+ values?: Array<string | number>,
451
593
  ): Promise<T[]> {
452
- if (values.length === 0) return [];
453
- return this.query().whereIn(column, values).exec();
594
+ if (typeof columnOrClause === "string") {
595
+ if (!values || values.length === 0) return [];
596
+ return this.query().whereIn(columnOrClause, values).exec();
597
+ }
598
+ let q = this.query();
599
+ for (const [k, v] of Object.entries(columnOrClause)) q = q.where(k, v);
600
+ return q.exec();
454
601
  }
455
602
 
456
603
  async all(): Promise<T[]> {
@@ -460,6 +607,33 @@ export class BaseRepository<T extends BaseEntity> {
460
607
  return this.query().orderBy(this.#primaryKey, "desc").exec();
461
608
  }
462
609
 
610
+ /**
611
+ * Empty this model's table (AdonisJS Lucid `Model.truncate`). Postgres/MySQL
612
+ * issue `TRUNCATE TABLE` (fast, resets identity); SQLite has no TRUNCATE so it
613
+ * falls back to `DELETE FROM`. `cascade` is Postgres-only (truncates dependent
614
+ * FK tables). The table name comes from entity metadata — never user input.
615
+ */
616
+ async truncate(cascade = false): Promise<void> {
617
+ // Quote each dotted segment so a schema-qualified table (`reporting.events`)
618
+ // becomes `"reporting"."events"`, not one dotted identifier that TRUNCATEs
619
+ // the wrong (nonexistent) table. Validate each segment even though the table
620
+ // name is app metadata: a bulletproof ORM must never emit malformed/injectable
621
+ // raw SQL from a `static table = 'x"; DROP…'` slip (same policy as qTable).
622
+ const wrap = (seg: string): string => {
623
+ if (!/^[A-Za-z0-9_]+$/.test(seg)) {
624
+ throw new Error(`Unsafe table identifier: '${seg}'`);
625
+ }
626
+ return this.#dialect === "mysql" ? `\`${seg}\`` : `"${seg}"`;
627
+ };
628
+ const quoted = this.#tableName.split(".").map(wrap).join(".");
629
+ if (this.#dialect === "sqlite") {
630
+ await this.#db.query(`DELETE FROM ${quoted}`, []);
631
+ return;
632
+ }
633
+ const suffix = cascade && this.#dialect === "postgres" ? " CASCADE" : "";
634
+ await this.#db.query(`TRUNCATE TABLE ${quoted}${suffix}`, []);
635
+ }
636
+
463
637
  async allWithTrashed(): Promise<T[]> {
464
638
  return this.query().withTrashed().exec();
465
639
  }
@@ -470,45 +644,77 @@ export class BaseRepository<T extends BaseEntity> {
470
644
  }
471
645
 
472
646
  async where(column: string, value: unknown): Promise<T[]> {
473
- // Order by the primary key (DESC = most recent insert first when the PK is
474
- // an auto-increment integer or a monotonic UUID). The ordering contract is
475
- // "most recent first by PK" for `repo.where(col, val)` as a convenience
476
- // finder. Through the builder for read-hook parity (see `find`).
477
- return this.query()
478
- .where(column, value)
479
- .orderBy(this.#primaryKey, "desc")
480
- .exec();
647
+ // No implicit ORDER BY the row order is left to the database, matching
648
+ // Lucid's query-builder `where` (only `all`/`findMany` order by PK desc,
649
+ // which Lucid itself does). Add `.orderBy()` explicitly when order matters.
650
+ // Through the builder for read-hook parity (see `find`).
651
+ return this.query().where(column, value).exec();
481
652
  }
482
653
 
483
654
  // ─── Create / Save / Delete ───────────────────────────────
484
655
 
485
656
  /**
486
- * Build an entity from a plain object and persist it. Fires `beforeSave` →
487
- * `beforeCreate` → INSERT → `afterCreate` → `afterSave`.
657
+ * Build an entity from a plain object and persist it. Fires `beforeCreate` →
658
+ * `beforeSave` → INSERT → `afterCreate` → `afterSave` (AdonisJS/Lucid order:
659
+ * the specific hook runs before the general `beforeSave`).
660
+ *
661
+ * An unknown key throws by default (Lucid); pass `{ allowExtraProperties: true }`
662
+ * to drop unknown keys instead. A bare boolean is the legacy `quiet` flag.
488
663
  */
489
- async create(data: Partial<Record<string, unknown>>): Promise<T> {
664
+ async create(
665
+ data: Partial<Record<string, unknown>>,
666
+ options: boolean | CreateOptions = false,
667
+ ): Promise<T> {
668
+ // Back-compat: a bare boolean is the legacy `quiet` flag.
669
+ const { quiet = false, allowExtraProperties = false } =
670
+ typeof options === "boolean" ? { quiet: options } : options;
490
671
  const entity = new this.#entityClass();
491
672
  for (const [key, value] of Object.entries(data)) {
492
673
  if (
493
674
  this.#validColumns.has(key) ||
494
675
  this.#validColumns.has(camelToSnake(key))
495
676
  ) {
496
- entity.setProp(key, value);
677
+ const prop = this.#toProperty(key);
678
+ entity.assertMassAssignable(prop);
679
+ entity.setProp(prop, value);
680
+ } else if (key.startsWith("$")) {
681
+ // Framework-internal (`$extras`, `$trx`, …) — leaks in when an entity
682
+ // instance is passed as data. Not a user column and not a typo; skip.
683
+ } else if (!allowExtraProperties) {
684
+ // Adonis Lucid throws on an unknown property by default (a typo'd or
685
+ // stray key is a bug, not something to silently drop). Opt out with
686
+ // `create(data, { allowExtraProperties: true })`.
687
+ throw new AtlasError(
688
+ "E_UNKNOWN_COLUMN",
689
+ `Cannot assign '${key}' — it is not a column on ${this.#entityClass.name}. ` +
690
+ "Pass { allowExtraProperties: true } to drop unknown keys instead.",
691
+ );
497
692
  }
498
693
  }
499
- await fireHooks(this.#entityClass, "beforeSave", entity);
500
- await fireHooks(this.#entityClass, "beforeCreate", entity);
694
+ if (!quiet) {
695
+ await fireHooks(this.#entityClass, "beforeCreate", entity);
696
+ await fireHooks(this.#entityClass, "beforeSave", entity);
697
+ }
501
698
  await this.#insert(entity);
502
- await fireHooks(this.#entityClass, "afterCreate", entity);
503
- await fireHooks(this.#entityClass, "afterSave", entity);
504
- await this.#dispatchDomainEvents(entity);
699
+ this.#attachRepoRef(entity);
700
+ if (!quiet) {
701
+ await fireHooks(this.#entityClass, "afterCreate", entity);
702
+ await fireHooks(this.#entityClass, "afterSave", entity);
703
+ }
704
+ await this.#dispatchOrDefer(entity, true);
505
705
  return entity;
506
706
  }
507
707
 
708
+ /** {@link create} without firing lifecycle hooks (AdonisJS Lucid `createQuietly`). */
709
+ createQuietly(data: Partial<Record<string, unknown>>): Promise<T> {
710
+ return this.create(data, true);
711
+ }
712
+
508
713
  /**
509
714
  * Persist an entity. Insert if PK is missing or row doesn't exist, update
510
- * otherwise. Fires `beforeSave` → (`beforeCreate` | `beforeUpdate`) → DB →
511
- * (`afterCreate` | `afterUpdate`) → `afterSave`, then dispatches
715
+ * otherwise. Fires (`beforeCreate` | `beforeUpdate`) → `beforeSave` → DB →
716
+ * (`afterCreate` | `afterUpdate`) → `afterSave` (AdonisJS/Lucid order: the
717
+ * specific hook runs before the general `beforeSave`), then dispatches
512
718
  * accumulated domain events through `onDomainEvents`.
513
719
  *
514
720
  * Race-safety: the `find(pk)` → branch decision has a TOCTOU window. If a
@@ -519,34 +725,114 @@ export class BaseRepository<T extends BaseEntity> {
519
725
  * `beforeCreate` hooks to be idempotent or move side-effects into
520
726
  * `afterCreate` / `afterSave` where they only fire on commit.
521
727
  */
522
- async save(entity: T): Promise<void> {
728
+ async save(entity: T, quiet = false): Promise<void> {
729
+ // A deleted instance must not be resurrected (AdonisJS Lucid parity —
730
+ // `save()` throws once `$isDeleted` is set). Prevents recreating a row the
731
+ // caller believes is gone, or clobbering one deleted concurrently.
732
+ if (entity.$isDeleted) {
733
+ throw new AtlasError(
734
+ "E_MODEL_DELETED",
735
+ `Cannot save a deleted ${this.#entityClass.name} instance.`,
736
+ {
737
+ hint: "The instance was already deleted; re-fetch it before saving again.",
738
+ },
739
+ );
740
+ }
523
741
  const pk = entity[this.#primaryKey];
524
- // Treat a present PK (including `0` and `''`) as a candidate update —
525
- // `pk && ...` would route legitimate zero / empty-string keys through
526
- // INSERT and double-write the row.
527
- const isUpdate = isProvidedPk(pk) && (await this.find(pk)) !== null;
528
-
529
- await fireHooks(this.#entityClass, "beforeSave", entity);
742
+ // A DB-originated entity whose PK wasn't loaded (an aggregate/alias partial
743
+ // projection) must NOT be treated as new that would INSERT a duplicate.
744
+ // Fail loud: re-fetch it fully or use `.pojo()` for projections.
745
+ if (entity.$isPersisted && !isProvidedPk(pk)) {
746
+ throw new AtlasError(
747
+ "E_MISSING_PRIMARY_KEY",
748
+ `Cannot save a ${this.#entityClass.name} loaded without its primary key ('${this.#primaryKey}').`,
749
+ {
750
+ hint: "Select the primary key (plain-column projections auto-include it) or use query().pojo() for aggregate/alias projections.",
751
+ },
752
+ );
753
+ }
754
+ // Decide insert-vs-update from the in-memory `$isPersisted` flag, exactly as
755
+ // AdonisJS/Lucid does — NOT a `find(pk)` SELECT probe. The old probe fired the
756
+ // `beforeFind`/`afterFind` read hooks on every `save()` (a spurious side
757
+ // effect: `save()` isn't a find) and cost an extra round-trip. A brand-new
758
+ // entity whose manual PK collides with an existing row still resolves to an
759
+ // UPDATE via the unique-violation fallback below.
760
+ const isUpdate = entity.$isPersisted;
761
+
762
+ // Snapshot the domain-event queue BEFORE hooks/write add to it, so a rollback
763
+ // (see #dispatchOrDefer) drops only this save's events, not ones the caller
764
+ // queued earlier.
765
+ const eventFloor = entity.domainEventCount();
766
+
767
+ if (!quiet) {
768
+ // AdonisJS/Lucid order: the SPECIFIC before-hook fires first, then the
769
+ // general `beforeSave`, then the DB write.
770
+ await fireHooks(
771
+ this.#entityClass,
772
+ isUpdate ? "beforeUpdate" : "beforeCreate",
773
+ entity,
774
+ );
775
+ await fireHooks(this.#entityClass, "beforeSave", entity);
776
+ }
777
+ // Whether THIS call inserted a brand-new row (vs updated an existing one).
778
+ // Drives the manual-transaction rollback restore: only a fresh INSERT's row
779
+ // vanishes on rollback, so only it reverts to not-persisted. The race-recovery
780
+ // fallback below stays false — the row pre-existed (a concurrent writer).
781
+ let didInsert = false;
530
782
  if (isUpdate) {
531
- await this.#runUpdateBranch(entity);
783
+ await this.#runUpdateBranch(entity, quiet);
532
784
  } else {
533
785
  try {
534
- await this.#runInsertBranch(entity);
786
+ await this.#runInsertBranch(entity, quiet);
787
+ didInsert = true;
535
788
  } catch (err) {
536
789
  // Race recovery: the row didn't exist when we checked, but a
537
790
  // concurrent insert beat us to it. Only fall back when the PK
538
791
  // was explicitly provided (auto-generated PK can't collide on
539
792
  // a fresh insert — DB generates a unique one per call).
540
793
  if (isProvidedPk(pk) && isUniqueKeyViolation(err)) {
541
- await this.#runUpdateBranch(entity);
794
+ // We already fired `beforeCreate`; fire `beforeUpdate` too so the
795
+ // update branch's contract holds (documented race quirk).
796
+ if (!quiet)
797
+ await fireHooks(this.#entityClass, "beforeUpdate", entity);
798
+ await this.#runUpdateBranch(entity, quiet);
542
799
  } else {
543
800
  throw err;
544
801
  }
545
802
  }
546
803
  }
547
- await fireHooks(this.#entityClass, "afterSave", entity);
804
+ this.#attachRepoRef(entity);
805
+ if (!quiet) await fireHooks(this.#entityClass, "afterSave", entity);
548
806
 
549
- await this.#dispatchDomainEvents(entity);
807
+ await this.#dispatchOrDefer(entity, didInsert, eventFloor);
808
+ }
809
+
810
+ /** {@link save} without firing lifecycle hooks (AdonisJS Lucid `saveQuietly`). */
811
+ saveQuietly(entity: T): Promise<void> {
812
+ return this.save(entity, true);
813
+ }
814
+
815
+ /**
816
+ * When true (a repo bound to an atlas-managed transaction), `create`/`save`/
817
+ * `createMany` BUFFER domain events on the entity instead of dispatching them
818
+ * inline. The managed helper flushes them only AFTER the transaction commits, so
819
+ * a rollback never emits events for rows that were rolled back.
820
+ */
821
+ #deferDomainEvents = false;
822
+
823
+ /**
824
+ * When set (a trx-bound repo whose owner wants to undo fresh inserts on
825
+ * rollback), every successful fresh INSERT through this repo pushes its entity
826
+ * here. The owner (a managed batch or a relation write) then reverts exactly
827
+ * these entities — the ones whose row provably did not exist before — to $isNew
828
+ * if the transaction rolls back, without a DB probe or find-vs-create bookkeeping.
829
+ * Undefined on a durable repo (nothing to undo — its writes are their own commit).
830
+ */
831
+ #insertTracker?: BaseEntity[];
832
+
833
+ /** Record a fresh INSERT so its owner can revert it on rollback (see {@link #insertTracker}). */
834
+ #trackInsert(entity: BaseEntity): void {
835
+ this.#insertTracker?.push(entity);
550
836
  }
551
837
 
552
838
  /**
@@ -568,16 +854,63 @@ export class BaseRepository<T extends BaseEntity> {
568
854
  }
569
855
  }
570
856
 
571
- async #runInsertBranch(entity: T): Promise<void> {
572
- await fireHooks(this.#entityClass, "beforeCreate", entity);
857
+ /**
858
+ * Dispatch an entity's domain events AND restore its in-memory state across a
859
+ * transaction boundary, honouring the post-commit contract in EVERY context
860
+ * (BaseEntity documents post-commit flush):
861
+ * - inside a MANAGED batch (`#inManagedTx` set `#deferDomainEvents`): skip —
862
+ * that helper flushes `collect(result)` on `trx.after('commit')`, and its
863
+ * callers (`#inManagedTx` re-attach / `saveMany` rollback catch) restore
864
+ * REPO_REF + $isPersisted + events themselves.
865
+ * - inside a MANUAL transaction (`repo.useTransaction(trx).create(...)`): the
866
+ * repo's `#db` IS the trx. Register post-transaction hooks:
867
+ * · commit → re-point REPO_REF at the durable repo (Lucid resets `$trx` on
868
+ * commit) then flush events (a rollback thus publishes NOTHING).
869
+ * · rollback → re-point REPO_REF at the durable repo (Lucid also resets
870
+ * `$trx` on rollback); revert a fresh INSERT to not-persisted — the row
871
+ * never existed, and keeping `$isPersisted` would let a later
872
+ * `entity.related('x').create()` skip the parent save and write a child
873
+ * with a phantom FK (named data-integrity deviation vs Lucid, same class
874
+ * as the saveMany rollback fix); and clear the queued domain events — they
875
+ * describe a write that didn't happen, so leaving them would double-publish
876
+ * on a re-save.
877
+ * - no transaction: dispatch immediately.
878
+ */
879
+ async #dispatchOrDefer(
880
+ entity: BaseEntity,
881
+ wasInsert: boolean,
882
+ eventFloor = 0,
883
+ ): Promise<void> {
884
+ if (this.#deferDomainEvents) return;
885
+ if (isTransactionClient(this.#db)) {
886
+ const durable = this.#durableParent ?? this;
887
+ this.#db.after("commit", async () => {
888
+ durable.#attachRepoRef(entity);
889
+ await this.#dispatchDomainEvents(entity);
890
+ });
891
+ this.#db.after("rollback", () => {
892
+ durable.#attachRepoRef(entity);
893
+ if (wasInsert) entity.markAsNotPersisted();
894
+ // Drop only the events THIS write queued (from `eventFloor` on), not the
895
+ // ones the caller queued before entering the transaction — those describe
896
+ // work outside the rolled-back write and must survive.
897
+ entity.restoreDomainEventsTo(eventFloor);
898
+ });
899
+ return;
900
+ }
901
+ await this.#dispatchDomainEvents(entity);
902
+ }
903
+
904
+ // The specific `beforeCreate`/`beforeUpdate` hook is fired by `save()` BEFORE
905
+ // `beforeSave` (Lucid order), so these branches only do the write + after-hook.
906
+ async #runInsertBranch(entity: T, quiet = false): Promise<void> {
573
907
  await this.#insert(entity);
574
- await fireHooks(this.#entityClass, "afterCreate", entity);
908
+ if (!quiet) await fireHooks(this.#entityClass, "afterCreate", entity);
575
909
  }
576
910
 
577
- async #runUpdateBranch(entity: T): Promise<void> {
578
- await fireHooks(this.#entityClass, "beforeUpdate", entity);
911
+ async #runUpdateBranch(entity: T, quiet = false): Promise<void> {
579
912
  await this.#update(entity);
580
- await fireHooks(this.#entityClass, "afterUpdate", entity);
913
+ if (!quiet) await fireHooks(this.#entityClass, "afterUpdate", entity);
581
914
  }
582
915
 
583
916
  /**
@@ -590,6 +923,7 @@ export class BaseRepository<T extends BaseEntity> {
590
923
  */
591
924
  async createMany(
592
925
  rows: Array<Partial<Record<string, unknown>>>,
926
+ quiet = false,
593
927
  ): Promise<T[]> {
594
928
  if (rows.length === 0) return [];
595
929
  const entities: T[] = rows.map((r) => {
@@ -598,19 +932,54 @@ export class BaseRepository<T extends BaseEntity> {
598
932
  if (
599
933
  this.#validColumns.has(k) ||
600
934
  this.#validColumns.has(camelToSnake(k))
601
- )
602
- e.setProp(k, v);
935
+ ) {
936
+ const prop = this.#toProperty(k);
937
+ e.assertMassAssignable(prop);
938
+ e.setProp(prop, v);
939
+ }
603
940
  }
604
941
  return e;
605
942
  });
606
- for (const e of entities) {
607
- await fireHooks(this.#entityClass, "beforeSave", e);
608
- await fireHooks(this.#entityClass, "beforeCreate", e);
943
+ // All-or-nothing (Lucid parity, same as saveMany): run the batch INSERT *and*
944
+ // its afterCreate/afterSave hooks inside ONE managed transaction, so a hook that
945
+ // throws rolls the whole batch back. Previously #persistFreshBatch ran the insert
946
+ // then the after-hooks with no surrounding transaction, so a failing after-hook
947
+ // left the rows committed while createMany rejected. The built entities are
948
+ // internal (returned only on success), so — unlike saveMany, whose instances the
949
+ // caller keeps — no rollback-restore of caller state is needed; the nested-under-
950
+ // external case is already handled by #inManagedTx's tracker.
951
+ return this.#inManagedTx(
952
+ (repo) => repo.#persistFreshBatch(entities, quiet),
953
+ (result) => result,
954
+ );
955
+ }
956
+
957
+ /**
958
+ * Persist a batch of NEW entity INSTANCES: fire create/save hooks, batch-INSERT
959
+ * (multi-row RETURNING; mysql falls back to N inserts in one managed tx), fire
960
+ * the after hooks, wire the repo ref, and dispatch domain events (unless
961
+ * deferred). Shared by `createMany` (which builds instances from rows) and
962
+ * `saveMany` (which passes the CALLER's own fresh instances) so hook mutations
963
+ * and hook-generated domain events always land on the exact objects the caller
964
+ * holds — never on discarded clones.
965
+ */
966
+ async #persistFreshBatch(entities: T[], quiet: boolean): Promise<T[]> {
967
+ if (entities.length === 0) return [];
968
+ if (!quiet) {
969
+ for (const e of entities) {
970
+ await fireHooks(this.#entityClass, "beforeCreate", e);
971
+ await fireHooks(this.#entityClass, "beforeSave", e);
972
+ }
609
973
  }
610
974
 
611
975
  if (this.#dialect === "mysql") {
612
- // mysql: loop single inserts (no RETURNING).
613
- for (const e of entities) await this.#insert(e);
976
+ // mysql has no multi-row RETURNING, so insert row-by-row — but inside a
977
+ // single managed transaction so the batch is all-or-nothing (Lucid parity;
978
+ // a mid-batch failure must not leave a partial insert committed).
979
+ await transaction(this.#db, async (trx) => {
980
+ const r = this.useTransaction(trx);
981
+ for (const e of entities) await r.#insert(e);
982
+ });
614
983
  } else {
615
984
  const specRows = entities.map((e) => this.#entityToRowPairs(e));
616
985
  const spec = {
@@ -619,8 +988,8 @@ export class BaseRepository<T extends BaseEntity> {
619
988
  rows: specRows,
620
989
  casts: this.#castTypes,
621
990
  returning: [
622
- camelToSnake(this.#primaryKey),
623
- ...this.#columns.map((c) => camelToSnake(c)),
991
+ this.#dbColumn(this.#primaryKey),
992
+ ...this.#columns.map((c) => this.#dbColumn(c)),
624
993
  ],
625
994
  };
626
995
  const compiled = compileStatementNative(spec, this.#dialect);
@@ -629,22 +998,38 @@ export class BaseRepository<T extends BaseEntity> {
629
998
  compiled.params,
630
999
  );
631
1000
  returned.forEach((row, i) => {
632
- for (const [k, v] of Object.entries(row))
633
- entities[i].setProp(snakeToCamel(k), v);
1001
+ for (const [k, v] of Object.entries(row)) {
1002
+ const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
1003
+ // Run the DB value through consume so date columns come back as
1004
+ // Chronos DateTime (not the raw ISO string) — mirrors #hydrate.
1005
+ entities[i].setProp(prop, this.#applyConsume(prop, v, entities[i]));
1006
+ }
634
1007
  entities[i].markAsPersisted();
635
1008
  });
636
1009
  }
637
1010
 
638
- for (const e of entities) {
639
- await fireHooks(this.#entityClass, "afterCreate", e);
640
- await fireHooks(this.#entityClass, "afterSave", e);
641
- }
642
- for (const e of entities) {
643
- await this.#dispatchDomainEvents(e);
1011
+ if (!quiet) {
1012
+ for (const e of entities) {
1013
+ await fireHooks(this.#entityClass, "afterCreate", e);
1014
+ await fireHooks(this.#entityClass, "afterSave", e);
1015
+ }
644
1016
  }
1017
+ for (const e of entities) this.#attachRepoRef(e);
1018
+ // Record the fresh inserts on THIS repo (the mysql path ran #insert on a nested
1019
+ // trx repo, so track here uniformly for both dialects) so the owning managed
1020
+ // batch can revert them on rollback.
1021
+ for (const e of entities) this.#trackInsert(e);
1022
+ for (const e of entities) await this.#dispatchOrDefer(e, true);
645
1023
  return entities;
646
1024
  }
647
1025
 
1026
+ /** {@link createMany} without firing lifecycle hooks (AdonisJS Lucid `createManyQuietly`). */
1027
+ createManyQuietly(
1028
+ rows: Array<Partial<Record<string, unknown>>>,
1029
+ ): Promise<T[]> {
1030
+ return this.createMany(rows, true);
1031
+ }
1032
+
648
1033
  /**
649
1034
  * Persist many already-constructed entity instances. Same hooks + batching
650
1035
  * as `createMany`, but accepts prebuilt entities so dirty tracking works.
@@ -653,32 +1038,64 @@ export class BaseRepository<T extends BaseEntity> {
653
1038
  */
654
1039
  async saveMany(entities: T[]): Promise<T[]> {
655
1040
  if (entities.length === 0) return [];
656
- // Split new vs already-persisted; for simplicity, persist new ones as a
657
- // batch and fall back to per-entity save for dirty ones.
1041
+ // All-or-nothing, like Lucid: `createMany` and every batch helper run in a
1042
+ // managed transaction, so a mid-batch failure rolls the WHOLE batch back
1043
+ // (verified against the Lucid CRUD docs). Fresh inserts AND dirty updates
1044
+ // commit together or not at all — previously the dirty ones were saved one
1045
+ // by one OUTSIDE any transaction, leaving earlier rows persisted on a later
1046
+ // failure. Events flush post-commit via #inManagedTx (deferred inside).
1047
+ // #inManagedTx re-points each returned entity's REPO_REF at the durable repo
1048
+ // after commit, so related()/refresh() work on the instances we hand back.
1049
+ // Split BEFORE the batch so the rollback path still knows which were fresh
1050
+ // (once #persistFreshBatch runs markAsPersisted, the flag flips). Classify by
1051
+ // `$isPersisted`, NOT by an empty `$original`: an aggregate/alias PROJECTION is
1052
+ // hydrated persisted but with `$original = {}`, so the old empty-$original test
1053
+ // misrouted it into the fresh INSERT batch — bypassing save()'s
1054
+ // E_MISSING_PRIMARY_KEY guard and turning a keyless projection into an INSERT.
1055
+ // A persisted projection now lands in `dirty` → save() → the guard fires.
658
1056
  const fresh: T[] = [];
659
1057
  const dirty: T[] = [];
660
1058
  for (const e of entities) {
661
- if (Object.keys(e.$original ?? {}).length === 0) fresh.push(e);
1059
+ if (!e.$isPersisted) fresh.push(e);
662
1060
  else dirty.push(e);
663
1061
  }
664
- if (fresh.length > 0) {
665
- const rows = fresh.map((e) => {
666
- const r: Record<string, unknown> = {};
667
- for (const c of this.#columns) {
668
- const v = e[c];
669
- if (v !== undefined) r[c] = v;
670
- }
671
- return r;
672
- });
673
- const created = await this.createMany(rows);
674
- // Copy generated PKs back to the original instances.
675
- created.forEach((c, i) => {
676
- fresh[i].setProp(this.#primaryKey, c[this.#primaryKey]);
677
- fresh[i].markAsPersisted();
678
- });
1062
+ // Snapshot each caller instance's domain-event floor BEFORE the batch, so a
1063
+ // rollback drops only the events this batch queued, keeping any the caller
1064
+ // queued earlier (#8).
1065
+ const eventFloors = new Map<BaseEntity, number>();
1066
+ for (const e of entities) eventFloors.set(e, e.domainEventCount());
1067
+ try {
1068
+ return await this.#inManagedTx(
1069
+ async (repo) => {
1070
+ // Persist the caller's OWN fresh instances (not clones): hook mutations
1071
+ // and hook-generated domain events stay on the objects we return.
1072
+ if (fresh.length > 0) await repo.#persistFreshBatch(fresh, false);
1073
+ for (const d of dirty) await repo.save(d);
1074
+ return entities;
1075
+ },
1076
+ (result) => result,
1077
+ eventFloors,
1078
+ );
1079
+ } catch (err) {
1080
+ // Rollback recovery. Re-point every instance's REPO_REF at the durable repo
1081
+ // (it was stamped at the now-finished trx) — Lucid resets `$trx` the same
1082
+ // way. And REVERT the FRESH instances to not-persisted: their INSERT was
1083
+ // rolled back, so keeping `$isPersisted` (Lucid does) would let a later
1084
+ // `fresh.related('x').create()` skip re-saving the parent and write a child
1085
+ // with a phantom foreign key. Reverting only the FRESH ones (provably
1086
+ // unpersisted before the batch) is a NAMED safety deviation; DIRTY rows
1087
+ // keep `$isPersisted` — their row still exists with its rolled-back values.
1088
+ for (const e of entities) this.#attachRepoRef(e);
1089
+ for (const e of fresh) e.markAsNotPersisted();
1090
+ // Drop the events THIS batch queued (from each instance's pre-batch floor):
1091
+ // the whole batch rolled back, so those describe writes that didn't happen.
1092
+ // Leaving them would double-publish when the caller re-saves the same
1093
+ // instance (its hooks re-queue the event). Events queued BEFORE the batch
1094
+ // survive (#8) — they describe work outside this rolled-back batch.
1095
+ for (const e of entities)
1096
+ e.restoreDomainEventsTo(eventFloors.get(e) ?? 0);
1097
+ throw err;
679
1098
  }
680
- for (const d of dirty) await this.save(d);
681
- return entities;
682
1099
  }
683
1100
 
684
1101
  /**
@@ -721,12 +1138,93 @@ export class BaseRepository<T extends BaseEntity> {
721
1138
  ): Promise<T> {
722
1139
  // Atomic (AdonisJS Lucid parity): find-under-lock then create inside one
723
1140
  // transaction, so two concurrent callers can't both miss and both INSERT.
724
- return transaction(this.#db, async (trx) => {
1141
+ return this.#inManagedTx(
1142
+ async (repo) => {
1143
+ const existing = await repo.#findBySearch(search, true);
1144
+ if (existing) return existing;
1145
+ return repo.create({ ...search, ...defaults });
1146
+ },
1147
+ (r) => [r],
1148
+ );
1149
+ }
1150
+
1151
+ /**
1152
+ * Run `body` inside an atlas-managed transaction whose trx-bound repo DEFERS
1153
+ * domain-event dispatch, then flush the collected entities' events AFTER the
1154
+ * commit — so a rollback emits no events for rows that were rolled back
1155
+ * (previously each create/save dispatched in-loop, before the batch committed).
1156
+ * `collect` picks the entities whose events flush post-commit.
1157
+ */
1158
+ async #inManagedTx<R>(
1159
+ body: (repo: BaseRepository<T>) => Promise<R>,
1160
+ collect: (result: R) => BaseEntity[],
1161
+ eventFloors?: ReadonlyMap<BaseEntity, number>,
1162
+ ): Promise<R> {
1163
+ // Records every fresh INSERT the body performs through the trx-bound repo, so
1164
+ // we can revert exactly those (not the found-and-updated rows) on rollback.
1165
+ const freshInserts: BaseEntity[] = [];
1166
+ const result = await transaction(this.#db, async (trx) => {
725
1167
  const repo = this.useTransaction(trx);
726
- const existing = await repo.#findBySearch(search, true);
727
- if (existing) return existing;
728
- return repo.create({ ...search, ...defaults });
1168
+ repo.#deferDomainEvents = true;
1169
+ repo.#insertTracker = freshInserts;
1170
+ const r = await body(repo);
1171
+ // Flush AFTER the transaction is durable. Registering on the trx (rather
1172
+ // than awaiting after `transaction(...)` returns) is what makes this
1173
+ // correct inside an EXTERNAL transaction: there `transaction()` only
1174
+ // opens a SAVEPOINT, so a post-return flush would fire before the outer
1175
+ // commit — and emit events for rows a later outer rollback discards.
1176
+ trx.after("commit", async () => {
1177
+ for (const e of collect(r)) await this.#dispatchDomainEvents(e);
1178
+ });
1179
+ return r;
729
1180
  });
1181
+ // Every entity produced here was created / hydrated through the trx-bound
1182
+ // repo, so its REPO_REF points at the (now-finished inner) transaction. Re-point
1183
+ // it at `this` so related()/refresh()/fresh() work on the returned instance —
1184
+ // covers firstOrCreate/updateOrCreate/*Many/saveMany.
1185
+ const produced = collect(result);
1186
+ for (const e of produced) this.#attachRepoRef(e);
1187
+ // When we ran NESTED inside an external transaction, `this.#db` is that outer
1188
+ // trx and the re-attach above pointed REPO_REF at the outer-trx repo (correct
1189
+ // while still inside it). But the inner SAVEPOINT's RELEASE is NOT durable — the
1190
+ // root can still roll back. Lucid resets `$trx` once the transaction it was bound
1191
+ // to resolves, either way, so re-point REPO_REF at the durable repo on BOTH the
1192
+ // outer commit and the outer rollback; otherwise the ref dangles on a finished
1193
+ // transaction ("transaction already finished") on any later related()/refresh().
1194
+ // AND on rollback, revert the rows that were FRESHLY INSERTED (the tracker proves
1195
+ // exactly which — found-and-updated rows still exist and stay persisted): keeping
1196
+ // $isPersisted on a row that no longer exists would let a later related().create()
1197
+ // skip re-saving the parent and orphan the FK (named data-integrity deviation, now
1198
+ // closed for the nested managed path too — freshness is proven, no longer at Lucid
1199
+ // parity as in the initial R21 pass).
1200
+ if (isTransactionClient(this.#db)) {
1201
+ const durable = this.#durableParent ?? this;
1202
+ this.#db.after("commit", () => {
1203
+ for (const e of produced) durable.#attachRepoRef(e);
1204
+ });
1205
+ this.#db.after("rollback", () => {
1206
+ for (const e of produced) {
1207
+ durable.#attachRepoRef(e);
1208
+ // Every produced entity was written (inserted OR updated) in the
1209
+ // rolled-back trx, so any event THIS batch queued describes a write that
1210
+ // never committed — drop it (else a later re-save double-publishes: a
1211
+ // beforeUpdate hook on a found+updated row is the canonical trigger).
1212
+ // Restore to the caller's pre-batch floor so events queued BEFORE the
1213
+ // batch (e.g. a caller's manual addDomainEvent) survive; absent a floor
1214
+ // the entity was tx-internal (floor 0 = clear).
1215
+ e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
1216
+ }
1217
+ // Fresh inserts additionally revert to $isNew — their row is gone.
1218
+ // Found+updated rows keep $isPersisted (their row still exists).
1219
+ // (restoreDomainEventsTo is idempotent — safe even if a fresh insert is not
1220
+ // among `produced`, e.g. an internal side-write not returned by collect.)
1221
+ for (const e of freshInserts) {
1222
+ e.markAsNotPersisted();
1223
+ e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
1224
+ }
1225
+ });
1226
+ }
1227
+ return result;
730
1228
  }
731
1229
 
732
1230
  /** Find a row or build an in-memory instance without persisting. */
@@ -738,8 +1236,14 @@ export class BaseRepository<T extends BaseEntity> {
738
1236
  if (existing) return existing;
739
1237
  const e = new this.#entityClass();
740
1238
  for (const [k, v] of Object.entries({ ...search, ...defaults })) {
741
- if (this.#validColumns.has(k) || this.#validColumns.has(camelToSnake(k)))
742
- e.setProp(k, v);
1239
+ if (
1240
+ this.#validColumns.has(k) ||
1241
+ this.#validColumns.has(camelToSnake(k))
1242
+ ) {
1243
+ const prop = this.#toProperty(k);
1244
+ e.assertMassAssignable(prop);
1245
+ e.setProp(prop, v);
1246
+ }
743
1247
  }
744
1248
  return e;
745
1249
  }
@@ -749,16 +1253,119 @@ export class BaseRepository<T extends BaseEntity> {
749
1253
  search: Record<string, unknown>,
750
1254
  values: Record<string, unknown>,
751
1255
  ): Promise<T> {
752
- return transaction(this.#db, async (trx) => {
753
- const repo = this.useTransaction(trx);
754
- const existing = await repo.#findBySearch(search, true);
755
- if (existing) {
756
- for (const [k, v] of Object.entries(values)) existing.setProp(k, v);
757
- await repo.save(existing);
758
- return existing;
759
- }
760
- return repo.create({ ...search, ...values });
761
- });
1256
+ return this.#inManagedTx(
1257
+ async (repo) => {
1258
+ const existing = await repo.#findBySearch(search, true);
1259
+ if (existing) {
1260
+ for (const [k, v] of Object.entries(values)) {
1261
+ const prop = this.#toProperty(k);
1262
+ existing.assertMassAssignable(prop);
1263
+ existing.setProp(prop, v);
1264
+ }
1265
+ await repo.save(existing);
1266
+ return existing;
1267
+ }
1268
+ return repo.create({ ...search, ...values });
1269
+ },
1270
+ (r) => [r],
1271
+ );
1272
+ }
1273
+
1274
+ /** Extract the search clause (the unique key column(s)) from a row. */
1275
+ #pickKeys(
1276
+ row: Record<string, unknown>,
1277
+ key: string | string[],
1278
+ ): Record<string, unknown> {
1279
+ const keys = Array.isArray(key) ? key : [key];
1280
+ const search: Record<string, unknown> = {};
1281
+ for (const k of keys) {
1282
+ // The predicate key AND the row may each be a TS property or a DB column
1283
+ // name. Normalise both to the property so `updateOrCreateMany('label', [{
1284
+ // full_label: 'x' }])` matches — mirrors the create() key normalization.
1285
+ const prop = this.#toProperty(k);
1286
+ const dbName = this.#dbColumn(prop);
1287
+ let value: unknown;
1288
+ if (k in row) value = row[k];
1289
+ else if (prop in row) value = row[prop];
1290
+ else value = row[dbName];
1291
+ search[prop] = value;
1292
+ }
1293
+ return search;
1294
+ }
1295
+
1296
+ /**
1297
+ * Bulk find-or-update-or-insert, keyed by a unique column (or columns), in ONE
1298
+ * transaction — all-or-nothing (AdonisJS Lucid `updateOrCreateMany`).
1299
+ */
1300
+ async updateOrCreateMany(
1301
+ key: string | string[],
1302
+ rows: Array<Record<string, unknown>>,
1303
+ ): Promise<T[]> {
1304
+ if (rows.length === 0) return [];
1305
+ return this.#inManagedTx(
1306
+ async (repo) => {
1307
+ const out: T[] = [];
1308
+ for (const row of rows) {
1309
+ const existing = await repo.#findBySearch(
1310
+ this.#pickKeys(row, key),
1311
+ true,
1312
+ );
1313
+ if (existing) {
1314
+ for (const [k, v] of Object.entries(row)) {
1315
+ const prop = this.#toProperty(k);
1316
+ existing.assertMassAssignable(prop);
1317
+ existing.setProp(prop, v);
1318
+ }
1319
+ await repo.save(existing);
1320
+ out.push(existing);
1321
+ } else {
1322
+ out.push(await repo.create(row));
1323
+ }
1324
+ }
1325
+ return out;
1326
+ },
1327
+ (out) => out,
1328
+ );
1329
+ }
1330
+
1331
+ /**
1332
+ * Bulk find-or-create keyed by a unique column(s) — existing rows are returned
1333
+ * untouched — in one transaction (AdonisJS Lucid `fetchOrCreateMany`).
1334
+ */
1335
+ async fetchOrCreateMany(
1336
+ key: string | string[],
1337
+ rows: Array<Record<string, unknown>>,
1338
+ ): Promise<T[]> {
1339
+ if (rows.length === 0) return [];
1340
+ return this.#inManagedTx(
1341
+ async (repo) => {
1342
+ const out: T[] = [];
1343
+ for (const row of rows) {
1344
+ const existing = await repo.#findBySearch(
1345
+ this.#pickKeys(row, key),
1346
+ true,
1347
+ );
1348
+ out.push(existing ?? (await repo.create(row)));
1349
+ }
1350
+ return out;
1351
+ },
1352
+ (out) => out,
1353
+ );
1354
+ }
1355
+
1356
+ /**
1357
+ * Bulk find-or-new keyed by a unique column(s): existing rows are returned,
1358
+ * misses become UNPERSISTED in-memory instances (AdonisJS `fetchOrNewUpMany`).
1359
+ */
1360
+ async fetchOrNewUpMany(
1361
+ key: string | string[],
1362
+ rows: Array<Record<string, unknown>>,
1363
+ ): Promise<T[]> {
1364
+ const out: T[] = [];
1365
+ for (const row of rows) {
1366
+ out.push(await this.firstOrNew(this.#pickKeys(row, key), row));
1367
+ }
1368
+ return out;
762
1369
  }
763
1370
 
764
1371
  async #findBySearch(
@@ -778,25 +1385,79 @@ export class BaseRepository<T extends BaseEntity> {
778
1385
  * contract — callback receives the raw value (including null/undefined) and
779
1386
  * decides what to do with it.
780
1387
  */
781
- #applyPrepare(propertyKey: string, value: unknown): unknown {
1388
+ #applyPrepare(key: string, value: unknown, model?: unknown): unknown {
1389
+ // Callers may pass a DB column name (e.g. updateWhere("starts_at", …) or a
1390
+ // `@Column({ columnName })` column) — prepare/dateColumns are keyed by the TS
1391
+ // property, so normalise via the reverse map first, else the adapter/date
1392
+ // conversion is silently skipped.
1393
+ const propertyKey = this.#columnByDbName.get(key) ?? key;
782
1394
  const prepare = this.#columnPrepares.get(propertyKey);
783
- if (!prepare) return value;
784
- let result: unknown;
785
- try {
786
- result = prepare(value);
787
- } catch (err) {
788
- throw wrapAdapterError("prepare", propertyKey, err);
1395
+ if (prepare) {
1396
+ let result: unknown;
1397
+ try {
1398
+ // Adonis Lucid signature: (value, attribute, model). `model` is
1399
+ // undefined on query-builder paths that carry no instance.
1400
+ result = prepare(value, propertyKey, model);
1401
+ } catch (err) {
1402
+ throw wrapAdapterError("prepare", propertyKey, err);
1403
+ }
1404
+ assertNotPromise("prepare", propertyKey, result);
1405
+ return result;
789
1406
  }
790
- assertNotPromise("prepare", propertyKey, result);
791
- return result;
1407
+ // No explicit `@Column({ prepare })`: lower a `@column.date()` /
1408
+ // `@column.dateTime()` value to its ISO 8601 string for the SQL bind.
1409
+ // Branch order mirrors Lucid's `prepareDateColumn` (strings pass through,
1410
+ // `DateTime` is formatted, anything else throws naming the column) — see
1411
+ // `#prepareDateString` for the one named deviation.
1412
+ if (this.#dateColumns[propertyKey] && value != null) {
1413
+ if (typeof value === "string") {
1414
+ return this.#prepareDateString(propertyKey, value);
1415
+ }
1416
+ // A raw JS `Date` is accepted where Lucid throws: `toISOString()` is
1417
+ // unambiguous UTC, so the strictness would buy nothing. Named deviation.
1418
+ if (value instanceof Date) return value.toISOString();
1419
+ // Otherwise the Chronos adapter's prepare serialises a `DateTime` — via
1420
+ // a STRUCTURAL check, so an instance from a duplicated `@c9up/chronos`
1421
+ // copy (another realm) round-trips instead of being passed raw to the
1422
+ // N-API bind.
1423
+ return dateTimeAtlasAdapter.prepare(value);
1424
+ }
1425
+ return value;
1426
+ }
1427
+
1428
+ /**
1429
+ * Lower a string assigned to a `@column.date()` / `@column.dateTime()`.
1430
+ *
1431
+ * Lucid lets every string through untouched (`prepareDateColumn`, first
1432
+ * branch), and atlas does the same — with ONE named deviation: a *naive*
1433
+ * datetime (no `Z`, no offset) is rejected. Such a string has no instant
1434
+ * attached, so the chronos read path resolves it in the JS runtime's local
1435
+ * zone: the very same row then hydrates to a different instant on a
1436
+ * developer laptop in Europe/Zurich and on a CI host running UTC. Silently
1437
+ * storing a value that means two different things is the failure this guard
1438
+ * exists to prevent; date-only strings and offset-bearing ones are
1439
+ * unambiguous and pass exactly like Lucid.
1440
+ */
1441
+ #prepareDateString(propertyKey: string, value: string): string {
1442
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
1443
+ const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value.trim());
1444
+ if (dateOnly || hasZone) return value;
1445
+ throw new AtlasError(
1446
+ "INVALID_DATE_COLUMN_VALUE",
1447
+ `${this.#entityClass.name}.${propertyKey}: "${value}" is a naive datetime — it carries no timezone, so it would read back differently depending on the machine.`,
1448
+ {
1449
+ hint: 'Add an offset ("2026-08-10T12:00:00Z"), pass a chronos DateTime, or use a date-only string ("2026-08-10").',
1450
+ },
1451
+ );
792
1452
  }
793
1453
 
794
- #applyConsume(propertyKey: string, value: unknown): unknown {
1454
+ #applyConsume(propertyKey: string, value: unknown, model?: unknown): unknown {
795
1455
  const consume = this.#columnConsumes.get(propertyKey);
796
1456
  if (consume) {
797
1457
  let result: unknown;
798
1458
  try {
799
- result = consume(value);
1459
+ // Adonis Lucid signature: (value, attribute, model).
1460
+ result = consume(value, propertyKey, model);
800
1461
  } catch (err) {
801
1462
  throw wrapAdapterError("consume", propertyKey, err);
802
1463
  }
@@ -804,19 +1465,13 @@ export class BaseRepository<T extends BaseEntity> {
804
1465
  return result;
805
1466
  }
806
1467
  // No explicit `@Column({ consume })`: a `@column.date()` / `@column.dateTime()`
807
- // column hydrates its DB value (an ISO string from the Rust decode) into a
808
- // JS `Date`, so `.getTime()` / date arithmetic work on read. Mirrors Adonis
809
- // Lucid hydrating date columns to a Luxon `DateTime` atlas standardises on
810
- // the native `Date` (no Luxon dependency). An unparseable string is left
811
- // untouched rather than turned into `Invalid Date`.
812
- if (
813
- this.#dateColumns[propertyKey] &&
814
- value != null &&
815
- !(value instanceof Date) &&
816
- (typeof value === "string" || typeof value === "number")
817
- ) {
818
- const d = new Date(value);
819
- if (!Number.isNaN(d.getTime())) return d;
1468
+ // column hydrates its DB value into a Chronos `DateTime` mirroring Adonis
1469
+ // Lucid, which hydrates date columns to a Luxon `DateTime` (here the Ream
1470
+ // date engine `@c9up/chronos` plays Luxon's role). The Chronos adapter's
1471
+ // consume is idempotent and uses a structural check, so a `DateTime` from a
1472
+ // different realm (duplicated package copy) is recognised too.
1473
+ if (this.#dateColumns[propertyKey] && value != null) {
1474
+ return dateTimeAtlasAdapter.consume(value);
820
1475
  }
821
1476
  return value;
822
1477
  }
@@ -828,10 +1483,9 @@ export class BaseRepository<T extends BaseEntity> {
828
1483
  // the Rust DML compiler / NAPI layer rejects it. `null` is allowed
829
1484
  // through because that's a meaningful SQL value.
830
1485
  if (v === undefined) continue;
831
- // Prepare map is keyed by camelCase property name. The input bag may use
832
- // either camel or snake try the raw key first, else convert.
833
- const propKey = this.#columnPrepares.has(k) ? k : snakeToCamel(k);
834
- pairs.push([this.#resolveColumn(k), this.#applyPrepare(propKey, v)]);
1486
+ // `#applyPrepare` normalises the key (property / snake / columnName) via
1487
+ // the reverse map, so pass the raw key straight through.
1488
+ pairs.push([this.#resolveColumn(k), this.#applyPrepare(k, v)]);
835
1489
  }
836
1490
  return pairs;
837
1491
  }
@@ -841,32 +1495,43 @@ export class BaseRepository<T extends BaseEntity> {
841
1495
  for (const col of this.#columns) {
842
1496
  const v = entity[col];
843
1497
  if (v !== undefined)
844
- pairs.push([camelToSnake(col), this.#applyPrepare(col, v)]);
1498
+ pairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
845
1499
  }
846
1500
  return pairs;
847
1501
  }
848
1502
 
849
1503
  /** Delete the entity. Fires `beforeDelete` → DB → `afterDelete`. Soft-delete aware. */
850
- async delete(entity: T): Promise<void> {
851
- await fireHooks(this.#entityClass, "beforeDelete", entity);
1504
+ async delete(entity: T, quiet = false): Promise<void> {
1505
+ // Guard BEFORE hooks — a projection entity with no PK must not fire
1506
+ // beforeDelete against a phantom row, then delete WHERE pk IS NULL.
1507
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "delete()");
1508
+ if (!quiet) await fireHooks(this.#entityClass, "beforeDelete", entity);
852
1509
  const pk = entity[this.#primaryKey];
853
1510
  if (this.#softDeletes) {
854
- const now = new Date().toISOString();
1511
+ const now = DateTime.now();
855
1512
  await this.#runUpdate(
856
- [["deleted_at", now]],
1513
+ [[this.#dbColumn("deletedAt"), now.toISO()]],
857
1514
  [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }],
858
1515
  );
1516
+ // In-memory value is a Chronos DateTime, matching how date columns hydrate.
859
1517
  entity.setProp("deletedAt", now);
860
1518
  } else {
861
1519
  await this.#runDelete([
862
1520
  { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
863
1521
  ]);
864
1522
  }
865
- await fireHooks(this.#entityClass, "afterDelete", entity);
1523
+ entity.markAsDeleted();
1524
+ if (!quiet) await fireHooks(this.#entityClass, "afterDelete", entity);
1525
+ }
1526
+
1527
+ /** {@link delete} without firing lifecycle hooks (AdonisJS Lucid `deleteQuietly`). */
1528
+ deleteQuietly(entity: T): Promise<void> {
1529
+ return this.delete(entity, true);
866
1530
  }
867
1531
 
868
1532
  /** Permanently delete (bypasses soft delete). Fires `beforeDelete` / `afterDelete` hooks. */
869
1533
  async forceDelete(entity: T): Promise<void> {
1534
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "forceDelete()");
870
1535
  await fireHooks(this.#entityClass, "beforeDelete", entity);
871
1536
  await this.#runDelete([
872
1537
  {
@@ -876,13 +1541,15 @@ export class BaseRepository<T extends BaseEntity> {
876
1541
  type: "and",
877
1542
  },
878
1543
  ]);
1544
+ entity.markAsDeleted();
879
1545
  await fireHooks(this.#entityClass, "afterDelete", entity);
880
1546
  }
881
1547
 
882
1548
  async restore(entity: T): Promise<void> {
883
1549
  if (!this.#softDeletes) return;
1550
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "restore()");
884
1551
  await this.#runUpdate(
885
- [["deleted_at", null]],
1552
+ [[this.#dbColumn("deletedAt"), null]],
886
1553
  [
887
1554
  {
888
1555
  column: this.#primaryKey,
@@ -915,7 +1582,14 @@ export class BaseRepository<T extends BaseEntity> {
915
1582
  const whereCol = this.#resolveColumn(column);
916
1583
  const set = this.#buildSetPairs(data);
917
1584
  await this.#runUpdate(set, [
918
- { column: whereCol, operator: "=", value: columnValue, type: "and" },
1585
+ {
1586
+ column: whereCol,
1587
+ operator: "=",
1588
+ // Prepare the filter value like the query()/where() path (DateTime→ISO,
1589
+ // @Column adapters) so updateWhere matches query().where().update().
1590
+ value: this.#applyPrepare(column, columnValue),
1591
+ type: "and",
1592
+ },
919
1593
  ]);
920
1594
  }
921
1595
 
@@ -973,6 +1647,21 @@ export class BaseRepository<T extends BaseEntity> {
973
1647
  // ─── Raw ──────────────────────────────────────────────────
974
1648
 
975
1649
  async raw(sql: string, ...params: unknown[]): Promise<T[]> {
1650
+ // Strict mode hardens the repository's raw surfaces (parity with
1651
+ // whereRaw/joinRaw/havingRaw): `raw()` splices a whole hand-written SQL
1652
+ // statement into the typed repo and hydrates it, so it's the widest raw
1653
+ // entry point of all. Block it and point at the connection-level break-glass
1654
+ // (`db.query()`/`db.execute()`, explicitly parameterised) — that stays the
1655
+ // sanctioned, greppable escape hatch, never a silent bypass of strict mode.
1656
+ if (isAtlasStrictMode()) {
1657
+ throw new AtlasError(
1658
+ "E_STRICT_MODE",
1659
+ `raw() is disabled in Atlas strict mode on ${this.#entityClass.name}.`,
1660
+ {
1661
+ 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().",
1662
+ },
1663
+ );
1664
+ }
976
1665
  const rows = await this.#db.query<Row>(sql, params);
977
1666
  return rows.map((r) => this.#hydrate(r));
978
1667
  }
@@ -988,6 +1677,46 @@ export class BaseRepository<T extends BaseEntity> {
988
1677
 
989
1678
  // ─── Private helpers ──────────────────────────────────────
990
1679
 
1680
+ /**
1681
+ * Guard for an op PREMISED on an existing DB row (refresh/fresh/delete/
1682
+ * forceDelete/restore/load*). These require a genuine database row, so the
1683
+ * entity must be `$isPersisted` — a locally-built instance with a manual PK is
1684
+ * NOT a row: deleting/refreshing off it would silently hit an unrelated row (or
1685
+ * none) and fire hooks against a hollow object. Mirrors Lucid, whose `refresh()`
1686
+ * rejects a non-persisted instance and whose destructive ops always run on a
1687
+ * loaded model; the extra strictness on delete/restore is a named safety
1688
+ * deviation. A persisted-but-keyless entity (aggregate/alias projection) is also
1689
+ * rejected, with the projection diagnostic.
1690
+ */
1691
+ #assertPersistedRow(
1692
+ entity: BaseEntity,
1693
+ key: unknown,
1694
+ op: string,
1695
+ keyName: string = this.#primaryKey,
1696
+ ): void {
1697
+ if (!entity.$isPersisted) {
1698
+ throw new AtlasError(
1699
+ "E_MODEL_NOT_PERSISTED",
1700
+ `Cannot ${op} a ${this.#entityClass.name} that is not persisted.`,
1701
+ {
1702
+ hint: "Load it from the database (find/query) first — a locally-built instance with a manual primary key is not a database row.",
1703
+ },
1704
+ );
1705
+ }
1706
+ if (!isProvidedPk(key)) {
1707
+ // Name the ACTUAL missing key — a relation with a custom `localKey` isn't
1708
+ // missing its primary key, it's missing that local key ('code', …).
1709
+ const isPk = keyName === this.#primaryKey;
1710
+ throw new AtlasError(
1711
+ "E_MISSING_PRIMARY_KEY",
1712
+ `Cannot ${op} a ${this.#entityClass.name} loaded without its ${isPk ? "primary key" : "key"} ('${keyName}').`,
1713
+ {
1714
+ hint: "This entity came from an aggregate/alias projection. Select the key or use query().pojo() for projections.",
1715
+ },
1716
+ );
1717
+ }
1718
+ }
1719
+
991
1720
  async #runDelete(wheres: Array<Record<string, unknown>>): Promise<void> {
992
1721
  const compiled = compileStatementNative(
993
1722
  { kind: "delete", table: this.#tableName, wheres },
@@ -1038,8 +1767,8 @@ export class BaseRepository<T extends BaseEntity> {
1038
1767
  values,
1039
1768
  casts: this.#castTypes,
1040
1769
  returning: [
1041
- camelToSnake(this.#primaryKey),
1042
- ...this.#columns.map((c) => camelToSnake(c)),
1770
+ this.#dbColumn(this.#primaryKey),
1771
+ ...this.#columns.map((c) => this.#dbColumn(c)),
1043
1772
  ],
1044
1773
  }
1045
1774
  : {
@@ -1077,8 +1806,11 @@ export class BaseRepository<T extends BaseEntity> {
1077
1806
  // callers see them on the entity without an extra `find()`. Mirrors
1078
1807
  // `createMany`, where the multi-row path already does this.
1079
1808
  if (result.row) {
1080
- for (const [k, v] of Object.entries(result.row))
1081
- entity.setProp(snakeToCamel(k), v);
1809
+ for (const [k, v] of Object.entries(result.row)) {
1810
+ const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
1811
+ // Consume so date columns hydrate to Chronos DateTime, not raw ISO.
1812
+ entity.setProp(prop, this.#applyConsume(prop, v, entity));
1813
+ }
1082
1814
  } else if (
1083
1815
  result.lastInsertRowid !== undefined &&
1084
1816
  !isProvidedPk(entity[this.#primaryKey])
@@ -1088,6 +1820,7 @@ export class BaseRepository<T extends BaseEntity> {
1088
1820
  // After a successful INSERT, the entity is now persisted — snapshot
1089
1821
  // its columns so subsequent dirty checks compare against the DB state.
1090
1822
  entity.markAsPersisted();
1823
+ this.#trackInsert(entity);
1091
1824
  }
1092
1825
 
1093
1826
  /**
@@ -1097,17 +1830,23 @@ export class BaseRepository<T extends BaseEntity> {
1097
1830
  * `save()` is called defensively without any real mutation).
1098
1831
  */
1099
1832
  async #update(entity: T): Promise<void> {
1100
- // Auto-bump @column.dateTime({ autoUpdate: true }) BEFORE computing $dirty
1101
- // so the bumped column lands in the SET if anything else is dirty.
1102
- this.#applyAutoTimestamps(entity, "update");
1833
+ const forced = entity.$consumeForceUpdate();
1834
+ const pk = entity[this.#primaryKey];
1835
+
1836
+ // Compute the REAL dirt BEFORE stamping autoUpdate, so a genuinely-clean
1837
+ // save() is a no-op — no `updated_at` bump, no query (AdonisJS Lucid parity;
1838
+ // stamping first would make every save() on an autoUpdate model dirty).
1839
+ const preDirty = entity.$dirty;
1840
+ delete preDirty[this.#primaryKey];
1841
+ if (Object.keys(preDirty).length === 0 && !forced) return; // nothing changed
1103
1842
 
1843
+ // A real change (or a forced update) is happening — now stamp
1844
+ // @column.dateTime({ autoUpdate: true }) so it lands in the SET.
1845
+ this.#applyAutoTimestamps(entity, "update");
1104
1846
  const dirty = entity.$dirty;
1105
- const pk = entity[this.#primaryKey];
1106
1847
  // Primary key is never part of the SET — it's the WHERE.
1107
1848
  delete dirty[this.#primaryKey];
1108
1849
 
1109
- if (Object.keys(dirty).length === 0) return; // nothing changed
1110
-
1111
1850
  // Map dirty camelCase keys to snake_case DB columns. `$dirty` keys are
1112
1851
  // already camelCase (they come from `entity.setProp` / direct assignment),
1113
1852
  // so the prepare lookup uses `k` as-is. Skip explicit `undefined`
@@ -1116,7 +1855,17 @@ export class BaseRepository<T extends BaseEntity> {
1116
1855
  const setPairs: Array<[string, unknown]> = [];
1117
1856
  for (const [k, v] of Object.entries(dirty)) {
1118
1857
  if (v === undefined) continue;
1119
- setPairs.push([camelToSnake(k), this.#applyPrepare(k, v)]);
1858
+ setPairs.push([this.#dbColumn(k), this.#applyPrepare(k, v)]);
1859
+ }
1860
+ // enableForceUpdate() with nothing dirty: re-persist the current non-PK
1861
+ // column values so an UPDATE still runs (fires triggers / bumps autoUpdate).
1862
+ if (setPairs.length === 0 && forced) {
1863
+ for (const col of this.#columns) {
1864
+ if (col === this.#primaryKey) continue;
1865
+ const v = entity[col];
1866
+ if (v !== undefined)
1867
+ setPairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
1868
+ }
1120
1869
  }
1121
1870
  if (setPairs.length === 0) {
1122
1871
  // All dirty entries were `undefined` (skipped above). Re-snapshot
@@ -1155,7 +1904,9 @@ export class BaseRepository<T extends BaseEntity> {
1155
1904
  * on the entity before persistence. Called from `#insert` and `#update`.
1156
1905
  */
1157
1906
  #applyAutoTimestamps(entity: T, phase: "insert" | "update"): void {
1158
- const now = new Date();
1907
+ // A Chronos `DateTime` (not a JS `Date`) so `autoCreate`/`autoUpdate` values
1908
+ // match the type `@column.dateTime` columns hydrate to — Adonis Lucid parity.
1909
+ const now = DateTime.now();
1159
1910
  for (const [prop, cfg] of Object.entries(this.#dateColumns)) {
1160
1911
  if (phase === "insert") {
1161
1912
  if (cfg.autoCreate && entity[prop] === undefined) {
@@ -1173,32 +1924,58 @@ export class BaseRepository<T extends BaseEntity> {
1173
1924
  #hydrate(row: Record<string, unknown>): T {
1174
1925
  const entity = new this.#entityClass();
1175
1926
  for (const [key, value] of Object.entries(row)) {
1176
- const camelKey = snakeToCamel(key);
1177
1927
  // Resolve against declared column metadata, not `in entity` — fields
1178
1928
  // using Adonis' `declare field: T` pattern are not own-properties of
1179
- // a freshly constructed instance.
1180
- const targetKey = this.#validColumns.has(camelKey)
1181
- ? camelKey
1182
- : this.#validColumns.has(key)
1183
- ? key
1184
- : null;
1929
+ // a freshly constructed instance. The reverse db→property map is
1930
+ // consulted first so an explicit `columnName` override resolves to the
1931
+ // right property (where `snakeToCamel` alone would not).
1932
+ const camelKey = snakeToCamel(key);
1933
+ const targetKey =
1934
+ this.#columnByDbName.get(key) ??
1935
+ (this.#validColumns.has(camelKey)
1936
+ ? camelKey
1937
+ : this.#validColumns.has(key)
1938
+ ? key
1939
+ : null);
1185
1940
  if (!targetKey) continue;
1186
1941
  // Apply `@Column({ consume })` if declared on this property. Unlike the
1187
1942
  // previous registry-based design, the callback receives every value
1188
1943
  // including `null` / `undefined` — the user's `consume` is responsible
1189
1944
  // for its own null-handling, matching Adonis Lucid's contract.
1190
- entity.setProp(targetKey, this.#applyConsume(targetKey, value));
1945
+ entity.setProp(targetKey, this.#applyConsume(targetKey, value, entity));
1191
1946
  }
1192
1947
  // Freeze the original snapshot — from now on, only columns changed AFTER
1193
1948
  // hydration are considered dirty by `entity.$dirty`.
1194
1949
  entity.markAsPersisted();
1195
- // Back-pointer so `entity.refresh()` / `entity.fresh()` can re-query.
1950
+ entity.markAsFromDatabase();
1951
+ this.#attachRepoRef(entity);
1952
+ return entity;
1953
+ }
1954
+
1955
+ /**
1956
+ * Back-pointer so a persisted instance can `related()` / `refresh()` / `fresh()`
1957
+ * / `load*()` without being re-fetched — AdonisJS Lucid parity: a model returned
1958
+ * by find/query AND by create/save/createMany/saveMany carries its query client.
1959
+ * Non-enumerable so it never serializes; `configurable` so re-persisting the same
1960
+ * instance is idempotent.
1961
+ */
1962
+ #attachRepoRef(entity: BaseEntity): void {
1196
1963
  Object.defineProperty(entity, REPO_REF, {
1197
1964
  value: this,
1198
1965
  enumerable: false,
1199
1966
  configurable: true,
1200
1967
  });
1201
- return entity;
1968
+ // Adonis Lucid `$trx` propagation: an instance loaded or created THROUGH a
1969
+ // transaction-bound repo binds to that transaction, so its own
1970
+ // `save()`/`delete()` stay inside it (and reset when the trx settles, via
1971
+ // the release hook `useTransaction` registers). Previously only an explicit
1972
+ // `model.useTransaction(trx)` did this, so a row read with
1973
+ // `Model.query({ client: trx })` could later save OUTSIDE the transaction.
1974
+ // Only BaseModel exposes `useTransaction`; a plain BaseEntity persists via
1975
+ // the repo directly, so it needs no instance binding.
1976
+ if (this.#durableParent !== undefined && isTransactionBindable(entity)) {
1977
+ entity.useTransaction(this.#db);
1978
+ }
1202
1979
  }
1203
1980
 
1204
1981
  /**
@@ -1209,11 +1986,7 @@ export class BaseRepository<T extends BaseEntity> {
1209
1986
  */
1210
1987
  async refresh(entity: BaseEntity): Promise<void> {
1211
1988
  const pk = entity[this.#primaryKey];
1212
- if (pk === undefined || pk === null) {
1213
- throw new EntityNotFoundError(this.#entityClass.name, {
1214
- [this.#primaryKey]: pk,
1215
- });
1216
- }
1989
+ this.#assertPersistedRow(entity, pk, "refresh()");
1217
1990
  const fresh = await this.find(pk as string | number);
1218
1991
  if (!fresh) {
1219
1992
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -1246,11 +2019,7 @@ export class BaseRepository<T extends BaseEntity> {
1246
2019
  alias?: string,
1247
2020
  ): Promise<void> {
1248
2021
  const pk = entity[this.#primaryKey];
1249
- if (pk === undefined || pk === null) {
1250
- throw new EntityNotFoundError(this.#entityClass.name, {
1251
- [this.#primaryKey]: pk,
1252
- });
1253
- }
2022
+ this.#assertPersistedRow(entity, pk, "loadCount()");
1254
2023
  const finalAlias = alias ?? `${relationName}_count`;
1255
2024
  const q = this.query()
1256
2025
  .where(this.#primaryKey, pk)
@@ -1273,11 +2042,7 @@ export class BaseRepository<T extends BaseEntity> {
1273
2042
  build: (q: unknown) => void,
1274
2043
  ): Promise<void> {
1275
2044
  const pk = entity[this.#primaryKey];
1276
- if (pk === undefined || pk === null) {
1277
- throw new EntityNotFoundError(this.#entityClass.name, {
1278
- [this.#primaryKey]: pk,
1279
- });
1280
- }
2045
+ this.#assertPersistedRow(entity, pk, "loadAggregate()");
1281
2046
  let capturedAlias: string | undefined;
1282
2047
  const q = this.query()
1283
2048
  .where(this.#primaryKey, pk)
@@ -1302,11 +2067,7 @@ export class BaseRepository<T extends BaseEntity> {
1302
2067
  callback?: (q: unknown) => void,
1303
2068
  ): Promise<void> {
1304
2069
  const pk = entity[this.#primaryKey];
1305
- if (pk === undefined || pk === null) {
1306
- throw new EntityNotFoundError(this.#entityClass.name, {
1307
- [this.#primaryKey]: pk,
1308
- });
1309
- }
2070
+ this.#assertPersistedRow(entity, pk, "loadRelation()");
1310
2071
  const q = this.query().where(this.#primaryKey, pk);
1311
2072
  if (callback)
1312
2073
  q.preload(relationName, callback as (q: ModelQuery<BaseEntity>) => void);
@@ -1334,15 +2095,21 @@ export class BaseRepository<T extends BaseEntity> {
1334
2095
  `Relation '${relationName}' not found on ${this.#entityClass.name}`,
1335
2096
  );
1336
2097
  const relatedClass = relation.target() as new () => BaseEntity;
1337
- const relatedMeta = getEntityMetadata(relatedClass);
1338
- if (!relatedMeta)
1339
- throw new Error(
1340
- `Entity metadata missing on related class ${relatedClass.name}`,
1341
- );
1342
- const relatedTable = relatedMeta.tableName;
2098
+ // Synthesize the related model's @Entity metadata on demand (static `table`
2099
+ // / naming strategy) — a related model referenced ONLY through this relation
2100
+ // may never have been instantiated, so `getEntityMetadata` alone would be
2101
+ // empty and related()/create-through would wrongly fail. Mirrors how the repo
2102
+ // constructor boots its own class (AdonisJS Lucid lazy-boots models).
2103
+ const relatedTable = ensureEntityMetadata(relatedClass).tableName;
1343
2104
  const parentPk =
1344
2105
  relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1345
- const parentIdValue = entity[parentPk];
2106
+ // Read the parent's key LAZILY, at operation time — not once at proxy
2107
+ // creation. Lucid resolves the pivot value when the query runs, so mutating
2108
+ // the parent's (custom local) key between `user.related('roles')` and a later
2109
+ // `.attach()` must target the CURRENT key, never a captured stale one.
2110
+ const readParentId = (): unknown => entity[parentPk];
2111
+ const keyLabel =
2112
+ parentPk === this.#primaryKey ? "primary key" : `key '${parentPk}'`;
1346
2113
  const relatedRepo = new BaseRepository<BaseEntity>(relatedClass, this.#db, {
1347
2114
  dialect: this.#dialect,
1348
2115
  });
@@ -1362,54 +2129,188 @@ export class BaseRepository<T extends BaseEntity> {
1362
2129
 
1363
2130
  const injectFk = (
1364
2131
  data: Record<string, unknown>,
2132
+ fkValue: unknown,
1365
2133
  ): Record<string, unknown> => ({
1366
2134
  ...data,
1367
- [fkCol]: parentIdValue,
1368
- [fkProp]: parentIdValue,
2135
+ [fkCol]: fkValue,
2136
+ [fkProp]: fkValue,
1369
2137
  });
1370
2138
 
2139
+ /**
2140
+ * Lucid persists the parent FIRST (inside a managed transaction) so its key
2141
+ * is available, then sets the child FK and writes the child — atomic, rolled
2142
+ * back on any failure. An already-persisted parent skips the save; a
2143
+ * persisted-but-keyless projection is rejected loud. Runs `body` with the
2144
+ * parent's now-guaranteed key and a trx-bound related repo.
2145
+ */
2146
+ const flushEvents = async (entities: BaseEntity[]): Promise<void> => {
2147
+ for (const e of entities) await this.#dispatchDomainEvents(e);
2148
+ };
2149
+ const withParentSaved = <R>(
2150
+ body: (
2151
+ fkValue: unknown,
2152
+ relRepoTx: BaseRepository<BaseEntity>,
2153
+ trx: TransactionClient,
2154
+ relatedFloors: Map<BaseEntity, number>,
2155
+ ) => Promise<R>,
2156
+ ): Promise<R> =>
2157
+ transaction(this.#db, async (trx) => {
2158
+ // Snapshot BEFORE the save flips the flag — the parent's events flush
2159
+ // ONLY if WE persisted it here. An already-persisted parent may carry
2160
+ // unrelated in-memory events that belong to whoever saves it; a child
2161
+ // mutation must not emit them as a side effect.
2162
+ const savedParentHere = !entity.$isPersisted;
2163
+ const parentDurable = this.#durableParent ?? this;
2164
+ if (savedParentHere) {
2165
+ // Floor the parent's event queue BEFORE we persist it, so rollback drops
2166
+ // only the events this write queues, keeping any the caller queued
2167
+ // earlier (#8).
2168
+ const parentEventFloor = entity.domainEventCount();
2169
+ // Persist the parent on the SAME trx. Build a BaseEntity-typed repo
2170
+ // for the parent class (mirrors `relatedRepo`) so `save(entity)`
2171
+ // accepts the generic `BaseEntity` without widening `this`.
2172
+ const parentRepoTx = new BaseRepository<BaseEntity>(
2173
+ this.#entityClass,
2174
+ trx,
2175
+ { dialect: this.#dialect },
2176
+ );
2177
+ parentRepoTx.onDomainEvents = this.onDomainEvents;
2178
+ parentRepoTx.#deferDomainEvents = true;
2179
+ await parentRepoTx.save(entity);
2180
+ // Register the parent's rollback restore IMMEDIATELY after its insert —
2181
+ // the parentPk check just below can throw (a custom `localKey` left unset
2182
+ // after the save), and that throw must still revert the freshly-inserted
2183
+ // parent instead of leaving it lying $isPersisted (same gotcha as
2184
+ // associate(): register the restore before ANY later throwable line).
2185
+ trx.after("rollback", () => {
2186
+ parentDurable.#attachRepoRef(entity);
2187
+ entity.markAsNotPersisted();
2188
+ entity.restoreDomainEventsTo(parentEventFloor);
2189
+ });
2190
+ }
2191
+ const fkValue = entity[parentPk];
2192
+ if (!isProvidedPk(fkValue)) {
2193
+ throw new AtlasError(
2194
+ "E_MISSING_PRIMARY_KEY",
2195
+ `Cannot use related('${relationName}') on a ${this.#entityClass.name} with no ${keyLabel}.`,
2196
+ {
2197
+ hint: "The parent is an aggregate/alias projection with no key. Select the key or use query().pojo().",
2198
+ },
2199
+ );
2200
+ }
2201
+ const relTx = relatedRepo.useTransaction(trx);
2202
+ relTx.#deferDomainEvents = true;
2203
+ // Track related rows inserted DIRECTLY through relTx (single create/save;
2204
+ // the batch helpers route through #inManagedTx, which tracks + reverts them
2205
+ // itself on this same trx). A caller-passed related instance we insert here
2206
+ // must, on rollback, revert to $isNew — its row is gone, and keeping
2207
+ // $isPersisted would orphan a later relation write (a M2M pivot-insert
2208
+ // failure AFTER `rel.save(related)` is the canonical trigger) — and drop its
2209
+ // queued events. On commit, re-point its REPO_REF at the durable related repo
2210
+ // (it was bound to the now-finished trx, so refresh()/related() would
2211
+ // otherwise throw "transaction already finished").
2212
+ const relInserts: BaseEntity[] = [];
2213
+ relTx.#insertTracker = relInserts;
2214
+ const relDurable = relatedRepo.#durableParent ?? relatedRepo;
2215
+ // Per-related event floor, populated by a caller-instance write (save):
2216
+ // a fresh child built by create() has floor 0 (clear), but a caller's own
2217
+ // instance passed to save() may carry events queued before the write (#8).
2218
+ const relatedFloors = new Map<BaseEntity, number>();
2219
+ trx.after("commit", () => {
2220
+ for (const r of relInserts) relDurable.#attachRepoRef(r);
2221
+ });
2222
+ trx.after("rollback", () => {
2223
+ for (const r of relInserts) {
2224
+ relDurable.#attachRepoRef(r);
2225
+ r.markAsNotPersisted();
2226
+ r.restoreDomainEventsTo(relatedFloors.get(r) ?? 0);
2227
+ }
2228
+ });
2229
+ // Parent COMMIT restore (its rollback restore is registered above, right
2230
+ // after the insert). ONLY if WE persisted it here (`parentRepoTx.save`
2231
+ // flipped it to $isPersisted with REPO_REF bound to the trx repo). Lucid
2232
+ // resets `$trx` on commit → re-point REPO_REF at the durable repo, then
2233
+ // flush the parent's events (a rollback thus publishes nothing). An
2234
+ // already-persisted parent is left untouched: its events belong to whoever
2235
+ // saves it, and its row already exists.
2236
+ if (savedParentHere) {
2237
+ trx.after("commit", () => {
2238
+ parentDurable.#attachRepoRef(entity);
2239
+ return this.#dispatchDomainEvents(entity);
2240
+ });
2241
+ }
2242
+ return body(fkValue, relTx, trx, relatedFloors);
2243
+ });
2244
+
1371
2245
  // Shared "has" proxy methods (create/createMany/save/saveMany +
1372
- // firstOrCreate/updateOrCreate scoped to this parent's FK).
2246
+ // firstOrCreate/updateOrCreate scoped to this parent's FK). Each persists the
2247
+ // parent first (Lucid parity) and writes the child with the FK set, atomically,
2248
+ // then flushes the child's domain events AFTER the transaction commits.
1373
2249
  const hasOps = {
1374
- async create(data: Record<string, unknown>) {
1375
- return relatedRepo.create(injectFk(data));
1376
- },
1377
- async createMany(rows: Array<Record<string, unknown>>) {
1378
- return relatedRepo.createMany(rows.map(injectFk));
1379
- },
1380
- // Scope the search to the parent's FK column so the lookup only sees
1381
- // this parent's rows; inject the FK into the created/updated row. The
1382
- // related repo's firstOrCreate/updateOrCreate are atomic (txn + lock).
1383
- async firstOrCreate(
2250
+ create: (data: Record<string, unknown>) =>
2251
+ withParentSaved(async (fk, rel, trx) => {
2252
+ const child = await rel.create(injectFk(data, fk));
2253
+ trx.after("commit", () => flushEvents([child]));
2254
+ return child;
2255
+ }),
2256
+ createMany: (rows: Array<Record<string, unknown>>) =>
2257
+ withParentSaved(async (fk, rel, _trx) => {
2258
+ // NO wrapper flush: since createMany now runs through #inManagedTx it
2259
+ // ALREADY dispatches the children's events post-commit (like
2260
+ // firstOrCreate/updateOrCreate/saveMany). A second flush would
2261
+ // re-dispatch events the first hook re-queued on a partial sink failure.
2262
+ return rel.createMany(rows.map((r) => injectFk(r, fk)));
2263
+ }),
2264
+ // Scope the search to the parent's FK column so the lookup only sees this
2265
+ // parent's rows; inject the FK into the created/updated row.
2266
+ firstOrCreate: (
1384
2267
  search: Record<string, unknown>,
1385
2268
  defaults: Record<string, unknown> = {},
1386
- ) {
1387
- return relatedRepo.firstOrCreate(
1388
- { ...search, [fkCol]: parentIdValue },
1389
- injectFk(defaults),
1390
- );
1391
- },
1392
- async updateOrCreate(
2269
+ ) =>
2270
+ withParentSaved(async (fk, rel, _trx) => {
2271
+ // NO wrapper flush here: unlike create/save, rel.firstOrCreate goes
2272
+ // through #inManagedTx, which ALREADY registers its own post-commit
2273
+ // dispatch for the child. A second flush would re-dispatch events the
2274
+ // first hook re-queued on a partial sink failure → bus duplication.
2275
+ return rel.firstOrCreate(
2276
+ { ...search, [fkCol]: fk },
2277
+ injectFk(defaults, fk),
2278
+ );
2279
+ }),
2280
+ updateOrCreate: (
1393
2281
  search: Record<string, unknown>,
1394
2282
  values: Record<string, unknown>,
1395
- ) {
1396
- return relatedRepo.updateOrCreate(
1397
- { ...search, [fkCol]: parentIdValue },
1398
- injectFk(values),
1399
- );
1400
- },
1401
- async save(related: BaseEntity) {
1402
- related.setProp(fkCol, parentIdValue);
1403
- related.setProp(fkProp, parentIdValue);
1404
- await relatedRepo.save(related);
1405
- },
1406
- async saveMany(related: BaseEntity[]) {
1407
- for (const r of related) {
1408
- r.setProp(fkCol, parentIdValue);
1409
- r.setProp(fkProp, parentIdValue);
1410
- }
1411
- return relatedRepo.saveMany(related);
1412
- },
2283
+ ) =>
2284
+ withParentSaved(async (fk, rel, _trx) => {
2285
+ // NO wrapper flush: rel.updateOrCreate goes through #inManagedTx which
2286
+ // already dispatches the child's events post-commit (see firstOrCreate).
2287
+ return rel.updateOrCreate(
2288
+ { ...search, [fkCol]: fk },
2289
+ injectFk(values, fk),
2290
+ );
2291
+ }),
2292
+ save: (related: BaseEntity) =>
2293
+ withParentSaved(async (fk, rel, trx, relatedFloors) => {
2294
+ related.setProp(fkCol, fk);
2295
+ related.setProp(fkProp, fk);
2296
+ // Floor BEFORE the write so a rollback keeps events the caller queued on
2297
+ // this instance earlier, dropping only what this save adds (#8).
2298
+ relatedFloors.set(related, related.domainEventCount());
2299
+ await rel.save(related);
2300
+ trx.after("commit", () => flushEvents([related]));
2301
+ }),
2302
+ saveMany: (related: BaseEntity[]) =>
2303
+ withParentSaved(async (fk, rel, _trx) => {
2304
+ for (const r of related) {
2305
+ r.setProp(fkCol, fk);
2306
+ r.setProp(fkProp, fk);
2307
+ }
2308
+ // NO wrapper flush: rel.saveMany now runs through #inManagedTx (it's
2309
+ // all-or-nothing), which ALREADY dispatches these instances' events
2310
+ // post-commit. A second flush would re-dispatch on a partial sink
2311
+ // failure (round-13 double-flush class).
2312
+ return rel.saveMany(related);
2313
+ }),
1413
2314
  };
1414
2315
 
1415
2316
  // Scoped query builder (Story 31.9) — pre-applies the FK predicate
@@ -1425,7 +2326,17 @@ export class BaseRepository<T extends BaseEntity> {
1425
2326
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1426
2327
  const pivotOther =
1427
2328
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1428
- const relatedPk = getPrimaryKey(relatedClass) ?? "id";
2329
+ // Resolve the related PK to its DB column (multi-word / columnName),
2330
+ // mirroring the eager-preload fix — a raw property name here targets
2331
+ // the wrong column in the correlated EXISTS.
2332
+ // The related-side key the pivot's otherKey references — the related
2333
+ // PK unless `relatedKey` overrides it (Adonis Lucid `relatedKey`).
2334
+ const relatedPkProp =
2335
+ pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
2336
+ const relatedPk =
2337
+ getColumnMetadata(relatedClass).find(
2338
+ (c) => c.propertyKey === relatedPkProp,
2339
+ )?.columnName ?? camelToSnake(relatedPkProp);
1429
2340
  // Inline validated quote (same policy as the m2m branch below).
1430
2341
  const dialect = this.#dialect;
1431
2342
  const quote = (name: string): string => {
@@ -1434,33 +2345,98 @@ export class BaseRepository<T extends BaseEntity> {
1434
2345
  }
1435
2346
  return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1436
2347
  };
2348
+ // Table identifiers may be schema-qualified (`schema.table`, e.g. a
2349
+ // Postgres `public.users_roles`) — quote each dotted segment on its own
2350
+ // so it becomes `"schema"."table"`, while EVERY segment still passes the
2351
+ // strict single-identifier guard above (no injection surface). Columns
2352
+ // stay single-segment via `quote`.
2353
+ const quoteTable = (name: string): string =>
2354
+ name.split(".").map(quote).join(".");
1437
2355
  // The bound `?` carries the parent PK type (often uuid). A raw `?`
1438
2356
  // can't be cast by the structured `casts` mechanism, so emit the
1439
2357
  // `::uuid` inline — `whereRaw` rewrites `?`→`$N`, yielding `$N::uuid`.
1440
2358
  // Postgres-only; sqlite/mysql coerce. Without it: `pivotFk = $N` is
1441
2359
  // `uuid = text`.
1442
- const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
2360
+ // Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
2361
+ // PK — an m2m with a custom localKey binds `entity[localKey]` into the
2362
+ // pivot FK, so the `::cast` must match that column's type.
2363
+ const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
1443
2364
  const ph =
1444
2365
  dialect === "postgres" && parentPkCast ? `?::${parentPkCast}` : "?";
1445
- // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk)
1446
- // Framework-internal raw fragment (identifiers already validated by
1447
- // the `quote` helper above) bypass strict mode so this path still
1448
- // works when the user enables `setAtlasStrictMode(true)` on their app.
1449
- runWithAtlasInternalBypass(() => {
1450
- q.whereRaw(
1451
- `EXISTS (SELECT 1 FROM ${quote(pivot.pivotTable)} ` +
1452
- `WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} = ${ph} ` +
1453
- `AND ${quote(pivot.pivotTable)}.${quote(pivotOther)} = ${quote(relatedTable)}.${quote(relatedPk)})`,
1454
- [parentIdValue],
1455
- );
2366
+ // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk
2367
+ // [AND pivot.col <op> ?]…)
2368
+ // Deferred (not an eager whereRaw): a `.wherePivot()` chained on the
2369
+ // query the proxy hands back must fold into THIS subquery, so we build it
2370
+ // at #buildSpec time with the pivot constraints known then. Identifiers
2371
+ // are validated by `quote`; values bind as params (no injection surface),
2372
+ // so this internal fragment needs no strict-mode bypass.
2373
+ const pivotTable = pivot.pivotTable;
2374
+ q.setPivotExistsBuilder((pivotWheres) => {
2375
+ const base =
2376
+ `EXISTS (SELECT 1 FROM ${quoteTable(pivotTable)} ` +
2377
+ `WHERE ${quoteTable(pivotTable)}.${quote(pivotFk)} = ${ph} ` +
2378
+ `AND ${quoteTable(pivotTable)}.${quote(pivotOther)} = ${quoteTable(relatedTable)}.${quote(relatedPk)}`;
2379
+ const bindings: unknown[] = [readParentId()];
2380
+ let extra = "";
2381
+ for (const w of pivotWheres) {
2382
+ const col = `${quoteTable(pivotTable)}.${quote(w.column)}`;
2383
+ if (w.operator === "IN" || w.operator === "NOT IN") {
2384
+ const vals = Array.isArray(w.value) ? w.value : [w.value];
2385
+ if (vals.length === 0) {
2386
+ // IN () matches nothing; NOT IN () matches everything.
2387
+ if (w.operator === "IN") extra += " AND 1 = 0";
2388
+ continue;
2389
+ }
2390
+ extra += ` AND ${col} ${w.operator} (${vals.map(() => "?").join(", ")})`;
2391
+ bindings.push(...vals);
2392
+ } else {
2393
+ extra += ` AND ${col} ${w.operator} ?`;
2394
+ bindings.push(w.value);
2395
+ }
2396
+ }
2397
+ return { sql: `${base}${extra})`, bindings };
1456
2398
  });
1457
2399
  } else if (relation.type === "belongsTo") {
1458
2400
  const ownerKey =
1459
2401
  relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1460
2402
  q.where(ownerKey, entity[fkProp] ?? entity[fkCol]);
2403
+ } else if (
2404
+ relation.type === "hasOneThrough" ||
2405
+ relation.type === "hasManyThrough"
2406
+ ) {
2407
+ // Lucid's read-only two-hop traversal (verified): the related rows are
2408
+ // reached VIA the intermediate ("through") table, never a direct FK.
2409
+ // related WHERE secondKey IN
2410
+ // (SELECT secondLocal FROM through WHERE firstKey = parent[localKey])
2411
+ // Same key resolution as the eager `#resolveThrough` loader so lazy and
2412
+ // eager agree. Returns a chainable ModelQuery (`.orderBy().limit()` …).
2413
+ if (!relation.through) {
2414
+ throw new Error(
2415
+ `@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
2416
+ );
2417
+ }
2418
+ const throughClass = relation.through() as new () => BaseEntity;
2419
+ const throughRepo = new BaseRepository<BaseEntity>(throughClass, db, {
2420
+ dialect: this.#dialect,
2421
+ });
2422
+ const throughPk = getPrimaryKey(throughClass) ?? "id";
2423
+ const parentLocal =
2424
+ relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
2425
+ const firstKey =
2426
+ relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
2427
+ const secondKey =
2428
+ relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
2429
+ const secondLocal = relation.secondLocalKey ?? throughPk;
2430
+ q.whereIn(
2431
+ secondKey,
2432
+ throughRepo
2433
+ .query()
2434
+ .select(secondLocal)
2435
+ .where(firstKey, entity[parentLocal]),
2436
+ );
1461
2437
  } else {
1462
2438
  // hasOne / hasMany
1463
- q.where(fkCol, parentIdValue);
2439
+ q.where(fkCol, readParentId());
1464
2440
  }
1465
2441
  return q;
1466
2442
  };
@@ -1472,10 +2448,24 @@ export class BaseRepository<T extends BaseEntity> {
1472
2448
  // the standard TS idiom for widening a generic `this` — safe because
1473
2449
  // `T extends BaseEntity`.
1474
2450
  const parentRepo = this as BaseRepository<BaseEntity>;
2451
+ // create/save/createMany/saveMany are INVALID for a belongsTo: the FK is on
2452
+ // THIS model, so `...hasOps` would inject the FK into the owner table and
2453
+ // save the current model before it has an owner. Reject them (same throwing
2454
+ // pattern as @HasOne's bulk methods); the only writes are associate /
2455
+ // dissociate.
2456
+ const rejectWrite = async (op: string): Promise<never> => {
2457
+ throw new Error(
2458
+ `related('${relationName}').${op}() is not supported on @BelongsTo — ` +
2459
+ `the foreign key is on this model; use associate() / dissociate().`,
2460
+ );
2461
+ };
1475
2462
  const proxy: BelongsToRelationProxy = {
1476
2463
  type: "belongsTo",
1477
- ...hasOps,
1478
2464
  query: scopedQuery,
2465
+ create: () => rejectWrite("create"),
2466
+ save: () => rejectWrite("save"),
2467
+ createMany: () => rejectWrite("createMany"),
2468
+ saveMany: () => rejectWrite("saveMany"),
1479
2469
  async associate(model: BaseEntity) {
1480
2470
  if (model === null || model === undefined) {
1481
2471
  throw new Error(
@@ -1484,10 +2474,83 @@ export class BaseRepository<T extends BaseEntity> {
1484
2474
  }
1485
2475
  const ownerKey =
1486
2476
  relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1487
- const fkValue = model[ownerKey];
1488
- entity.setProp(fkCol, fkValue);
1489
- entity.setProp(fkProp, fkValue);
1490
- await parentRepo.save(entity);
2477
+ // Lucid: persist an unsaved owner FIRST (so a generated key exists),
2478
+ // set the parent FK to the owner's key, then save the parent — all in
2479
+ // ONE transaction (atomic, rolled back on failure). Reject a keyless
2480
+ // owner instead of silently setting the FK to `undefined` (which the
2481
+ // UPDATE would skip → stale/absent association). Events flush post-commit.
2482
+ await transaction(db, async (trx) => {
2483
+ const ownerTx = relatedRepo.useTransaction(trx);
2484
+ ownerTx.#deferDomainEvents = true;
2485
+ // Snapshot BEFORE the save: associate() only persists an unsaved
2486
+ // owner, so an already-persisted owner's pending domain events are
2487
+ // NOT ours to flush (same side-effect fix as withParentSaved).
2488
+ const savedOwnerHere = !model.$isPersisted;
2489
+ const ownerDurable = relatedRepo.#durableParent ?? relatedRepo;
2490
+ // Floor the owner's events before we persist it (#8).
2491
+ const ownerEventFloor = model.domainEventCount();
2492
+ if (savedOwnerHere) {
2493
+ await ownerTx.save(model);
2494
+ // Register the owner's rollback restore IMMEDIATELY after its
2495
+ // insert. The ownerKey check just below AND the parent save later
2496
+ // can BOTH throw after this point, and either must still revert the
2497
+ // freshly-inserted owner (Lucid resets `$trx` on rollback): re-point
2498
+ // its REPO_REF at the durable repo, revert it to $isNew (its row
2499
+ // vanished), and drop its queued events. Registering it here (not
2500
+ // after the checks) is the fix — a throw between the save and a
2501
+ // later registration would leave the owner lying $isPersisted.
2502
+ trx.after("rollback", () => {
2503
+ ownerDurable.#attachRepoRef(model);
2504
+ model.markAsNotPersisted();
2505
+ model.restoreDomainEventsTo(ownerEventFloor);
2506
+ });
2507
+ }
2508
+ const fkValue = model[ownerKey];
2509
+ if (!isProvidedPk(fkValue)) {
2510
+ throw new AtlasError(
2511
+ "E_MISSING_OWNER_KEY",
2512
+ `Cannot associate('${relationName}'): the owner ${relatedClass.name} has no ${ownerKey} to reference.`,
2513
+ {
2514
+ hint: "Pass an owner whose key is set — a keyless aggregate/alias projection can't be a foreign-key target.",
2515
+ },
2516
+ );
2517
+ }
2518
+ entity.setProp(fkCol, fkValue);
2519
+ entity.setProp(fkProp, fkValue);
2520
+ const parentTx = parentRepo.useTransaction(trx);
2521
+ parentTx.#deferDomainEvents = true;
2522
+ // Snapshot BEFORE the save flips the flag: the parent reverts on
2523
+ // rollback ONLY if WE inserted it here (a fresh parent). An
2524
+ // already-persisted parent's row survives the rollback.
2525
+ const savedParentHere = !entity.$isPersisted;
2526
+ const parentDurable = parentRepo.#durableParent ?? parentRepo;
2527
+ // Floor the parent's events before its save (#8).
2528
+ const parentEventFloor = entity.domainEventCount();
2529
+ // Register the parent resolution hooks BEFORE the risky parent save —
2530
+ // that save can throw (a beforeUpdate hook, a constraint) AFTER the
2531
+ // owner was inserted, and a throw here must still restore state.
2532
+ trx.after("commit", async () => {
2533
+ // Lucid resets `$trx` on commit → re-point REPO_REF at the durable
2534
+ // repo (else a post-commit refresh() hits the finished trx), then
2535
+ // flush events. The parent is always saved here → always flush its
2536
+ // events. The owner's events flush only if WE saved the owner.
2537
+ parentDurable.#attachRepoRef(entity);
2538
+ if (savedOwnerHere) {
2539
+ ownerDurable.#attachRepoRef(model);
2540
+ await parentRepo.#dispatchDomainEvents(model);
2541
+ }
2542
+ await parentRepo.#dispatchDomainEvents(entity);
2543
+ });
2544
+ trx.after("rollback", () => {
2545
+ // Restore the PARENT (the owner's restore is registered above, right
2546
+ // after its insert). We do NOT revert the parent's FK value — Lucid
2547
+ // never reverts attribute values on rollback.
2548
+ parentDurable.#attachRepoRef(entity);
2549
+ if (savedParentHere) entity.markAsNotPersisted();
2550
+ entity.restoreDomainEventsTo(parentEventFloor);
2551
+ });
2552
+ await parentTx.save(entity);
2553
+ });
1491
2554
  },
1492
2555
  async dissociate() {
1493
2556
  entity.setProp(fkCol, null);
@@ -1517,16 +2580,20 @@ export class BaseRepository<T extends BaseEntity> {
1517
2580
  // every pivot statement (sync's currentIds SELECT, detach DELETE, attach
1518
2581
  // INSERT) must carry these explicitly, else `pivotFk = $1` is `uuid = text`.
1519
2582
  const pivotKeyCasts: Record<string, string> = {};
1520
- const parentPkCast = this.#castTypes[camelToSnake(this.#primaryKey)];
2583
+ // Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
2584
+ // PK — an m2m with a custom localKey binds `entity[localKey]` into the
2585
+ // pivot FK, so the `::cast` must match that column's type.
2586
+ const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
1521
2587
  if (parentPkCast) pivotKeyCasts[pivotFk] = parentPkCast;
1522
- const relatedPkCast =
1523
- computeCastTypes(relatedClass)[
1524
- camelToSnake(getPrimaryKey(relatedClass) ?? "id")
1525
- ];
2588
+ const relatedPk = pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
2589
+ const relatedPkDb =
2590
+ getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPk)
2591
+ ?.columnName ?? camelToSnake(relatedPk);
2592
+ const relatedPkCast = computeCastTypes(relatedClass)[relatedPkDb];
1526
2593
  if (relatedPkCast) pivotKeyCasts[pivotOther] = relatedPkCast;
1527
2594
 
1528
2595
  /**
1529
- * Resolve pivot timestamp column names from the decorator config.
2596
+ * Pivot timestamp column names, resolved once from the decorator config.
1530
2597
  *
1531
2598
  * Three forms supported:
1532
2599
  * - `pivotTimestamps: true` → { created_at, updated_at } default names
@@ -1536,53 +2603,123 @@ export class BaseRepository<T extends BaseEntity> {
1536
2603
  * `false` opts a timestamp out; a string overrides the column name;
1537
2604
  * `undefined` falls back to the default name.
1538
2605
  */
1539
- const resolveTimestamps = (): Record<string, unknown> => {
2606
+ let createdCol: string | null = null;
2607
+ let updatedCol: string | null = null;
2608
+ if (tsConfig === true) {
2609
+ createdCol = "created_at";
2610
+ updatedCol = "updated_at";
2611
+ } else if (tsConfig) {
2612
+ createdCol =
2613
+ tsConfig.createdAt === false
2614
+ ? null
2615
+ : (tsConfig.createdAt ?? "created_at");
2616
+ updatedCol =
2617
+ tsConfig.updatedAt === false
2618
+ ? null
2619
+ : (tsConfig.updatedAt ?? "updated_at");
2620
+ }
2621
+ const tsColumnSet = new Set(
2622
+ [createdCol, updatedCol].filter((c): c is string => c !== null),
2623
+ );
2624
+ // INSERT (attach) stamps both created_at + updated_at; UPDATE (sync's
2625
+ // attribute refresh) bumps only updated_at — Adonis Lucid pivot semantics.
2626
+ const timestampValues = (
2627
+ mode: "insert" | "update",
2628
+ ): Record<string, unknown> => {
1540
2629
  if (!tsConfig) return {};
1541
2630
  const now = new Date().toISOString();
1542
- let createdCol: string | null;
1543
- let updatedCol: string | null;
1544
- if (tsConfig === true) {
1545
- createdCol = "created_at";
1546
- updatedCol = "updated_at";
1547
- } else {
1548
- createdCol =
1549
- tsConfig.createdAt === false
1550
- ? null
1551
- : (tsConfig.createdAt ?? "created_at");
1552
- updatedCol =
1553
- tsConfig.updatedAt === false
1554
- ? null
1555
- : (tsConfig.updatedAt ?? "updated_at");
1556
- }
1557
2631
  const out: Record<string, unknown> = {};
1558
- if (createdCol) out[createdCol] = now;
2632
+ if (mode === "insert" && createdCol) out[createdCol] = now;
1559
2633
  if (updatedCol) out[updatedCol] = now;
1560
2634
  return out;
1561
2635
  };
1562
2636
 
2637
+ // Object literal keys are ALWAYS strings, so `sync({ 1: {…} })` /
2638
+ // `attach({ 1: {…} })` arrive with id "1", not 1. Bound as text, a numeric
2639
+ // pivot FK column fails on Postgres (`text` ≠ `integer`, no implicit cast)
2640
+ // and the sync diff mis-compares "1" against the numeric id the DB returns.
2641
+ // Coerce a *canonical* integer back to a number; the round-trip guard
2642
+ // leaves uuid / zero-padded / oversized string keys (`"01234"`, `"abc"`)
2643
+ // untouched so they still bind as text.
2644
+ const canonicalizeId = (id: string | number): string | number => {
2645
+ if (typeof id === "number") return id;
2646
+ return /^-?\d+$/.test(id) &&
2647
+ Number.isSafeInteger(Number(id)) &&
2648
+ String(Number(id)) === id
2649
+ ? Number(id)
2650
+ : id;
2651
+ };
2652
+
1563
2653
  const normalizeAttach = (
1564
2654
  arg: Array<string | number> | Record<string, Record<string, unknown>>,
1565
2655
  ): Array<{ id: string | number; extras: Record<string, unknown> }> => {
1566
- if (Array.isArray(arg)) return arg.map((id) => ({ id, extras: {} }));
1567
- return Object.entries(arg).map(([id, extras]) => ({ id, extras }));
2656
+ if (Array.isArray(arg))
2657
+ return arg.map((id) => ({ id: canonicalizeId(id), extras: {} }));
2658
+ return Object.entries(arg).map(([id, extras]) => ({
2659
+ id: canonicalizeId(id),
2660
+ extras,
2661
+ }));
1568
2662
  };
1569
2663
 
1570
- // Current pivot rows compiled through the Rust SELECT path so the
1571
- // pivot identifiers go through `quote_identifier` (rejects anything
1572
- // outside `[A-Za-z0-9_]`), rather than through the ad-hoc `quote`
1573
- // helper that would blindly wrap a malicious metadata string.
1574
- //
1575
- // Now async — every site in `sync()` is in an async closure.
1576
- const currentIds = async (): Promise<Array<string | number>> => {
2664
+ // Apply a pivot column's `prepare` adapter (model DB), shared by the
2665
+ // INSERT (attach) and UPDATE (sync) write paths.
2666
+ const encodeExtra = (k: string, raw: unknown): unknown => {
2667
+ const prepare = pivotAdapters?.[k]?.prepare;
2668
+ if (!prepare) return raw;
2669
+ let encoded: unknown;
2670
+ try {
2671
+ // Adonis Lucid signature: (value, attribute, model). A pivot-row
2672
+ // write carries no single model instance.
2673
+ encoded = prepare(raw, k, undefined);
2674
+ } catch (err) {
2675
+ throw wrapAdapterError("prepare", k, err);
2676
+ }
2677
+ assertNotPromise("prepare", k, encoded);
2678
+ return encoded;
2679
+ };
2680
+
2681
+ // Reject an extras key colliding with a reserved pivot column. Without
2682
+ // this guard the FK case would silently override `parentIdValue`
2683
+ // (corrupting the join) and the timestamp case would duplicate the column
2684
+ // (driver-dependent failure or last-wins overwrite).
2685
+ const assertExtraKeyAllowed = (k: string): void => {
2686
+ if (k === pivotFk || k === pivotOther) {
2687
+ throw new Error(
2688
+ `Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`,
2689
+ );
2690
+ }
2691
+ if (tsColumnSet.has(k)) {
2692
+ throw new Error(
2693
+ `Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`,
2694
+ );
2695
+ }
2696
+ };
2697
+
2698
+ // Narrow an unknown pivot id to a bindable scalar without an `as` cast.
2699
+ const asId = (v: unknown): string | number =>
2700
+ typeof v === "number" ? v : String(v);
2701
+
2702
+ // Current pivot rows for this parent — the other-key plus any attribute
2703
+ // columns the caller needs (so sync() can diff changed pivot rows).
2704
+ // Compiled through the Rust SELECT path so the pivot identifiers go
2705
+ // through `quote_identifier` (rejects anything outside `[A-Za-z0-9_]`),
2706
+ // never the ad-hoc `quote` helper. Runs on `conn` — a transaction inside
2707
+ // sync(), the pool otherwise.
2708
+ const currentPivotRows = async (
2709
+ attrCols: string[],
2710
+ conn: DatabaseConnection = db,
2711
+ ): Promise<
2712
+ Array<{ id: string | number; row: Record<string, unknown> }>
2713
+ > => {
1577
2714
  const selectSpec = {
1578
2715
  kind: "select",
1579
2716
  table: pivotTable,
1580
- select: [pivotOther],
2717
+ select: [pivotOther, ...attrCols],
1581
2718
  wheres: [
1582
2719
  {
1583
2720
  column: pivotFk,
1584
2721
  operator: "=",
1585
- value: parentIdValue,
2722
+ value: readParentId(),
1586
2723
  type: "and",
1587
2724
  },
1588
2725
  ],
@@ -1600,19 +2737,27 @@ export class BaseRepository<T extends BaseEntity> {
1600
2737
  casts: pivotKeyCasts,
1601
2738
  };
1602
2739
  const compiled = compileStatementNative(selectSpec, dialect);
1603
- const rows = await db.query<Record<string, unknown>>(
2740
+ const rows = await conn.query<Record<string, unknown>>(
1604
2741
  compiled.statements[0],
1605
2742
  compiled.params,
1606
2743
  );
1607
- return rows.map((r) => r[pivotOther] as string | number);
2744
+ return rows.map((r) => ({ id: asId(r[pivotOther]), row: r }));
1608
2745
  };
1609
2746
 
1610
2747
  // Delete via the Rust DELETE compiler so the pivot table + columns get
1611
2748
  // `quote_identifier` validation (rejects `"`, `;`, etc.) — safer than
1612
2749
  // the previous hand-built SQL with a dumb `"` wrapper.
1613
- const detach = async (ids?: Array<string | number>): Promise<void> => {
2750
+ const detach = async (
2751
+ ids?: Array<string | number>,
2752
+ conn: DatabaseConnection = db,
2753
+ ): Promise<void> => {
1614
2754
  const wheres: Array<Record<string, unknown>> = [
1615
- { column: pivotFk, operator: "=", value: parentIdValue, type: "and" },
2755
+ {
2756
+ column: pivotFk,
2757
+ operator: "=",
2758
+ value: readParentId(),
2759
+ type: "and",
2760
+ },
1616
2761
  ];
1617
2762
  if (ids && ids.length > 0) {
1618
2763
  wheres.push({
@@ -1630,62 +2775,32 @@ export class BaseRepository<T extends BaseEntity> {
1630
2775
  casts: pivotKeyCasts,
1631
2776
  };
1632
2777
  const compiled = compileStatementNative(spec, dialect);
1633
- await db.execute(compiled.statements[0], compiled.params);
2778
+ await conn.execute(compiled.statements[0], compiled.params);
1634
2779
  };
1635
2780
 
1636
2781
  const attach = async (
1637
2782
  ids: Array<string | number> | Record<string, Record<string, unknown>>,
2783
+ conn: DatabaseConnection = db,
2784
+ parentFk: unknown = readParentId(),
1638
2785
  ): Promise<void> => {
1639
2786
  const entries = normalizeAttach(ids);
1640
2787
  if (entries.length === 0) return;
1641
- const ts = resolveTimestamps();
1642
- // Normalize heterogeneous extras: compute the union of extra keys
1643
- // across all entries and back-fill missing keys with `null`, so every
1644
- // row in the multi-insert shares the same column set (required by the
1645
- // Rust compiler's homogeneity check).
2788
+ const ts = timestampValues("insert");
2789
+ // Union of extra keys across all entries; back-fill missing keys with
2790
+ // `null` so every row in the multi-insert shares the same column set
2791
+ // (required by the Rust compiler's homogeneity check).
1646
2792
  const extraKeys = new Set<string>();
1647
2793
  for (const e of entries) {
1648
2794
  for (const k of Object.keys(e.extras)) extraKeys.add(k);
1649
2795
  }
1650
- // Reject extras keys that collide with reserved pivot columns. Without
1651
- // this guard, an extras entry named after the FK or a timestamp column
1652
- // would emit a duplicate column in the INSERT row pair: the FK case
1653
- // silently overrides `parentIdValue` (corrupting the join); the
1654
- // timestamp case duplicates the column entirely (driver-dependent
1655
- // failure or last-wins overwrite).
1656
- for (const k of extraKeys) {
1657
- if (k === pivotFk || k === pivotOther) {
1658
- throw new Error(
1659
- `Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`,
1660
- );
1661
- }
1662
- if (Object.hasOwn(ts, k)) {
1663
- throw new Error(
1664
- `Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`,
1665
- );
1666
- }
1667
- }
2796
+ for (const k of extraKeys) assertExtraKeyAllowed(k);
1668
2797
  const rowPairs = entries.map((e) => {
1669
2798
  const pairs: Array<[string, unknown]> = [
1670
- [pivotFk, parentIdValue],
2799
+ [pivotFk, parentFk],
1671
2800
  [pivotOther, e.id],
1672
2801
  ];
1673
- for (const k of extraKeys) {
1674
- const raw = e.extras[k] ?? null;
1675
- const prepare = pivotAdapters?.[k]?.prepare;
1676
- if (!prepare) {
1677
- pairs.push([k, raw]);
1678
- continue;
1679
- }
1680
- let encoded: unknown;
1681
- try {
1682
- encoded = prepare(raw);
1683
- } catch (err) {
1684
- throw wrapAdapterError("prepare", k, err);
1685
- }
1686
- assertNotPromise("prepare", k, encoded);
1687
- pairs.push([k, encoded]);
1688
- }
2802
+ for (const k of extraKeys)
2803
+ pairs.push([k, encodeExtra(k, e.extras[k] ?? null)]);
1689
2804
  for (const [k, v] of Object.entries(ts)) pairs.push([k, v]);
1690
2805
  return pairs;
1691
2806
  });
@@ -1701,20 +2816,55 @@ export class BaseRepository<T extends BaseEntity> {
1701
2816
  casts: pivotCasts,
1702
2817
  };
1703
2818
  const compiled = compileStatementNative(spec, dialect);
1704
- await db.execute(compiled.statements[0], compiled.params);
2819
+ await conn.execute(compiled.statements[0], compiled.params);
2820
+ };
2821
+
2822
+ // Refresh one already-attached pivot row's attributes (sync's update arm,
2823
+ // Adonis Lucid parity): set the provided extras (adapter-encoded) and bump
2824
+ // only updated_at.
2825
+ const updatePivot = async (
2826
+ id: string | number,
2827
+ extras: Record<string, unknown>,
2828
+ conn: DatabaseConnection = db,
2829
+ ): Promise<void> => {
2830
+ const ts = timestampValues("update");
2831
+ const set: Array<[string, unknown]> = [];
2832
+ for (const [k, raw] of Object.entries(extras)) {
2833
+ assertExtraKeyAllowed(k);
2834
+ set.push([k, encodeExtra(k, raw ?? null)]);
2835
+ }
2836
+ for (const [k, v] of Object.entries(ts)) set.push([k, v]);
2837
+ if (set.length === 0) return;
2838
+ const casts: Record<string, string> = { ...pivotKeyCasts };
2839
+ for (const k of Object.keys(ts)) casts[k] = "timestamp";
2840
+ const spec = {
2841
+ kind: "update",
2842
+ table: pivotTable,
2843
+ set,
2844
+ wheres: [
2845
+ {
2846
+ column: pivotFk,
2847
+ operator: "=",
2848
+ value: readParentId(),
2849
+ type: "and",
2850
+ },
2851
+ { column: pivotOther, operator: "=", value: id, type: "and" },
2852
+ ],
2853
+ returning: [],
2854
+ casts,
2855
+ };
2856
+ const compiled = compileStatementNative(spec, dialect);
2857
+ await conn.execute(compiled.statements[0], compiled.params);
1705
2858
  };
1706
2859
 
1707
2860
  /**
1708
- * Diff the current pivot state against a target set and apply the
1709
- * minimum attach/detach to converge.
1710
- *
1711
- * **NOT ATOMIC.** `sync` reads the pivot, computes the diff, then
1712
- * writes another process mutating the pivot between the read and
1713
- * the writes will cause divergence. Wrap the call in a transaction
1714
- * if you need strong consistency under concurrent writers.
1715
- *
1716
- * On SQLite this is typically fine because better-sqlite3 serializes
1717
- * writes per connection; on Postgres/MySQL use `useTransaction` first.
2861
+ * Diff the current pivot state against a target set and apply the minimum
2862
+ * insert / update / delete to converge (Adonis Lucid `sync`): rows missing
2863
+ * from the pivot are attached, already-attached rows whose pivot attributes
2864
+ * changed are updated, and rows absent from the target are detached (unless
2865
+ * `additive`). The read and all three writes run inside ONE managed
2866
+ * transaction atomic and rolled back on any failure, so a concurrent
2867
+ * writer can't wedge the pivot into a half-synced state.
1718
2868
  */
1719
2869
  const sync = async (
1720
2870
  target:
@@ -1722,61 +2872,207 @@ export class BaseRepository<T extends BaseEntity> {
1722
2872
  | Record<string, Record<string, unknown>>,
1723
2873
  additive = false,
1724
2874
  ): Promise<void> => {
1725
- const current = new Set(await currentIds());
1726
2875
  const entries = normalizeAttach(target);
1727
- const desired = new Set(entries.map((e) => e.id));
1728
- const toAttach = entries.filter((e) => !current.has(e.id));
1729
- const toDetach = additive
1730
- ? []
1731
- : [...current].filter((id) => !desired.has(id));
1732
- if (toDetach.length > 0) await detach(toDetach);
1733
- if (toAttach.length > 0) {
1734
- const attachArg: Record<string, Record<string, unknown>> = {};
1735
- for (const e of toAttach) attachArg[String(e.id)] = e.extras;
1736
- await attach(attachArg);
2876
+ // Attribute columns to read back so we can detect changed pivot rows.
2877
+ const attrCols = new Set<string>();
2878
+ for (const e of entries) {
2879
+ for (const k of Object.keys(e.extras)) attrCols.add(k);
1737
2880
  }
2881
+ const desiredIds = new Set(entries.map((e) => String(e.id)));
2882
+
2883
+ await transaction(db, async (trx) => {
2884
+ const current = await currentPivotRows([...attrCols], trx);
2885
+ const currentById = new Map<
2886
+ string,
2887
+ { id: string | number; row: Record<string, unknown> }
2888
+ >();
2889
+ for (const c of current) currentById.set(String(c.id), c);
2890
+
2891
+ // Diff by String(id): the DB returns numeric ids for an integer
2892
+ // pivot column while object-form targets carry canonicalized ids —
2893
+ // stringifying both sides keeps the comparison type-agnostic.
2894
+ const toDetach = additive
2895
+ ? []
2896
+ : current
2897
+ .filter((c) => !desiredIds.has(String(c.id)))
2898
+ .map((c) => c.id);
2899
+ const toAttach = entries.filter(
2900
+ (e) => !currentById.has(String(e.id)),
2901
+ );
2902
+ const toUpdate = entries.filter((e) => {
2903
+ if (Object.keys(e.extras).length === 0) return false;
2904
+ const cur = currentById.get(String(e.id));
2905
+ if (!cur) return false;
2906
+ // Only rewrite when a provided attribute actually differs — a
2907
+ // no-op sync must not churn rows or bump updated_at. Compare
2908
+ // nullish and empty-string as DISTINCT (a `String(x ?? "")`
2909
+ // collapse would treat `null` and `""` as equal and miss a real
2910
+ // attribute change from one to the other).
2911
+ return Object.keys(e.extras).some((k) => {
2912
+ const stored = cur.row[k];
2913
+ const next = encodeExtra(k, e.extras[k] ?? null);
2914
+ const storedNull = stored === null || stored === undefined;
2915
+ const nextNull = next === null || next === undefined;
2916
+ if (storedNull || nextNull) return storedNull !== nextNull;
2917
+ return String(stored) !== String(next);
2918
+ });
2919
+ });
2920
+
2921
+ if (toDetach.length > 0) await detach(toDetach, trx);
2922
+ for (const e of toUpdate) await updatePivot(e.id, e.extras, trx);
2923
+ if (toAttach.length > 0) {
2924
+ const attachArg: Record<string, Record<string, unknown>> = {};
2925
+ for (const e of toAttach) attachArg[String(e.id)] = e.extras;
2926
+ await attach(attachArg, trx);
2927
+ }
2928
+ });
1738
2929
  };
1739
2930
 
1740
2931
  // m2m create/save persist the related row THEN insert a pivot row —
1741
2932
  // NOT `hasOps.injectFk`, which would write a bogus `<parent>_id` column
1742
2933
  // onto the related table and never touch the pivot (silent corruption).
1743
- const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
1744
- const attachIds = (rows: BaseEntity[]): Promise<void> => {
2934
+ // The whole chain (persist unsaved parent → write related → insert pivot)
2935
+ // runs in ONE transaction via `withParentSaved` (AdonisJS/Lucid parity):
2936
+ // atomic, rolled back on any failure (no orphan related row, no pivot to a
2937
+ // missing parent), with domain events flushed only after commit.
2938
+ const relatedPkProp =
2939
+ pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
2940
+ const attachRows = (
2941
+ rows: BaseEntity[],
2942
+ trx: TransactionClient,
2943
+ fk: unknown,
2944
+ pivotFor?: (index: number) => Record<string, unknown>,
2945
+ ): Promise<void> => {
1745
2946
  if (rows.length === 0) return Promise.resolve();
1746
2947
  const arg: Record<string, Record<string, unknown>> = {};
1747
- for (const r of rows) arg[String(r[relatedPkProp])] = {};
1748
- return attach(arg);
2948
+ rows.forEach((r, i) => {
2949
+ arg[String(r[relatedPkProp])] = pivotFor?.(i) ?? {};
2950
+ });
2951
+ return attach(arg, trx, fk);
1749
2952
  };
2953
+ // create/save accept per-row pivot attributes (Adonis Lucid `create(values,
2954
+ // pivotAttributes)` / `save(related, pivotAttributes)`) — written onto the
2955
+ // pivot row alongside the FK/otherKey, in the same transaction.
1750
2956
  const m2mOps = {
1751
- async create(data: Record<string, unknown>): Promise<BaseEntity> {
1752
- const created = await relatedRepo.create(data);
1753
- await attachIds([created]);
1754
- return created;
1755
- },
1756
- async createMany(
2957
+ create: (
2958
+ data: Record<string, unknown>,
2959
+ pivotAttributes?: Record<string, unknown>,
2960
+ ): Promise<BaseEntity> =>
2961
+ withParentSaved(async (fk, rel, trx) => {
2962
+ const created = await rel.create(data);
2963
+ await attachRows([created], trx, fk, () => pivotAttributes ?? {});
2964
+ trx.after("commit", () => flushEvents([created]));
2965
+ return created;
2966
+ }),
2967
+ createMany: (
1757
2968
  rows: Array<Record<string, unknown>>,
1758
- ): Promise<BaseEntity[]> {
1759
- const created = await relatedRepo.createMany(rows);
1760
- await attachIds(created);
1761
- return created;
1762
- },
1763
- async save(related: BaseEntity): Promise<void> {
1764
- await relatedRepo.save(related);
1765
- await attachIds([related]);
1766
- },
1767
- async saveMany(related: BaseEntity[]): Promise<BaseEntity[]> {
1768
- const saved = await relatedRepo.saveMany(related);
1769
- await attachIds(saved);
1770
- return saved;
1771
- },
2969
+ pivotAttributes?: Array<Record<string, unknown>>,
2970
+ ): Promise<BaseEntity[]> =>
2971
+ withParentSaved(async (fk, rel, trx) => {
2972
+ const created = await rel.createMany(rows);
2973
+ await attachRows(
2974
+ created,
2975
+ trx,
2976
+ fk,
2977
+ (i) => pivotAttributes?.[i] ?? {},
2978
+ );
2979
+ // NO wrapper flush: rel.createMany now self-dispatches via
2980
+ // #inManagedTx (like saveMany). The pivot rows carry no events; a
2981
+ // second flush would double the related rows' events on a partial
2982
+ // sink failure.
2983
+ return created;
2984
+ }),
2985
+ save: (
2986
+ related: BaseEntity,
2987
+ pivotAttributes?: Record<string, unknown>,
2988
+ ): Promise<void> =>
2989
+ withParentSaved(async (fk, rel, trx) => {
2990
+ await rel.save(related);
2991
+ await attachRows([related], trx, fk, () => pivotAttributes ?? {});
2992
+ trx.after("commit", () => flushEvents([related]));
2993
+ }),
2994
+ saveMany: (
2995
+ related: BaseEntity[],
2996
+ pivotAttributes?: Array<Record<string, unknown>>,
2997
+ ): Promise<BaseEntity[]> =>
2998
+ withParentSaved(async (fk, rel, trx) => {
2999
+ const saved = await rel.saveMany(related);
3000
+ await attachRows(saved, trx, fk, (i) => pivotAttributes?.[i] ?? {});
3001
+ // NO wrapper flush: rel.saveMany self-dispatches via #inManagedTx
3002
+ // (all-or-nothing). A second flush would double on partial failure.
3003
+ return saved;
3004
+ }),
1772
3005
  };
3006
+ // attach/detach/sync operate DIRECTLY on the pivot using the parent key —
3007
+ // unlike create/save they never persist the parent (there's no related row
3008
+ // to hang the transaction on). Lucid requires a persisted parent WITH a key
3009
+ // here (every doc example starts from `findOrFail`); without the guard a
3010
+ // keyless/unsaved parent would write a pivot row with a null FK or target a
3011
+ // nonexistent parent. Same seam as delete/refresh: E_MODEL_NOT_PERSISTED on
3012
+ // an unsaved instance, E_MISSING_PRIMARY_KEY on a keyless projection.
3013
+ const guardParent = (op: string): void =>
3014
+ this.#assertPersistedRow(
3015
+ entity,
3016
+ readParentId(),
3017
+ `related('${relationName}').${op}`,
3018
+ // The pivot FK references `parentPk` (localKey ?? PK) — name THAT key
3019
+ // in a missing-key diagnostic, not always 'id'.
3020
+ parentPk,
3021
+ );
1773
3022
  const proxy: ManyToManyRelationProxy = {
1774
3023
  type: "manyToMany",
1775
3024
  ...m2mOps,
1776
3025
  query: scopedQuery,
1777
- attach,
1778
- detach,
1779
- sync,
3026
+ // A query builder on the PIVOT table itself, scoped to this parent
3027
+ // (Adonis Lucid `pivotQuery`) — for reading/updating/deleting pivot
3028
+ // rows directly, beyond attach/detach/sync.
3029
+ pivotQuery: () => {
3030
+ guardParent("pivotQuery()");
3031
+ return new DatabaseQueryBuilder(this.#db, dialect, pivotTable).where(
3032
+ pivotFk,
3033
+ readParentId(),
3034
+ );
3035
+ },
3036
+ // async so the guard throw surfaces as a REJECTED promise — a method
3037
+ // typed `Promise<void>` must never throw synchronously.
3038
+ attach: async (ids) => {
3039
+ guardParent("attach()");
3040
+ return attach(ids);
3041
+ },
3042
+ detach: async (ids) => {
3043
+ guardParent("detach()");
3044
+ return detach(ids);
3045
+ },
3046
+ sync: async (target, additive) => {
3047
+ guardParent("sync()");
3048
+ return sync(target, additive);
3049
+ },
3050
+ };
3051
+ return proxy;
3052
+ }
3053
+
3054
+ if (
3055
+ relation.type === "hasOneThrough" ||
3056
+ relation.type === "hasManyThrough"
3057
+ ) {
3058
+ // READ-ONLY (Lucid parity, verified): a through relation exposes only
3059
+ // query()/preload. Every write is rejected — the old code fell through to
3060
+ // the hasMany default and wrote to the WRONG table with a bogus direct FK.
3061
+ // To persist, the caller must go through the intermediate model.
3062
+ const rejectWrite = async (op: string): Promise<never> => {
3063
+ throw new Error(
3064
+ `related('${relationName}').${op}() is not supported on ` +
3065
+ `@HasManyThrough/@HasOneThrough — through relations are READ-ONLY ` +
3066
+ `(Lucid parity); persist via the intermediate model.`,
3067
+ );
3068
+ };
3069
+ const proxy: HasManyThroughRelationProxy = {
3070
+ type: relation.type,
3071
+ query: scopedQuery,
3072
+ create: () => rejectWrite("create"),
3073
+ save: () => rejectWrite("save"),
3074
+ createMany: () => rejectWrite("createMany"),
3075
+ saveMany: () => rejectWrite("saveMany"),
1780
3076
  };
1781
3077
  return proxy;
1782
3078
  }
@@ -1816,11 +3112,7 @@ export class BaseRepository<T extends BaseEntity> {
1816
3112
 
1817
3113
  async fresh(entity: T): Promise<T> {
1818
3114
  const pk = entity[this.#primaryKey];
1819
- if (pk === undefined || pk === null) {
1820
- throw new EntityNotFoundError(this.#entityClass.name, {
1821
- [this.#primaryKey]: pk,
1822
- });
1823
- }
3115
+ this.#assertPersistedRow(entity, pk, "fresh()");
1824
3116
  const found = await this.find(pk as string | number);
1825
3117
  if (!found) {
1826
3118
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -1840,7 +3132,7 @@ export class BaseRepository<T extends BaseEntity> {
1840
3132
  for (const col of this.#columns) {
1841
3133
  const value = entity[col];
1842
3134
  if (value !== undefined) {
1843
- row[camelToSnake(col)] = this.#applyPrepare(col, value);
3135
+ row[this.#dbColumn(col)] = this.#applyPrepare(col, value, entity);
1844
3136
  }
1845
3137
  }
1846
3138
  return row;
@@ -1853,11 +3145,8 @@ export class BaseRepository<T extends BaseEntity> {
1853
3145
  for (const [key, value] of Object.entries(data)) {
1854
3146
  // Mirror `#plainToRowPairs` — skip undefined so updates can't bind it.
1855
3147
  if (value === undefined) continue;
1856
- const propKey = this.#columnPrepares.has(key) ? key : snakeToCamel(key);
1857
- pairs.push([
1858
- this.#resolveColumn(key),
1859
- this.#applyPrepare(propKey, value),
1860
- ]);
3148
+ // `#applyPrepare` normalises the key (property / snake / columnName).
3149
+ pairs.push([this.#resolveColumn(key), this.#applyPrepare(key, value)]);
1861
3150
  }
1862
3151
  return pairs;
1863
3152
  }
@@ -1892,7 +3181,7 @@ export class BaseRepository<T extends BaseEntity> {
1892
3181
  * adapter rejected — the dev has to bisect across every adapter-tagged
1893
3182
  * property to find the culprit.
1894
3183
  */
1895
- function wrapAdapterError(
3184
+ export function wrapAdapterError(
1896
3185
  phase: "prepare" | "consume",
1897
3186
  propertyKey: string,
1898
3187
  err: unknown,
@@ -1914,7 +3203,7 @@ function wrapAdapterError(
1914
3203
  * gives the user a column-annotated error instead of an opaque "Invalid bind
1915
3204
  * value" downstream when the unawaited Promise hits the NAPI boundary.
1916
3205
  */
1917
- function assertNotPromise(
3206
+ export function assertNotPromise(
1918
3207
  phase: "prepare" | "consume",
1919
3208
  propertyKey: string,
1920
3209
  value: unknown,