@c9up/atlas 0.1.19 → 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 (121) 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 +164 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +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 +71 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1355 -384
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +341 -18
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1406 -177
  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/TableBuilder.d.ts +247 -8
  70. package/dist/schema/TableBuilder.d.ts.map +1 -1
  71. package/dist/schema/TableBuilder.js +607 -41
  72. package/dist/schema/TableBuilder.js.map +1 -1
  73. package/dist/schema/catalog.d.ts +47 -0
  74. package/dist/schema/catalog.d.ts.map +1 -0
  75. package/dist/schema/catalog.js +111 -0
  76. package/dist/schema/catalog.js.map +1 -0
  77. package/dist/schema/types.d.ts +150 -1
  78. package/dist/schema/types.d.ts.map +1 -1
  79. package/dist/schema/types.js +11 -0
  80. package/dist/schema/types.js.map +1 -1
  81. package/dist/services/db.d.ts +6 -0
  82. package/dist/services/db.d.ts.map +1 -1
  83. package/dist/services/db.js +17 -0
  84. package/dist/services/db.js.map +1 -1
  85. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  86. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  87. package/dist/testing/DatabaseCleanup.js +21 -18
  88. package/dist/testing/DatabaseCleanup.js.map +1 -1
  89. package/dist/testing/Factory.d.ts +70 -5
  90. package/dist/testing/Factory.d.ts.map +1 -1
  91. package/dist/testing/Factory.js +209 -10
  92. package/dist/testing/Factory.js.map +1 -1
  93. package/index.darwin-arm64.node +0 -0
  94. package/index.darwin-x64.node +0 -0
  95. package/index.linux-arm64-gnu.node +0 -0
  96. package/index.linux-x64-gnu.node +0 -0
  97. package/index.win32-x64-msvc.node +0 -0
  98. package/package.json +3 -1
  99. package/scripts/guard-publish.mjs +15 -0
  100. package/src/AtlasProvider.ts +8 -1
  101. package/src/BaseEntity.ts +431 -41
  102. package/src/BaseModel.ts +324 -0
  103. package/src/BaseRepository.ts +1589 -419
  104. package/src/ModelQuery.ts +1817 -203
  105. package/src/Transaction.ts +68 -5
  106. package/src/adapters/NapiDbAdapter.ts +159 -10
  107. package/src/console/migrationCommands.ts +258 -0
  108. package/src/decorators/entity.ts +53 -6
  109. package/src/events.ts +112 -0
  110. package/src/index.ts +19 -0
  111. package/src/metadata-keys.ts +3 -2
  112. package/src/naming/NamingStrategy.ts +23 -0
  113. package/src/schema/Migration.ts +42 -3
  114. package/src/schema/MigrationRunner.ts +270 -27
  115. package/src/schema/Schema.ts +210 -3
  116. package/src/schema/TableBuilder.ts +735 -41
  117. package/src/schema/catalog.ts +166 -0
  118. package/src/schema/types.ts +137 -2
  119. package/src/services/db.ts +28 -0
  120. package/src/testing/DatabaseCleanup.ts +23 -22
  121. package/src/testing/Factory.ts +332 -15
