@c9up/atlas 0.1.18 → 0.2.0

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