@c9up/atlas 0.1.18 → 0.2.0

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