@@ -5,12 +5,18 @@
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import type { TransactionOptions } from "./adapters/NapiDbAdapter.js";
8
+ import { DateTime } from "@c9up/chronos";
9
+ import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
10
+ import type {
11
+ QueryMeta,
12
+ TransactionOptions,
13
+ } from "./adapters/NapiDbAdapter.js";
9
14
  import type {
10
15
  BaseEntity,
11
16
  BelongsToRelationProxy,
12
17
  DomainEvent,
13
18
  HasManyRelationProxy,
19
+ HasManyThroughRelationProxy,
14
20
  HasOneRelationProxy,
15
21
  ManyToManyRelationProxy,
16
22
  RelationProxy,
@@ -18,9 +24,9 @@ import type {
18
24
  import { REPO_REF } from "./BaseEntity.js";
19
25
  import {
20
26
  type DateColumnConfig,
27
+ ensureEntityMetadata,
21
28
  getColumnMetadata,
22
29
  getDateColumnConfig,
23
- getEntityMetadata,
24
30
  getPrimaryKey,
25
31
  getPrimaryKeyGenerator,
26
32
  getRelationMetadata,
@@ -29,7 +35,7 @@ import {
29
35
  } from "./decorators/entity.js";
30
36
  import { fireHooks } from "./decorators/hooks.js";
31
37
  import { AtlasError, EntityNotFoundError } from "./errors.js";
32
- import { ModelQuery, runWithAtlasInternalBypass } from "./ModelQuery.js";
38
+ import { isAtlasStrictMode, ModelQuery } from "./ModelQuery.js";
33
39
  import {
34
40
  type AtlasDialect,
35
41
  compileStatementNative,
@@ -39,6 +45,7 @@ import {
39
45
  } from "./query/native.js";
40
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).
@@ -175,10 +196,19 @@ export function computeCastTypes(
175
196
  entityClass: Parameters<typeof getColumnMetadata>[0],
176
197
  ): Record<string, string> {
177
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
+ }
178
208
  for (const col of getColumnMetadata(entityClass)) {
179
209
  const t = col.type?.toLowerCase();
180
210
  if (t && POSTGRES_CAST_TYPES.has(t)) {
181
- out[camelToSnake(col.propertyKey)] = t;
211
+ out[dbNameOf.get(col.propertyKey) ?? camelToSnake(col.propertyKey)] = t;
182
212
  }
183
213
  }
184
214
  // `@column.date()` / `@column.dateTime()` columns are tracked in a SEPARATE
@@ -190,11 +220,14 @@ export function computeCastTypes(
190
220
  // expression is of type text`. An explicit recognized `col.type` already set
191
221
  // in the loop above wins via `??=`.
192
222
  for (const [prop, cfg] of Object.entries(getDateColumnConfig(entityClass))) {
193
- out[camelToSnake(prop)] ??= cfg.dateOnly ? "date" : "timestamp";
223
+ out[dbNameOf.get(prop) ?? camelToSnake(prop)] ??= cfg.dateOnly
224
+ ? "date"
225
+ : "timestamp";
194
226
  }
195
227
  // A uuid-strategy primary key is generated app-side as a string.
196
228
  if (getPrimaryKeyGenerator(entityClass) === "uuid") {
197
- out[camelToSnake(getPrimaryKey(entityClass) ?? "id")] ??= "uuid";
229
+ const pk = getPrimaryKey(entityClass) ?? "id";
230
+ out[dbNameOf.get(pk) ?? camelToSnake(pk)] ??= "uuid";
198
231
  }
199
232
  return out;
200
233
  }
@@ -207,7 +240,8 @@ export class BaseRepository<T extends BaseEntity> {
207
240
  #db: DatabaseConnection;
208
241
  #softDeletes: boolean;
209
242
  #validColumns: Set<string>;
210
- #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)
211
245
  #dateColumns: Record<string, DateColumnConfig>;
212
246
  /** Snake column → logical type for params needing a Postgres `::cast`. */
213
247
  #castTypes: Record<string, string>;
@@ -216,13 +250,19 @@ export class BaseRepository<T extends BaseEntity> {
216
250
  * `@Column({ prepare })` metadata. Keyed by camelCase `propertyKey`.
217
251
  * Mirror of Adonis Lucid's `@column.prepare`. Story 35.10.
218
252
  */
219
- #columnPrepares: Map<string, (value: unknown) => unknown>;
253
+ #columnPrepares: Map<
254
+ string,
255
+ (value: unknown, attribute?: string, model?: unknown) => unknown
256
+ >;
220
257
  /**
221
258
  * Per-property `consume` (DB → model) callbacks lifted directly from
222
259
  * `@Column({ consume })` metadata. Keyed by camelCase `propertyKey`.
223
260
  * Mirror of Adonis Lucid's `@column.consume`. Story 35.10.
224
261
  */
225
- #columnConsumes: Map<string, (value: unknown) => unknown>;
262
+ #columnConsumes: Map<
263
+ string,
264
+ (value: unknown, attribute?: string, model?: unknown) => unknown
265
+ >;
226
266
  /**
227
267
  * SQL dialect used by this repository. Resolved at construction time from
228
268
  * the connection (if it exposes a `dialect` property) or from the explicit
@@ -236,6 +276,15 @@ export class BaseRepository<T extends BaseEntity> {
236
276
  /** Callback to dispatch domain events (set by framework integration). */
237
277
  onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
238
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
+
239
288
  constructor(
240
289
  entityClass: EntityConstructor<T>,
241
290
  db: DatabaseConnection,
@@ -260,16 +309,10 @@ export class BaseRepository<T extends BaseEntity> {
260
309
  const connDialect = (db as { dialect?: AtlasDialect }).dialect;
261
310
  this.#dialect = options?.dialect ?? connDialect ?? getAtlasDialect();
262
311
 
263
- const meta = getEntityMetadata(entityClass);
264
- if (!meta) {
265
- throw new AtlasError(
266
- "NOT_ENTITY",
267
- `Class '${entityClass.name}' is not decorated with @Entity()`,
268
- {
269
- hint: "Add @Entity('table_name') decorator to the class.",
270
- },
271
- );
272
- }
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);
273
316
 
274
317
  this.#tableName = meta.tableName;
275
318
  this.#primaryKey = getPrimaryKey(entityClass) ?? "id";
@@ -282,8 +325,14 @@ export class BaseRepository<T extends BaseEntity> {
282
325
  // No global registry, no late-registration concern: callbacks are baked
283
326
  // into the entity definition. Mirrors Adonis Lucid's `@column.prepare` /
284
327
  // `@column.consume` pattern.
285
- this.#columnPrepares = new Map<string, (value: unknown) => unknown>();
286
- 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
+ >();
287
336
  for (const col of columnsMeta) {
288
337
  if (col.prepare) this.#columnPrepares.set(col.propertyKey, col.prepare);
289
338
  if (col.consume) this.#columnConsumes.set(col.propertyKey, col.consume);
@@ -297,16 +346,31 @@ export class BaseRepository<T extends BaseEntity> {
297
346
  // time, before any repository for that entity is instantiated.
298
347
  this.#validColumns = new Set<string>();
299
348
  this.#columnMap = new Map<string, string>();
300
- for (const col of this.#columns) {
301
- const snake = camelToSnake(col);
302
- this.#validColumns.add(col);
303
- this.#validColumns.add(snake);
304
- this.#columnMap.set(col, snake);
305
- this.#columnMap.set(snake, snake);
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);
306
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.
307
366
  this.#validColumns.add(this.#primaryKey);
308
- this.#validColumns.add(camelToSnake(this.#primaryKey));
309
- 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
+ }
310
374
 
311
375
  // Postgres cast hints: sqlx binds JS strings as `text`, which Postgres
312
376
  // won't coerce to timestamp/uuid/date. See `computeCastTypes`.
@@ -329,15 +393,22 @@ export class BaseRepository<T extends BaseEntity> {
329
393
  // FK lives on THIS table, references the related (owner) PK.
330
394
  const fk = rel.foreignKey ?? `${camelToSnake(related.name)}_id`;
331
395
  const ownerKey = rel.ownerKey ?? getPrimaryKey(related) ?? "id";
332
- 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];
333
400
  if (cast) registerColumnCast(this.#tableName, fk, cast);
334
401
  } else {
335
402
  // hasOne / hasMany: FK lives on the RELATED table, references THIS PK.
336
403
  const fk = rel.foreignKey ?? `${camelToSnake(entityClass.name)}_id`;
337
404
  const localKey = rel.localKey ?? this.#primaryKey;
338
- const cast = this.#castTypes[camelToSnake(localKey)];
339
- const relatedMeta = getEntityMetadata(related);
340
- 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) {
341
412
  registerColumnCast(relatedMeta.tableName, fk, cast);
342
413
  }
343
414
  }
@@ -363,6 +434,25 @@ export class BaseRepository<T extends BaseEntity> {
363
434
  );
364
435
  }
365
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
+
366
456
  // ─── Query builder ────────────────────────────────────────
367
457
 
368
458
  query(): ModelQuery<T> {
@@ -374,6 +464,8 @@ export class BaseRepository<T extends BaseEntity> {
374
464
  (col) => this.#resolveColumn(col),
375
465
  this.#softDeletes,
376
466
  this.#dialect,
467
+ (prop, value) => this.#applyPrepare(prop, value),
468
+ this.onDomainEvents,
377
469
  );
378
470
  }
379
471
 
@@ -389,6 +481,9 @@ export class BaseRepository<T extends BaseEntity> {
389
481
  dialect: this.#dialect,
390
482
  });
391
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;
392
487
  return repo;
393
488
  }
394
489
 
@@ -419,18 +514,39 @@ export class BaseRepository<T extends BaseEntity> {
419
514
  return entity;
420
515
  }
421
516
 
422
- 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> {
423
523
  // Through the builder for read-hook parity (see `find`).
424
- 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();
425
531
  }
426
532
 
427
- /** Find by a column or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
428
- async findByOrFail(column: string, value: unknown): Promise<T> {
429
- const entity = await this.findBy(column, value);
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);
430
544
  if (!entity) {
431
- throw new EntityNotFoundError(this.#entityClass.name, {
432
- [column]: value,
433
- });
545
+ const criteria =
546
+ typeof columnOrClause === "string"
547
+ ? { [columnOrClause]: value }
548
+ : columnOrClause;
549
+ throw new EntityNotFoundError(this.#entityClass.name, criteria);
434
550
  }
435
551
  return entity;
436
552
  }
@@ -444,13 +560,23 @@ export class BaseRepository<T extends BaseEntity> {
444
560
  .exec();
445
561
  }
446
562
 
447
- /** Find many rows by an arbitrary column (AdonisJS `findManyBy`). */
563
+ /** Find many rows by a column IN values, or by an object clause (AdonisJS `findManyBy`). */
448
564
  async findManyBy(
449
565
  column: string,
450
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>,
451
572
  ): Promise<T[]> {
452
- if (values.length === 0) return [];
453
- return this.query().whereIn(column, values).exec();
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();
454
580
  }
455
581
 
456
582
  async all(): Promise<T[]> {
@@ -460,6 +586,33 @@ export class BaseRepository<T extends BaseEntity> {
460
586
  return this.query().orderBy(this.#primaryKey, "desc").exec();
461
587
  }
462
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}`, []);
614
+ }
615
+
463
616
  async allWithTrashed(): Promise<T[]> {
464
617
  return this.query().withTrashed().exec();
465
618
  }
@@ -470,45 +623,59 @@ export class BaseRepository<T extends BaseEntity> {
470
623
  }
471
624
 
472
625
  async where(column: string, value: unknown): Promise<T[]> {
473
- // Order by the primary key (DESC = most recent insert first when the PK is
474
- // an auto-increment integer or a monotonic UUID). The ordering contract is
475
- // "most recent first by PK" for `repo.where(col, val)` as a convenience
476
- // finder. Through the builder for read-hook parity (see `find`).
477
- return this.query()
478
- .where(column, value)
479
- .orderBy(this.#primaryKey, "desc")
480
- .exec();
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();
481
631
  }
482
632
 
483
633
  // ─── Create / Save / Delete ───────────────────────────────
484
634
 
485
635
  /**
486
- * Build an entity from a plain object and persist it. Fires `beforeSave` →
487
- * `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`).
488
639
  */
489
- async create(data: Partial<Record<string, unknown>>): Promise<T> {
640
+ async create(
641
+ data: Partial<Record<string, unknown>>,
642
+ quiet = false,
643
+ ): Promise<T> {
490
644
  const entity = new this.#entityClass();
491
645
  for (const [key, value] of Object.entries(data)) {
492
646
  if (
493
647
  this.#validColumns.has(key) ||
494
648
  this.#validColumns.has(camelToSnake(key))
495
649
  ) {
496
- entity.setProp(key, value);
650
+ const prop = this.#toProperty(key);
651
+ entity.assertMassAssignable(prop);
652
+ entity.setProp(prop, value);
497
653
  }
498
654
  }
499
- await fireHooks(this.#entityClass, "beforeSave", entity);
500
- await fireHooks(this.#entityClass, "beforeCreate", entity);
655
+ if (!quiet) {
656
+ await fireHooks(this.#entityClass, "beforeCreate", entity);
657
+ await fireHooks(this.#entityClass, "beforeSave", entity);
658
+ }
501
659
  await this.#insert(entity);
502
- await fireHooks(this.#entityClass, "afterCreate", entity);
503
- await fireHooks(this.#entityClass, "afterSave", entity);
504
- 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);
505
666
  return entity;
506
667
  }
507
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
+
508
674
  /**
509
675
  * Persist an entity. Insert if PK is missing or row doesn't exist, update
510
- * otherwise. Fires `beforeSave` → (`beforeCreate` | `beforeUpdate`) → DB →
511
- * (`afterCreate` | `afterUpdate`) → `afterSave`, then dispatches
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
512
679
  * accumulated domain events through `onDomainEvents`.
513
680
  *
514
681
  * Race-safety: the `find(pk)` → branch decision has a TOCTOU window. If a
@@ -519,34 +686,114 @@ export class BaseRepository<T extends BaseEntity> {
519
686
  * `beforeCreate` hooks to be idempotent or move side-effects into
520
687
  * `afterCreate` / `afterSave` where they only fire on commit.
521
688
  */
522
- 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
+ }
523
702
  const pk = entity[this.#primaryKey];
524
- // Treat a present PK (including `0` and `''`) as a candidate update —
525
- // `pk && ...` would route legitimate zero / empty-string keys through
526
- // INSERT and double-write the row.
527
- const isUpdate = isProvidedPk(pk) && (await this.find(pk)) !== null;
528
-
529
- await fireHooks(this.#entityClass, "beforeSave", entity);
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;
530
743
  if (isUpdate) {
531
- await this.#runUpdateBranch(entity);
744
+ await this.#runUpdateBranch(entity, quiet);
532
745
  } else {
533
746
  try {
534
- await this.#runInsertBranch(entity);
747
+ await this.#runInsertBranch(entity, quiet);
748
+ didInsert = true;
535
749
  } catch (err) {
536
750
  // Race recovery: the row didn't exist when we checked, but a
537
751
  // concurrent insert beat us to it. Only fall back when the PK
538
752
  // was explicitly provided (auto-generated PK can't collide on
539
753
  // a fresh insert — DB generates a unique one per call).
540
754
  if (isProvidedPk(pk) && isUniqueKeyViolation(err)) {
541
- 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);
542
760
  } else {
543
761
  throw err;
544
762
  }
545
763
  }
546
764
  }
547
- await fireHooks(this.#entityClass, "afterSave", entity);
765
+ this.#attachRepoRef(entity);
766
+ if (!quiet) await fireHooks(this.#entityClass, "afterSave", entity);
548
767
 
549
- 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);
550
797
  }
551
798
 
552
799
  /**
@@ -568,16 +815,63 @@ export class BaseRepository<T extends BaseEntity> {
568
815
  }
569
816
  }
570
817
 
571
- async #runInsertBranch(entity: T): Promise<void> {
572
- 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> {
573
868
  await this.#insert(entity);
574
- await fireHooks(this.#entityClass, "afterCreate", entity);
869
+ if (!quiet) await fireHooks(this.#entityClass, "afterCreate", entity);
575
870
  }
576
871
 
577
- async #runUpdateBranch(entity: T): Promise<void> {
578
- await fireHooks(this.#entityClass, "beforeUpdate", entity);
872
+ async #runUpdateBranch(entity: T, quiet = false): Promise<void> {
579
873
  await this.#update(entity);
580
- await fireHooks(this.#entityClass, "afterUpdate", entity);
874
+ if (!quiet) await fireHooks(this.#entityClass, "afterUpdate", entity);
581
875
  }
582
876
 
583
877
  /**
@@ -590,6 +884,7 @@ export class BaseRepository<T extends BaseEntity> {
590
884
  */
591
885
  async createMany(
592
886
  rows: Array<Partial<Record<string, unknown>>>,
887
+ quiet = false,
593
888
  ): Promise<T[]> {
594
889
  if (rows.length === 0) return [];
595
890
  const entities: T[] = rows.map((r) => {
@@ -598,19 +893,54 @@ export class BaseRepository<T extends BaseEntity> {
598
893
  if (
599
894
  this.#validColumns.has(k) ||
600
895
  this.#validColumns.has(camelToSnake(k))
601
- )
602
- e.setProp(k, v);
896
+ ) {
897
+ const prop = this.#toProperty(k);
898
+ e.assertMassAssignable(prop);
899
+ e.setProp(prop, v);
900
+ }
603
901
  }
604
902
  return e;
605
903
  });
606
- for (const e of entities) {
607
- await fireHooks(this.#entityClass, "beforeSave", e);
608
- 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
+ }
609
934
  }
610
935
 
611
936
  if (this.#dialect === "mysql") {
612
- // mysql: loop single inserts (no RETURNING).
613
- 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
+ });
614
944
  } else {
615
945
  const specRows = entities.map((e) => this.#entityToRowPairs(e));
616
946
  const spec = {
@@ -619,8 +949,8 @@ export class BaseRepository<T extends BaseEntity> {
619
949
  rows: specRows,
620
950
  casts: this.#castTypes,
621
951
  returning: [
622
- camelToSnake(this.#primaryKey),
623
- ...this.#columns.map((c) => camelToSnake(c)),
952
+ this.#dbColumn(this.#primaryKey),
953
+ ...this.#columns.map((c) => this.#dbColumn(c)),
624
954
  ],
625
955
  };
626
956
  const compiled = compileStatementNative(spec, this.#dialect);
@@ -629,22 +959,38 @@ export class BaseRepository<T extends BaseEntity> {
629
959
  compiled.params,
630
960
  );
631
961
  returned.forEach((row, i) => {
632
- for (const [k, v] of Object.entries(row))
633
- 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
+ }
634
968
  entities[i].markAsPersisted();
635
969
  });
636
970
  }
637
971
 
638
- for (const e of entities) {
639
- await fireHooks(this.#entityClass, "afterCreate", e);
640
- await fireHooks(this.#entityClass, "afterSave", e);
641
- }
642
- for (const e of entities) {
643
- await this.#dispatchDomainEvents(e);
972
+ if (!quiet) {
973
+ for (const e of entities) {
974
+ await fireHooks(this.#entityClass, "afterCreate", e);
975
+ await fireHooks(this.#entityClass, "afterSave", e);
976
+ }
644
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);
645
984
  return entities;
646
985
  }
647
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
+
648
994
  /**
649
995
  * Persist many already-constructed entity instances. Same hooks + batching
650
996
  * as `createMany`, but accepts prebuilt entities so dirty tracking works.
@@ -653,32 +999,64 @@ export class BaseRepository<T extends BaseEntity> {
653
999
  */
654
1000
  async saveMany(entities: T[]): Promise<T[]> {
655
1001
  if (entities.length === 0) return [];
656
- // Split new vs already-persisted; for simplicity, persist new ones as a
657
- // batch and fall back to per-entity save for dirty ones.
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.
658
1017
  const fresh: T[] = [];
659
1018
  const dirty: T[] = [];
660
1019
  for (const e of entities) {
661
- if (Object.keys(e.$original ?? {}).length === 0) fresh.push(e);
1020
+ if (!e.$isPersisted) fresh.push(e);
662
1021
  else dirty.push(e);
663
1022
  }
664
- if (fresh.length > 0) {
665
- const rows = fresh.map((e) => {
666
- const r: Record<string, unknown> = {};
667
- for (const c of this.#columns) {
668
- const v = e[c];
669
- if (v !== undefined) r[c] = v;
670
- }
671
- return r;
672
- });
673
- const created = await this.createMany(rows);
674
- // Copy generated PKs back to the original instances.
675
- created.forEach((c, i) => {
676
- fresh[i].setProp(this.#primaryKey, c[this.#primaryKey]);
677
- fresh[i].markAsPersisted();
678
- });
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;
679
1059
  }
680
- for (const d of dirty) await this.save(d);
681
- return entities;
682
1060
  }
683
1061
 
684
1062
  /**
@@ -721,12 +1099,93 @@ export class BaseRepository<T extends BaseEntity> {
721
1099
  ): Promise<T> {
722
1100
  // Atomic (AdonisJS Lucid parity): find-under-lock then create inside one
723
1101
  // transaction, so two concurrent callers can't both miss and both INSERT.
724
- return transaction(this.#db, async (trx) => {
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) => {
725
1128
  const repo = this.useTransaction(trx);
726
- const existing = await repo.#findBySearch(search, true);
727
- if (existing) return existing;
728
- return repo.create({ ...search, ...defaults });
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;
729
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;
730
1189
  }
731
1190
 
732
1191
  /** Find a row or build an in-memory instance without persisting. */
@@ -738,8 +1197,14 @@ export class BaseRepository<T extends BaseEntity> {
738
1197
  if (existing) return existing;
739
1198
  const e = new this.#entityClass();
740
1199
  for (const [k, v] of Object.entries({ ...search, ...defaults })) {
741
- if (this.#validColumns.has(k) || this.#validColumns.has(camelToSnake(k)))
742
- e.setProp(k, v);
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
+ }
743
1208
  }
744
1209
  return e;
745
1210
  }
@@ -749,16 +1214,119 @@ export class BaseRepository<T extends BaseEntity> {
749
1214
  search: Record<string, unknown>,
750
1215
  values: Record<string, unknown>,
751
1216
  ): Promise<T> {
752
- return transaction(this.#db, async (trx) => {
753
- const repo = this.useTransaction(trx);
754
- const existing = await repo.#findBySearch(search, true);
755
- if (existing) {
756
- for (const [k, v] of Object.entries(values)) existing.setProp(k, v);
757
- await repo.save(existing);
758
- return existing;
759
- }
760
- return repo.create({ ...search, ...values });
761
- });
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;
1253
+ }
1254
+ return search;
1255
+ }
1256
+
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;
762
1330
  }
763
1331
 
764
1332
  async #findBySearch(
@@ -778,25 +1346,45 @@ export class BaseRepository<T extends BaseEntity> {
778
1346
  * contract — callback receives the raw value (including null/undefined) and
779
1347
  * decides what to do with it.
780
1348
  */
781
- #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;
782
1355
  const prepare = this.#columnPrepares.get(propertyKey);
783
- if (!prepare) return value;
784
- let result: unknown;
785
- try {
786
- result = prepare(value);
787
- } catch (err) {
788
- throw wrapAdapterError("prepare", propertyKey, err);
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;
789
1367
  }
790
- assertNotPromise("prepare", propertyKey, result);
791
- 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;
792
1379
  }
793
1380
 
794
- #applyConsume(propertyKey: string, value: unknown): unknown {
1381
+ #applyConsume(propertyKey: string, value: unknown, model?: unknown): unknown {
795
1382
  const consume = this.#columnConsumes.get(propertyKey);
796
1383
  if (consume) {
797
1384
  let result: unknown;
798
1385
  try {
799
- result = consume(value);
1386
+ // Adonis Lucid signature: (value, attribute, model).
1387
+ result = consume(value, propertyKey, model);
800
1388
  } catch (err) {
801
1389
  throw wrapAdapterError("consume", propertyKey, err);
802
1390
  }
@@ -804,19 +1392,13 @@ export class BaseRepository<T extends BaseEntity> {
804
1392
  return result;
805
1393
  }
806
1394
  // No explicit `@Column({ consume })`: a `@column.date()` / `@column.dateTime()`
807
- // column hydrates its DB value (an ISO string from the Rust decode) into a
808
- // JS `Date`, so `.getTime()` / date arithmetic work on read. Mirrors Adonis
809
- // Lucid hydrating date columns to a Luxon `DateTime` atlas standardises on
810
- // the native `Date` (no Luxon dependency). An unparseable string is left
811
- // untouched rather than turned into `Invalid Date`.
812
- if (
813
- this.#dateColumns[propertyKey] &&
814
- value != null &&
815
- !(value instanceof Date) &&
816
- (typeof value === "string" || typeof value === "number")
817
- ) {
818
- const d = new Date(value);
819
- if (!Number.isNaN(d.getTime())) return d;
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);
820
1402
  }
821
1403
  return value;
822
1404
  }
@@ -828,10 +1410,9 @@ export class BaseRepository<T extends BaseEntity> {
828
1410
  // the Rust DML compiler / NAPI layer rejects it. `null` is allowed
829
1411
  // through because that's a meaningful SQL value.
830
1412
  if (v === undefined) continue;
831
- // Prepare map is keyed by camelCase property name. The input bag may use
832
- // either camel or snake try the raw key first, else convert.
833
- const propKey = this.#columnPrepares.has(k) ? k : snakeToCamel(k);
834
- pairs.push([this.#resolveColumn(k), this.#applyPrepare(propKey, v)]);
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)]);
835
1416
  }
836
1417
  return pairs;
837
1418
  }
@@ -841,32 +1422,43 @@ export class BaseRepository<T extends BaseEntity> {
841
1422
  for (const col of this.#columns) {
842
1423
  const v = entity[col];
843
1424
  if (v !== undefined)
844
- pairs.push([camelToSnake(col), this.#applyPrepare(col, v)]);
1425
+ pairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
845
1426
  }
846
1427
  return pairs;
847
1428
  }
848
1429
 
849
1430
  /** Delete the entity. Fires `beforeDelete` → DB → `afterDelete`. Soft-delete aware. */
850
- async delete(entity: T): Promise<void> {
851
- 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);
852
1436
  const pk = entity[this.#primaryKey];
853
1437
  if (this.#softDeletes) {
854
- const now = new Date().toISOString();
1438
+ const now = DateTime.now();
855
1439
  await this.#runUpdate(
856
- [["deleted_at", now]],
1440
+ [[this.#dbColumn("deletedAt"), now.toISO()]],
857
1441
  [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }],
858
1442
  );
1443
+ // In-memory value is a Chronos DateTime, matching how date columns hydrate.
859
1444
  entity.setProp("deletedAt", now);
860
1445
  } else {
861
1446
  await this.#runDelete([
862
1447
  { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
863
1448
  ]);
864
1449
  }
865
- 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);
866
1457
  }
867
1458
 
868
1459
  /** Permanently delete (bypasses soft delete). Fires `beforeDelete` / `afterDelete` hooks. */
869
1460
  async forceDelete(entity: T): Promise<void> {
1461
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "forceDelete()");
870
1462
  await fireHooks(this.#entityClass, "beforeDelete", entity);
871
1463
  await this.#runDelete([
872
1464
  {
@@ -876,13 +1468,15 @@ export class BaseRepository<T extends BaseEntity> {
876
1468
  type: "and",
877
1469
  },
878
1470
  ]);
1471
+ entity.markAsDeleted();
879
1472
  await fireHooks(this.#entityClass, "afterDelete", entity);
880
1473
  }
881
1474
 
882
1475
  async restore(entity: T): Promise<void> {
883
1476
  if (!this.#softDeletes) return;
1477
+ this.#assertPersistedRow(entity, entity[this.#primaryKey], "restore()");
884
1478
  await this.#runUpdate(
885
- [["deleted_at", null]],
1479
+ [[this.#dbColumn("deletedAt"), null]],
886
1480
  [
887
1481
  {
888
1482
  column: this.#primaryKey,
@@ -915,7 +1509,14 @@ export class BaseRepository<T extends BaseEntity> {
915
1509
  const whereCol = this.#resolveColumn(column);
916
1510
  const set = this.#buildSetPairs(data);
917
1511
  await this.#runUpdate(set, [
918
- { 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
+ },
919
1520
  ]);
920
1521
  }
921
1522
 
@@ -973,6 +1574,21 @@ export class BaseRepository<T extends BaseEntity> {
973
1574
  // ─── Raw ──────────────────────────────────────────────────
974
1575
 
975
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
+ }
976
1592
  const rows = await this.#db.query<Row>(sql, params);
977
1593
  return rows.map((r) => this.#hydrate(r));
978
1594
  }
@@ -988,6 +1604,46 @@ export class BaseRepository<T extends BaseEntity> {
988
1604
 
989
1605
  // ─── Private helpers ──────────────────────────────────────
990
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
+
991
1647
  async #runDelete(wheres: Array<Record<string, unknown>>): Promise<void> {
992
1648
  const compiled = compileStatementNative(
993
1649
  { kind: "delete", table: this.#tableName, wheres },
@@ -1038,8 +1694,8 @@ export class BaseRepository<T extends BaseEntity> {
1038
1694
  values,
1039
1695
  casts: this.#castTypes,
1040
1696
  returning: [
1041
- camelToSnake(this.#primaryKey),
1042
- ...this.#columns.map((c) => camelToSnake(c)),
1697
+ this.#dbColumn(this.#primaryKey),
1698
+ ...this.#columns.map((c) => this.#dbColumn(c)),
1043
1699
  ],
1044
1700
  }
1045
1701
  : {
@@ -1077,8 +1733,11 @@ export class BaseRepository<T extends BaseEntity> {
1077
1733
  // callers see them on the entity without an extra `find()`. Mirrors
1078
1734
  // `createMany`, where the multi-row path already does this.
1079
1735
  if (result.row) {
1080
- for (const [k, v] of Object.entries(result.row))
1081
- 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
+ }
1082
1741
  } else if (
1083
1742
  result.lastInsertRowid !== undefined &&
1084
1743
  !isProvidedPk(entity[this.#primaryKey])
@@ -1088,6 +1747,7 @@ export class BaseRepository<T extends BaseEntity> {
1088
1747
  // After a successful INSERT, the entity is now persisted — snapshot
1089
1748
  // its columns so subsequent dirty checks compare against the DB state.
1090
1749
  entity.markAsPersisted();
1750
+ this.#trackInsert(entity);
1091
1751
  }
1092
1752
 
1093
1753
  /**
@@ -1097,17 +1757,23 @@ export class BaseRepository<T extends BaseEntity> {
1097
1757
  * `save()` is called defensively without any real mutation).
1098
1758
  */
1099
1759
  async #update(entity: T): Promise<void> {
1100
- // Auto-bump @column.dateTime({ autoUpdate: true }) BEFORE computing $dirty
1101
- // so the bumped column lands in the SET if anything else is dirty.
1102
- this.#applyAutoTimestamps(entity, "update");
1760
+ const forced = entity.$consumeForceUpdate();
1761
+ const pk = entity[this.#primaryKey];
1103
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
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");
1104
1773
  const dirty = entity.$dirty;
1105
- const pk = entity[this.#primaryKey];
1106
1774
  // Primary key is never part of the SET — it's the WHERE.
1107
1775
  delete dirty[this.#primaryKey];
1108
1776
 
1109
- if (Object.keys(dirty).length === 0) return; // nothing changed
1110
-
1111
1777
  // Map dirty camelCase keys to snake_case DB columns. `$dirty` keys are
1112
1778
  // already camelCase (they come from `entity.setProp` / direct assignment),
1113
1779
  // so the prepare lookup uses `k` as-is. Skip explicit `undefined`
@@ -1116,7 +1782,17 @@ export class BaseRepository<T extends BaseEntity> {
1116
1782
  const setPairs: Array<[string, unknown]> = [];
1117
1783
  for (const [k, v] of Object.entries(dirty)) {
1118
1784
  if (v === undefined) continue;
1119
- 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
+ }
1120
1796
  }
1121
1797
  if (setPairs.length === 0) {
1122
1798
  // All dirty entries were `undefined` (skipped above). Re-snapshot
@@ -1155,7 +1831,9 @@ export class BaseRepository<T extends BaseEntity> {
1155
1831
  * on the entity before persistence. Called from `#insert` and `#update`.
1156
1832
  */
1157
1833
  #applyAutoTimestamps(entity: T, phase: "insert" | "update"): void {
1158
- 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();
1159
1837
  for (const [prop, cfg] of Object.entries(this.#dateColumns)) {
1160
1838
  if (phase === "insert") {
1161
1839
  if (cfg.autoCreate && entity[prop] === undefined) {
@@ -1173,32 +1851,47 @@ export class BaseRepository<T extends BaseEntity> {
1173
1851
  #hydrate(row: Record<string, unknown>): T {
1174
1852
  const entity = new this.#entityClass();
1175
1853
  for (const [key, value] of Object.entries(row)) {
1176
- const camelKey = snakeToCamel(key);
1177
1854
  // Resolve against declared column metadata, not `in entity` — fields
1178
1855
  // using Adonis' `declare field: T` pattern are not own-properties of
1179
- // a freshly constructed instance.
1180
- const targetKey = this.#validColumns.has(camelKey)
1181
- ? camelKey
1182
- : this.#validColumns.has(key)
1183
- ? key
1184
- : null;
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);
1185
1867
  if (!targetKey) continue;
1186
1868
  // Apply `@Column({ consume })` if declared on this property. Unlike the
1187
1869
  // previous registry-based design, the callback receives every value
1188
1870
  // including `null` / `undefined` — the user's `consume` is responsible
1189
1871
  // for its own null-handling, matching Adonis Lucid's contract.
1190
- entity.setProp(targetKey, this.#applyConsume(targetKey, value));
1872
+ entity.setProp(targetKey, this.#applyConsume(targetKey, value, entity));
1191
1873
  }
1192
1874
  // Freeze the original snapshot — from now on, only columns changed AFTER
1193
1875
  // hydration are considered dirty by `entity.$dirty`.
1194
1876
  entity.markAsPersisted();
1195
- // 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 {
1196
1890
  Object.defineProperty(entity, REPO_REF, {
1197
1891
  value: this,
1198
1892
  enumerable: false,
1199
1893
  configurable: true,
1200
1894
  });
1201
- return entity;
1202
1895
  }
1203
1896
 
1204
1897
  /**
@@ -1209,11 +1902,7 @@ export class BaseRepository<T extends BaseEntity> {
1209
1902
  */
1210
1903
  async refresh(entity: BaseEntity): Promise<void> {
1211
1904
  const pk = entity[this.#primaryKey];
1212
- if (pk === undefined || pk === null) {
1213
- throw new EntityNotFoundError(this.#entityClass.name, {
1214
- [this.#primaryKey]: pk,
1215
- });
1216
- }
1905
+ this.#assertPersistedRow(entity, pk, "refresh()");
1217
1906
  const fresh = await this.find(pk as string | number);
1218
1907
  if (!fresh) {
1219
1908
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -1246,11 +1935,7 @@ export class BaseRepository<T extends BaseEntity> {
1246
1935
  alias?: string,
1247
1936
  ): Promise<void> {
1248
1937
  const pk = entity[this.#primaryKey];
1249
- if (pk === undefined || pk === null) {
1250
- throw new EntityNotFoundError(this.#entityClass.name, {
1251
- [this.#primaryKey]: pk,
1252
- });
1253
- }
1938
+ this.#assertPersistedRow(entity, pk, "loadCount()");
1254
1939
  const finalAlias = alias ?? `${relationName}_count`;
1255
1940
  const q = this.query()
1256
1941
  .where(this.#primaryKey, pk)
@@ -1273,11 +1958,7 @@ export class BaseRepository<T extends BaseEntity> {
1273
1958
  build: (q: unknown) => void,
1274
1959
  ): Promise<void> {
1275
1960
  const pk = entity[this.#primaryKey];
1276
- if (pk === undefined || pk === null) {
1277
- throw new EntityNotFoundError(this.#entityClass.name, {
1278
- [this.#primaryKey]: pk,
1279
- });
1280
- }
1961
+ this.#assertPersistedRow(entity, pk, "loadAggregate()");
1281
1962
  let capturedAlias: string | undefined;
1282
1963
  const q = this.query()
1283
1964
  .where(this.#primaryKey, pk)
@@ -1302,11 +1983,7 @@ export class BaseRepository<T extends BaseEntity> {
1302
1983
  callback?: (q: unknown) => void,
1303
1984
  ): Promise<void> {
1304
1985
  const pk = entity[this.#primaryKey];
1305
- if (pk === undefined || pk === null) {
1306
- throw new EntityNotFoundError(this.#entityClass.name, {
1307
- [this.#primaryKey]: pk,
1308
- });
1309
- }
1986
+ this.#assertPersistedRow(entity, pk, "loadRelation()");
1310
1987
  const q = this.query().where(this.#primaryKey, pk);
1311
1988
  if (callback)
1312
1989
  q.preload(relationName, callback as (q: ModelQuery<BaseEntity>) => void);
@@ -1334,15 +2011,21 @@ export class BaseRepository<T extends BaseEntity> {
1334
2011
  `Relation '${relationName}' not found on ${this.#entityClass.name}`,
1335
2012
  );
1336
2013
  const relatedClass = relation.target() as new () => BaseEntity;
1337
- const relatedMeta = getEntityMetadata(relatedClass);
1338
- if (!relatedMeta)
1339
- throw new Error(
1340
- `Entity metadata missing on related class ${relatedClass.name}`,
1341
- );
1342
- const relatedTable = relatedMeta.tableName;
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;
1343
2020
  const parentPk =
1344
2021
  relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
1345
- 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}'`;
1346
2029
  const relatedRepo = new BaseRepository<BaseEntity>(relatedClass, this.#db, {
1347
2030
  dialect: this.#dialect,
1348
2031
  });
@@ -1362,54 +2045,188 @@ export class BaseRepository<T extends BaseEntity> {
1362
2045
 
1363
2046
  const injectFk = (
1364
2047
  data: Record<string, unknown>,
2048
+ fkValue: unknown,
1365
2049
  ): Record<string, unknown> => ({
1366
2050
  ...data,
1367
- [fkCol]: parentIdValue,
1368
- [fkProp]: parentIdValue,
2051
+ [fkCol]: fkValue,
2052
+ [fkProp]: fkValue,
1369
2053
  });
1370
2054
 
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
+ });
2106
+ }
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
+
1371
2161
  // Shared "has" proxy methods (create/createMany/save/saveMany +
1372
- // firstOrCreate/updateOrCreate scoped to this parent's FK).
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.
1373
2165
  const hasOps = {
1374
- async create(data: Record<string, unknown>) {
1375
- return relatedRepo.create(injectFk(data));
1376
- },
1377
- async createMany(rows: Array<Record<string, unknown>>) {
1378
- return relatedRepo.createMany(rows.map(injectFk));
1379
- },
1380
- // Scope the search to the parent's FK column so the lookup only sees
1381
- // this parent's rows; inject the FK into the created/updated row. The
1382
- // related repo's firstOrCreate/updateOrCreate are atomic (txn + lock).
1383
- async firstOrCreate(
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: (
1384
2183
  search: Record<string, unknown>,
1385
2184
  defaults: Record<string, unknown> = {},
1386
- ) {
1387
- return relatedRepo.firstOrCreate(
1388
- { ...search, [fkCol]: parentIdValue },
1389
- injectFk(defaults),
1390
- );
1391
- },
1392
- async updateOrCreate(
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: (
1393
2197
  search: Record<string, unknown>,
1394
2198
  values: Record<string, unknown>,
1395
- ) {
1396
- return relatedRepo.updateOrCreate(
1397
- { ...search, [fkCol]: parentIdValue },
1398
- injectFk(values),
1399
- );
1400
- },
1401
- async save(related: BaseEntity) {
1402
- related.setProp(fkCol, parentIdValue);
1403
- related.setProp(fkProp, parentIdValue);
1404
- await relatedRepo.save(related);
1405
- },
1406
- async saveMany(related: BaseEntity[]) {
1407
- for (const r of related) {
1408
- r.setProp(fkCol, parentIdValue);
1409
- r.setProp(fkProp, parentIdValue);
1410
- }
1411
- return relatedRepo.saveMany(related);
1412
- },
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
+ }),
1413
2230
  };
1414
2231
 
1415
2232
  // Scoped query builder (Story 31.9) — pre-applies the FK predicate
@@ -1425,7 +2242,14 @@ export class BaseRepository<T extends BaseEntity> {
1425
2242
  pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1426
2243
  const pivotOther =
1427
2244
  pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1428
- 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);
1429
2253
  // Inline validated quote (same policy as the m2m branch below).
1430
2254
  const dialect = this.#dialect;
1431
2255
  const quote = (name: string): string => {
@@ -1434,33 +2258,98 @@ export class BaseRepository<T extends BaseEntity> {
1434
2258
  }
1435
2259
  return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1436
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(".");
1437
2268
  // The bound `?` carries the parent PK type (often uuid). A raw `?`
1438
2269
  // can't be cast by the structured `casts` mechanism, so emit the
1439
2270
  // `::uuid` inline — `whereRaw` rewrites `?`→`$N`, yielding `$N::uuid`.
1440
2271
  // Postgres-only; sqlite/mysql coerce. Without it: `pivotFk = $N` is
1441
2272
  // `uuid = text`.
1442
- 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)];
1443
2277
  const ph =
1444
2278
  dialect === "postgres" && parentPkCast ? `?::${parentPkCast}` : "?";
1445
- // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk)
1446
- // Framework-internal raw fragment (identifiers already validated by
1447
- // the `quote` helper above) bypass strict mode so this path still
1448
- // works when the user enables `setAtlasStrictMode(true)` on their app.
1449
- runWithAtlasInternalBypass(() => {
1450
- q.whereRaw(
1451
- `EXISTS (SELECT 1 FROM ${quote(pivot.pivotTable)} ` +
1452
- `WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} = ${ph} ` +
1453
- `AND ${quote(pivot.pivotTable)}.${quote(pivotOther)} = ${quote(relatedTable)}.${quote(relatedPk)})`,
1454
- [parentIdValue],
1455
- );
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 };
1456
2311
  });
1457
2312
  } else if (relation.type === "belongsTo") {
1458
2313
  const ownerKey =
1459
2314
  relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1460
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
+ );
1461
2350
  } else {
1462
2351
  // hasOne / hasMany
1463
- q.where(fkCol, parentIdValue);
2352
+ q.where(fkCol, readParentId());
1464
2353
  }
1465
2354
  return q;
1466
2355
  };
@@ -1472,10 +2361,24 @@ export class BaseRepository<T extends BaseEntity> {
1472
2361
  // the standard TS idiom for widening a generic `this` — safe because
1473
2362
  // `T extends BaseEntity`.
1474
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
+ };
1475
2375
  const proxy: BelongsToRelationProxy = {
1476
2376
  type: "belongsTo",
1477
- ...hasOps,
1478
2377
  query: scopedQuery,
2378
+ create: () => rejectWrite("create"),
2379
+ save: () => rejectWrite("save"),
2380
+ createMany: () => rejectWrite("createMany"),
2381
+ saveMany: () => rejectWrite("saveMany"),
1479
2382
  async associate(model: BaseEntity) {
1480
2383
  if (model === null || model === undefined) {
1481
2384
  throw new Error(
@@ -1484,10 +2387,83 @@ export class BaseRepository<T extends BaseEntity> {
1484
2387
  }
1485
2388
  const ownerKey =
1486
2389
  relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1487
- const fkValue = model[ownerKey];
1488
- entity.setProp(fkCol, fkValue);
1489
- entity.setProp(fkProp, fkValue);
1490
- await parentRepo.save(entity);
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
+ });
1491
2467
  },
1492
2468
  async dissociate() {
1493
2469
  entity.setProp(fkCol, null);
@@ -1517,16 +2493,20 @@ export class BaseRepository<T extends BaseEntity> {
1517
2493
  // every pivot statement (sync's currentIds SELECT, detach DELETE, attach
1518
2494
  // INSERT) must carry these explicitly, else `pivotFk = $1` is `uuid = text`.
1519
2495
  const pivotKeyCasts: Record<string, string> = {};
1520
- 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)];
1521
2500
  if (parentPkCast) pivotKeyCasts[pivotFk] = parentPkCast;
1522
- const relatedPkCast =
1523
- computeCastTypes(relatedClass)[
1524
- camelToSnake(getPrimaryKey(relatedClass) ?? "id")
1525
- ];
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];
1526
2506
  if (relatedPkCast) pivotKeyCasts[pivotOther] = relatedPkCast;
1527
2507
 
1528
2508
  /**
1529
- * Resolve pivot timestamp column names from the decorator config.
2509
+ * Pivot timestamp column names, resolved once from the decorator config.
1530
2510
  *
1531
2511
  * Three forms supported:
1532
2512
  * - `pivotTimestamps: true` → { created_at, updated_at } default names
@@ -1536,53 +2516,123 @@ export class BaseRepository<T extends BaseEntity> {
1536
2516
  * `false` opts a timestamp out; a string overrides the column name;
1537
2517
  * `undefined` falls back to the default name.
1538
2518
  */
1539
- 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> => {
1540
2542
  if (!tsConfig) return {};
1541
2543
  const now = new Date().toISOString();
1542
- let createdCol: string | null;
1543
- let updatedCol: string | null;
1544
- if (tsConfig === true) {
1545
- createdCol = "created_at";
1546
- updatedCol = "updated_at";
1547
- } else {
1548
- createdCol =
1549
- tsConfig.createdAt === false
1550
- ? null
1551
- : (tsConfig.createdAt ?? "created_at");
1552
- updatedCol =
1553
- tsConfig.updatedAt === false
1554
- ? null
1555
- : (tsConfig.updatedAt ?? "updated_at");
1556
- }
1557
2544
  const out: Record<string, unknown> = {};
1558
- if (createdCol) out[createdCol] = now;
2545
+ if (mode === "insert" && createdCol) out[createdCol] = now;
1559
2546
  if (updatedCol) out[updatedCol] = now;
1560
2547
  return out;
1561
2548
  };
1562
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
+
1563
2566
  const normalizeAttach = (
1564
2567
  arg: Array<string | number> | Record<string, Record<string, unknown>>,
1565
2568
  ): Array<{ id: string | number; extras: Record<string, unknown> }> => {
1566
- if (Array.isArray(arg)) return arg.map((id) => ({ id, extras: {} }));
1567
- return Object.entries(arg).map(([id, extras]) => ({ id, extras }));
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
+ }));
1568
2575
  };
1569
2576
 
1570
- // Current pivot rows compiled through the Rust SELECT path so the
1571
- // pivot identifiers go through `quote_identifier` (rejects anything
1572
- // outside `[A-Za-z0-9_]`), rather than through the ad-hoc `quote`
1573
- // helper that would blindly wrap a malicious metadata string.
1574
- //
1575
- // Now async — every site in `sync()` is in an async closure.
1576
- const currentIds = async (): Promise<Array<string | number>> => {
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
+ }
2609
+ };
2610
+
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
+ > => {
1577
2627
  const selectSpec = {
1578
2628
  kind: "select",
1579
2629
  table: pivotTable,
1580
- select: [pivotOther],
2630
+ select: [pivotOther, ...attrCols],
1581
2631
  wheres: [
1582
2632
  {
1583
2633
  column: pivotFk,
1584
2634
  operator: "=",
1585
- value: parentIdValue,
2635
+ value: readParentId(),
1586
2636
  type: "and",
1587
2637
  },
1588
2638
  ],
@@ -1600,19 +2650,27 @@ export class BaseRepository<T extends BaseEntity> {
1600
2650
  casts: pivotKeyCasts,
1601
2651
  };
1602
2652
  const compiled = compileStatementNative(selectSpec, dialect);
1603
- const rows = await db.query<Record<string, unknown>>(
2653
+ const rows = await conn.query<Record<string, unknown>>(
1604
2654
  compiled.statements[0],
1605
2655
  compiled.params,
1606
2656
  );
1607
- return rows.map((r) => r[pivotOther] as string | number);
2657
+ return rows.map((r) => ({ id: asId(r[pivotOther]), row: r }));
1608
2658
  };
1609
2659
 
1610
2660
  // Delete via the Rust DELETE compiler so the pivot table + columns get
1611
2661
  // `quote_identifier` validation (rejects `"`, `;`, etc.) — safer than
1612
2662
  // the previous hand-built SQL with a dumb `"` wrapper.
1613
- 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> => {
1614
2667
  const wheres: Array<Record<string, unknown>> = [
1615
- { column: pivotFk, operator: "=", value: parentIdValue, type: "and" },
2668
+ {
2669
+ column: pivotFk,
2670
+ operator: "=",
2671
+ value: readParentId(),
2672
+ type: "and",
2673
+ },
1616
2674
  ];
1617
2675
  if (ids && ids.length > 0) {
1618
2676
  wheres.push({
@@ -1630,62 +2688,32 @@ export class BaseRepository<T extends BaseEntity> {
1630
2688
  casts: pivotKeyCasts,
1631
2689
  };
1632
2690
  const compiled = compileStatementNative(spec, dialect);
1633
- await db.execute(compiled.statements[0], compiled.params);
2691
+ await conn.execute(compiled.statements[0], compiled.params);
1634
2692
  };
1635
2693
 
1636
2694
  const attach = async (
1637
2695
  ids: Array<string | number> | Record<string, Record<string, unknown>>,
2696
+ conn: DatabaseConnection = db,
2697
+ parentFk: unknown = readParentId(),
1638
2698
  ): Promise<void> => {
1639
2699
  const entries = normalizeAttach(ids);
1640
2700
  if (entries.length === 0) return;
1641
- const ts = resolveTimestamps();
1642
- // Normalize heterogeneous extras: compute the union of extra keys
1643
- // across all entries and back-fill missing keys with `null`, so every
1644
- // row in the multi-insert shares the same column set (required by the
1645
- // Rust compiler's homogeneity check).
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).
1646
2705
  const extraKeys = new Set<string>();
1647
2706
  for (const e of entries) {
1648
2707
  for (const k of Object.keys(e.extras)) extraKeys.add(k);
1649
2708
  }
1650
- // Reject extras keys that collide with reserved pivot columns. Without
1651
- // this guard, an extras entry named after the FK or a timestamp column
1652
- // would emit a duplicate column in the INSERT row pair: the FK case
1653
- // silently overrides `parentIdValue` (corrupting the join); the
1654
- // timestamp case duplicates the column entirely (driver-dependent
1655
- // failure or last-wins overwrite).
1656
- for (const k of extraKeys) {
1657
- if (k === pivotFk || k === pivotOther) {
1658
- throw new Error(
1659
- `Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`,
1660
- );
1661
- }
1662
- if (Object.hasOwn(ts, k)) {
1663
- throw new Error(
1664
- `Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`,
1665
- );
1666
- }
1667
- }
2709
+ for (const k of extraKeys) assertExtraKeyAllowed(k);
1668
2710
  const rowPairs = entries.map((e) => {
1669
2711
  const pairs: Array<[string, unknown]> = [
1670
- [pivotFk, parentIdValue],
2712
+ [pivotFk, parentFk],
1671
2713
  [pivotOther, e.id],
1672
2714
  ];
1673
- for (const k of extraKeys) {
1674
- const raw = e.extras[k] ?? null;
1675
- const prepare = pivotAdapters?.[k]?.prepare;
1676
- if (!prepare) {
1677
- pairs.push([k, raw]);
1678
- continue;
1679
- }
1680
- let encoded: unknown;
1681
- try {
1682
- encoded = prepare(raw);
1683
- } catch (err) {
1684
- throw wrapAdapterError("prepare", k, err);
1685
- }
1686
- assertNotPromise("prepare", k, encoded);
1687
- pairs.push([k, encoded]);
1688
- }
2715
+ for (const k of extraKeys)
2716
+ pairs.push([k, encodeExtra(k, e.extras[k] ?? null)]);
1689
2717
  for (const [k, v] of Object.entries(ts)) pairs.push([k, v]);
1690
2718
  return pairs;
1691
2719
  });
@@ -1701,20 +2729,55 @@ export class BaseRepository<T extends BaseEntity> {
1701
2729
  casts: pivotCasts,
1702
2730
  };
1703
2731
  const compiled = compileStatementNative(spec, dialect);
1704
- 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);
1705
2771
  };
1706
2772
 
1707
2773
  /**
1708
- * Diff the current pivot state against a target set and apply the
1709
- * minimum attach/detach to converge.
1710
- *
1711
- * **NOT ATOMIC.** `sync` reads the pivot, computes the diff, then
1712
- * writes another process mutating the pivot between the read and
1713
- * the writes will cause divergence. Wrap the call in a transaction
1714
- * if you need strong consistency under concurrent writers.
1715
- *
1716
- * On SQLite this is typically fine because better-sqlite3 serializes
1717
- * writes per connection; on Postgres/MySQL use `useTransaction` first.
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.
1718
2781
  */
1719
2782
  const sync = async (
1720
2783
  target:
@@ -1722,61 +2785,175 @@ export class BaseRepository<T extends BaseEntity> {
1722
2785
  | Record<string, Record<string, unknown>>,
1723
2786
  additive = false,
1724
2787
  ): Promise<void> => {
1725
- const current = new Set(await currentIds());
1726
2788
  const entries = normalizeAttach(target);
1727
- const desired = new Set(entries.map((e) => e.id));
1728
- const toAttach = entries.filter((e) => !current.has(e.id));
1729
- const toDetach = additive
1730
- ? []
1731
- : [...current].filter((id) => !desired.has(id));
1732
- if (toDetach.length > 0) await detach(toDetach);
1733
- if (toAttach.length > 0) {
1734
- const attachArg: Record<string, Record<string, unknown>> = {};
1735
- for (const e of toAttach) attachArg[String(e.id)] = e.extras;
1736
- await attach(attachArg);
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);
1737
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
+ });
1738
2842
  };
1739
2843
 
1740
2844
  // m2m create/save persist the related row THEN insert a pivot row —
1741
2845
  // NOT `hasOps.injectFk`, which would write a bogus `<parent>_id` column
1742
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.
1743
2851
  const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
1744
- const attachIds = (rows: BaseEntity[]): Promise<void> => {
2852
+ const attachRows = (
2853
+ rows: BaseEntity[],
2854
+ trx: TransactionClient,
2855
+ fk: unknown,
2856
+ ): Promise<void> => {
1745
2857
  if (rows.length === 0) return Promise.resolve();
1746
2858
  const arg: Record<string, Record<string, unknown>> = {};
1747
2859
  for (const r of rows) arg[String(r[relatedPkProp])] = {};
1748
- return attach(arg);
2860
+ return attach(arg, trx, fk);
1749
2861
  };
1750
2862
  const m2mOps = {
1751
- async create(data: Record<string, unknown>): Promise<BaseEntity> {
1752
- const created = await relatedRepo.create(data);
1753
- await attachIds([created]);
1754
- return created;
1755
- },
1756
- async createMany(
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: (
1757
2871
  rows: Array<Record<string, unknown>>,
1758
- ): Promise<BaseEntity[]> {
1759
- const created = await relatedRepo.createMany(rows);
1760
- await attachIds(created);
1761
- return created;
1762
- },
1763
- async save(related: BaseEntity): Promise<void> {
1764
- await relatedRepo.save(related);
1765
- await attachIds([related]);
1766
- },
1767
- async saveMany(related: BaseEntity[]): Promise<BaseEntity[]> {
1768
- const saved = await relatedRepo.saveMany(related);
1769
- await attachIds(saved);
1770
- return saved;
1771
- },
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
+ }),
1772
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
+ );
1773
2913
  const proxy: ManyToManyRelationProxy = {
1774
2914
  type: "manyToMany",
1775
2915
  ...m2mOps,
1776
2916
  query: scopedQuery,
1777
- attach,
1778
- detach,
1779
- 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"),
1780
2957
  };
1781
2958
  return proxy;
1782
2959
  }
@@ -1816,11 +2993,7 @@ export class BaseRepository<T extends BaseEntity> {
1816
2993
 
1817
2994
  async fresh(entity: T): Promise<T> {
1818
2995
  const pk = entity[this.#primaryKey];
1819
- if (pk === undefined || pk === null) {
1820
- throw new EntityNotFoundError(this.#entityClass.name, {
1821
- [this.#primaryKey]: pk,
1822
- });
1823
- }
2996
+ this.#assertPersistedRow(entity, pk, "fresh()");
1824
2997
  const found = await this.find(pk as string | number);
1825
2998
  if (!found) {
1826
2999
  throw new EntityNotFoundError(this.#entityClass.name, {
@@ -1840,7 +3013,7 @@ export class BaseRepository<T extends BaseEntity> {
1840
3013
  for (const col of this.#columns) {
1841
3014
  const value = entity[col];
1842
3015
  if (value !== undefined) {
1843
- row[camelToSnake(col)] = this.#applyPrepare(col, value);
3016
+ row[this.#dbColumn(col)] = this.#applyPrepare(col, value, entity);
1844
3017
  }
1845
3018
  }
1846
3019
  return row;
@@ -1853,11 +3026,8 @@ export class BaseRepository<T extends BaseEntity> {
1853
3026
  for (const [key, value] of Object.entries(data)) {
1854
3027
  // Mirror `#plainToRowPairs` — skip undefined so updates can't bind it.
1855
3028
  if (value === undefined) continue;
1856
- const propKey = this.#columnPrepares.has(key) ? key : snakeToCamel(key);
1857
- pairs.push([
1858
- this.#resolveColumn(key),
1859
- this.#applyPrepare(propKey, value),
1860
- ]);
3029
+ // `#applyPrepare` normalises the key (property / snake / columnName).
3030
+ pairs.push([this.#resolveColumn(key), this.#applyPrepare(key, value)]);
1861
3031
  }
1862
3032
  return pairs;
1863
3033
  }
@@ -1892,7 +3062,7 @@ export class BaseRepository<T extends BaseEntity> {
1892
3062
  * adapter rejected — the dev has to bisect across every adapter-tagged
1893
3063
  * property to find the culprit.
1894
3064
  */
1895
- function wrapAdapterError(
3065
+ export function wrapAdapterError(
1896
3066
  phase: "prepare" | "consume",
1897
3067
  propertyKey: string,
1898
3068
  err: unknown,
@@ -1914,7 +3084,7 @@ function wrapAdapterError(
1914
3084
  * gives the user a column-annotated error instead of an opaque "Invalid bind
1915
3085
  * value" downstream when the unawaited Promise hits the NAPI boundary.
1916
3086
  */
1917
- function assertNotPromise(
3087
+ export function assertNotPromise(
1918
3088
  phase: "prepare" | "consume",
1919
3089
  propertyKey: string,
1920
3090
  value: unknown,