@c9up/atlas 0.1.3

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