@c9up/atlas 0.1.3 → 0.1.4

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 (143) hide show
  1. package/db.darwin-arm64.node +0 -0
  2. package/db.darwin-x64.node +0 -0
  3. package/db.linux-arm64-gnu.node +0 -0
  4. package/db.linux-x64-gnu.node +0 -0
  5. package/db.win32-x64-msvc.node +0 -0
  6. package/dist/AtlasProvider.d.ts +106 -0
  7. package/dist/AtlasProvider.d.ts.map +1 -0
  8. package/dist/AtlasProvider.js +190 -0
  9. package/dist/AtlasProvider.js.map +1 -0
  10. package/dist/BaseEntity.d.ts +255 -0
  11. package/dist/BaseEntity.d.ts.map +1 -0
  12. package/dist/BaseEntity.js +420 -0
  13. package/dist/BaseEntity.js.map +1 -0
  14. package/dist/BaseRepository.d.ts +175 -0
  15. package/dist/BaseRepository.d.ts.map +1 -0
  16. package/dist/BaseRepository.js +1394 -0
  17. package/dist/BaseRepository.js.map +1 -0
  18. package/dist/ModelQuery.d.ts +338 -0
  19. package/dist/ModelQuery.d.ts.map +1 -0
  20. package/dist/ModelQuery.js +1598 -0
  21. package/dist/ModelQuery.js.map +1 -0
  22. package/dist/Transaction.d.ts +15 -0
  23. package/dist/Transaction.d.ts.map +1 -0
  24. package/dist/Transaction.js +67 -0
  25. package/dist/Transaction.js.map +1 -0
  26. package/dist/adapters/NapiDbAdapter.d.ts +34 -0
  27. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -0
  28. package/dist/adapters/NapiDbAdapter.js +96 -0
  29. package/dist/adapters/NapiDbAdapter.js.map +1 -0
  30. package/dist/config.d.ts +4 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +4 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/configure.d.ts +10 -0
  35. package/dist/configure.d.ts.map +1 -0
  36. package/dist/configure.js +25 -0
  37. package/dist/configure.js.map +1 -0
  38. package/dist/decorators/entity.d.ts +235 -0
  39. package/dist/decorators/entity.d.ts.map +1 -0
  40. package/dist/decorators/entity.js +264 -0
  41. package/dist/decorators/entity.js.map +1 -0
  42. package/dist/decorators/hooks.d.ts +70 -0
  43. package/dist/decorators/hooks.d.ts.map +1 -0
  44. package/dist/decorators/hooks.js +107 -0
  45. package/dist/decorators/hooks.js.map +1 -0
  46. package/dist/decorators/scope.d.ts +35 -0
  47. package/dist/decorators/scope.d.ts.map +1 -0
  48. package/dist/decorators/scope.js +29 -0
  49. package/dist/decorators/scope.js.map +1 -0
  50. package/dist/errors.d.ts +54 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +82 -0
  53. package/dist/errors.js.map +1 -0
  54. package/dist/index.d.ts +39 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/naming/NamingStrategy.d.ts +52 -0
  59. package/dist/naming/NamingStrategy.d.ts.map +1 -0
  60. package/dist/naming/NamingStrategy.js +65 -0
  61. package/dist/naming/NamingStrategy.js.map +1 -0
  62. package/dist/query/QueryBuilder.d.ts +95 -0
  63. package/dist/query/QueryBuilder.d.ts.map +1 -0
  64. package/dist/query/QueryBuilder.js +310 -0
  65. package/dist/query/QueryBuilder.js.map +1 -0
  66. package/dist/query/native.d.ts +18 -0
  67. package/dist/query/native.d.ts.map +1 -0
  68. package/dist/query/native.js +49 -0
  69. package/dist/query/native.js.map +1 -0
  70. package/dist/schema/Migration.d.ts +55 -0
  71. package/dist/schema/Migration.d.ts.map +1 -0
  72. package/dist/schema/Migration.js +68 -0
  73. package/dist/schema/Migration.js.map +1 -0
  74. package/dist/schema/MigrationRunner.d.ts +115 -0
  75. package/dist/schema/MigrationRunner.d.ts.map +1 -0
  76. package/dist/schema/MigrationRunner.js +402 -0
  77. package/dist/schema/MigrationRunner.js.map +1 -0
  78. package/dist/schema/Schema.d.ts +22 -0
  79. package/dist/schema/Schema.d.ts.map +1 -0
  80. package/dist/schema/Schema.js +51 -0
  81. package/dist/schema/Schema.js.map +1 -0
  82. package/dist/schema/SchemaBuilder.d.ts +14 -0
  83. package/dist/schema/SchemaBuilder.d.ts.map +1 -0
  84. package/dist/schema/SchemaBuilder.js +13 -0
  85. package/dist/schema/SchemaBuilder.js.map +1 -0
  86. package/dist/schema/Seeder.d.ts +54 -0
  87. package/dist/schema/Seeder.d.ts.map +1 -0
  88. package/dist/schema/Seeder.js +89 -0
  89. package/dist/schema/Seeder.js.map +1 -0
  90. package/dist/schema/TableBuilder.d.ts +86 -0
  91. package/dist/schema/TableBuilder.d.ts.map +1 -0
  92. package/dist/schema/TableBuilder.js +211 -0
  93. package/dist/schema/TableBuilder.js.map +1 -0
  94. package/dist/schema/types.d.ts +28 -0
  95. package/dist/schema/types.d.ts.map +1 -0
  96. package/dist/schema/types.js +19 -0
  97. package/dist/schema/types.js.map +1 -0
  98. package/dist/services/db.d.ts +22 -0
  99. package/dist/services/db.d.ts.map +1 -0
  100. package/dist/services/db.js +37 -0
  101. package/dist/services/db.js.map +1 -0
  102. package/dist/testing/DatabaseCleanup.d.ts +19 -0
  103. package/dist/testing/DatabaseCleanup.d.ts.map +1 -0
  104. package/dist/testing/DatabaseCleanup.js +40 -0
  105. package/dist/testing/DatabaseCleanup.js.map +1 -0
  106. package/dist/testing/Factory.d.ts +59 -0
  107. package/dist/testing/Factory.d.ts.map +1 -0
  108. package/dist/testing/Factory.js +105 -0
  109. package/dist/testing/Factory.js.map +1 -0
  110. package/dist/testing/TestDatabase.d.ts +25 -0
  111. package/dist/testing/TestDatabase.d.ts.map +1 -0
  112. package/dist/testing/TestDatabase.js +52 -0
  113. package/dist/testing/TestDatabase.js.map +1 -0
  114. package/dist/testing/index.d.ts +4 -0
  115. package/dist/testing/index.d.ts.map +1 -0
  116. package/dist/testing/index.js +4 -0
  117. package/dist/testing/index.js.map +1 -0
  118. package/dist/utils/casing.d.ts +5 -0
  119. package/dist/utils/casing.d.ts.map +1 -0
  120. package/dist/utils/casing.js +9 -0
  121. package/dist/utils/casing.js.map +1 -0
  122. package/dist/utils/dialectFromUrl.d.ts +10 -0
  123. package/dist/utils/dialectFromUrl.d.ts.map +1 -0
  124. package/dist/utils/dialectFromUrl.js +8 -0
  125. package/dist/utils/dialectFromUrl.js.map +1 -0
  126. package/dist/utils/identifier.d.ts +19 -0
  127. package/dist/utils/identifier.d.ts.map +1 -0
  128. package/dist/utils/identifier.js +27 -0
  129. package/dist/utils/identifier.js.map +1 -0
  130. package/dist/utils/safePath.d.ts +22 -0
  131. package/dist/utils/safePath.d.ts.map +1 -0
  132. package/dist/utils/safePath.js +46 -0
  133. package/dist/utils/safePath.js.map +1 -0
  134. package/dist/utils/transactionBrand.d.ts +5 -0
  135. package/dist/utils/transactionBrand.d.ts.map +1 -0
  136. package/dist/utils/transactionBrand.js +5 -0
  137. package/dist/utils/transactionBrand.js.map +1 -0
  138. package/index.darwin-arm64.node +0 -0
  139. package/index.darwin-x64.node +0 -0
  140. package/index.linux-arm64-gnu.node +0 -0
  141. package/index.linux-x64-gnu.node +0 -0
  142. package/index.win32-x64-msvc.node +0 -0
  143. package/package.json +1 -1
