@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +69 -0
- package/scripts/copy-napi.mjs +86 -0
- package/src/AtlasProvider.ts +297 -0
- package/src/BaseEntity.ts +585 -0
- package/src/BaseRepository.ts +1694 -0
- package/src/ModelQuery.ts +2293 -0
- package/src/Transaction.ts +83 -0
- package/src/adapters/NapiDbAdapter.ts +178 -0
- package/src/config.ts +7 -0
- package/src/configure.ts +37 -0
- package/src/decorators/entity.ts +532 -0
- package/src/decorators/hooks.ts +169 -0
- package/src/decorators/scope.ts +44 -0
- package/src/errors.ts +111 -0
- package/src/index.ts +114 -0
- package/src/naming/NamingStrategy.ts +106 -0
- package/src/query/QueryBuilder.ts +422 -0
- package/src/query/native.ts +74 -0
- package/src/schema/Migration.ts +81 -0
- package/src/schema/MigrationRunner.ts +532 -0
- package/src/schema/Schema.ts +78 -0
- package/src/schema/SchemaBuilder.ts +14 -0
- package/src/schema/Seeder.ts +132 -0
- package/src/schema/TableBuilder.ts +238 -0
- package/src/schema/types.ts +51 -0
- package/src/services/db.ts +45 -0
- package/src/testing/DatabaseCleanup.ts +49 -0
- package/src/testing/Factory.ts +164 -0
- package/src/testing/TestDatabase.ts +81 -0
- package/src/testing/index.ts +3 -0
- package/src/utils/casing.ts +11 -0
- package/src/utils/dialectFromUrl.ts +16 -0
- package/src/utils/identifier.ts +35 -0
- package/src/utils/safePath.ts +59 -0
- 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
|
+
}
|