@@ -0,0 +1,1394 @@
1
+ /**
2
+ * BaseRepository — Data Mapper ORM with typed CRUD, soft deletes, and domain events.
3
+ *
4
+ * @implements FR29, FR31, FR35
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { REPO_REF } from "./BaseEntity.js";
8
+ import { getColumnMetadata, getDateColumnConfig, getEntityMetadata, getPrimaryKey, getPrimaryKeyGenerator, getRelationMetadata, hasSoftDeletes, } from "./decorators/entity.js";
9
+ import { fireHooks } from "./decorators/hooks.js";
10
+ import { AtlasError, EntityNotFoundError } from "./errors.js";
11
+ import { ModelQuery, runWithAtlasInternalBypass } from "./ModelQuery.js";
12
+ import { compileStatementNative, getAtlasDialect, } from "./query/native.js";
13
+ import { camelToSnake, snakeToCamel } from "./utils/casing.js";
14
+ /**
15
+ * Coerce a `lastInsertRowid` to a JS number when it fits, leaving large
16
+ * mysql/sqlite values as bigint so callers don't silently lose precision.
17
+ */
18
+ function normalizeRowid(rowid) {
19
+ if (typeof rowid === "number")
20
+ return rowid;
21
+ return rowid <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(rowid) : rowid;
22
+ }
23
+ /**
24
+ * Whether a primary-key value should be treated as supplied. Distinguishes
25
+ * "explicit zero / empty-string id" from "unset" — only `null`/`undefined`
26
+ * route through the INSERT path; every other value is a candidate UPDATE.
27
+ */
28
+ function isProvidedPk(pk) {
29
+ return pk !== undefined && pk !== null;
30
+ }
31
+ /**
32
+ * Detect a unique-key / primary-key violation from the underlying driver
33
+ * error. Used by `save()` to recover from a TOCTOU race between the
34
+ * `find(pk)` check and the `INSERT`: a concurrent insert that wins the PK
35
+ * race surfaces as one of these codes, and we fall back to UPDATE rather
36
+ * than propagate a DB constraint error.
37
+ *
38
+ * - PostgreSQL: SQLSTATE `23505` (`unique_violation`)
39
+ * - SQLite: `SQLITE_CONSTRAINT_PRIMARYKEY` / `SQLITE_CONSTRAINT_UNIQUE`
40
+ * - MySQL: `ER_DUP_ENTRY` (named) / errno `1062` (numeric)
41
+ */
42
+ function isUniqueKeyViolation(err) {
43
+ if (err === null || typeof err !== "object")
44
+ return false;
45
+ const e = err;
46
+ const code = e.code;
47
+ const errno = e.errno;
48
+ return (code === "23505" ||
49
+ code === "SQLITE_CONSTRAINT_PRIMARYKEY" ||
50
+ code === "SQLITE_CONSTRAINT_UNIQUE" ||
51
+ code === "ER_DUP_ENTRY" ||
52
+ errno === 1062);
53
+ }
54
+ // ─── Repository ─────────────────────────────────────────────
55
+ export class BaseRepository {
56
+ #entityClass;
57
+ #tableName;
58
+ #primaryKey;
59
+ #columns;
60
+ #db;
61
+ #softDeletes;
62
+ #validColumns;
63
+ #columnMap; // camelCase → snake_case (cached)
64
+ #dateColumns;
65
+ /**
66
+ * Per-property `prepare` (model → DB) callbacks lifted directly from
67
+ * `@Column({ prepare })` metadata. Keyed by camelCase `propertyKey`.
68
+ * Mirror of Adonis Lucid's `@column.prepare`. Story 35.10.
69
+ */
70
+ #columnPrepares;
71
+ /**
72
+ * Per-property `consume` (DB → model) callbacks lifted directly from
73
+ * `@Column({ consume })` metadata. Keyed by camelCase `propertyKey`.
74
+ * Mirror of Adonis Lucid's `@column.consume`. Story 35.10.
75
+ */
76
+ #columnConsumes;
77
+ /**
78
+ * SQL dialect used by this repository. Resolved at construction time from
79
+ * the connection (if it exposes a `dialect` property) or from the explicit
80
+ * `options.dialect` override, falling back to the process-wide default as
81
+ * the last resort. Passed to every `compileStatementNative` call so that
82
+ * multi-connection apps with heterogeneous dialects (postgres + mysql, …)
83
+ * compile each query with the correct target.
84
+ */
85
+ #dialect;
86
+ /** Callback to dispatch domain events (set by framework integration). */
87
+ onDomainEvents;
88
+ constructor(entityClass, db, options) {
89
+ this.#entityClass = entityClass;
90
+ this.#db = db;
91
+ // Dialect resolution order: explicit option > connection.dialect > process default.
92
+ const connDialect = db.dialect;
93
+ this.#dialect = options?.dialect ?? connDialect ?? getAtlasDialect();
94
+ const meta = getEntityMetadata(entityClass);
95
+ if (!meta) {
96
+ throw new AtlasError("NOT_ENTITY", `Class '${entityClass.name}' is not decorated with @Entity()`, {
97
+ hint: "Add @Entity('table_name') decorator to the class.",
98
+ });
99
+ }
100
+ this.#tableName = meta.tableName;
101
+ this.#primaryKey = getPrimaryKey(entityClass) ?? "id";
102
+ const columnsMeta = getColumnMetadata(entityClass);
103
+ this.#columns = columnsMeta.map((c) => c.propertyKey);
104
+ this.#softDeletes = hasSoftDeletes(entityClass);
105
+ this.#dateColumns = getDateColumnConfig(entityClass);
106
+ // Lift per-column `prepare` / `consume` callbacks directly from metadata.
107
+ // No global registry, no late-registration concern: callbacks are baked
108
+ // into the entity definition. Mirrors Adonis Lucid's `@column.prepare` /
109
+ // `@column.consume` pattern.
110
+ this.#columnPrepares = new Map();
111
+ this.#columnConsumes = new Map();
112
+ for (const col of columnsMeta) {
113
+ if (col.prepare)
114
+ this.#columnPrepares.set(col.propertyKey, col.prepare);
115
+ if (col.consume)
116
+ this.#columnConsumes.set(col.propertyKey, col.consume);
117
+ }
118
+ // Pre-compute column mappings for validation + hydration.
119
+ // Snapshot is frozen at construction — `@Column` decorators that run
120
+ // AFTER the repository instance is created (e.g. lazy/dynamic
121
+ // definitions) are invisible to the validator and will be rejected
122
+ // by `#resolveColumn`. Decorators must run at class-body evaluation
123
+ // time, before any repository for that entity is instantiated.
124
+ this.#validColumns = new Set();
125
+ this.#columnMap = new Map();
126
+ for (const col of this.#columns) {
127
+ const snake = camelToSnake(col);
128
+ this.#validColumns.add(col);
129
+ this.#validColumns.add(snake);
130
+ this.#columnMap.set(col, snake);
131
+ this.#columnMap.set(snake, snake);
132
+ }
133
+ this.#validColumns.add(this.#primaryKey);
134
+ this.#validColumns.add(camelToSnake(this.#primaryKey));
135
+ this.#columnMap.set(this.#primaryKey, camelToSnake(this.#primaryKey));
136
+ }
137
+ // ─── Column validation ────────────────────────────────────
138
+ /** Resolve a column name to snake_case. Throws on invalid column. */
139
+ #resolveColumn(column) {
140
+ const mapped = this.#columnMap.get(column);
141
+ if (mapped)
142
+ return mapped;
143
+ const snake = camelToSnake(column);
144
+ if (this.#validColumns.has(snake))
145
+ return snake;
146
+ throw new AtlasError("E_INVALID_COLUMN", `Column '${column}' does not exist on ${this.#entityClass.name}`, {
147
+ hint: `Valid columns: ${this.#columns.join(", ")}`,
148
+ });
149
+ }
150
+ // ─── Query builder ────────────────────────────────────────
151
+ query() {
152
+ return new ModelQuery(this.#tableName, this.#db, (row) => this.#hydrate(row), this.#entityClass, (col) => this.#resolveColumn(col), this.#softDeletes, this.#dialect);
153
+ }
154
+ // ─── Transaction ──────────────────────────────────────────
155
+ useTransaction(trx) {
156
+ // Propagate the owning repo's dialect so the transactional copy stays on
157
+ // the correct SQL flavour — critical for multi-connection apps where the
158
+ // primary is postgres but a tenant runs on sqlite (or vice versa).
159
+ // Without this the transactional repo silently fell back to the global
160
+ // default and compiled mis-quoted SQL.
161
+ const repo = new BaseRepository(this.#entityClass, trx, {
162
+ dialect: this.#dialect,
163
+ });
164
+ repo.onDomainEvents = this.onDomainEvents;
165
+ return repo;
166
+ }
167
+ // ─── Finders ──────────────────────────────────────────────
168
+ async find(id) {
169
+ const wheres = [
170
+ { column: this.#primaryKey, operator: "=", value: id, type: "and" },
171
+ ];
172
+ this.#appendSoftScope(wheres);
173
+ const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
174
+ const rows = await this.#db.query(sql, params);
175
+ const row = rows[0];
176
+ if (!row)
177
+ return null;
178
+ return this.#hydrate(row);
179
+ }
180
+ async findOrFail(id) {
181
+ const entity = await this.find(id);
182
+ if (!entity) {
183
+ throw new EntityNotFoundError(this.#entityClass.name, {
184
+ [this.#primaryKey]: id,
185
+ });
186
+ }
187
+ return entity;
188
+ }
189
+ async findBy(column, value) {
190
+ const col = this.#resolveColumn(column);
191
+ const wheres = [
192
+ { column: col, operator: "=", value, type: "and" },
193
+ ];
194
+ this.#appendSoftScope(wheres);
195
+ const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
196
+ const rows = await this.#db.query(sql, params);
197
+ const row = rows[0];
198
+ if (!row)
199
+ return null;
200
+ return this.#hydrate(row);
201
+ }
202
+ async all() {
203
+ const wheres = [];
204
+ this.#appendSoftScope(wheres);
205
+ return this.#runSelect({ wheres });
206
+ }
207
+ async allWithTrashed() {
208
+ return this.#runSelect({});
209
+ }
210
+ async onlyTrashed() {
211
+ if (!this.#softDeletes)
212
+ return [];
213
+ return this.#runSelect({
214
+ wheres: [
215
+ {
216
+ column: "deleted_at",
217
+ operator: "IS NOT NULL",
218
+ value: null,
219
+ type: "and",
220
+ },
221
+ ],
222
+ });
223
+ }
224
+ async where(column, value) {
225
+ const col = this.#resolveColumn(column);
226
+ const wheres = [
227
+ { column: col, operator: "=", value, type: "and" },
228
+ ];
229
+ this.#appendSoftScope(wheres);
230
+ // Order by the resolved primary key (DESC = most recent insert first when
231
+ // the PK is an auto-increment integer or a monotonic UUID). Previously
232
+ // this hard-coded `rowid DESC`, which is a SQLite-only pseudo-column and
233
+ // blew up on Postgres/MySQL the moment the app ran against a real driver.
234
+ // Using the PK works on every dialect and matches the user's actual
235
+ // schema — the ordering contract is "most recent first by PK" for
236
+ // `repo.where(col, val)` as a convenience finder.
237
+ const pkCol = camelToSnake(this.#primaryKey);
238
+ return this.#runSelect({
239
+ wheres,
240
+ orderBy: [{ column: pkCol, direction: "desc" }],
241
+ });
242
+ }
243
+ // ─── Create / Save / Delete ───────────────────────────────
244
+ /**
245
+ * Build an entity from a plain object and persist it. Fires `beforeSave` →
246
+ * `beforeCreate` → INSERT → `afterCreate` → `afterSave`.
247
+ */
248
+ async create(data) {
249
+ const entity = new this.#entityClass();
250
+ for (const [key, value] of Object.entries(data)) {
251
+ if (this.#validColumns.has(key) ||
252
+ this.#validColumns.has(camelToSnake(key))) {
253
+ entity.setProp(key, value);
254
+ }
255
+ }
256
+ await fireHooks(this.#entityClass, "beforeSave", entity);
257
+ await fireHooks(this.#entityClass, "beforeCreate", entity);
258
+ await this.#insert(entity);
259
+ await fireHooks(this.#entityClass, "afterCreate", entity);
260
+ await fireHooks(this.#entityClass, "afterSave", entity);
261
+ return entity;
262
+ }
263
+ /**
264
+ * Persist an entity. Insert if PK is missing or row doesn't exist, update
265
+ * otherwise. Fires `beforeSave` → (`beforeCreate` | `beforeUpdate`) → DB →
266
+ * (`afterCreate` | `afterUpdate`) → `afterSave`, then dispatches
267
+ * accumulated domain events through `onDomainEvents`.
268
+ *
269
+ * Race-safety: the `find(pk)` → branch decision has a TOCTOU window. If a
270
+ * concurrent save inserts the same PK between our `find` and our `#insert`,
271
+ * the INSERT hits a unique-key violation; we catch it and fall back to the
272
+ * UPDATE path. The race-loser still fires `beforeCreate` before the
273
+ * recovery (its hook ran once before the conflict surfaced) — design
274
+ * `beforeCreate` hooks to be idempotent or move side-effects into
275
+ * `afterCreate` / `afterSave` where they only fire on commit.
276
+ */
277
+ async save(entity) {
278
+ const pk = entity[this.#primaryKey];
279
+ // Treat a present PK (including `0` and `''`) as a candidate update —
280
+ // `pk && ...` would route legitimate zero / empty-string keys through
281
+ // INSERT and double-write the row.
282
+ const isUpdate = isProvidedPk(pk) && (await this.find(pk)) !== null;
283
+ await fireHooks(this.#entityClass, "beforeSave", entity);
284
+ if (isUpdate) {
285
+ await this.#runUpdateBranch(entity);
286
+ }
287
+ else {
288
+ try {
289
+ await this.#runInsertBranch(entity);
290
+ }
291
+ catch (err) {
292
+ // Race recovery: the row didn't exist when we checked, but a
293
+ // concurrent insert beat us to it. Only fall back when the PK
294
+ // was explicitly provided (auto-generated PK can't collide on
295
+ // a fresh insert — DB generates a unique one per call).
296
+ if (isProvidedPk(pk) && isUniqueKeyViolation(err)) {
297
+ await this.#runUpdateBranch(entity);
298
+ }
299
+ else {
300
+ throw err;
301
+ }
302
+ }
303
+ }
304
+ await fireHooks(this.#entityClass, "afterSave", entity);
305
+ const events = entity.flushDomainEvents();
306
+ if (events.length > 0 && this.onDomainEvents) {
307
+ try {
308
+ await this.onDomainEvents([...events]);
309
+ }
310
+ catch (err) {
311
+ for (const e of events)
312
+ entity.addDomainEvent(e.name, e.data);
313
+ throw err;
314
+ }
315
+ }
316
+ }
317
+ async #runInsertBranch(entity) {
318
+ await fireHooks(this.#entityClass, "beforeCreate", entity);
319
+ await this.#insert(entity);
320
+ await fireHooks(this.#entityClass, "afterCreate", entity);
321
+ }
322
+ async #runUpdateBranch(entity) {
323
+ await fireHooks(this.#entityClass, "beforeUpdate", entity);
324
+ await this.#update(entity);
325
+ await fireHooks(this.#entityClass, "afterUpdate", entity);
326
+ }
327
+ /**
328
+ * Insert many rows in a single multi-row INSERT. Fires beforeSave/beforeCreate
329
+ * on each hydrated entity, then hydrates from the RETURNING clause (postgres +
330
+ * sqlite) before firing afterCreate/afterSave. On mysql, falls back to N single
331
+ * INSERTs (documented limitation).
332
+ *
333
+ * @implements Story 30.1 + 30.5
334
+ */
335
+ async createMany(rows) {
336
+ if (rows.length === 0)
337
+ return [];
338
+ const entities = rows.map((r) => {
339
+ const e = new this.#entityClass();
340
+ for (const [k, v] of Object.entries(r)) {
341
+ if (this.#validColumns.has(k) ||
342
+ this.#validColumns.has(camelToSnake(k)))
343
+ e.setProp(k, v);
344
+ }
345
+ return e;
346
+ });
347
+ for (const e of entities) {
348
+ await fireHooks(this.#entityClass, "beforeSave", e);
349
+ await fireHooks(this.#entityClass, "beforeCreate", e);
350
+ }
351
+ if (this.#dialect === "mysql") {
352
+ // mysql: loop single inserts (no RETURNING).
353
+ for (const e of entities)
354
+ await this.#insert(e);
355
+ }
356
+ else {
357
+ const specRows = entities.map((e) => this.#entityToRowPairs(e));
358
+ const spec = {
359
+ kind: "insert",
360
+ table: this.#tableName,
361
+ rows: specRows,
362
+ returning: [
363
+ camelToSnake(this.#primaryKey),
364
+ ...this.#columns.map((c) => camelToSnake(c)),
365
+ ],
366
+ };
367
+ const compiled = compileStatementNative(spec, this.#dialect);
368
+ const returned = await this.#db.query(compiled.statements[0], compiled.params);
369
+ returned.forEach((row, i) => {
370
+ for (const [k, v] of Object.entries(row))
371
+ entities[i].setProp(snakeToCamel(k), v);
372
+ entities[i].markAsPersisted();
373
+ });
374
+ }
375
+ for (const e of entities) {
376
+ await fireHooks(this.#entityClass, "afterCreate", e);
377
+ await fireHooks(this.#entityClass, "afterSave", e);
378
+ }
379
+ return entities;
380
+ }
381
+ /**
382
+ * Persist many already-constructed entity instances. Same hooks + batching
383
+ * as `createMany`, but accepts prebuilt entities so dirty tracking works.
384
+ *
385
+ * @implements Story 30.5
386
+ */
387
+ async saveMany(entities) {
388
+ if (entities.length === 0)
389
+ return [];
390
+ // Split new vs already-persisted; for simplicity, persist new ones as a
391
+ // batch and fall back to per-entity save for dirty ones.
392
+ const fresh = [];
393
+ const dirty = [];
394
+ for (const e of entities) {
395
+ if (Object.keys(e.$original ?? {}).length === 0)
396
+ fresh.push(e);
397
+ else
398
+ dirty.push(e);
399
+ }
400
+ if (fresh.length > 0) {
401
+ const rows = fresh.map((e) => {
402
+ const r = {};
403
+ for (const c of this.#columns) {
404
+ const v = e[c];
405
+ if (v !== undefined)
406
+ r[c] = v;
407
+ }
408
+ return r;
409
+ });
410
+ const created = await this.createMany(rows);
411
+ // Copy generated PKs back to the original instances.
412
+ created.forEach((c, i) => {
413
+ fresh[i].setProp(this.#primaryKey, c[this.#primaryKey]);
414
+ fresh[i].markAsPersisted();
415
+ });
416
+ }
417
+ for (const d of dirty)
418
+ await this.save(d);
419
+ return entities;
420
+ }
421
+ /**
422
+ * Dialect-aware upsert. postgres + sqlite emit `ON CONFLICT DO UPDATE`; mysql
423
+ * emits `ON DUPLICATE KEY UPDATE`. Empty `updateColumns` = DO NOTHING.
424
+ *
425
+ * @implements Story 30.4
426
+ */
427
+ async upsert(data, conflictColumns, updateColumns = []) {
428
+ const rowsArr = Array.isArray(data) ? data : [data];
429
+ const rows = rowsArr.map((r) => this.#plainToRowPairs(r));
430
+ const spec = {
431
+ kind: "upsert",
432
+ table: this.#tableName,
433
+ rows,
434
+ conflictColumns: conflictColumns.map((c) => this.#resolveColumn(c)),
435
+ updateColumns: updateColumns.map((c) => this.#resolveColumn(c)),
436
+ };
437
+ const compiled = compileStatementNative(spec, this.#dialect);
438
+ const result = await this.#db.execute(compiled.statements[0], compiled.params);
439
+ return result.rowsAffected;
440
+ }
441
+ /**
442
+ * Find a row matching `search` or create one merged with `defaults`.
443
+ *
444
+ * @implements Story 30.6
445
+ */
446
+ async firstOrCreate(search, defaults = {}) {
447
+ const existing = await this.#findBySearch(search);
448
+ if (existing)
449
+ return existing;
450
+ return this.create({ ...search, ...defaults });
451
+ }
452
+ /** Find a row or build an in-memory instance without persisting. */
453
+ async firstOrNew(search, defaults = {}) {
454
+ const existing = await this.#findBySearch(search);
455
+ if (existing)
456
+ return existing;
457
+ const e = new this.#entityClass();
458
+ for (const [k, v] of Object.entries({ ...search, ...defaults })) {
459
+ if (this.#validColumns.has(k) || this.#validColumns.has(camelToSnake(k)))
460
+ e.setProp(k, v);
461
+ }
462
+ return e;
463
+ }
464
+ /** Atomic find-or-update-or-insert. */
465
+ async updateOrCreate(search, values) {
466
+ const existing = await this.#findBySearch(search);
467
+ if (existing) {
468
+ for (const [k, v] of Object.entries(values))
469
+ existing.setProp(k, v);
470
+ await this.save(existing);
471
+ return existing;
472
+ }
473
+ return this.create({ ...search, ...values });
474
+ }
475
+ async #findBySearch(search) {
476
+ let q = this.query();
477
+ for (const [k, v] of Object.entries(search))
478
+ q = q.where(k, v);
479
+ return q.first();
480
+ }
481
+ /**
482
+ * Apply `@Column({ prepare })` (model → DB) when declared. Adonis Lucid's
483
+ * contract — callback receives the raw value (including null/undefined) and
484
+ * decides what to do with it.
485
+ */
486
+ #applyPrepare(propertyKey, value) {
487
+ const prepare = this.#columnPrepares.get(propertyKey);
488
+ if (!prepare)
489
+ return value;
490
+ let result;
491
+ try {
492
+ result = prepare(value);
493
+ }
494
+ catch (err) {
495
+ throw wrapAdapterError("prepare", propertyKey, err);
496
+ }
497
+ assertNotPromise("prepare", propertyKey, result);
498
+ return result;
499
+ }
500
+ #applyConsume(propertyKey, value) {
501
+ const consume = this.#columnConsumes.get(propertyKey);
502
+ if (!consume)
503
+ return value;
504
+ let result;
505
+ try {
506
+ result = consume(value);
507
+ }
508
+ catch (err) {
509
+ throw wrapAdapterError("consume", propertyKey, err);
510
+ }
511
+ assertNotPromise("consume", propertyKey, result);
512
+ return result;
513
+ }
514
+ #plainToRowPairs(obj) {
515
+ const pairs = [];
516
+ for (const [k, v] of Object.entries(obj)) {
517
+ // Skip explicit `undefined` so we don't emit `undefined` as a SQL bind —
518
+ // the Rust DML compiler / NAPI layer rejects it. `null` is allowed
519
+ // through because that's a meaningful SQL value.
520
+ if (v === undefined)
521
+ continue;
522
+ // Prepare map is keyed by camelCase property name. The input bag may use
523
+ // either camel or snake — try the raw key first, else convert.
524
+ const propKey = this.#columnPrepares.has(k) ? k : snakeToCamel(k);
525
+ pairs.push([this.#resolveColumn(k), this.#applyPrepare(propKey, v)]);
526
+ }
527
+ return pairs;
528
+ }
529
+ #entityToRowPairs(entity) {
530
+ const pairs = [];
531
+ for (const col of this.#columns) {
532
+ const v = entity[col];
533
+ if (v !== undefined)
534
+ pairs.push([camelToSnake(col), this.#applyPrepare(col, v)]);
535
+ }
536
+ return pairs;
537
+ }
538
+ /** Delete the entity. Fires `beforeDelete` → DB → `afterDelete`. Soft-delete aware. */
539
+ async delete(entity) {
540
+ await fireHooks(this.#entityClass, "beforeDelete", entity);
541
+ const pk = entity[this.#primaryKey];
542
+ if (this.#softDeletes) {
543
+ const now = new Date().toISOString();
544
+ await this.#runUpdate([["deleted_at", now]], [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }]);
545
+ entity.setProp("deletedAt", now);
546
+ }
547
+ else {
548
+ await this.#runDelete([
549
+ { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
550
+ ]);
551
+ }
552
+ await fireHooks(this.#entityClass, "afterDelete", entity);
553
+ }
554
+ /** Permanently delete (bypasses soft delete). Fires `beforeDelete` / `afterDelete` hooks. */
555
+ async forceDelete(entity) {
556
+ await fireHooks(this.#entityClass, "beforeDelete", entity);
557
+ await this.#runDelete([
558
+ {
559
+ column: this.#primaryKey,
560
+ operator: "=",
561
+ value: entity[this.#primaryKey],
562
+ type: "and",
563
+ },
564
+ ]);
565
+ await fireHooks(this.#entityClass, "afterDelete", entity);
566
+ }
567
+ async restore(entity) {
568
+ if (!this.#softDeletes)
569
+ return;
570
+ await this.#runUpdate([["deleted_at", null]], [
571
+ {
572
+ column: this.#primaryKey,
573
+ operator: "=",
574
+ value: entity[this.#primaryKey],
575
+ type: "and",
576
+ },
577
+ ]);
578
+ entity.setProp("deletedAt", null);
579
+ }
580
+ // ─── Bulk updates ─────────────────────────────────────────
581
+ async updateById(id, data) {
582
+ const set = this.#buildSetPairs(data);
583
+ await this.#runUpdate(set, [
584
+ { column: this.#primaryKey, operator: "=", value: id, type: "and" },
585
+ ]);
586
+ }
587
+ async updateWhere(column, columnValue, data) {
588
+ const whereCol = this.#resolveColumn(column);
589
+ const set = this.#buildSetPairs(data);
590
+ await this.#runUpdate(set, [
591
+ { column: whereCol, operator: "=", value: columnValue, type: "and" },
592
+ ]);
593
+ }
594
+ async increment(id, columnOrMap, amount = 1) {
595
+ const set = this.#buildIncrementPairs(columnOrMap, amount, "increment");
596
+ await this.#runUpdate(set, [
597
+ { column: this.#primaryKey, operator: "=", value: id, type: "and" },
598
+ ]);
599
+ }
600
+ async decrement(id, columnOrMap, amount = 1) {
601
+ const set = this.#buildIncrementPairs(columnOrMap, amount, "decrement");
602
+ await this.#runUpdate(set, [
603
+ { column: this.#primaryKey, operator: "=", value: id, type: "and" },
604
+ ]);
605
+ }
606
+ // ─── Raw ──────────────────────────────────────────────────
607
+ async raw(sql, ...params) {
608
+ const rows = await this.#db.query(sql, params);
609
+ return rows.map((r) => this.#hydrate(r));
610
+ }
611
+ // ─── Accessors ────────────────────────────────────────────
612
+ getTableName() {
613
+ return this.#tableName;
614
+ }
615
+ getPrimaryKeyColumn() {
616
+ return this.#primaryKey;
617
+ }
618
+ // ─── Private helpers ──────────────────────────────────────
619
+ #compileSelect(opts) {
620
+ const spec = {
621
+ kind: "select",
622
+ table: this.#tableName,
623
+ select: ["*"],
624
+ wheres: opts.wheres ?? [],
625
+ orderBy: opts.orderBy ?? [],
626
+ groupBy: [],
627
+ having: [],
628
+ limit: opts.limit ?? null,
629
+ offset: null,
630
+ distinct: false,
631
+ ctes: [],
632
+ unions: [],
633
+ };
634
+ const compiled = compileStatementNative(spec, this.#dialect);
635
+ return { sql: compiled.statements[0], params: compiled.params };
636
+ }
637
+ async #runSelect(opts) {
638
+ const { sql, params } = this.#compileSelect(opts);
639
+ const rows = await this.#db.query(sql, params);
640
+ return rows.map((r) => this.#hydrate(r));
641
+ }
642
+ async #runDelete(wheres) {
643
+ const compiled = compileStatementNative({ kind: "delete", table: this.#tableName, wheres }, this.#dialect);
644
+ await this.#db.execute(compiled.statements[0], compiled.params);
645
+ }
646
+ /**
647
+ * Emit an UPDATE. Each entry in `set` is either `[col, rawValue]` (plain
648
+ * binding — `SET col = ?`) or `[col, { op: 'increment' | 'decrement', value }]`
649
+ * (atomic expression — `SET col = col ± ?`). The Rust compiler picks the
650
+ * right SQL via `SetValue::Value` / `SetValue::Expression`.
651
+ */
652
+ async #runUpdate(set, wheres) {
653
+ if (set.length === 0)
654
+ return;
655
+ const compiled = compileStatementNative({ kind: "update", table: this.#tableName, set, wheres }, this.#dialect);
656
+ await this.#db.execute(compiled.statements[0], compiled.params);
657
+ }
658
+ /**
659
+ * Execute the INSERT and return whatever the engine surfaces about the
660
+ * fresh row: the RETURNING projection (postgres / sqlite) when available,
661
+ * otherwise just the rowsAffected count (mysql doesn't support RETURNING).
662
+ * Caller decides how much to rehydrate.
663
+ */
664
+ async #runInsert(values) {
665
+ if (values.length === 0)
666
+ return {};
667
+ const supportsReturning = this.#dialect !== "mysql";
668
+ const spec = supportsReturning
669
+ ? {
670
+ kind: "insert",
671
+ table: this.#tableName,
672
+ values,
673
+ returning: [
674
+ camelToSnake(this.#primaryKey),
675
+ ...this.#columns.map((c) => camelToSnake(c)),
676
+ ],
677
+ }
678
+ : { kind: "insert", table: this.#tableName, values };
679
+ const compiled = compileStatementNative(spec, this.#dialect);
680
+ if (supportsReturning) {
681
+ const rows = await this.#db.query(compiled.statements[0], compiled.params);
682
+ const first = rows[0];
683
+ return first ? { row: first } : {};
684
+ }
685
+ await this.#db.execute(compiled.statements[0], compiled.params);
686
+ // MySQL path: napi adapter doesn't surface lastInsertRowid through
687
+ // `execute()`. Callers that need it must use an explicit dialect-
688
+ // specific query (e.g. `SELECT LAST_INSERT_ID()`). For Atlas's
689
+ // public surface, the entity carries the PK already (either set
690
+ // by the caller or generated client-side as a UUID).
691
+ return {};
692
+ }
693
+ #appendSoftScope(wheres) {
694
+ if (this.#softDeletes) {
695
+ wheres.push({
696
+ column: "deleted_at",
697
+ operator: "IS NULL",
698
+ value: null,
699
+ type: "and",
700
+ });
701
+ }
702
+ }
703
+ async #insert(entity) {
704
+ // Auto-generate the PK when declared via `@PrimaryKey({ generated })`.
705
+ this.#applyPrimaryKeyGenerator(entity);
706
+ // Auto-populate @column.dateTime({ autoCreate: true }) fields before building the row.
707
+ this.#applyAutoTimestamps(entity, "insert");
708
+ const data = this.#entityToRow(entity);
709
+ const result = await this.#runInsert(Object.entries(data));
710
+ // Hydrate DB-generated values (auto-increment ids, default columns) so
711
+ // callers see them on the entity without an extra `find()`. Mirrors
712
+ // `createMany`, where the multi-row path already does this.
713
+ if (result.row) {
714
+ for (const [k, v] of Object.entries(result.row))
715
+ entity.setProp(snakeToCamel(k), v);
716
+ }
717
+ else if (result.lastInsertRowid !== undefined &&
718
+ !isProvidedPk(entity[this.#primaryKey])) {
719
+ entity.setProp(this.#primaryKey, normalizeRowid(result.lastInsertRowid));
720
+ }
721
+ // After a successful INSERT, the entity is now persisted — snapshot
722
+ // its columns so subsequent dirty checks compare against the DB state.
723
+ entity.markAsPersisted();
724
+ }
725
+ /**
726
+ * UPDATE the entity — emits only the dirty columns (story 32.2).
727
+ *
728
+ * If no column is dirty, skips the query entirely (common case when a
729
+ * `save()` is called defensively without any real mutation).
730
+ */
731
+ async #update(entity) {
732
+ // Auto-bump @column.dateTime({ autoUpdate: true }) BEFORE computing $dirty
733
+ // so the bumped column lands in the SET if anything else is dirty.
734
+ this.#applyAutoTimestamps(entity, "update");
735
+ const dirty = entity.$dirty;
736
+ const pk = entity[this.#primaryKey];
737
+ // Primary key is never part of the SET — it's the WHERE.
738
+ delete dirty[this.#primaryKey];
739
+ if (Object.keys(dirty).length === 0)
740
+ return; // nothing changed
741
+ // Map dirty camelCase keys to snake_case DB columns. `$dirty` keys are
742
+ // already camelCase (they come from `entity.setProp` / direct assignment),
743
+ // so the prepare lookup uses `k` as-is. Skip explicit `undefined`
744
+ // assignments to mirror `#buildSetPairs` / `#plainToRowPairs` — the
745
+ // Rust DML compiler / NAPI layer rejects `undefined` binds.
746
+ const setPairs = [];
747
+ for (const [k, v] of Object.entries(dirty)) {
748
+ if (v === undefined)
749
+ continue;
750
+ setPairs.push([camelToSnake(k), this.#applyPrepare(k, v)]);
751
+ }
752
+ if (setPairs.length === 0) {
753
+ // All dirty entries were `undefined` (skipped above). Re-snapshot
754
+ // anyway: without this, `$dirty` keeps reporting the same
755
+ // undefined keys forever and a caller checking `entity.isDirty()`
756
+ // loops on a no-op save.
757
+ entity.markAsPersisted();
758
+ return;
759
+ }
760
+ await this.#runUpdate(setPairs, [
761
+ { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
762
+ ]);
763
+ // Re-snapshot after a successful UPDATE.
764
+ entity.markAsPersisted();
765
+ }
766
+ /**
767
+ * Generate the primary key on INSERT when the entity declares
768
+ * `@PrimaryKey({ generated: 'uuid' })` and no value is set. Caller-supplied
769
+ * PKs win — we only fill in when the field is `undefined`.
770
+ */
771
+ #applyPrimaryKeyGenerator(entity) {
772
+ const strategy = getPrimaryKeyGenerator(this.#entityClass);
773
+ if (!strategy)
774
+ return;
775
+ if (entity[this.#primaryKey] !== undefined)
776
+ return;
777
+ if (strategy === "uuid") {
778
+ entity.setProp(this.#primaryKey, randomUUID());
779
+ }
780
+ }
781
+ /**
782
+ * Apply auto-timestamp columns (`@column.dateTime({ autoCreate, autoUpdate })`)
783
+ * on the entity before persistence. Called from `#insert` and `#update`.
784
+ */
785
+ #applyAutoTimestamps(entity, phase) {
786
+ const now = new Date();
787
+ for (const [prop, cfg] of Object.entries(this.#dateColumns)) {
788
+ if (phase === "insert") {
789
+ if (cfg.autoCreate && entity[prop] === undefined) {
790
+ entity.setProp(prop, now);
791
+ }
792
+ if (cfg.autoUpdate && entity[prop] === undefined) {
793
+ entity.setProp(prop, now);
794
+ }
795
+ }
796
+ else if (phase === "update" && cfg.autoUpdate) {
797
+ entity.setProp(prop, now);
798
+ }
799
+ }
800
+ }
801
+ #hydrate(row) {
802
+ const entity = new this.#entityClass();
803
+ for (const [key, value] of Object.entries(row)) {
804
+ const camelKey = snakeToCamel(key);
805
+ // Resolve against declared column metadata, not `in entity` — fields
806
+ // using Adonis' `declare field: T` pattern are not own-properties of
807
+ // a freshly constructed instance.
808
+ const targetKey = this.#validColumns.has(camelKey)
809
+ ? camelKey
810
+ : this.#validColumns.has(key)
811
+ ? key
812
+ : null;
813
+ if (!targetKey)
814
+ continue;
815
+ // Apply `@Column({ consume })` if declared on this property. Unlike the
816
+ // previous registry-based design, the callback receives every value
817
+ // including `null` / `undefined` — the user's `consume` is responsible
818
+ // for its own null-handling, matching Adonis Lucid's contract.
819
+ entity.setProp(targetKey, this.#applyConsume(targetKey, value));
820
+ }
821
+ // Freeze the original snapshot — from now on, only columns changed AFTER
822
+ // hydration are considered dirty by `entity.$dirty`.
823
+ entity.markAsPersisted();
824
+ // Back-pointer so `entity.refresh()` / `entity.fresh()` can re-query.
825
+ Object.defineProperty(entity, REPO_REF, {
826
+ value: this,
827
+ enumerable: false,
828
+ configurable: true,
829
+ });
830
+ return entity;
831
+ }
832
+ /**
833
+ * Re-read the entity's row from the database and mutate the instance in place.
834
+ * Used by `entity.refresh()` — not normally called directly.
835
+ *
836
+ * @implements Story 32.6
837
+ */
838
+ async refresh(entity) {
839
+ const pk = entity[this.#primaryKey];
840
+ if (pk === undefined || pk === null) {
841
+ throw new EntityNotFoundError(this.#entityClass.name, {
842
+ [this.#primaryKey]: pk,
843
+ });
844
+ }
845
+ const fresh = await this.find(pk);
846
+ if (!fresh) {
847
+ throw new EntityNotFoundError(this.#entityClass.name, {
848
+ [this.#primaryKey]: pk,
849
+ });
850
+ }
851
+ // Copy all column values from the fresh row onto the existing instance.
852
+ for (const col of this.#columns) {
853
+ entity.setProp(col, fresh[col]);
854
+ }
855
+ entity.setProp(this.#primaryKey, fresh[this.#primaryKey]);
856
+ entity.markAsPersisted();
857
+ }
858
+ /**
859
+ * Re-read the entity's row and return a NEW instance (the input is untouched).
860
+ *
861
+ * @implements Story 32.6
862
+ */
863
+ /**
864
+ * Lazy-load a relation count into `entity.$extras[alias ?? `${relationName}_count`]`.
865
+ * Uses `ModelQuery.withCount` with a restrictive `WHERE pk = ?` so it reads
866
+ * one entity's row back with the aggregate column attached.
867
+ *
868
+ * @implements Story 29.2
869
+ */
870
+ async loadCount(entity, relationName, alias) {
871
+ const pk = entity[this.#primaryKey];
872
+ if (pk === undefined || pk === null) {
873
+ throw new EntityNotFoundError(this.#entityClass.name, {
874
+ [this.#primaryKey]: pk,
875
+ });
876
+ }
877
+ const finalAlias = alias ?? `${relationName}_count`;
878
+ const q = this.query()
879
+ .where(this.#primaryKey, pk)
880
+ .withCount(relationName, (sub) => {
881
+ sub.as(finalAlias);
882
+ });
883
+ const [refreshed] = await q.exec();
884
+ if (refreshed)
885
+ entity.setExtra(finalAlias, refreshed.getExtra(finalAlias));
886
+ }
887
+ /**
888
+ * Lazy-load a relation aggregate. The builder callback sets the aggregate via
889
+ * `.sum/.avg/.min/.max/.count` and the alias via `.as('name')`.
890
+ *
891
+ * @implements Story 29.2
892
+ */
893
+ async loadAggregate(entity, relationName, build) {
894
+ const pk = entity[this.#primaryKey];
895
+ if (pk === undefined || pk === null) {
896
+ throw new EntityNotFoundError(this.#entityClass.name, {
897
+ [this.#primaryKey]: pk,
898
+ });
899
+ }
900
+ let capturedAlias;
901
+ const q = this.query()
902
+ .where(this.#primaryKey, pk)
903
+ .withAggregate(relationName, (sub) => {
904
+ build(sub);
905
+ capturedAlias = sub.subqueryAlias;
906
+ });
907
+ const [refreshed] = await q.exec();
908
+ const alias = capturedAlias ?? relationName;
909
+ if (refreshed)
910
+ entity.setExtra(alias, refreshed.getExtra(alias));
911
+ }
912
+ /**
913
+ * Lazy-load a relation onto an already-fetched entity. Re-uses the preload
914
+ * resolver by running a fresh query with `.where(pk = entity.pk).preload(...)`.
915
+ *
916
+ * @implements Story 31.10
917
+ */
918
+ async loadRelation(entity, relationName, callback) {
919
+ const pk = entity[this.#primaryKey];
920
+ if (pk === undefined || pk === null) {
921
+ throw new EntityNotFoundError(this.#entityClass.name, {
922
+ [this.#primaryKey]: pk,
923
+ });
924
+ }
925
+ const q = this.query().where(this.#primaryKey, pk);
926
+ if (callback)
927
+ q.preload(relationName, callback);
928
+ else
929
+ q.preload(relationName);
930
+ const [hydrated] = await q.exec();
931
+ if (hydrated) {
932
+ // Copy the loaded relation onto the caller's instance.
933
+ const value = hydrated[relationName];
934
+ entity.setProp(relationName, value);
935
+ }
936
+ }
937
+ /**
938
+ * Return a thin relation proxy bound to the given parent instance. Only
939
+ * `hasOne` / `hasMany` (and trivially `manyToMany` insert paths) are wired
940
+ * here; richer operations (attach/detach/sync) live in Story 31.7's proxy.
941
+ *
942
+ * @implements Story 31.5
943
+ */
944
+ relatedProxy(entity, relationName) {
945
+ const relations = getRelationMetadata(this.#entityClass);
946
+ const relation = relations.find((r) => r.propertyKey === relationName);
947
+ if (!relation)
948
+ throw new Error(`Relation '${relationName}' not found on ${this.#entityClass.name}`);
949
+ const relatedClass = relation.target();
950
+ const relatedMeta = getEntityMetadata(relatedClass);
951
+ if (!relatedMeta)
952
+ throw new Error(`Entity metadata missing on related class ${relatedClass.name}`);
953
+ const relatedTable = relatedMeta.tableName;
954
+ const parentPk = relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
955
+ const parentIdValue = entity[parentPk];
956
+ const relatedRepo = new BaseRepository(relatedClass, this.#db, {
957
+ dialect: this.#dialect,
958
+ });
959
+ const db = this.#db;
960
+ // FK column naming: belongsTo stores the FK on THIS side; has* / m2m on the OTHER side.
961
+ const fkCol = relation.foreignKey ??
962
+ (relation.type === "belongsTo"
963
+ ? `${camelToSnake(relatedClass.name)}_id`
964
+ : `${camelToSnake(this.#entityClass.name)}_id`);
965
+ const fkProp = snakeToCamel(fkCol);
966
+ const injectFk = (data) => ({
967
+ ...data,
968
+ [fkCol]: parentIdValue,
969
+ [fkProp]: parentIdValue,
970
+ });
971
+ // Shared "has" proxy methods (create/createMany/save/saveMany).
972
+ const hasOps = {
973
+ async create(data) {
974
+ return relatedRepo.create(injectFk(data));
975
+ },
976
+ async createMany(rows) {
977
+ return relatedRepo.createMany(rows.map(injectFk));
978
+ },
979
+ async save(related) {
980
+ related.setProp(fkCol, parentIdValue);
981
+ related.setProp(fkProp, parentIdValue);
982
+ await relatedRepo.save(related);
983
+ },
984
+ async saveMany(related) {
985
+ for (const r of related) {
986
+ r.setProp(fkCol, parentIdValue);
987
+ r.setProp(fkProp, parentIdValue);
988
+ }
989
+ return relatedRepo.saveMany(related);
990
+ },
991
+ };
992
+ // Scoped query builder (Story 31.9) — pre-applies the FK predicate
993
+ // (or pivot JOIN for m2m) so downstream filters/updates/deletes stay
994
+ // inside the relation boundary.
995
+ const scopedQuery = () => {
996
+ const q = relatedRepo.query();
997
+ if (relation.type === "manyToMany") {
998
+ if (!relation.pivot)
999
+ throw new Error(`@ManyToMany ${relationName} requires pivot options`);
1000
+ const pivot = relation.pivot;
1001
+ const pivotFk = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1002
+ const pivotOther = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1003
+ const relatedPk = getPrimaryKey(relatedClass) ?? "id";
1004
+ // Inline validated quote (same policy as the m2m branch below).
1005
+ const dialect = this.#dialect;
1006
+ const quote = (name) => {
1007
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
1008
+ throw new Error(`Unsafe identifier in pivot metadata: '${name}'`);
1009
+ }
1010
+ return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1011
+ };
1012
+ // EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk)
1013
+ // Framework-internal raw fragment (identifiers already validated by
1014
+ // the `quote` helper above) — bypass strict mode so this path still
1015
+ // works when the user enables `setAtlasStrictMode(true)` on their app.
1016
+ runWithAtlasInternalBypass(() => {
1017
+ q.whereRaw(`EXISTS (SELECT 1 FROM ${quote(pivot.pivotTable)} ` +
1018
+ `WHERE ${quote(pivot.pivotTable)}.${quote(pivotFk)} = ? ` +
1019
+ `AND ${quote(pivot.pivotTable)}.${quote(pivotOther)} = ${quote(relatedTable)}.${quote(relatedPk)})`, [parentIdValue]);
1020
+ });
1021
+ }
1022
+ else if (relation.type === "belongsTo") {
1023
+ const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1024
+ q.where(ownerKey, entity[fkProp] ?? entity[fkCol]);
1025
+ }
1026
+ else {
1027
+ // hasOne / hasMany
1028
+ q.where(fkCol, parentIdValue);
1029
+ }
1030
+ return q;
1031
+ };
1032
+ if (relation.type === "belongsTo") {
1033
+ // Story 31.6 — associate / dissociate set the FK on THIS entity and save
1034
+ // it through the outer repository. Both methods close over `parentRepo`,
1035
+ // which is the repo that owns `entity` (i.e. `this`). The double cast is
1036
+ // the standard TS idiom for widening a generic `this` — safe because
1037
+ // `T extends BaseEntity`.
1038
+ const parentRepo = this;
1039
+ const proxy = {
1040
+ type: "belongsTo",
1041
+ ...hasOps,
1042
+ query: scopedQuery,
1043
+ async associate(model) {
1044
+ if (model === null || model === undefined) {
1045
+ throw new Error(`related('${relationName}').associate() rejects null/undefined — use dissociate() instead`);
1046
+ }
1047
+ const ownerKey = relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
1048
+ const fkValue = model[ownerKey];
1049
+ entity.setProp(fkCol, fkValue);
1050
+ entity.setProp(fkProp, fkValue);
1051
+ await parentRepo.save(entity);
1052
+ },
1053
+ async dissociate() {
1054
+ entity.setProp(fkCol, null);
1055
+ entity.setProp(fkProp, null);
1056
+ await parentRepo.save(entity);
1057
+ },
1058
+ };
1059
+ return proxy;
1060
+ }
1061
+ if (relation.type === "manyToMany") {
1062
+ if (!relation.pivot)
1063
+ throw new Error(`@ManyToMany ${relationName} requires pivot options`);
1064
+ const pivot = relation.pivot;
1065
+ const pivotTable = pivot.pivotTable;
1066
+ const pivotFk = pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
1067
+ const pivotOther = pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
1068
+ const tsConfig = pivot.pivotTimestamps;
1069
+ const pivotAdapters = pivot.pivotColumnAdapters;
1070
+ const dialect = this.#dialect;
1071
+ // Validated quote: allow only `[A-Za-z0-9_]` so a malicious metadata
1072
+ // value never breaks out of the identifier. Anything else throws.
1073
+ const quote = (name) => {
1074
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
1075
+ throw new Error(`Unsafe identifier in pivot metadata: '${name}'`);
1076
+ }
1077
+ return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
1078
+ };
1079
+ /**
1080
+ * Resolve pivot timestamp column names from the decorator config.
1081
+ *
1082
+ * Three forms supported:
1083
+ * - `pivotTimestamps: true` → { created_at, updated_at } default names
1084
+ * - `pivotTimestamps: { createdAt: false, updatedAt: 'updated_on' }` → opt-out / rename
1085
+ * - `pivotTimestamps: undefined` → no timestamps written
1086
+ *
1087
+ * `false` opts a timestamp out; a string overrides the column name;
1088
+ * `undefined` falls back to the default name.
1089
+ */
1090
+ const resolveTimestamps = () => {
1091
+ if (!tsConfig)
1092
+ return {};
1093
+ const now = new Date().toISOString();
1094
+ let createdCol;
1095
+ let updatedCol;
1096
+ if (tsConfig === true) {
1097
+ createdCol = "created_at";
1098
+ updatedCol = "updated_at";
1099
+ }
1100
+ else {
1101
+ createdCol =
1102
+ tsConfig.createdAt === false
1103
+ ? null
1104
+ : (tsConfig.createdAt ?? "created_at");
1105
+ updatedCol =
1106
+ tsConfig.updatedAt === false
1107
+ ? null
1108
+ : (tsConfig.updatedAt ?? "updated_at");
1109
+ }
1110
+ const out = {};
1111
+ if (createdCol)
1112
+ out[createdCol] = now;
1113
+ if (updatedCol)
1114
+ out[updatedCol] = now;
1115
+ return out;
1116
+ };
1117
+ const normalizeAttach = (arg) => {
1118
+ if (Array.isArray(arg))
1119
+ return arg.map((id) => ({ id, extras: {} }));
1120
+ return Object.entries(arg).map(([id, extras]) => ({ id, extras }));
1121
+ };
1122
+ // Current pivot rows — compiled through the Rust SELECT path so the
1123
+ // pivot identifiers go through `quote_identifier` (rejects anything
1124
+ // outside `[A-Za-z0-9_]`), rather than through the ad-hoc `quote`
1125
+ // helper that would blindly wrap a malicious metadata string.
1126
+ //
1127
+ // Now async — every site in `sync()` is in an async closure.
1128
+ const currentIds = async () => {
1129
+ const selectSpec = {
1130
+ kind: "select",
1131
+ table: pivotTable,
1132
+ select: [pivotOther],
1133
+ wheres: [
1134
+ {
1135
+ column: pivotFk,
1136
+ operator: "=",
1137
+ value: parentIdValue,
1138
+ type: "and",
1139
+ },
1140
+ ],
1141
+ selectSubqueries: [],
1142
+ orderBy: [],
1143
+ groupBy: [],
1144
+ having: [],
1145
+ limit: null,
1146
+ offset: null,
1147
+ distinct: false,
1148
+ ctes: [],
1149
+ unions: [],
1150
+ joins: [],
1151
+ lockMode: null,
1152
+ };
1153
+ const compiled = compileStatementNative(selectSpec, dialect);
1154
+ const rows = await db.query(compiled.statements[0], compiled.params);
1155
+ return rows.map((r) => r[pivotOther]);
1156
+ };
1157
+ // Delete via the Rust DELETE compiler so the pivot table + columns get
1158
+ // `quote_identifier` validation (rejects `"`, `;`, etc.) — safer than
1159
+ // the previous hand-built SQL with a dumb `"` wrapper.
1160
+ const detach = async (ids) => {
1161
+ const wheres = [
1162
+ { column: pivotFk, operator: "=", value: parentIdValue, type: "and" },
1163
+ ];
1164
+ if (ids && ids.length > 0) {
1165
+ wheres.push({
1166
+ column: pivotOther,
1167
+ operator: "IN",
1168
+ value: ids,
1169
+ type: "and",
1170
+ });
1171
+ }
1172
+ const spec = {
1173
+ kind: "delete",
1174
+ table: pivotTable,
1175
+ wheres,
1176
+ returning: [],
1177
+ };
1178
+ const compiled = compileStatementNative(spec, dialect);
1179
+ await db.execute(compiled.statements[0], compiled.params);
1180
+ };
1181
+ const attach = async (ids) => {
1182
+ const entries = normalizeAttach(ids);
1183
+ if (entries.length === 0)
1184
+ return;
1185
+ const ts = resolveTimestamps();
1186
+ // Normalize heterogeneous extras: compute the union of extra keys
1187
+ // across all entries and back-fill missing keys with `null`, so every
1188
+ // row in the multi-insert shares the same column set (required by the
1189
+ // Rust compiler's homogeneity check).
1190
+ const extraKeys = new Set();
1191
+ for (const e of entries) {
1192
+ for (const k of Object.keys(e.extras))
1193
+ extraKeys.add(k);
1194
+ }
1195
+ // Reject extras keys that collide with reserved pivot columns. Without
1196
+ // this guard, an extras entry named after the FK or a timestamp column
1197
+ // would emit a duplicate column in the INSERT row pair: the FK case
1198
+ // silently overrides `parentIdValue` (corrupting the join); the
1199
+ // timestamp case duplicates the column entirely (driver-dependent
1200
+ // failure or last-wins overwrite).
1201
+ for (const k of extraKeys) {
1202
+ if (k === pivotFk || k === pivotOther) {
1203
+ 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.`);
1204
+ }
1205
+ if (Object.hasOwn(ts, k)) {
1206
+ 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.`);
1207
+ }
1208
+ }
1209
+ const rowPairs = entries.map((e) => {
1210
+ const pairs = [
1211
+ [pivotFk, parentIdValue],
1212
+ [pivotOther, e.id],
1213
+ ];
1214
+ for (const k of extraKeys) {
1215
+ const raw = e.extras[k] ?? null;
1216
+ const prepare = pivotAdapters?.[k]?.prepare;
1217
+ if (!prepare) {
1218
+ pairs.push([k, raw]);
1219
+ continue;
1220
+ }
1221
+ let encoded;
1222
+ try {
1223
+ encoded = prepare(raw);
1224
+ }
1225
+ catch (err) {
1226
+ throw wrapAdapterError("prepare", k, err);
1227
+ }
1228
+ assertNotPromise("prepare", k, encoded);
1229
+ pairs.push([k, encoded]);
1230
+ }
1231
+ for (const [k, v] of Object.entries(ts))
1232
+ pairs.push([k, v]);
1233
+ return pairs;
1234
+ });
1235
+ const spec = { kind: "insert", table: pivotTable, rows: rowPairs };
1236
+ const compiled = compileStatementNative(spec, dialect);
1237
+ await db.execute(compiled.statements[0], compiled.params);
1238
+ };
1239
+ /**
1240
+ * Diff the current pivot state against a target set and apply the
1241
+ * minimum attach/detach to converge.
1242
+ *
1243
+ * **NOT ATOMIC.** `sync` reads the pivot, computes the diff, then
1244
+ * writes — another process mutating the pivot between the read and
1245
+ * the writes will cause divergence. Wrap the call in a transaction
1246
+ * if you need strong consistency under concurrent writers.
1247
+ *
1248
+ * On SQLite this is typically fine because better-sqlite3 serializes
1249
+ * writes per connection; on Postgres/MySQL use `useTransaction` first.
1250
+ */
1251
+ const sync = async (target, additive = false) => {
1252
+ const current = new Set(await currentIds());
1253
+ const entries = normalizeAttach(target);
1254
+ const desired = new Set(entries.map((e) => e.id));
1255
+ const toAttach = entries.filter((e) => !current.has(e.id));
1256
+ const toDetach = additive
1257
+ ? []
1258
+ : [...current].filter((id) => !desired.has(id));
1259
+ if (toDetach.length > 0)
1260
+ await detach(toDetach);
1261
+ if (toAttach.length > 0) {
1262
+ const attachArg = {};
1263
+ for (const e of toAttach)
1264
+ attachArg[String(e.id)] = e.extras;
1265
+ await attach(attachArg);
1266
+ }
1267
+ };
1268
+ const proxy = {
1269
+ type: "manyToMany",
1270
+ ...hasOps,
1271
+ query: scopedQuery,
1272
+ attach,
1273
+ detach,
1274
+ sync,
1275
+ };
1276
+ return proxy;
1277
+ }
1278
+ // Default: hasOne / hasMany
1279
+ if (relation.type === "hasOne") {
1280
+ // @HasOne is a one-to-one relation — createMany/saveMany would violate
1281
+ // the invariant at the ORM level (and silently shadow a missing UNIQUE
1282
+ // constraint at the DB level). The typed proxy declares them as
1283
+ // `Promise<never>` so callers get a compile-time signal; at runtime
1284
+ // both throw a clear error.
1285
+ const reject = async (op) => {
1286
+ throw new Error(`related('${relationName}').${op}() is not supported on @HasOne — ` +
1287
+ `use .create() / .save() for a single related row.`);
1288
+ };
1289
+ const proxy = {
1290
+ type: "hasOne",
1291
+ create: hasOps.create,
1292
+ save: hasOps.save,
1293
+ createMany: () => reject("createMany"),
1294
+ saveMany: () => reject("saveMany"),
1295
+ query: scopedQuery,
1296
+ };
1297
+ return proxy;
1298
+ }
1299
+ const proxy = {
1300
+ type: "hasMany",
1301
+ ...hasOps,
1302
+ query: scopedQuery,
1303
+ };
1304
+ return proxy;
1305
+ }
1306
+ async fresh(entity) {
1307
+ const pk = entity[this.#primaryKey];
1308
+ if (pk === undefined || pk === null) {
1309
+ throw new EntityNotFoundError(this.#entityClass.name, {
1310
+ [this.#primaryKey]: pk,
1311
+ });
1312
+ }
1313
+ const found = await this.find(pk);
1314
+ if (!found) {
1315
+ throw new EntityNotFoundError(this.#entityClass.name, {
1316
+ [this.#primaryKey]: pk,
1317
+ });
1318
+ }
1319
+ return found;
1320
+ }
1321
+ #entityToRow(entity) {
1322
+ // `#columns` already includes the primary-key property: `@PrimaryKey()`
1323
+ // internally calls `@Column()` to register the PK as a regular column
1324
+ // (see decorators/entity.ts). The earlier trailing block re-emitted
1325
+ // the PK as a raw camelCase key, producing a double-write for non-`id`
1326
+ // PK names (`{ user_id: ..., userId: ... }` would land in the row dict).
1327
+ const row = {};
1328
+ for (const col of this.#columns) {
1329
+ const value = entity[col];
1330
+ if (value !== undefined) {
1331
+ row[camelToSnake(col)] = this.#applyPrepare(col, value);
1332
+ }
1333
+ }
1334
+ return row;
1335
+ }
1336
+ #buildSetPairs(data) {
1337
+ const pairs = [];
1338
+ for (const [key, value] of Object.entries(data)) {
1339
+ // Mirror `#plainToRowPairs` — skip undefined so updates can't bind it.
1340
+ if (value === undefined)
1341
+ continue;
1342
+ const propKey = this.#columnPrepares.has(key) ? key : snakeToCamel(key);
1343
+ pairs.push([
1344
+ this.#resolveColumn(key),
1345
+ this.#applyPrepare(propKey, value),
1346
+ ]);
1347
+ }
1348
+ return pairs;
1349
+ }
1350
+ /**
1351
+ * Build SET pairs for `increment`/`decrement` — each entry carries a
1352
+ * `{ op, value }` payload that the Rust compiler turns into
1353
+ * `SET col = col ± ?` instead of the standard `SET col = ?`.
1354
+ */
1355
+ #buildIncrementPairs(columnOrMap, amount, op) {
1356
+ if (typeof columnOrMap === "string") {
1357
+ return [[this.#resolveColumn(columnOrMap), { op, value: amount }]];
1358
+ }
1359
+ return Object.entries(columnOrMap).map(([col, delta]) => [this.#resolveColumn(col), { op, value: delta }]);
1360
+ }
1361
+ }
1362
+ /**
1363
+ * Annotate an adapter callback failure with the property key that triggered
1364
+ * it. Without this, a `prepare`/`consume` throwing on row N silently surfaces
1365
+ * as "Invalid bind value" or similar, with no hint at WHICH column the
1366
+ * adapter rejected — the dev has to bisect across every adapter-tagged
1367
+ * property to find the culprit.
1368
+ */
1369
+ function wrapAdapterError(phase, propertyKey, err) {
1370
+ const message = err instanceof Error ? err.message : String(err);
1371
+ // `cause: err` preserves the original error (and its stack) per ES2022
1372
+ // Error Cause. The wrapped Error keeps its own `stack` pointing at the
1373
+ // wrap site so `console.error(wrapped)` shows the column-annotated
1374
+ // header; Node ≥16.9 walks the cause chain to print the underlying
1375
+ // throw's stack underneath.
1376
+ return new Error(`@Column.${phase} threw on '${propertyKey}': ${message}`, {
1377
+ cause: err,
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Adapter callbacks must be synchronous — the bind layer cannot await before
1382
+ * handing values to the Rust DML compiler. Catching an `async` adapter here
1383
+ * gives the user a column-annotated error instead of an opaque "Invalid bind
1384
+ * value" downstream when the unawaited Promise hits the NAPI boundary.
1385
+ */
1386
+ function assertNotPromise(phase, propertyKey, value) {
1387
+ if (value !== null &&
1388
+ typeof value === "object" &&
1389
+ "then" in value &&
1390
+ typeof Reflect.get(value, "then") === "function") {
1391
+ throw new Error(`@Column.${phase} on '${propertyKey}' returned a Promise — adapters must be synchronous (the bind layer cannot await).`);
1392
+ }
1393
+ }
1394
+ //# sourceMappingURL=BaseRepository.js.map