@c9up/atlas 0.1.19 → 0.2.1
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/README.md +63 -14
- 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/dist/AtlasProvider.d.ts +66 -7
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +205 -39
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +181 -5
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -33
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +127 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +250 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +81 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1433 -387
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ConnectionManager.d.ts +106 -0
- package/dist/ConnectionManager.d.ts.map +1 -0
- package/dist/ConnectionManager.js +228 -0
- package/dist/ConnectionManager.js.map +1 -0
- package/dist/ModelQuery.d.ts +577 -41
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +2188 -291
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +71 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +138 -24
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +44 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +131 -13
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/contract.d.ts +62 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +38 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/factoryCommands.d.ts +23 -0
- package/dist/console/factoryCommands.d.ts.map +1 -0
- package/dist/console/factoryCommands.js +62 -0
- package/dist/console/factoryCommands.js.map +1 -0
- package/dist/console/migrationCommands.d.ts +78 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +327 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/console/schemaCheckCommand.d.ts +11 -17
- package/dist/console/schemaCheckCommand.d.ts.map +1 -1
- package/dist/console/schemaCheckCommand.js +21 -15
- package/dist/console/schemaCheckCommand.js.map +1 -1
- package/dist/console/schemaDumpCommand.d.ts +30 -0
- package/dist/console/schemaDumpCommand.d.ts.map +1 -0
- package/dist/console/schemaDumpCommand.js +69 -0
- package/dist/console/schemaDumpCommand.js.map +1 -0
- package/dist/console/schemaGenerateCommand.d.ts +100 -0
- package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
- package/dist/console/schemaGenerateCommand.js +246 -0
- package/dist/console/schemaGenerateCommand.js.map +1 -0
- package/dist/console/seederCommands.d.ts +46 -0
- package/dist/console/seederCommands.d.ts.map +1 -0
- package/dist/console/seederCommands.js +136 -0
- package/dist/console/seederCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +50 -8
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +42 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/decorators/hooks.d.ts +3 -3
- package/dist/decorators/hooks.d.ts.map +1 -1
- package/dist/decorators/hooks.js.map +1 -1
- package/dist/events.d.ts +69 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/factories.d.ts +8 -0
- package/dist/factories.d.ts.map +1 -0
- package/dist/factories.js +8 -0
- package/dist/factories.js.map +1 -0
- package/dist/index.d.ts +15 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/lucid-schema.d.ts +15 -0
- package/dist/lucid-schema.d.ts.map +1 -0
- package/dist/lucid-schema.js +16 -0
- package/dist/lucid-schema.js.map +1 -0
- package/dist/metadata-keys.d.ts +3 -2
- package/dist/metadata-keys.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.d.ts +7 -0
- package/dist/naming/NamingStrategy.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.js +16 -0
- package/dist/naming/NamingStrategy.js.map +1 -1
- package/dist/orm.d.ts +14 -0
- package/dist/orm.d.ts.map +1 -0
- package/dist/orm.js +14 -0
- package/dist/orm.js.map +1 -0
- package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
- package/dist/query/DatabaseQueryBuilder.js +1798 -0
- package/dist/query/DatabaseQueryBuilder.js.map +1 -0
- package/dist/query/DmlBuilder.d.ts +62 -0
- package/dist/query/DmlBuilder.d.ts.map +1 -0
- package/dist/query/DmlBuilder.js +84 -0
- package/dist/query/DmlBuilder.js.map +1 -0
- package/dist/query/QueryBuilder.d.ts +5 -0
- package/dist/query/QueryBuilder.d.ts.map +1 -1
- package/dist/query/QueryBuilder.js +7 -0
- package/dist/query/QueryBuilder.js.map +1 -1
- package/dist/query/RawQueryBuilder.d.ts +47 -0
- package/dist/query/RawQueryBuilder.d.ts.map +1 -0
- package/dist/query/RawQueryBuilder.js +138 -0
- package/dist/query/RawQueryBuilder.js.map +1 -0
- package/dist/query/interpolate.d.ts +45 -0
- package/dist/query/interpolate.d.ts.map +1 -0
- package/dist/query/interpolate.js +51 -0
- package/dist/query/interpolate.js.map +1 -0
- package/dist/query/native.d.ts +8 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +14 -0
- package/dist/query/native.js.map +1 -1
- package/dist/query/operators.d.ts +4 -0
- package/dist/query/operators.d.ts.map +1 -0
- package/dist/query/operators.js +27 -0
- package/dist/query/operators.js.map +1 -0
- package/dist/schema/Migration.d.ts +50 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +60 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +95 -34
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +523 -68
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +114 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +272 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaDumper.d.ts +121 -0
- package/dist/schema/SchemaDumper.d.ts.map +1 -0
- package/dist/schema/SchemaDumper.js +365 -0
- package/dist/schema/SchemaDumper.js.map +1 -0
- package/dist/schema/Seeder.d.ts +13 -0
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +28 -5
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +274 -16
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +656 -52
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +71 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +158 -0
- package/dist/schema/catalog.js.map +1 -0
- package/dist/schema/introspect.d.ts +1 -1
- package/dist/schema/introspect.d.ts.map +1 -1
- package/dist/schema/introspect.js +26 -11
- package/dist/schema/introspect.js.map +1 -1
- package/dist/schema/types.d.ts +150 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +11 -0
- package/dist/schema/types.js.map +1 -1
- package/dist/seeders.d.ts +7 -0
- package/dist/seeders.d.ts.map +1 -0
- package/dist/seeders.js +7 -0
- package/dist/seeders.js.map +1 -0
- package/dist/services/db.d.ts +122 -5
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +187 -14
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +33 -7
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +54 -24
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/DbAssertions.d.ts +48 -0
- package/dist/testing/DbAssertions.d.ts.map +1 -0
- package/dist/testing/DbAssertions.js +80 -0
- package/dist/testing/DbAssertions.js.map +1 -0
- package/dist/testing/Factory.d.ts +166 -15
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +497 -23
- package/dist/testing/Factory.js.map +1 -1
- package/dist/testing/TestUtils.d.ts +49 -0
- package/dist/testing/TestUtils.d.ts.map +1 -0
- package/dist/testing/TestUtils.js +79 -0
- package/dist/testing/TestUtils.js.map +1 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +3 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types/relations.d.ts +31 -0
- package/dist/types/relations.d.ts.map +1 -0
- package/dist/types/relations.js +20 -0
- package/dist/types/relations.js.map +1 -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 +32 -2
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +305 -52
- package/src/BaseEntity.ts +460 -41
- package/src/BaseModel.ts +461 -0
- package/src/BaseRepository.ts +1710 -421
- package/src/ConnectionManager.ts +298 -0
- package/src/ModelQuery.ts +2998 -335
- package/src/Transaction.ts +227 -30
- package/src/adapters/NapiDbAdapter.ts +224 -17
- package/src/console/contract.ts +96 -0
- package/src/console/factoryCommands.ts +73 -0
- package/src/console/migrationCommands.ts +448 -0
- package/src/console/schemaCheckCommand.ts +26 -22
- package/src/console/schemaDumpCommand.ts +96 -0
- package/src/console/schemaGenerateCommand.ts +380 -0
- package/src/console/seederCommands.ts +174 -0
- package/src/decorators/entity.ts +71 -8
- package/src/decorators/hooks.ts +5 -3
- package/src/events.ts +117 -0
- package/src/factories.ts +10 -0
- package/src/index.ts +69 -3
- package/src/lucid-schema.ts +22 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/orm.ts +13 -0
- package/src/query/DatabaseQueryBuilder.ts +2724 -0
- package/src/query/DmlBuilder.ts +131 -0
- package/src/query/QueryBuilder.ts +8 -0
- package/src/query/RawQueryBuilder.ts +173 -0
- package/src/query/interpolate.ts +73 -0
- package/src/query/native.ts +29 -0
- package/src/query/operators.ts +27 -0
- package/src/schema/Migration.ts +71 -3
- package/src/schema/MigrationRunner.ts +676 -69
- package/src/schema/Schema.ts +359 -3
- package/src/schema/SchemaDumper.ts +518 -0
- package/src/schema/Seeder.ts +47 -10
- package/src/schema/TableBuilder.ts +783 -52
- package/src/schema/catalog.ts +236 -0
- package/src/schema/introspect.ts +26 -9
- package/src/schema/types.ts +137 -2
- package/src/seeders.ts +16 -0
- package/src/services/db.ts +361 -21
- package/src/testing/DatabaseCleanup.ts +78 -25
- package/src/testing/DbAssertions.ts +127 -0
- package/src/testing/Factory.ts +837 -40
- package/src/testing/TestUtils.ts +123 -0
- package/src/testing/index.ts +12 -1
- package/src/types/relations.ts +43 -0
package/src/BaseRepository.ts
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import
|
|
8
|
+
import { DateTime } from "@c9up/chronos";
|
|
9
|
+
import { dateTimeAtlasAdapter } from "@c9up/chronos/atlas";
|
|
10
|
+
import type {
|
|
11
|
+
QueryMeta,
|
|
12
|
+
TransactionOptions,
|
|
13
|
+
} from "./adapters/NapiDbAdapter.js";
|
|
9
14
|
import type {
|
|
10
15
|
BaseEntity,
|
|
11
16
|
BelongsToRelationProxy,
|
|
12
17
|
DomainEvent,
|
|
13
18
|
HasManyRelationProxy,
|
|
19
|
+
HasManyThroughRelationProxy,
|
|
14
20
|
HasOneRelationProxy,
|
|
15
21
|
ManyToManyRelationProxy,
|
|
16
22
|
RelationProxy,
|
|
@@ -18,9 +24,9 @@ import type {
|
|
|
18
24
|
import { REPO_REF } from "./BaseEntity.js";
|
|
19
25
|
import {
|
|
20
26
|
type DateColumnConfig,
|
|
27
|
+
ensureEntityMetadata,
|
|
21
28
|
getColumnMetadata,
|
|
22
29
|
getDateColumnConfig,
|
|
23
|
-
getEntityMetadata,
|
|
24
30
|
getPrimaryKey,
|
|
25
31
|
getPrimaryKeyGenerator,
|
|
26
32
|
getRelationMetadata,
|
|
@@ -29,7 +35,8 @@ import {
|
|
|
29
35
|
} from "./decorators/entity.js";
|
|
30
36
|
import { fireHooks } from "./decorators/hooks.js";
|
|
31
37
|
import { AtlasError, EntityNotFoundError } from "./errors.js";
|
|
32
|
-
import {
|
|
38
|
+
import { isAtlasStrictMode, ModelQuery } from "./ModelQuery.js";
|
|
39
|
+
import { DatabaseQueryBuilder } from "./query/DatabaseQueryBuilder.js";
|
|
33
40
|
import {
|
|
34
41
|
type AtlasDialect,
|
|
35
42
|
compileStatementNative,
|
|
@@ -39,6 +46,7 @@ import {
|
|
|
39
46
|
} from "./query/native.js";
|
|
40
47
|
import { type TransactionClient, transaction } from "./Transaction.js";
|
|
41
48
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
49
|
+
import { isTransactionClient } from "./utils/transactionBrand.js";
|
|
42
50
|
|
|
43
51
|
type EntityConstructor<T extends BaseEntity> = new () => T;
|
|
44
52
|
|
|
@@ -115,11 +123,33 @@ function isUniqueKeyViolation(err: unknown): boolean {
|
|
|
115
123
|
* Drivers backed by `AsyncDatabaseConnection` (`createNapiConnection`)
|
|
116
124
|
* satisfy this interface out-of-the-box.
|
|
117
125
|
*/
|
|
126
|
+
/** Options for {@link BaseRepository.create} (Adonis Lucid `create` options). */
|
|
127
|
+
export interface CreateOptions {
|
|
128
|
+
/** Skip lifecycle hooks + event dispatch (atlas's legacy `quiet` flag). */
|
|
129
|
+
quiet?: boolean;
|
|
130
|
+
/** Silently drop keys that are not columns instead of throwing (Lucid default: throw). */
|
|
131
|
+
allowExtraProperties?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
export interface DatabaseConnection {
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Run a write statement; returns rowsAffected.
|
|
137
|
+
*
|
|
138
|
+
* `meta` is optional context for the `db:query` event (model, method,
|
|
139
|
+
* per-query debug). A connection that ignores it — every test fake — is
|
|
140
|
+
* still a valid `DatabaseConnection`.
|
|
141
|
+
*/
|
|
142
|
+
execute(
|
|
143
|
+
sql: string,
|
|
144
|
+
params?: unknown[],
|
|
145
|
+
meta?: QueryMeta,
|
|
146
|
+
): Promise<{ rowsAffected: number }>;
|
|
147
|
+
/** Run a SELECT and return all rows. See {@link execute} for `meta`. */
|
|
148
|
+
query<T = Row>(
|
|
149
|
+
sql: string,
|
|
150
|
+
params?: unknown[],
|
|
151
|
+
meta?: QueryMeta,
|
|
152
|
+
): Promise<T[]>;
|
|
123
153
|
/**
|
|
124
154
|
* Optional — open an interactive transaction pinned to ONE connection
|
|
125
155
|
* (Lucid's `db.transaction`: manual without a callback, managed with one).
|
|
@@ -175,10 +205,19 @@ export function computeCastTypes(
|
|
|
175
205
|
entityClass: Parameters<typeof getColumnMetadata>[0],
|
|
176
206
|
): Record<string, string> {
|
|
177
207
|
const out: Record<string, string> = {};
|
|
208
|
+
// Resolve each property to its real DB column, honouring `@Column({ columnName })`
|
|
209
|
+
// — the cast MUST key off the column name that actually appears in the SQL.
|
|
210
|
+
const dbNameOf = new Map<string, string>();
|
|
211
|
+
for (const col of getColumnMetadata(entityClass)) {
|
|
212
|
+
dbNameOf.set(
|
|
213
|
+
col.propertyKey,
|
|
214
|
+
col.columnName ?? camelToSnake(col.propertyKey),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
178
217
|
for (const col of getColumnMetadata(entityClass)) {
|
|
179
218
|
const t = col.type?.toLowerCase();
|
|
180
219
|
if (t && POSTGRES_CAST_TYPES.has(t)) {
|
|
181
|
-
out[camelToSnake(col.propertyKey)] = t;
|
|
220
|
+
out[dbNameOf.get(col.propertyKey) ?? camelToSnake(col.propertyKey)] = t;
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
// `@column.date()` / `@column.dateTime()` columns are tracked in a SEPARATE
|
|
@@ -190,15 +229,30 @@ export function computeCastTypes(
|
|
|
190
229
|
// expression is of type text`. An explicit recognized `col.type` already set
|
|
191
230
|
// in the loop above wins via `??=`.
|
|
192
231
|
for (const [prop, cfg] of Object.entries(getDateColumnConfig(entityClass))) {
|
|
193
|
-
out[camelToSnake(prop)] ??= cfg.dateOnly
|
|
232
|
+
out[dbNameOf.get(prop) ?? camelToSnake(prop)] ??= cfg.dateOnly
|
|
233
|
+
? "date"
|
|
234
|
+
: "timestamp";
|
|
194
235
|
}
|
|
195
236
|
// A uuid-strategy primary key is generated app-side as a string.
|
|
196
237
|
if (getPrimaryKeyGenerator(entityClass) === "uuid") {
|
|
197
|
-
|
|
238
|
+
const pk = getPrimaryKey(entityClass) ?? "id";
|
|
239
|
+
out[dbNameOf.get(pk) ?? camelToSnake(pk)] ??= "uuid";
|
|
198
240
|
}
|
|
199
241
|
return out;
|
|
200
242
|
}
|
|
201
243
|
|
|
244
|
+
/** A model instance that can bind to a transaction (BaseModel `useTransaction`). */
|
|
245
|
+
interface TransactionBindable {
|
|
246
|
+
useTransaction(trx: DatabaseConnection): unknown;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Structural guard: does this entity expose `useTransaction` (i.e. is a BaseModel)? */
|
|
250
|
+
function isTransactionBindable(entity: object): entity is TransactionBindable {
|
|
251
|
+
return (
|
|
252
|
+
"useTransaction" in entity && typeof entity.useTransaction === "function"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
202
256
|
export class BaseRepository<T extends BaseEntity> {
|
|
203
257
|
#entityClass: EntityConstructor<T>;
|
|
204
258
|
#tableName: string;
|
|
@@ -207,7 +261,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
207
261
|
#db: DatabaseConnection;
|
|
208
262
|
#softDeletes: boolean;
|
|
209
263
|
#validColumns: Set<string>;
|
|
210
|
-
#columnMap: Map<string, string>; //
|
|
264
|
+
#columnMap: Map<string, string>; // property/db name → resolved db column (cached)
|
|
265
|
+
#columnByDbName: Map<string, string>; // resolved db column → property (for hydrate)
|
|
211
266
|
#dateColumns: Record<string, DateColumnConfig>;
|
|
212
267
|
/** Snake column → logical type for params needing a Postgres `::cast`. */
|
|
213
268
|
#castTypes: Record<string, string>;
|
|
@@ -216,13 +271,19 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
216
271
|
* `@Column({ prepare })` metadata. Keyed by camelCase `propertyKey`.
|
|
217
272
|
* Mirror of Adonis Lucid's `@column.prepare`. Story 35.10.
|
|
218
273
|
*/
|
|
219
|
-
#columnPrepares: Map<
|
|
274
|
+
#columnPrepares: Map<
|
|
275
|
+
string,
|
|
276
|
+
(value: unknown, attribute?: string, model?: unknown) => unknown
|
|
277
|
+
>;
|
|
220
278
|
/**
|
|
221
279
|
* Per-property `consume` (DB → model) callbacks lifted directly from
|
|
222
280
|
* `@Column({ consume })` metadata. Keyed by camelCase `propertyKey`.
|
|
223
281
|
* Mirror of Adonis Lucid's `@column.consume`. Story 35.10.
|
|
224
282
|
*/
|
|
225
|
-
#columnConsumes: Map<
|
|
283
|
+
#columnConsumes: Map<
|
|
284
|
+
string,
|
|
285
|
+
(value: unknown, attribute?: string, model?: unknown) => unknown
|
|
286
|
+
>;
|
|
226
287
|
/**
|
|
227
288
|
* SQL dialect used by this repository. Resolved at construction time from
|
|
228
289
|
* the connection (if it exposes a `dialect` property) or from the explicit
|
|
@@ -236,6 +297,15 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
236
297
|
/** Callback to dispatch domain events (set by framework integration). */
|
|
237
298
|
onDomainEvents?: (events: DomainEvent[]) => Promise<void>;
|
|
238
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The durable (non-transactional) repo a `useTransaction(trx)` copy was forked
|
|
302
|
+
* from. Lucid resets a model's `$trx` on commit AND rollback, so after a manual
|
|
303
|
+
* transaction ends every entity persisted through the trx-bound repo must have
|
|
304
|
+
* its REPO_REF re-pointed here — otherwise related()/refresh() run on a finished
|
|
305
|
+
* transaction. Undefined on a durable repo (it IS the durable parent).
|
|
306
|
+
*/
|
|
307
|
+
#durableParent?: BaseRepository<T>;
|
|
308
|
+
|
|
239
309
|
constructor(
|
|
240
310
|
entityClass: EntityConstructor<T>,
|
|
241
311
|
db: DatabaseConnection,
|
|
@@ -260,16 +330,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
260
330
|
const connDialect = (db as { dialect?: AtlasDialect }).dialect;
|
|
261
331
|
this.#dialect = options?.dialect ?? connDialect ?? getAtlasDialect();
|
|
262
332
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
`Class '${entityClass.name}' is not decorated with @Entity()`,
|
|
268
|
-
{
|
|
269
|
-
hint: "Add @Entity('table_name') decorator to the class.",
|
|
270
|
-
},
|
|
271
|
-
);
|
|
272
|
-
}
|
|
333
|
+
// Infer the table name (naming strategy / `static table`) when @Entity is
|
|
334
|
+
// absent — AdonisJS Lucid parity, shared with BaseModel via one helper so
|
|
335
|
+
// the Data-Mapper and Active-Record paths agree on the convention.
|
|
336
|
+
const meta = ensureEntityMetadata(entityClass);
|
|
273
337
|
|
|
274
338
|
this.#tableName = meta.tableName;
|
|
275
339
|
this.#primaryKey = getPrimaryKey(entityClass) ?? "id";
|
|
@@ -282,8 +346,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
282
346
|
// No global registry, no late-registration concern: callbacks are baked
|
|
283
347
|
// into the entity definition. Mirrors Adonis Lucid's `@column.prepare` /
|
|
284
348
|
// `@column.consume` pattern.
|
|
285
|
-
this.#columnPrepares = new Map<
|
|
286
|
-
|
|
349
|
+
this.#columnPrepares = new Map<
|
|
350
|
+
string,
|
|
351
|
+
(value: unknown, attribute?: string, model?: unknown) => unknown
|
|
352
|
+
>();
|
|
353
|
+
this.#columnConsumes = new Map<
|
|
354
|
+
string,
|
|
355
|
+
(value: unknown, attribute?: string, model?: unknown) => unknown
|
|
356
|
+
>();
|
|
287
357
|
for (const col of columnsMeta) {
|
|
288
358
|
if (col.prepare) this.#columnPrepares.set(col.propertyKey, col.prepare);
|
|
289
359
|
if (col.consume) this.#columnConsumes.set(col.propertyKey, col.consume);
|
|
@@ -297,16 +367,31 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
297
367
|
// time, before any repository for that entity is instantiated.
|
|
298
368
|
this.#validColumns = new Set<string>();
|
|
299
369
|
this.#columnMap = new Map<string, string>();
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
this.#
|
|
370
|
+
this.#columnByDbName = new Map<string, string>();
|
|
371
|
+
for (const col of columnsMeta) {
|
|
372
|
+
const prop = col.propertyKey;
|
|
373
|
+
// Explicit `@Column({ columnName })` wins over the snake_case convention.
|
|
374
|
+
const db = col.columnName ?? camelToSnake(prop);
|
|
375
|
+
this.#validColumns.add(prop);
|
|
376
|
+
this.#validColumns.add(db);
|
|
377
|
+
this.#columnMap.set(prop, db);
|
|
378
|
+
this.#columnMap.set(db, db);
|
|
379
|
+
// Reverse map for hydration — a DB row keyed by the real column name maps
|
|
380
|
+
// back to the TS property (covers explicit overrides AND the default,
|
|
381
|
+
// where `snakeToCamel(db)` would otherwise mis-resolve an override).
|
|
382
|
+
this.#columnByDbName.set(db, prop);
|
|
306
383
|
}
|
|
384
|
+
// PK is registered as a column too (via @PrimaryKey → Column), so the loop
|
|
385
|
+
// above already mapped it, honouring any columnName. Fall back for the rare
|
|
386
|
+
// PK declared outside the column metadata.
|
|
307
387
|
this.#validColumns.add(this.#primaryKey);
|
|
308
|
-
this.#
|
|
309
|
-
|
|
388
|
+
if (!this.#columnMap.has(this.#primaryKey)) {
|
|
389
|
+
const pkDb = camelToSnake(this.#primaryKey);
|
|
390
|
+
this.#validColumns.add(pkDb);
|
|
391
|
+
this.#columnMap.set(this.#primaryKey, pkDb);
|
|
392
|
+
this.#columnMap.set(pkDb, pkDb);
|
|
393
|
+
this.#columnByDbName.set(pkDb, this.#primaryKey);
|
|
394
|
+
}
|
|
310
395
|
|
|
311
396
|
// Postgres cast hints: sqlx binds JS strings as `text`, which Postgres
|
|
312
397
|
// won't coerce to timestamp/uuid/date. See `computeCastTypes`.
|
|
@@ -329,15 +414,22 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
329
414
|
// FK lives on THIS table, references the related (owner) PK.
|
|
330
415
|
const fk = rel.foreignKey ?? `${camelToSnake(related.name)}_id`;
|
|
331
416
|
const ownerKey = rel.ownerKey ?? getPrimaryKey(related) ?? "id";
|
|
332
|
-
const
|
|
417
|
+
const ownerDb =
|
|
418
|
+
getColumnMetadata(related).find((c) => c.propertyKey === ownerKey)
|
|
419
|
+
?.columnName ?? camelToSnake(ownerKey);
|
|
420
|
+
const cast = computeCastTypes(related)[ownerDb];
|
|
333
421
|
if (cast) registerColumnCast(this.#tableName, fk, cast);
|
|
334
422
|
} else {
|
|
335
423
|
// hasOne / hasMany: FK lives on the RELATED table, references THIS PK.
|
|
336
424
|
const fk = rel.foreignKey ?? `${camelToSnake(entityClass.name)}_id`;
|
|
337
425
|
const localKey = rel.localKey ?? this.#primaryKey;
|
|
338
|
-
const cast = this.#castTypes[
|
|
339
|
-
|
|
340
|
-
|
|
426
|
+
const cast = this.#castTypes[this.#dbColumn(localKey)];
|
|
427
|
+
// Boot the related model on demand (Lucid lazy-boot): a related model
|
|
428
|
+
// with only `static table` (no @Entity yet) would otherwise miss its FK
|
|
429
|
+
// cast → a uuid FK relation query compiles without `::uuid` and breaks on
|
|
430
|
+
// Postgres. Mirrors relatedProxy / the preload paths.
|
|
431
|
+
const relatedMeta = ensureEntityMetadata(related);
|
|
432
|
+
if (cast) {
|
|
341
433
|
registerColumnCast(relatedMeta.tableName, fk, cast);
|
|
342
434
|
}
|
|
343
435
|
}
|
|
@@ -363,6 +455,25 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
363
455
|
);
|
|
364
456
|
}
|
|
365
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Resolve a KNOWN property (from `this.#columns`) to its real DB column name,
|
|
460
|
+
* honouring `@Column({ columnName })`. Non-throwing — used on the write path
|
|
461
|
+
* where the column set is already trusted. Falls back to the snake convention.
|
|
462
|
+
*/
|
|
463
|
+
#dbColumn(prop: string): string {
|
|
464
|
+
return this.#columnMap.get(prop) ?? camelToSnake(prop);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Normalise a mass-assignment key to its TS property. A payload may key by the
|
|
469
|
+
* DB column name (incl. an explicit `columnName`); without this, `create({
|
|
470
|
+
* full_label: 'x' })` would set a `full_label` property that the INSERT (which
|
|
471
|
+
* reads declared properties) then drops silently.
|
|
472
|
+
*/
|
|
473
|
+
#toProperty(key: string): string {
|
|
474
|
+
return this.#columnByDbName.get(key) ?? key;
|
|
475
|
+
}
|
|
476
|
+
|
|
366
477
|
// ─── Query builder ────────────────────────────────────────
|
|
367
478
|
|
|
368
479
|
query(): ModelQuery<T> {
|
|
@@ -374,6 +485,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
374
485
|
(col) => this.#resolveColumn(col),
|
|
375
486
|
this.#softDeletes,
|
|
376
487
|
this.#dialect,
|
|
488
|
+
(prop, value) => this.#applyPrepare(prop, value),
|
|
489
|
+
this.onDomainEvents,
|
|
377
490
|
);
|
|
378
491
|
}
|
|
379
492
|
|
|
@@ -389,6 +502,9 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
389
502
|
dialect: this.#dialect,
|
|
390
503
|
});
|
|
391
504
|
repo.onDomainEvents = this.onDomainEvents;
|
|
505
|
+
// Chain back to the true durable root (a nested useTransaction forwards it)
|
|
506
|
+
// so post-transaction REPO_REF restoration always lands on a live connection.
|
|
507
|
+
repo.#durableParent = this.#durableParent ?? this;
|
|
392
508
|
return repo;
|
|
393
509
|
}
|
|
394
510
|
|
|
@@ -419,18 +535,39 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
419
535
|
return entity;
|
|
420
536
|
}
|
|
421
537
|
|
|
422
|
-
async findBy(column: string, value: unknown): Promise<T | null
|
|
538
|
+
async findBy(column: string, value: unknown): Promise<T | null>;
|
|
539
|
+
async findBy(clause: Record<string, unknown>): Promise<T | null>;
|
|
540
|
+
async findBy(
|
|
541
|
+
columnOrClause: string | Record<string, unknown>,
|
|
542
|
+
value?: unknown,
|
|
543
|
+
): Promise<T | null> {
|
|
423
544
|
// Through the builder for read-hook parity (see `find`).
|
|
424
|
-
|
|
545
|
+
let q = this.query();
|
|
546
|
+
if (typeof columnOrClause === "string") {
|
|
547
|
+
q = q.where(columnOrClause, value);
|
|
548
|
+
} else {
|
|
549
|
+
for (const [k, v] of Object.entries(columnOrClause)) q = q.where(k, v);
|
|
550
|
+
}
|
|
551
|
+
return q.first();
|
|
425
552
|
}
|
|
426
553
|
|
|
427
|
-
/** Find by a column or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
|
|
428
|
-
async findByOrFail(column: string, value: unknown): Promise<T
|
|
429
|
-
|
|
554
|
+
/** Find by a column/clause or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
|
|
555
|
+
async findByOrFail(column: string, value: unknown): Promise<T>;
|
|
556
|
+
async findByOrFail(clause: Record<string, unknown>): Promise<T>;
|
|
557
|
+
async findByOrFail(
|
|
558
|
+
columnOrClause: string | Record<string, unknown>,
|
|
559
|
+
value?: unknown,
|
|
560
|
+
): Promise<T> {
|
|
561
|
+
const entity =
|
|
562
|
+
typeof columnOrClause === "string"
|
|
563
|
+
? await this.findBy(columnOrClause, value)
|
|
564
|
+
: await this.findBy(columnOrClause);
|
|
430
565
|
if (!entity) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
566
|
+
const criteria =
|
|
567
|
+
typeof columnOrClause === "string"
|
|
568
|
+
? { [columnOrClause]: value }
|
|
569
|
+
: columnOrClause;
|
|
570
|
+
throw new EntityNotFoundError(this.#entityClass.name, criteria);
|
|
434
571
|
}
|
|
435
572
|
return entity;
|
|
436
573
|
}
|
|
@@ -444,13 +581,23 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
444
581
|
.exec();
|
|
445
582
|
}
|
|
446
583
|
|
|
447
|
-
/** Find many rows by an
|
|
584
|
+
/** Find many rows by a column IN values, or by an object clause (AdonisJS `findManyBy`). */
|
|
448
585
|
async findManyBy(
|
|
449
586
|
column: string,
|
|
450
587
|
values: Array<string | number>,
|
|
588
|
+
): Promise<T[]>;
|
|
589
|
+
async findManyBy(clause: Record<string, unknown>): Promise<T[]>;
|
|
590
|
+
async findManyBy(
|
|
591
|
+
columnOrClause: string | Record<string, unknown>,
|
|
592
|
+
values?: Array<string | number>,
|
|
451
593
|
): Promise<T[]> {
|
|
452
|
-
if (
|
|
453
|
-
|
|
594
|
+
if (typeof columnOrClause === "string") {
|
|
595
|
+
if (!values || values.length === 0) return [];
|
|
596
|
+
return this.query().whereIn(columnOrClause, values).exec();
|
|
597
|
+
}
|
|
598
|
+
let q = this.query();
|
|
599
|
+
for (const [k, v] of Object.entries(columnOrClause)) q = q.where(k, v);
|
|
600
|
+
return q.exec();
|
|
454
601
|
}
|
|
455
602
|
|
|
456
603
|
async all(): Promise<T[]> {
|
|
@@ -460,6 +607,33 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
460
607
|
return this.query().orderBy(this.#primaryKey, "desc").exec();
|
|
461
608
|
}
|
|
462
609
|
|
|
610
|
+
/**
|
|
611
|
+
* Empty this model's table (AdonisJS Lucid `Model.truncate`). Postgres/MySQL
|
|
612
|
+
* issue `TRUNCATE TABLE` (fast, resets identity); SQLite has no TRUNCATE so it
|
|
613
|
+
* falls back to `DELETE FROM`. `cascade` is Postgres-only (truncates dependent
|
|
614
|
+
* FK tables). The table name comes from entity metadata — never user input.
|
|
615
|
+
*/
|
|
616
|
+
async truncate(cascade = false): Promise<void> {
|
|
617
|
+
// Quote each dotted segment so a schema-qualified table (`reporting.events`)
|
|
618
|
+
// becomes `"reporting"."events"`, not one dotted identifier that TRUNCATEs
|
|
619
|
+
// the wrong (nonexistent) table. Validate each segment even though the table
|
|
620
|
+
// name is app metadata: a bulletproof ORM must never emit malformed/injectable
|
|
621
|
+
// raw SQL from a `static table = 'x"; DROP…'` slip (same policy as qTable).
|
|
622
|
+
const wrap = (seg: string): string => {
|
|
623
|
+
if (!/^[A-Za-z0-9_]+$/.test(seg)) {
|
|
624
|
+
throw new Error(`Unsafe table identifier: '${seg}'`);
|
|
625
|
+
}
|
|
626
|
+
return this.#dialect === "mysql" ? `\`${seg}\`` : `"${seg}"`;
|
|
627
|
+
};
|
|
628
|
+
const quoted = this.#tableName.split(".").map(wrap).join(".");
|
|
629
|
+
if (this.#dialect === "sqlite") {
|
|
630
|
+
await this.#db.query(`DELETE FROM ${quoted}`, []);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const suffix = cascade && this.#dialect === "postgres" ? " CASCADE" : "";
|
|
634
|
+
await this.#db.query(`TRUNCATE TABLE ${quoted}${suffix}`, []);
|
|
635
|
+
}
|
|
636
|
+
|
|
463
637
|
async allWithTrashed(): Promise<T[]> {
|
|
464
638
|
return this.query().withTrashed().exec();
|
|
465
639
|
}
|
|
@@ -470,45 +644,77 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
470
644
|
}
|
|
471
645
|
|
|
472
646
|
async where(column: string, value: unknown): Promise<T[]> {
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
return this.query()
|
|
478
|
-
.where(column, value)
|
|
479
|
-
.orderBy(this.#primaryKey, "desc")
|
|
480
|
-
.exec();
|
|
647
|
+
// No implicit ORDER BY — the row order is left to the database, matching
|
|
648
|
+
// Lucid's query-builder `where` (only `all`/`findMany` order by PK desc,
|
|
649
|
+
// which Lucid itself does). Add `.orderBy()` explicitly when order matters.
|
|
650
|
+
// Through the builder for read-hook parity (see `find`).
|
|
651
|
+
return this.query().where(column, value).exec();
|
|
481
652
|
}
|
|
482
653
|
|
|
483
654
|
// ─── Create / Save / Delete ───────────────────────────────
|
|
484
655
|
|
|
485
656
|
/**
|
|
486
|
-
* Build an entity from a plain object and persist it. Fires `
|
|
487
|
-
* `
|
|
657
|
+
* Build an entity from a plain object and persist it. Fires `beforeCreate` →
|
|
658
|
+
* `beforeSave` → INSERT → `afterCreate` → `afterSave` (AdonisJS/Lucid order:
|
|
659
|
+
* the specific hook runs before the general `beforeSave`).
|
|
660
|
+
*
|
|
661
|
+
* An unknown key throws by default (Lucid); pass `{ allowExtraProperties: true }`
|
|
662
|
+
* to drop unknown keys instead. A bare boolean is the legacy `quiet` flag.
|
|
488
663
|
*/
|
|
489
|
-
async create(
|
|
664
|
+
async create(
|
|
665
|
+
data: Partial<Record<string, unknown>>,
|
|
666
|
+
options: boolean | CreateOptions = false,
|
|
667
|
+
): Promise<T> {
|
|
668
|
+
// Back-compat: a bare boolean is the legacy `quiet` flag.
|
|
669
|
+
const { quiet = false, allowExtraProperties = false } =
|
|
670
|
+
typeof options === "boolean" ? { quiet: options } : options;
|
|
490
671
|
const entity = new this.#entityClass();
|
|
491
672
|
for (const [key, value] of Object.entries(data)) {
|
|
492
673
|
if (
|
|
493
674
|
this.#validColumns.has(key) ||
|
|
494
675
|
this.#validColumns.has(camelToSnake(key))
|
|
495
676
|
) {
|
|
496
|
-
|
|
677
|
+
const prop = this.#toProperty(key);
|
|
678
|
+
entity.assertMassAssignable(prop);
|
|
679
|
+
entity.setProp(prop, value);
|
|
680
|
+
} else if (key.startsWith("$")) {
|
|
681
|
+
// Framework-internal (`$extras`, `$trx`, …) — leaks in when an entity
|
|
682
|
+
// instance is passed as data. Not a user column and not a typo; skip.
|
|
683
|
+
} else if (!allowExtraProperties) {
|
|
684
|
+
// Adonis Lucid throws on an unknown property by default (a typo'd or
|
|
685
|
+
// stray key is a bug, not something to silently drop). Opt out with
|
|
686
|
+
// `create(data, { allowExtraProperties: true })`.
|
|
687
|
+
throw new AtlasError(
|
|
688
|
+
"E_UNKNOWN_COLUMN",
|
|
689
|
+
`Cannot assign '${key}' — it is not a column on ${this.#entityClass.name}. ` +
|
|
690
|
+
"Pass { allowExtraProperties: true } to drop unknown keys instead.",
|
|
691
|
+
);
|
|
497
692
|
}
|
|
498
693
|
}
|
|
499
|
-
|
|
500
|
-
|
|
694
|
+
if (!quiet) {
|
|
695
|
+
await fireHooks(this.#entityClass, "beforeCreate", entity);
|
|
696
|
+
await fireHooks(this.#entityClass, "beforeSave", entity);
|
|
697
|
+
}
|
|
501
698
|
await this.#insert(entity);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
699
|
+
this.#attachRepoRef(entity);
|
|
700
|
+
if (!quiet) {
|
|
701
|
+
await fireHooks(this.#entityClass, "afterCreate", entity);
|
|
702
|
+
await fireHooks(this.#entityClass, "afterSave", entity);
|
|
703
|
+
}
|
|
704
|
+
await this.#dispatchOrDefer(entity, true);
|
|
505
705
|
return entity;
|
|
506
706
|
}
|
|
507
707
|
|
|
708
|
+
/** {@link create} without firing lifecycle hooks (AdonisJS Lucid `createQuietly`). */
|
|
709
|
+
createQuietly(data: Partial<Record<string, unknown>>): Promise<T> {
|
|
710
|
+
return this.create(data, true);
|
|
711
|
+
}
|
|
712
|
+
|
|
508
713
|
/**
|
|
509
714
|
* Persist an entity. Insert if PK is missing or row doesn't exist, update
|
|
510
|
-
* otherwise. Fires
|
|
511
|
-
* (`afterCreate` | `afterUpdate`) → `afterSave
|
|
715
|
+
* otherwise. Fires (`beforeCreate` | `beforeUpdate`) → `beforeSave` → DB →
|
|
716
|
+
* (`afterCreate` | `afterUpdate`) → `afterSave` (AdonisJS/Lucid order: the
|
|
717
|
+
* specific hook runs before the general `beforeSave`), then dispatches
|
|
512
718
|
* accumulated domain events through `onDomainEvents`.
|
|
513
719
|
*
|
|
514
720
|
* Race-safety: the `find(pk)` → branch decision has a TOCTOU window. If a
|
|
@@ -519,34 +725,114 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
519
725
|
* `beforeCreate` hooks to be idempotent or move side-effects into
|
|
520
726
|
* `afterCreate` / `afterSave` where they only fire on commit.
|
|
521
727
|
*/
|
|
522
|
-
async save(entity: T): Promise<void> {
|
|
728
|
+
async save(entity: T, quiet = false): Promise<void> {
|
|
729
|
+
// A deleted instance must not be resurrected (AdonisJS Lucid parity —
|
|
730
|
+
// `save()` throws once `$isDeleted` is set). Prevents recreating a row the
|
|
731
|
+
// caller believes is gone, or clobbering one deleted concurrently.
|
|
732
|
+
if (entity.$isDeleted) {
|
|
733
|
+
throw new AtlasError(
|
|
734
|
+
"E_MODEL_DELETED",
|
|
735
|
+
`Cannot save a deleted ${this.#entityClass.name} instance.`,
|
|
736
|
+
{
|
|
737
|
+
hint: "The instance was already deleted; re-fetch it before saving again.",
|
|
738
|
+
},
|
|
739
|
+
);
|
|
740
|
+
}
|
|
523
741
|
const pk = entity[this.#primaryKey];
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
742
|
+
// A DB-originated entity whose PK wasn't loaded (an aggregate/alias partial
|
|
743
|
+
// projection) must NOT be treated as new — that would INSERT a duplicate.
|
|
744
|
+
// Fail loud: re-fetch it fully or use `.pojo()` for projections.
|
|
745
|
+
if (entity.$isPersisted && !isProvidedPk(pk)) {
|
|
746
|
+
throw new AtlasError(
|
|
747
|
+
"E_MISSING_PRIMARY_KEY",
|
|
748
|
+
`Cannot save a ${this.#entityClass.name} loaded without its primary key ('${this.#primaryKey}').`,
|
|
749
|
+
{
|
|
750
|
+
hint: "Select the primary key (plain-column projections auto-include it) or use query().pojo() for aggregate/alias projections.",
|
|
751
|
+
},
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
// Decide insert-vs-update from the in-memory `$isPersisted` flag, exactly as
|
|
755
|
+
// AdonisJS/Lucid does — NOT a `find(pk)` SELECT probe. The old probe fired the
|
|
756
|
+
// `beforeFind`/`afterFind` read hooks on every `save()` (a spurious side
|
|
757
|
+
// effect: `save()` isn't a find) and cost an extra round-trip. A brand-new
|
|
758
|
+
// entity whose manual PK collides with an existing row still resolves to an
|
|
759
|
+
// UPDATE via the unique-violation fallback below.
|
|
760
|
+
const isUpdate = entity.$isPersisted;
|
|
761
|
+
|
|
762
|
+
// Snapshot the domain-event queue BEFORE hooks/write add to it, so a rollback
|
|
763
|
+
// (see #dispatchOrDefer) drops only this save's events, not ones the caller
|
|
764
|
+
// queued earlier.
|
|
765
|
+
const eventFloor = entity.domainEventCount();
|
|
766
|
+
|
|
767
|
+
if (!quiet) {
|
|
768
|
+
// AdonisJS/Lucid order: the SPECIFIC before-hook fires first, then the
|
|
769
|
+
// general `beforeSave`, then the DB write.
|
|
770
|
+
await fireHooks(
|
|
771
|
+
this.#entityClass,
|
|
772
|
+
isUpdate ? "beforeUpdate" : "beforeCreate",
|
|
773
|
+
entity,
|
|
774
|
+
);
|
|
775
|
+
await fireHooks(this.#entityClass, "beforeSave", entity);
|
|
776
|
+
}
|
|
777
|
+
// Whether THIS call inserted a brand-new row (vs updated an existing one).
|
|
778
|
+
// Drives the manual-transaction rollback restore: only a fresh INSERT's row
|
|
779
|
+
// vanishes on rollback, so only it reverts to not-persisted. The race-recovery
|
|
780
|
+
// fallback below stays false — the row pre-existed (a concurrent writer).
|
|
781
|
+
let didInsert = false;
|
|
530
782
|
if (isUpdate) {
|
|
531
|
-
await this.#runUpdateBranch(entity);
|
|
783
|
+
await this.#runUpdateBranch(entity, quiet);
|
|
532
784
|
} else {
|
|
533
785
|
try {
|
|
534
|
-
await this.#runInsertBranch(entity);
|
|
786
|
+
await this.#runInsertBranch(entity, quiet);
|
|
787
|
+
didInsert = true;
|
|
535
788
|
} catch (err) {
|
|
536
789
|
// Race recovery: the row didn't exist when we checked, but a
|
|
537
790
|
// concurrent insert beat us to it. Only fall back when the PK
|
|
538
791
|
// was explicitly provided (auto-generated PK can't collide on
|
|
539
792
|
// a fresh insert — DB generates a unique one per call).
|
|
540
793
|
if (isProvidedPk(pk) && isUniqueKeyViolation(err)) {
|
|
541
|
-
|
|
794
|
+
// We already fired `beforeCreate`; fire `beforeUpdate` too so the
|
|
795
|
+
// update branch's contract holds (documented race quirk).
|
|
796
|
+
if (!quiet)
|
|
797
|
+
await fireHooks(this.#entityClass, "beforeUpdate", entity);
|
|
798
|
+
await this.#runUpdateBranch(entity, quiet);
|
|
542
799
|
} else {
|
|
543
800
|
throw err;
|
|
544
801
|
}
|
|
545
802
|
}
|
|
546
803
|
}
|
|
547
|
-
|
|
804
|
+
this.#attachRepoRef(entity);
|
|
805
|
+
if (!quiet) await fireHooks(this.#entityClass, "afterSave", entity);
|
|
548
806
|
|
|
549
|
-
await this.#
|
|
807
|
+
await this.#dispatchOrDefer(entity, didInsert, eventFloor);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** {@link save} without firing lifecycle hooks (AdonisJS Lucid `saveQuietly`). */
|
|
811
|
+
saveQuietly(entity: T): Promise<void> {
|
|
812
|
+
return this.save(entity, true);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* When true (a repo bound to an atlas-managed transaction), `create`/`save`/
|
|
817
|
+
* `createMany` BUFFER domain events on the entity instead of dispatching them
|
|
818
|
+
* inline. The managed helper flushes them only AFTER the transaction commits, so
|
|
819
|
+
* a rollback never emits events for rows that were rolled back.
|
|
820
|
+
*/
|
|
821
|
+
#deferDomainEvents = false;
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* When set (a trx-bound repo whose owner wants to undo fresh inserts on
|
|
825
|
+
* rollback), every successful fresh INSERT through this repo pushes its entity
|
|
826
|
+
* here. The owner (a managed batch or a relation write) then reverts exactly
|
|
827
|
+
* these entities — the ones whose row provably did not exist before — to $isNew
|
|
828
|
+
* if the transaction rolls back, without a DB probe or find-vs-create bookkeeping.
|
|
829
|
+
* Undefined on a durable repo (nothing to undo — its writes are their own commit).
|
|
830
|
+
*/
|
|
831
|
+
#insertTracker?: BaseEntity[];
|
|
832
|
+
|
|
833
|
+
/** Record a fresh INSERT so its owner can revert it on rollback (see {@link #insertTracker}). */
|
|
834
|
+
#trackInsert(entity: BaseEntity): void {
|
|
835
|
+
this.#insertTracker?.push(entity);
|
|
550
836
|
}
|
|
551
837
|
|
|
552
838
|
/**
|
|
@@ -568,16 +854,63 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
568
854
|
}
|
|
569
855
|
}
|
|
570
856
|
|
|
571
|
-
|
|
572
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Dispatch an entity's domain events AND restore its in-memory state across a
|
|
859
|
+
* transaction boundary, honouring the post-commit contract in EVERY context
|
|
860
|
+
* (BaseEntity documents post-commit flush):
|
|
861
|
+
* - inside a MANAGED batch (`#inManagedTx` set `#deferDomainEvents`): skip —
|
|
862
|
+
* that helper flushes `collect(result)` on `trx.after('commit')`, and its
|
|
863
|
+
* callers (`#inManagedTx` re-attach / `saveMany` rollback catch) restore
|
|
864
|
+
* REPO_REF + $isPersisted + events themselves.
|
|
865
|
+
* - inside a MANUAL transaction (`repo.useTransaction(trx).create(...)`): the
|
|
866
|
+
* repo's `#db` IS the trx. Register post-transaction hooks:
|
|
867
|
+
* · commit → re-point REPO_REF at the durable repo (Lucid resets `$trx` on
|
|
868
|
+
* commit) then flush events (a rollback thus publishes NOTHING).
|
|
869
|
+
* · rollback → re-point REPO_REF at the durable repo (Lucid also resets
|
|
870
|
+
* `$trx` on rollback); revert a fresh INSERT to not-persisted — the row
|
|
871
|
+
* never existed, and keeping `$isPersisted` would let a later
|
|
872
|
+
* `entity.related('x').create()` skip the parent save and write a child
|
|
873
|
+
* with a phantom FK (named data-integrity deviation vs Lucid, same class
|
|
874
|
+
* as the saveMany rollback fix); and clear the queued domain events — they
|
|
875
|
+
* describe a write that didn't happen, so leaving them would double-publish
|
|
876
|
+
* on a re-save.
|
|
877
|
+
* - no transaction: dispatch immediately.
|
|
878
|
+
*/
|
|
879
|
+
async #dispatchOrDefer(
|
|
880
|
+
entity: BaseEntity,
|
|
881
|
+
wasInsert: boolean,
|
|
882
|
+
eventFloor = 0,
|
|
883
|
+
): Promise<void> {
|
|
884
|
+
if (this.#deferDomainEvents) return;
|
|
885
|
+
if (isTransactionClient(this.#db)) {
|
|
886
|
+
const durable = this.#durableParent ?? this;
|
|
887
|
+
this.#db.after("commit", async () => {
|
|
888
|
+
durable.#attachRepoRef(entity);
|
|
889
|
+
await this.#dispatchDomainEvents(entity);
|
|
890
|
+
});
|
|
891
|
+
this.#db.after("rollback", () => {
|
|
892
|
+
durable.#attachRepoRef(entity);
|
|
893
|
+
if (wasInsert) entity.markAsNotPersisted();
|
|
894
|
+
// Drop only the events THIS write queued (from `eventFloor` on), not the
|
|
895
|
+
// ones the caller queued before entering the transaction — those describe
|
|
896
|
+
// work outside the rolled-back write and must survive.
|
|
897
|
+
entity.restoreDomainEventsTo(eventFloor);
|
|
898
|
+
});
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
await this.#dispatchDomainEvents(entity);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// The specific `beforeCreate`/`beforeUpdate` hook is fired by `save()` BEFORE
|
|
905
|
+
// `beforeSave` (Lucid order), so these branches only do the write + after-hook.
|
|
906
|
+
async #runInsertBranch(entity: T, quiet = false): Promise<void> {
|
|
573
907
|
await this.#insert(entity);
|
|
574
|
-
await fireHooks(this.#entityClass, "afterCreate", entity);
|
|
908
|
+
if (!quiet) await fireHooks(this.#entityClass, "afterCreate", entity);
|
|
575
909
|
}
|
|
576
910
|
|
|
577
|
-
async #runUpdateBranch(entity: T): Promise<void> {
|
|
578
|
-
await fireHooks(this.#entityClass, "beforeUpdate", entity);
|
|
911
|
+
async #runUpdateBranch(entity: T, quiet = false): Promise<void> {
|
|
579
912
|
await this.#update(entity);
|
|
580
|
-
await fireHooks(this.#entityClass, "afterUpdate", entity);
|
|
913
|
+
if (!quiet) await fireHooks(this.#entityClass, "afterUpdate", entity);
|
|
581
914
|
}
|
|
582
915
|
|
|
583
916
|
/**
|
|
@@ -590,6 +923,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
590
923
|
*/
|
|
591
924
|
async createMany(
|
|
592
925
|
rows: Array<Partial<Record<string, unknown>>>,
|
|
926
|
+
quiet = false,
|
|
593
927
|
): Promise<T[]> {
|
|
594
928
|
if (rows.length === 0) return [];
|
|
595
929
|
const entities: T[] = rows.map((r) => {
|
|
@@ -598,19 +932,54 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
598
932
|
if (
|
|
599
933
|
this.#validColumns.has(k) ||
|
|
600
934
|
this.#validColumns.has(camelToSnake(k))
|
|
601
|
-
)
|
|
602
|
-
|
|
935
|
+
) {
|
|
936
|
+
const prop = this.#toProperty(k);
|
|
937
|
+
e.assertMassAssignable(prop);
|
|
938
|
+
e.setProp(prop, v);
|
|
939
|
+
}
|
|
603
940
|
}
|
|
604
941
|
return e;
|
|
605
942
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
943
|
+
// All-or-nothing (Lucid parity, same as saveMany): run the batch INSERT *and*
|
|
944
|
+
// its afterCreate/afterSave hooks inside ONE managed transaction, so a hook that
|
|
945
|
+
// throws rolls the whole batch back. Previously #persistFreshBatch ran the insert
|
|
946
|
+
// then the after-hooks with no surrounding transaction, so a failing after-hook
|
|
947
|
+
// left the rows committed while createMany rejected. The built entities are
|
|
948
|
+
// internal (returned only on success), so — unlike saveMany, whose instances the
|
|
949
|
+
// caller keeps — no rollback-restore of caller state is needed; the nested-under-
|
|
950
|
+
// external case is already handled by #inManagedTx's tracker.
|
|
951
|
+
return this.#inManagedTx(
|
|
952
|
+
(repo) => repo.#persistFreshBatch(entities, quiet),
|
|
953
|
+
(result) => result,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Persist a batch of NEW entity INSTANCES: fire create/save hooks, batch-INSERT
|
|
959
|
+
* (multi-row RETURNING; mysql falls back to N inserts in one managed tx), fire
|
|
960
|
+
* the after hooks, wire the repo ref, and dispatch domain events (unless
|
|
961
|
+
* deferred). Shared by `createMany` (which builds instances from rows) and
|
|
962
|
+
* `saveMany` (which passes the CALLER's own fresh instances) so hook mutations
|
|
963
|
+
* and hook-generated domain events always land on the exact objects the caller
|
|
964
|
+
* holds — never on discarded clones.
|
|
965
|
+
*/
|
|
966
|
+
async #persistFreshBatch(entities: T[], quiet: boolean): Promise<T[]> {
|
|
967
|
+
if (entities.length === 0) return [];
|
|
968
|
+
if (!quiet) {
|
|
969
|
+
for (const e of entities) {
|
|
970
|
+
await fireHooks(this.#entityClass, "beforeCreate", e);
|
|
971
|
+
await fireHooks(this.#entityClass, "beforeSave", e);
|
|
972
|
+
}
|
|
609
973
|
}
|
|
610
974
|
|
|
611
975
|
if (this.#dialect === "mysql") {
|
|
612
|
-
// mysql
|
|
613
|
-
|
|
976
|
+
// mysql has no multi-row RETURNING, so insert row-by-row — but inside a
|
|
977
|
+
// single managed transaction so the batch is all-or-nothing (Lucid parity;
|
|
978
|
+
// a mid-batch failure must not leave a partial insert committed).
|
|
979
|
+
await transaction(this.#db, async (trx) => {
|
|
980
|
+
const r = this.useTransaction(trx);
|
|
981
|
+
for (const e of entities) await r.#insert(e);
|
|
982
|
+
});
|
|
614
983
|
} else {
|
|
615
984
|
const specRows = entities.map((e) => this.#entityToRowPairs(e));
|
|
616
985
|
const spec = {
|
|
@@ -619,8 +988,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
619
988
|
rows: specRows,
|
|
620
989
|
casts: this.#castTypes,
|
|
621
990
|
returning: [
|
|
622
|
-
|
|
623
|
-
...this.#columns.map((c) =>
|
|
991
|
+
this.#dbColumn(this.#primaryKey),
|
|
992
|
+
...this.#columns.map((c) => this.#dbColumn(c)),
|
|
624
993
|
],
|
|
625
994
|
};
|
|
626
995
|
const compiled = compileStatementNative(spec, this.#dialect);
|
|
@@ -629,22 +998,38 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
629
998
|
compiled.params,
|
|
630
999
|
);
|
|
631
1000
|
returned.forEach((row, i) => {
|
|
632
|
-
for (const [k, v] of Object.entries(row))
|
|
633
|
-
|
|
1001
|
+
for (const [k, v] of Object.entries(row)) {
|
|
1002
|
+
const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
|
|
1003
|
+
// Run the DB value through consume so date columns come back as
|
|
1004
|
+
// Chronos DateTime (not the raw ISO string) — mirrors #hydrate.
|
|
1005
|
+
entities[i].setProp(prop, this.#applyConsume(prop, v, entities[i]));
|
|
1006
|
+
}
|
|
634
1007
|
entities[i].markAsPersisted();
|
|
635
1008
|
});
|
|
636
1009
|
}
|
|
637
1010
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
await this.#dispatchDomainEvents(e);
|
|
1011
|
+
if (!quiet) {
|
|
1012
|
+
for (const e of entities) {
|
|
1013
|
+
await fireHooks(this.#entityClass, "afterCreate", e);
|
|
1014
|
+
await fireHooks(this.#entityClass, "afterSave", e);
|
|
1015
|
+
}
|
|
644
1016
|
}
|
|
1017
|
+
for (const e of entities) this.#attachRepoRef(e);
|
|
1018
|
+
// Record the fresh inserts on THIS repo (the mysql path ran #insert on a nested
|
|
1019
|
+
// trx repo, so track here uniformly for both dialects) so the owning managed
|
|
1020
|
+
// batch can revert them on rollback.
|
|
1021
|
+
for (const e of entities) this.#trackInsert(e);
|
|
1022
|
+
for (const e of entities) await this.#dispatchOrDefer(e, true);
|
|
645
1023
|
return entities;
|
|
646
1024
|
}
|
|
647
1025
|
|
|
1026
|
+
/** {@link createMany} without firing lifecycle hooks (AdonisJS Lucid `createManyQuietly`). */
|
|
1027
|
+
createManyQuietly(
|
|
1028
|
+
rows: Array<Partial<Record<string, unknown>>>,
|
|
1029
|
+
): Promise<T[]> {
|
|
1030
|
+
return this.createMany(rows, true);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
648
1033
|
/**
|
|
649
1034
|
* Persist many already-constructed entity instances. Same hooks + batching
|
|
650
1035
|
* as `createMany`, but accepts prebuilt entities so dirty tracking works.
|
|
@@ -653,32 +1038,64 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
653
1038
|
*/
|
|
654
1039
|
async saveMany(entities: T[]): Promise<T[]> {
|
|
655
1040
|
if (entities.length === 0) return [];
|
|
656
|
-
//
|
|
657
|
-
//
|
|
1041
|
+
// All-or-nothing, like Lucid: `createMany` and every batch helper run in a
|
|
1042
|
+
// managed transaction, so a mid-batch failure rolls the WHOLE batch back
|
|
1043
|
+
// (verified against the Lucid CRUD docs). Fresh inserts AND dirty updates
|
|
1044
|
+
// commit together or not at all — previously the dirty ones were saved one
|
|
1045
|
+
// by one OUTSIDE any transaction, leaving earlier rows persisted on a later
|
|
1046
|
+
// failure. Events flush post-commit via #inManagedTx (deferred inside).
|
|
1047
|
+
// #inManagedTx re-points each returned entity's REPO_REF at the durable repo
|
|
1048
|
+
// after commit, so related()/refresh() work on the instances we hand back.
|
|
1049
|
+
// Split BEFORE the batch so the rollback path still knows which were fresh
|
|
1050
|
+
// (once #persistFreshBatch runs markAsPersisted, the flag flips). Classify by
|
|
1051
|
+
// `$isPersisted`, NOT by an empty `$original`: an aggregate/alias PROJECTION is
|
|
1052
|
+
// hydrated persisted but with `$original = {}`, so the old empty-$original test
|
|
1053
|
+
// misrouted it into the fresh INSERT batch — bypassing save()'s
|
|
1054
|
+
// E_MISSING_PRIMARY_KEY guard and turning a keyless projection into an INSERT.
|
|
1055
|
+
// A persisted projection now lands in `dirty` → save() → the guard fires.
|
|
658
1056
|
const fresh: T[] = [];
|
|
659
1057
|
const dirty: T[] = [];
|
|
660
1058
|
for (const e of entities) {
|
|
661
|
-
if (
|
|
1059
|
+
if (!e.$isPersisted) fresh.push(e);
|
|
662
1060
|
else dirty.push(e);
|
|
663
1061
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1062
|
+
// Snapshot each caller instance's domain-event floor BEFORE the batch, so a
|
|
1063
|
+
// rollback drops only the events this batch queued, keeping any the caller
|
|
1064
|
+
// queued earlier (#8).
|
|
1065
|
+
const eventFloors = new Map<BaseEntity, number>();
|
|
1066
|
+
for (const e of entities) eventFloors.set(e, e.domainEventCount());
|
|
1067
|
+
try {
|
|
1068
|
+
return await this.#inManagedTx(
|
|
1069
|
+
async (repo) => {
|
|
1070
|
+
// Persist the caller's OWN fresh instances (not clones): hook mutations
|
|
1071
|
+
// and hook-generated domain events stay on the objects we return.
|
|
1072
|
+
if (fresh.length > 0) await repo.#persistFreshBatch(fresh, false);
|
|
1073
|
+
for (const d of dirty) await repo.save(d);
|
|
1074
|
+
return entities;
|
|
1075
|
+
},
|
|
1076
|
+
(result) => result,
|
|
1077
|
+
eventFloors,
|
|
1078
|
+
);
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
// Rollback recovery. Re-point every instance's REPO_REF at the durable repo
|
|
1081
|
+
// (it was stamped at the now-finished trx) — Lucid resets `$trx` the same
|
|
1082
|
+
// way. And REVERT the FRESH instances to not-persisted: their INSERT was
|
|
1083
|
+
// rolled back, so keeping `$isPersisted` (Lucid does) would let a later
|
|
1084
|
+
// `fresh.related('x').create()` skip re-saving the parent and write a child
|
|
1085
|
+
// with a phantom foreign key. Reverting only the FRESH ones (provably
|
|
1086
|
+
// unpersisted before the batch) is a NAMED safety deviation; DIRTY rows
|
|
1087
|
+
// keep `$isPersisted` — their row still exists with its rolled-back values.
|
|
1088
|
+
for (const e of entities) this.#attachRepoRef(e);
|
|
1089
|
+
for (const e of fresh) e.markAsNotPersisted();
|
|
1090
|
+
// Drop the events THIS batch queued (from each instance's pre-batch floor):
|
|
1091
|
+
// the whole batch rolled back, so those describe writes that didn't happen.
|
|
1092
|
+
// Leaving them would double-publish when the caller re-saves the same
|
|
1093
|
+
// instance (its hooks re-queue the event). Events queued BEFORE the batch
|
|
1094
|
+
// survive (#8) — they describe work outside this rolled-back batch.
|
|
1095
|
+
for (const e of entities)
|
|
1096
|
+
e.restoreDomainEventsTo(eventFloors.get(e) ?? 0);
|
|
1097
|
+
throw err;
|
|
679
1098
|
}
|
|
680
|
-
for (const d of dirty) await this.save(d);
|
|
681
|
-
return entities;
|
|
682
1099
|
}
|
|
683
1100
|
|
|
684
1101
|
/**
|
|
@@ -721,12 +1138,93 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
721
1138
|
): Promise<T> {
|
|
722
1139
|
// Atomic (AdonisJS Lucid parity): find-under-lock then create inside one
|
|
723
1140
|
// transaction, so two concurrent callers can't both miss and both INSERT.
|
|
724
|
-
return
|
|
1141
|
+
return this.#inManagedTx(
|
|
1142
|
+
async (repo) => {
|
|
1143
|
+
const existing = await repo.#findBySearch(search, true);
|
|
1144
|
+
if (existing) return existing;
|
|
1145
|
+
return repo.create({ ...search, ...defaults });
|
|
1146
|
+
},
|
|
1147
|
+
(r) => [r],
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Run `body` inside an atlas-managed transaction whose trx-bound repo DEFERS
|
|
1153
|
+
* domain-event dispatch, then flush the collected entities' events AFTER the
|
|
1154
|
+
* commit — so a rollback emits no events for rows that were rolled back
|
|
1155
|
+
* (previously each create/save dispatched in-loop, before the batch committed).
|
|
1156
|
+
* `collect` picks the entities whose events flush post-commit.
|
|
1157
|
+
*/
|
|
1158
|
+
async #inManagedTx<R>(
|
|
1159
|
+
body: (repo: BaseRepository<T>) => Promise<R>,
|
|
1160
|
+
collect: (result: R) => BaseEntity[],
|
|
1161
|
+
eventFloors?: ReadonlyMap<BaseEntity, number>,
|
|
1162
|
+
): Promise<R> {
|
|
1163
|
+
// Records every fresh INSERT the body performs through the trx-bound repo, so
|
|
1164
|
+
// we can revert exactly those (not the found-and-updated rows) on rollback.
|
|
1165
|
+
const freshInserts: BaseEntity[] = [];
|
|
1166
|
+
const result = await transaction(this.#db, async (trx) => {
|
|
725
1167
|
const repo = this.useTransaction(trx);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1168
|
+
repo.#deferDomainEvents = true;
|
|
1169
|
+
repo.#insertTracker = freshInserts;
|
|
1170
|
+
const r = await body(repo);
|
|
1171
|
+
// Flush AFTER the transaction is durable. Registering on the trx (rather
|
|
1172
|
+
// than awaiting after `transaction(...)` returns) is what makes this
|
|
1173
|
+
// correct inside an EXTERNAL transaction: there `transaction()` only
|
|
1174
|
+
// opens a SAVEPOINT, so a post-return flush would fire before the outer
|
|
1175
|
+
// commit — and emit events for rows a later outer rollback discards.
|
|
1176
|
+
trx.after("commit", async () => {
|
|
1177
|
+
for (const e of collect(r)) await this.#dispatchDomainEvents(e);
|
|
1178
|
+
});
|
|
1179
|
+
return r;
|
|
729
1180
|
});
|
|
1181
|
+
// Every entity produced here was created / hydrated through the trx-bound
|
|
1182
|
+
// repo, so its REPO_REF points at the (now-finished inner) transaction. Re-point
|
|
1183
|
+
// it at `this` so related()/refresh()/fresh() work on the returned instance —
|
|
1184
|
+
// covers firstOrCreate/updateOrCreate/*Many/saveMany.
|
|
1185
|
+
const produced = collect(result);
|
|
1186
|
+
for (const e of produced) this.#attachRepoRef(e);
|
|
1187
|
+
// When we ran NESTED inside an external transaction, `this.#db` is that outer
|
|
1188
|
+
// trx and the re-attach above pointed REPO_REF at the outer-trx repo (correct
|
|
1189
|
+
// while still inside it). But the inner SAVEPOINT's RELEASE is NOT durable — the
|
|
1190
|
+
// root can still roll back. Lucid resets `$trx` once the transaction it was bound
|
|
1191
|
+
// to resolves, either way, so re-point REPO_REF at the durable repo on BOTH the
|
|
1192
|
+
// outer commit and the outer rollback; otherwise the ref dangles on a finished
|
|
1193
|
+
// transaction ("transaction already finished") on any later related()/refresh().
|
|
1194
|
+
// AND on rollback, revert the rows that were FRESHLY INSERTED (the tracker proves
|
|
1195
|
+
// exactly which — found-and-updated rows still exist and stay persisted): keeping
|
|
1196
|
+
// $isPersisted on a row that no longer exists would let a later related().create()
|
|
1197
|
+
// skip re-saving the parent and orphan the FK (named data-integrity deviation, now
|
|
1198
|
+
// closed for the nested managed path too — freshness is proven, no longer at Lucid
|
|
1199
|
+
// parity as in the initial R21 pass).
|
|
1200
|
+
if (isTransactionClient(this.#db)) {
|
|
1201
|
+
const durable = this.#durableParent ?? this;
|
|
1202
|
+
this.#db.after("commit", () => {
|
|
1203
|
+
for (const e of produced) durable.#attachRepoRef(e);
|
|
1204
|
+
});
|
|
1205
|
+
this.#db.after("rollback", () => {
|
|
1206
|
+
for (const e of produced) {
|
|
1207
|
+
durable.#attachRepoRef(e);
|
|
1208
|
+
// Every produced entity was written (inserted OR updated) in the
|
|
1209
|
+
// rolled-back trx, so any event THIS batch queued describes a write that
|
|
1210
|
+
// never committed — drop it (else a later re-save double-publishes: a
|
|
1211
|
+
// beforeUpdate hook on a found+updated row is the canonical trigger).
|
|
1212
|
+
// Restore to the caller's pre-batch floor so events queued BEFORE the
|
|
1213
|
+
// batch (e.g. a caller's manual addDomainEvent) survive; absent a floor
|
|
1214
|
+
// the entity was tx-internal (floor 0 = clear).
|
|
1215
|
+
e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
|
|
1216
|
+
}
|
|
1217
|
+
// Fresh inserts additionally revert to $isNew — their row is gone.
|
|
1218
|
+
// Found+updated rows keep $isPersisted (their row still exists).
|
|
1219
|
+
// (restoreDomainEventsTo is idempotent — safe even if a fresh insert is not
|
|
1220
|
+
// among `produced`, e.g. an internal side-write not returned by collect.)
|
|
1221
|
+
for (const e of freshInserts) {
|
|
1222
|
+
e.markAsNotPersisted();
|
|
1223
|
+
e.restoreDomainEventsTo(eventFloors?.get(e) ?? 0);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
return result;
|
|
730
1228
|
}
|
|
731
1229
|
|
|
732
1230
|
/** Find a row or build an in-memory instance without persisting. */
|
|
@@ -738,8 +1236,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
738
1236
|
if (existing) return existing;
|
|
739
1237
|
const e = new this.#entityClass();
|
|
740
1238
|
for (const [k, v] of Object.entries({ ...search, ...defaults })) {
|
|
741
|
-
if (
|
|
742
|
-
|
|
1239
|
+
if (
|
|
1240
|
+
this.#validColumns.has(k) ||
|
|
1241
|
+
this.#validColumns.has(camelToSnake(k))
|
|
1242
|
+
) {
|
|
1243
|
+
const prop = this.#toProperty(k);
|
|
1244
|
+
e.assertMassAssignable(prop);
|
|
1245
|
+
e.setProp(prop, v);
|
|
1246
|
+
}
|
|
743
1247
|
}
|
|
744
1248
|
return e;
|
|
745
1249
|
}
|
|
@@ -749,16 +1253,119 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
749
1253
|
search: Record<string, unknown>,
|
|
750
1254
|
values: Record<string, unknown>,
|
|
751
1255
|
): Promise<T> {
|
|
752
|
-
return
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1256
|
+
return this.#inManagedTx(
|
|
1257
|
+
async (repo) => {
|
|
1258
|
+
const existing = await repo.#findBySearch(search, true);
|
|
1259
|
+
if (existing) {
|
|
1260
|
+
for (const [k, v] of Object.entries(values)) {
|
|
1261
|
+
const prop = this.#toProperty(k);
|
|
1262
|
+
existing.assertMassAssignable(prop);
|
|
1263
|
+
existing.setProp(prop, v);
|
|
1264
|
+
}
|
|
1265
|
+
await repo.save(existing);
|
|
1266
|
+
return existing;
|
|
1267
|
+
}
|
|
1268
|
+
return repo.create({ ...search, ...values });
|
|
1269
|
+
},
|
|
1270
|
+
(r) => [r],
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Extract the search clause (the unique key column(s)) from a row. */
|
|
1275
|
+
#pickKeys(
|
|
1276
|
+
row: Record<string, unknown>,
|
|
1277
|
+
key: string | string[],
|
|
1278
|
+
): Record<string, unknown> {
|
|
1279
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
1280
|
+
const search: Record<string, unknown> = {};
|
|
1281
|
+
for (const k of keys) {
|
|
1282
|
+
// The predicate key AND the row may each be a TS property or a DB column
|
|
1283
|
+
// name. Normalise both to the property so `updateOrCreateMany('label', [{
|
|
1284
|
+
// full_label: 'x' }])` matches — mirrors the create() key normalization.
|
|
1285
|
+
const prop = this.#toProperty(k);
|
|
1286
|
+
const dbName = this.#dbColumn(prop);
|
|
1287
|
+
let value: unknown;
|
|
1288
|
+
if (k in row) value = row[k];
|
|
1289
|
+
else if (prop in row) value = row[prop];
|
|
1290
|
+
else value = row[dbName];
|
|
1291
|
+
search[prop] = value;
|
|
1292
|
+
}
|
|
1293
|
+
return search;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Bulk find-or-update-or-insert, keyed by a unique column (or columns), in ONE
|
|
1298
|
+
* transaction — all-or-nothing (AdonisJS Lucid `updateOrCreateMany`).
|
|
1299
|
+
*/
|
|
1300
|
+
async updateOrCreateMany(
|
|
1301
|
+
key: string | string[],
|
|
1302
|
+
rows: Array<Record<string, unknown>>,
|
|
1303
|
+
): Promise<T[]> {
|
|
1304
|
+
if (rows.length === 0) return [];
|
|
1305
|
+
return this.#inManagedTx(
|
|
1306
|
+
async (repo) => {
|
|
1307
|
+
const out: T[] = [];
|
|
1308
|
+
for (const row of rows) {
|
|
1309
|
+
const existing = await repo.#findBySearch(
|
|
1310
|
+
this.#pickKeys(row, key),
|
|
1311
|
+
true,
|
|
1312
|
+
);
|
|
1313
|
+
if (existing) {
|
|
1314
|
+
for (const [k, v] of Object.entries(row)) {
|
|
1315
|
+
const prop = this.#toProperty(k);
|
|
1316
|
+
existing.assertMassAssignable(prop);
|
|
1317
|
+
existing.setProp(prop, v);
|
|
1318
|
+
}
|
|
1319
|
+
await repo.save(existing);
|
|
1320
|
+
out.push(existing);
|
|
1321
|
+
} else {
|
|
1322
|
+
out.push(await repo.create(row));
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return out;
|
|
1326
|
+
},
|
|
1327
|
+
(out) => out,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Bulk find-or-create keyed by a unique column(s) — existing rows are returned
|
|
1333
|
+
* untouched — in one transaction (AdonisJS Lucid `fetchOrCreateMany`).
|
|
1334
|
+
*/
|
|
1335
|
+
async fetchOrCreateMany(
|
|
1336
|
+
key: string | string[],
|
|
1337
|
+
rows: Array<Record<string, unknown>>,
|
|
1338
|
+
): Promise<T[]> {
|
|
1339
|
+
if (rows.length === 0) return [];
|
|
1340
|
+
return this.#inManagedTx(
|
|
1341
|
+
async (repo) => {
|
|
1342
|
+
const out: T[] = [];
|
|
1343
|
+
for (const row of rows) {
|
|
1344
|
+
const existing = await repo.#findBySearch(
|
|
1345
|
+
this.#pickKeys(row, key),
|
|
1346
|
+
true,
|
|
1347
|
+
);
|
|
1348
|
+
out.push(existing ?? (await repo.create(row)));
|
|
1349
|
+
}
|
|
1350
|
+
return out;
|
|
1351
|
+
},
|
|
1352
|
+
(out) => out,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Bulk find-or-new keyed by a unique column(s): existing rows are returned,
|
|
1358
|
+
* misses become UNPERSISTED in-memory instances (AdonisJS `fetchOrNewUpMany`).
|
|
1359
|
+
*/
|
|
1360
|
+
async fetchOrNewUpMany(
|
|
1361
|
+
key: string | string[],
|
|
1362
|
+
rows: Array<Record<string, unknown>>,
|
|
1363
|
+
): Promise<T[]> {
|
|
1364
|
+
const out: T[] = [];
|
|
1365
|
+
for (const row of rows) {
|
|
1366
|
+
out.push(await this.firstOrNew(this.#pickKeys(row, key), row));
|
|
1367
|
+
}
|
|
1368
|
+
return out;
|
|
762
1369
|
}
|
|
763
1370
|
|
|
764
1371
|
async #findBySearch(
|
|
@@ -778,25 +1385,79 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
778
1385
|
* contract — callback receives the raw value (including null/undefined) and
|
|
779
1386
|
* decides what to do with it.
|
|
780
1387
|
*/
|
|
781
|
-
#applyPrepare(
|
|
1388
|
+
#applyPrepare(key: string, value: unknown, model?: unknown): unknown {
|
|
1389
|
+
// Callers may pass a DB column name (e.g. updateWhere("starts_at", …) or a
|
|
1390
|
+
// `@Column({ columnName })` column) — prepare/dateColumns are keyed by the TS
|
|
1391
|
+
// property, so normalise via the reverse map first, else the adapter/date
|
|
1392
|
+
// conversion is silently skipped.
|
|
1393
|
+
const propertyKey = this.#columnByDbName.get(key) ?? key;
|
|
782
1394
|
const prepare = this.#columnPrepares.get(propertyKey);
|
|
783
|
-
if (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1395
|
+
if (prepare) {
|
|
1396
|
+
let result: unknown;
|
|
1397
|
+
try {
|
|
1398
|
+
// Adonis Lucid signature: (value, attribute, model). `model` is
|
|
1399
|
+
// undefined on query-builder paths that carry no instance.
|
|
1400
|
+
result = prepare(value, propertyKey, model);
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
throw wrapAdapterError("prepare", propertyKey, err);
|
|
1403
|
+
}
|
|
1404
|
+
assertNotPromise("prepare", propertyKey, result);
|
|
1405
|
+
return result;
|
|
789
1406
|
}
|
|
790
|
-
|
|
791
|
-
|
|
1407
|
+
// No explicit `@Column({ prepare })`: lower a `@column.date()` /
|
|
1408
|
+
// `@column.dateTime()` value to its ISO 8601 string for the SQL bind.
|
|
1409
|
+
// Branch order mirrors Lucid's `prepareDateColumn` (strings pass through,
|
|
1410
|
+
// `DateTime` is formatted, anything else throws naming the column) — see
|
|
1411
|
+
// `#prepareDateString` for the one named deviation.
|
|
1412
|
+
if (this.#dateColumns[propertyKey] && value != null) {
|
|
1413
|
+
if (typeof value === "string") {
|
|
1414
|
+
return this.#prepareDateString(propertyKey, value);
|
|
1415
|
+
}
|
|
1416
|
+
// A raw JS `Date` is accepted where Lucid throws: `toISOString()` is
|
|
1417
|
+
// unambiguous UTC, so the strictness would buy nothing. Named deviation.
|
|
1418
|
+
if (value instanceof Date) return value.toISOString();
|
|
1419
|
+
// Otherwise the Chronos adapter's prepare serialises a `DateTime` — via
|
|
1420
|
+
// a STRUCTURAL check, so an instance from a duplicated `@c9up/chronos`
|
|
1421
|
+
// copy (another realm) round-trips instead of being passed raw to the
|
|
1422
|
+
// N-API bind.
|
|
1423
|
+
return dateTimeAtlasAdapter.prepare(value);
|
|
1424
|
+
}
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Lower a string assigned to a `@column.date()` / `@column.dateTime()`.
|
|
1430
|
+
*
|
|
1431
|
+
* Lucid lets every string through untouched (`prepareDateColumn`, first
|
|
1432
|
+
* branch), and atlas does the same — with ONE named deviation: a *naive*
|
|
1433
|
+
* datetime (no `Z`, no offset) is rejected. Such a string has no instant
|
|
1434
|
+
* attached, so the chronos read path resolves it in the JS runtime's local
|
|
1435
|
+
* zone: the very same row then hydrates to a different instant on a
|
|
1436
|
+
* developer laptop in Europe/Zurich and on a CI host running UTC. Silently
|
|
1437
|
+
* storing a value that means two different things is the failure this guard
|
|
1438
|
+
* exists to prevent; date-only strings and offset-bearing ones are
|
|
1439
|
+
* unambiguous and pass exactly like Lucid.
|
|
1440
|
+
*/
|
|
1441
|
+
#prepareDateString(propertyKey: string, value: string): string {
|
|
1442
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
|
|
1443
|
+
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value.trim());
|
|
1444
|
+
if (dateOnly || hasZone) return value;
|
|
1445
|
+
throw new AtlasError(
|
|
1446
|
+
"INVALID_DATE_COLUMN_VALUE",
|
|
1447
|
+
`${this.#entityClass.name}.${propertyKey}: "${value}" is a naive datetime — it carries no timezone, so it would read back differently depending on the machine.`,
|
|
1448
|
+
{
|
|
1449
|
+
hint: 'Add an offset ("2026-08-10T12:00:00Z"), pass a chronos DateTime, or use a date-only string ("2026-08-10").',
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
792
1452
|
}
|
|
793
1453
|
|
|
794
|
-
#applyConsume(propertyKey: string, value: unknown): unknown {
|
|
1454
|
+
#applyConsume(propertyKey: string, value: unknown, model?: unknown): unknown {
|
|
795
1455
|
const consume = this.#columnConsumes.get(propertyKey);
|
|
796
1456
|
if (consume) {
|
|
797
1457
|
let result: unknown;
|
|
798
1458
|
try {
|
|
799
|
-
|
|
1459
|
+
// Adonis Lucid signature: (value, attribute, model).
|
|
1460
|
+
result = consume(value, propertyKey, model);
|
|
800
1461
|
} catch (err) {
|
|
801
1462
|
throw wrapAdapterError("consume", propertyKey, err);
|
|
802
1463
|
}
|
|
@@ -804,19 +1465,13 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
804
1465
|
return result;
|
|
805
1466
|
}
|
|
806
1467
|
// No explicit `@Column({ consume })`: a `@column.date()` / `@column.dateTime()`
|
|
807
|
-
// column hydrates its DB value
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
value != null &&
|
|
815
|
-
!(value instanceof Date) &&
|
|
816
|
-
(typeof value === "string" || typeof value === "number")
|
|
817
|
-
) {
|
|
818
|
-
const d = new Date(value);
|
|
819
|
-
if (!Number.isNaN(d.getTime())) return d;
|
|
1468
|
+
// column hydrates its DB value into a Chronos `DateTime` — mirroring Adonis
|
|
1469
|
+
// Lucid, which hydrates date columns to a Luxon `DateTime` (here the Ream
|
|
1470
|
+
// date engine `@c9up/chronos` plays Luxon's role). The Chronos adapter's
|
|
1471
|
+
// consume is idempotent and uses a structural check, so a `DateTime` from a
|
|
1472
|
+
// different realm (duplicated package copy) is recognised too.
|
|
1473
|
+
if (this.#dateColumns[propertyKey] && value != null) {
|
|
1474
|
+
return dateTimeAtlasAdapter.consume(value);
|
|
820
1475
|
}
|
|
821
1476
|
return value;
|
|
822
1477
|
}
|
|
@@ -828,10 +1483,9 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
828
1483
|
// the Rust DML compiler / NAPI layer rejects it. `null` is allowed
|
|
829
1484
|
// through because that's a meaningful SQL value.
|
|
830
1485
|
if (v === undefined) continue;
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
pairs.push([this.#resolveColumn(k), this.#applyPrepare(propKey, v)]);
|
|
1486
|
+
// `#applyPrepare` normalises the key (property / snake / columnName) via
|
|
1487
|
+
// the reverse map, so pass the raw key straight through.
|
|
1488
|
+
pairs.push([this.#resolveColumn(k), this.#applyPrepare(k, v)]);
|
|
835
1489
|
}
|
|
836
1490
|
return pairs;
|
|
837
1491
|
}
|
|
@@ -841,32 +1495,43 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
841
1495
|
for (const col of this.#columns) {
|
|
842
1496
|
const v = entity[col];
|
|
843
1497
|
if (v !== undefined)
|
|
844
|
-
pairs.push([
|
|
1498
|
+
pairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
|
|
845
1499
|
}
|
|
846
1500
|
return pairs;
|
|
847
1501
|
}
|
|
848
1502
|
|
|
849
1503
|
/** Delete the entity. Fires `beforeDelete` → DB → `afterDelete`. Soft-delete aware. */
|
|
850
|
-
async delete(entity: T): Promise<void> {
|
|
851
|
-
|
|
1504
|
+
async delete(entity: T, quiet = false): Promise<void> {
|
|
1505
|
+
// Guard BEFORE hooks — a projection entity with no PK must not fire
|
|
1506
|
+
// beforeDelete against a phantom row, then delete WHERE pk IS NULL.
|
|
1507
|
+
this.#assertPersistedRow(entity, entity[this.#primaryKey], "delete()");
|
|
1508
|
+
if (!quiet) await fireHooks(this.#entityClass, "beforeDelete", entity);
|
|
852
1509
|
const pk = entity[this.#primaryKey];
|
|
853
1510
|
if (this.#softDeletes) {
|
|
854
|
-
const now =
|
|
1511
|
+
const now = DateTime.now();
|
|
855
1512
|
await this.#runUpdate(
|
|
856
|
-
[["
|
|
1513
|
+
[[this.#dbColumn("deletedAt"), now.toISO()]],
|
|
857
1514
|
[{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }],
|
|
858
1515
|
);
|
|
1516
|
+
// In-memory value is a Chronos DateTime, matching how date columns hydrate.
|
|
859
1517
|
entity.setProp("deletedAt", now);
|
|
860
1518
|
} else {
|
|
861
1519
|
await this.#runDelete([
|
|
862
1520
|
{ column: this.#primaryKey, operator: "=", value: pk, type: "and" },
|
|
863
1521
|
]);
|
|
864
1522
|
}
|
|
865
|
-
|
|
1523
|
+
entity.markAsDeleted();
|
|
1524
|
+
if (!quiet) await fireHooks(this.#entityClass, "afterDelete", entity);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** {@link delete} without firing lifecycle hooks (AdonisJS Lucid `deleteQuietly`). */
|
|
1528
|
+
deleteQuietly(entity: T): Promise<void> {
|
|
1529
|
+
return this.delete(entity, true);
|
|
866
1530
|
}
|
|
867
1531
|
|
|
868
1532
|
/** Permanently delete (bypasses soft delete). Fires `beforeDelete` / `afterDelete` hooks. */
|
|
869
1533
|
async forceDelete(entity: T): Promise<void> {
|
|
1534
|
+
this.#assertPersistedRow(entity, entity[this.#primaryKey], "forceDelete()");
|
|
870
1535
|
await fireHooks(this.#entityClass, "beforeDelete", entity);
|
|
871
1536
|
await this.#runDelete([
|
|
872
1537
|
{
|
|
@@ -876,13 +1541,15 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
876
1541
|
type: "and",
|
|
877
1542
|
},
|
|
878
1543
|
]);
|
|
1544
|
+
entity.markAsDeleted();
|
|
879
1545
|
await fireHooks(this.#entityClass, "afterDelete", entity);
|
|
880
1546
|
}
|
|
881
1547
|
|
|
882
1548
|
async restore(entity: T): Promise<void> {
|
|
883
1549
|
if (!this.#softDeletes) return;
|
|
1550
|
+
this.#assertPersistedRow(entity, entity[this.#primaryKey], "restore()");
|
|
884
1551
|
await this.#runUpdate(
|
|
885
|
-
[["
|
|
1552
|
+
[[this.#dbColumn("deletedAt"), null]],
|
|
886
1553
|
[
|
|
887
1554
|
{
|
|
888
1555
|
column: this.#primaryKey,
|
|
@@ -915,7 +1582,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
915
1582
|
const whereCol = this.#resolveColumn(column);
|
|
916
1583
|
const set = this.#buildSetPairs(data);
|
|
917
1584
|
await this.#runUpdate(set, [
|
|
918
|
-
{
|
|
1585
|
+
{
|
|
1586
|
+
column: whereCol,
|
|
1587
|
+
operator: "=",
|
|
1588
|
+
// Prepare the filter value like the query()/where() path (DateTime→ISO,
|
|
1589
|
+
// @Column adapters) so updateWhere matches query().where().update().
|
|
1590
|
+
value: this.#applyPrepare(column, columnValue),
|
|
1591
|
+
type: "and",
|
|
1592
|
+
},
|
|
919
1593
|
]);
|
|
920
1594
|
}
|
|
921
1595
|
|
|
@@ -973,6 +1647,21 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
973
1647
|
// ─── Raw ──────────────────────────────────────────────────
|
|
974
1648
|
|
|
975
1649
|
async raw(sql: string, ...params: unknown[]): Promise<T[]> {
|
|
1650
|
+
// Strict mode hardens the repository's raw surfaces (parity with
|
|
1651
|
+
// whereRaw/joinRaw/havingRaw): `raw()` splices a whole hand-written SQL
|
|
1652
|
+
// statement into the typed repo and hydrates it, so it's the widest raw
|
|
1653
|
+
// entry point of all. Block it and point at the connection-level break-glass
|
|
1654
|
+
// (`db.query()`/`db.execute()`, explicitly parameterised) — that stays the
|
|
1655
|
+
// sanctioned, greppable escape hatch, never a silent bypass of strict mode.
|
|
1656
|
+
if (isAtlasStrictMode()) {
|
|
1657
|
+
throw new AtlasError(
|
|
1658
|
+
"E_STRICT_MODE",
|
|
1659
|
+
`raw() is disabled in Atlas strict mode on ${this.#entityClass.name}.`,
|
|
1660
|
+
{
|
|
1661
|
+
hint: "Use the typed query() builder, or db.query()/db.execute() with bound params for a deliberate break-glass query. Call setAtlasStrictMode(false) at bootstrap if you truly need repo.raw().",
|
|
1662
|
+
},
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
976
1665
|
const rows = await this.#db.query<Row>(sql, params);
|
|
977
1666
|
return rows.map((r) => this.#hydrate(r));
|
|
978
1667
|
}
|
|
@@ -988,6 +1677,46 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
988
1677
|
|
|
989
1678
|
// ─── Private helpers ──────────────────────────────────────
|
|
990
1679
|
|
|
1680
|
+
/**
|
|
1681
|
+
* Guard for an op PREMISED on an existing DB row (refresh/fresh/delete/
|
|
1682
|
+
* forceDelete/restore/load*). These require a genuine database row, so the
|
|
1683
|
+
* entity must be `$isPersisted` — a locally-built instance with a manual PK is
|
|
1684
|
+
* NOT a row: deleting/refreshing off it would silently hit an unrelated row (or
|
|
1685
|
+
* none) and fire hooks against a hollow object. Mirrors Lucid, whose `refresh()`
|
|
1686
|
+
* rejects a non-persisted instance and whose destructive ops always run on a
|
|
1687
|
+
* loaded model; the extra strictness on delete/restore is a named safety
|
|
1688
|
+
* deviation. A persisted-but-keyless entity (aggregate/alias projection) is also
|
|
1689
|
+
* rejected, with the projection diagnostic.
|
|
1690
|
+
*/
|
|
1691
|
+
#assertPersistedRow(
|
|
1692
|
+
entity: BaseEntity,
|
|
1693
|
+
key: unknown,
|
|
1694
|
+
op: string,
|
|
1695
|
+
keyName: string = this.#primaryKey,
|
|
1696
|
+
): void {
|
|
1697
|
+
if (!entity.$isPersisted) {
|
|
1698
|
+
throw new AtlasError(
|
|
1699
|
+
"E_MODEL_NOT_PERSISTED",
|
|
1700
|
+
`Cannot ${op} a ${this.#entityClass.name} that is not persisted.`,
|
|
1701
|
+
{
|
|
1702
|
+
hint: "Load it from the database (find/query) first — a locally-built instance with a manual primary key is not a database row.",
|
|
1703
|
+
},
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
if (!isProvidedPk(key)) {
|
|
1707
|
+
// Name the ACTUAL missing key — a relation with a custom `localKey` isn't
|
|
1708
|
+
// missing its primary key, it's missing that local key ('code', …).
|
|
1709
|
+
const isPk = keyName === this.#primaryKey;
|
|
1710
|
+
throw new AtlasError(
|
|
1711
|
+
"E_MISSING_PRIMARY_KEY",
|
|
1712
|
+
`Cannot ${op} a ${this.#entityClass.name} loaded without its ${isPk ? "primary key" : "key"} ('${keyName}').`,
|
|
1713
|
+
{
|
|
1714
|
+
hint: "This entity came from an aggregate/alias projection. Select the key or use query().pojo() for projections.",
|
|
1715
|
+
},
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
991
1720
|
async #runDelete(wheres: Array<Record<string, unknown>>): Promise<void> {
|
|
992
1721
|
const compiled = compileStatementNative(
|
|
993
1722
|
{ kind: "delete", table: this.#tableName, wheres },
|
|
@@ -1038,8 +1767,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1038
1767
|
values,
|
|
1039
1768
|
casts: this.#castTypes,
|
|
1040
1769
|
returning: [
|
|
1041
|
-
|
|
1042
|
-
...this.#columns.map((c) =>
|
|
1770
|
+
this.#dbColumn(this.#primaryKey),
|
|
1771
|
+
...this.#columns.map((c) => this.#dbColumn(c)),
|
|
1043
1772
|
],
|
|
1044
1773
|
}
|
|
1045
1774
|
: {
|
|
@@ -1077,8 +1806,11 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1077
1806
|
// callers see them on the entity without an extra `find()`. Mirrors
|
|
1078
1807
|
// `createMany`, where the multi-row path already does this.
|
|
1079
1808
|
if (result.row) {
|
|
1080
|
-
for (const [k, v] of Object.entries(result.row))
|
|
1081
|
-
|
|
1809
|
+
for (const [k, v] of Object.entries(result.row)) {
|
|
1810
|
+
const prop = this.#columnByDbName.get(k) ?? snakeToCamel(k);
|
|
1811
|
+
// Consume so date columns hydrate to Chronos DateTime, not raw ISO.
|
|
1812
|
+
entity.setProp(prop, this.#applyConsume(prop, v, entity));
|
|
1813
|
+
}
|
|
1082
1814
|
} else if (
|
|
1083
1815
|
result.lastInsertRowid !== undefined &&
|
|
1084
1816
|
!isProvidedPk(entity[this.#primaryKey])
|
|
@@ -1088,6 +1820,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1088
1820
|
// After a successful INSERT, the entity is now persisted — snapshot
|
|
1089
1821
|
// its columns so subsequent dirty checks compare against the DB state.
|
|
1090
1822
|
entity.markAsPersisted();
|
|
1823
|
+
this.#trackInsert(entity);
|
|
1091
1824
|
}
|
|
1092
1825
|
|
|
1093
1826
|
/**
|
|
@@ -1097,17 +1830,23 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1097
1830
|
* `save()` is called defensively without any real mutation).
|
|
1098
1831
|
*/
|
|
1099
1832
|
async #update(entity: T): Promise<void> {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1833
|
+
const forced = entity.$consumeForceUpdate();
|
|
1834
|
+
const pk = entity[this.#primaryKey];
|
|
1835
|
+
|
|
1836
|
+
// Compute the REAL dirt BEFORE stamping autoUpdate, so a genuinely-clean
|
|
1837
|
+
// save() is a no-op — no `updated_at` bump, no query (AdonisJS Lucid parity;
|
|
1838
|
+
// stamping first would make every save() on an autoUpdate model dirty).
|
|
1839
|
+
const preDirty = entity.$dirty;
|
|
1840
|
+
delete preDirty[this.#primaryKey];
|
|
1841
|
+
if (Object.keys(preDirty).length === 0 && !forced) return; // nothing changed
|
|
1103
1842
|
|
|
1843
|
+
// A real change (or a forced update) is happening — now stamp
|
|
1844
|
+
// @column.dateTime({ autoUpdate: true }) so it lands in the SET.
|
|
1845
|
+
this.#applyAutoTimestamps(entity, "update");
|
|
1104
1846
|
const dirty = entity.$dirty;
|
|
1105
|
-
const pk = entity[this.#primaryKey];
|
|
1106
1847
|
// Primary key is never part of the SET — it's the WHERE.
|
|
1107
1848
|
delete dirty[this.#primaryKey];
|
|
1108
1849
|
|
|
1109
|
-
if (Object.keys(dirty).length === 0) return; // nothing changed
|
|
1110
|
-
|
|
1111
1850
|
// Map dirty camelCase keys to snake_case DB columns. `$dirty` keys are
|
|
1112
1851
|
// already camelCase (they come from `entity.setProp` / direct assignment),
|
|
1113
1852
|
// so the prepare lookup uses `k` as-is. Skip explicit `undefined`
|
|
@@ -1116,7 +1855,17 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1116
1855
|
const setPairs: Array<[string, unknown]> = [];
|
|
1117
1856
|
for (const [k, v] of Object.entries(dirty)) {
|
|
1118
1857
|
if (v === undefined) continue;
|
|
1119
|
-
setPairs.push([
|
|
1858
|
+
setPairs.push([this.#dbColumn(k), this.#applyPrepare(k, v)]);
|
|
1859
|
+
}
|
|
1860
|
+
// enableForceUpdate() with nothing dirty: re-persist the current non-PK
|
|
1861
|
+
// column values so an UPDATE still runs (fires triggers / bumps autoUpdate).
|
|
1862
|
+
if (setPairs.length === 0 && forced) {
|
|
1863
|
+
for (const col of this.#columns) {
|
|
1864
|
+
if (col === this.#primaryKey) continue;
|
|
1865
|
+
const v = entity[col];
|
|
1866
|
+
if (v !== undefined)
|
|
1867
|
+
setPairs.push([this.#dbColumn(col), this.#applyPrepare(col, v)]);
|
|
1868
|
+
}
|
|
1120
1869
|
}
|
|
1121
1870
|
if (setPairs.length === 0) {
|
|
1122
1871
|
// All dirty entries were `undefined` (skipped above). Re-snapshot
|
|
@@ -1155,7 +1904,9 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1155
1904
|
* on the entity before persistence. Called from `#insert` and `#update`.
|
|
1156
1905
|
*/
|
|
1157
1906
|
#applyAutoTimestamps(entity: T, phase: "insert" | "update"): void {
|
|
1158
|
-
|
|
1907
|
+
// A Chronos `DateTime` (not a JS `Date`) so `autoCreate`/`autoUpdate` values
|
|
1908
|
+
// match the type `@column.dateTime` columns hydrate to — Adonis Lucid parity.
|
|
1909
|
+
const now = DateTime.now();
|
|
1159
1910
|
for (const [prop, cfg] of Object.entries(this.#dateColumns)) {
|
|
1160
1911
|
if (phase === "insert") {
|
|
1161
1912
|
if (cfg.autoCreate && entity[prop] === undefined) {
|
|
@@ -1173,32 +1924,58 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1173
1924
|
#hydrate(row: Record<string, unknown>): T {
|
|
1174
1925
|
const entity = new this.#entityClass();
|
|
1175
1926
|
for (const [key, value] of Object.entries(row)) {
|
|
1176
|
-
const camelKey = snakeToCamel(key);
|
|
1177
1927
|
// Resolve against declared column metadata, not `in entity` — fields
|
|
1178
1928
|
// using Adonis' `declare field: T` pattern are not own-properties of
|
|
1179
|
-
// a freshly constructed instance.
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1929
|
+
// a freshly constructed instance. The reverse db→property map is
|
|
1930
|
+
// consulted first so an explicit `columnName` override resolves to the
|
|
1931
|
+
// right property (where `snakeToCamel` alone would not).
|
|
1932
|
+
const camelKey = snakeToCamel(key);
|
|
1933
|
+
const targetKey =
|
|
1934
|
+
this.#columnByDbName.get(key) ??
|
|
1935
|
+
(this.#validColumns.has(camelKey)
|
|
1936
|
+
? camelKey
|
|
1937
|
+
: this.#validColumns.has(key)
|
|
1938
|
+
? key
|
|
1939
|
+
: null);
|
|
1185
1940
|
if (!targetKey) continue;
|
|
1186
1941
|
// Apply `@Column({ consume })` if declared on this property. Unlike the
|
|
1187
1942
|
// previous registry-based design, the callback receives every value
|
|
1188
1943
|
// including `null` / `undefined` — the user's `consume` is responsible
|
|
1189
1944
|
// for its own null-handling, matching Adonis Lucid's contract.
|
|
1190
|
-
entity.setProp(targetKey, this.#applyConsume(targetKey, value));
|
|
1945
|
+
entity.setProp(targetKey, this.#applyConsume(targetKey, value, entity));
|
|
1191
1946
|
}
|
|
1192
1947
|
// Freeze the original snapshot — from now on, only columns changed AFTER
|
|
1193
1948
|
// hydration are considered dirty by `entity.$dirty`.
|
|
1194
1949
|
entity.markAsPersisted();
|
|
1195
|
-
|
|
1950
|
+
entity.markAsFromDatabase();
|
|
1951
|
+
this.#attachRepoRef(entity);
|
|
1952
|
+
return entity;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Back-pointer so a persisted instance can `related()` / `refresh()` / `fresh()`
|
|
1957
|
+
* / `load*()` without being re-fetched — AdonisJS Lucid parity: a model returned
|
|
1958
|
+
* by find/query AND by create/save/createMany/saveMany carries its query client.
|
|
1959
|
+
* Non-enumerable so it never serializes; `configurable` so re-persisting the same
|
|
1960
|
+
* instance is idempotent.
|
|
1961
|
+
*/
|
|
1962
|
+
#attachRepoRef(entity: BaseEntity): void {
|
|
1196
1963
|
Object.defineProperty(entity, REPO_REF, {
|
|
1197
1964
|
value: this,
|
|
1198
1965
|
enumerable: false,
|
|
1199
1966
|
configurable: true,
|
|
1200
1967
|
});
|
|
1201
|
-
|
|
1968
|
+
// Adonis Lucid `$trx` propagation: an instance loaded or created THROUGH a
|
|
1969
|
+
// transaction-bound repo binds to that transaction, so its own
|
|
1970
|
+
// `save()`/`delete()` stay inside it (and reset when the trx settles, via
|
|
1971
|
+
// the release hook `useTransaction` registers). Previously only an explicit
|
|
1972
|
+
// `model.useTransaction(trx)` did this, so a row read with
|
|
1973
|
+
// `Model.query({ client: trx })` could later save OUTSIDE the transaction.
|
|
1974
|
+
// Only BaseModel exposes `useTransaction`; a plain BaseEntity persists via
|
|
1975
|
+
// the repo directly, so it needs no instance binding.
|
|
1976
|
+
if (this.#durableParent !== undefined && isTransactionBindable(entity)) {
|
|
1977
|
+
entity.useTransaction(this.#db);
|
|
1978
|
+
}
|
|
1202
1979
|
}
|
|
1203
1980
|
|
|
1204
1981
|
/**
|
|
@@ -1209,11 +1986,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1209
1986
|
*/
|
|
1210
1987
|
async refresh(entity: BaseEntity): Promise<void> {
|
|
1211
1988
|
const pk = entity[this.#primaryKey];
|
|
1212
|
-
|
|
1213
|
-
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
1214
|
-
[this.#primaryKey]: pk,
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1989
|
+
this.#assertPersistedRow(entity, pk, "refresh()");
|
|
1217
1990
|
const fresh = await this.find(pk as string | number);
|
|
1218
1991
|
if (!fresh) {
|
|
1219
1992
|
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
@@ -1246,11 +2019,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1246
2019
|
alias?: string,
|
|
1247
2020
|
): Promise<void> {
|
|
1248
2021
|
const pk = entity[this.#primaryKey];
|
|
1249
|
-
|
|
1250
|
-
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
1251
|
-
[this.#primaryKey]: pk,
|
|
1252
|
-
});
|
|
1253
|
-
}
|
|
2022
|
+
this.#assertPersistedRow(entity, pk, "loadCount()");
|
|
1254
2023
|
const finalAlias = alias ?? `${relationName}_count`;
|
|
1255
2024
|
const q = this.query()
|
|
1256
2025
|
.where(this.#primaryKey, pk)
|
|
@@ -1273,11 +2042,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1273
2042
|
build: (q: unknown) => void,
|
|
1274
2043
|
): Promise<void> {
|
|
1275
2044
|
const pk = entity[this.#primaryKey];
|
|
1276
|
-
|
|
1277
|
-
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
1278
|
-
[this.#primaryKey]: pk,
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
2045
|
+
this.#assertPersistedRow(entity, pk, "loadAggregate()");
|
|
1281
2046
|
let capturedAlias: string | undefined;
|
|
1282
2047
|
const q = this.query()
|
|
1283
2048
|
.where(this.#primaryKey, pk)
|
|
@@ -1302,11 +2067,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1302
2067
|
callback?: (q: unknown) => void,
|
|
1303
2068
|
): Promise<void> {
|
|
1304
2069
|
const pk = entity[this.#primaryKey];
|
|
1305
|
-
|
|
1306
|
-
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
1307
|
-
[this.#primaryKey]: pk,
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
2070
|
+
this.#assertPersistedRow(entity, pk, "loadRelation()");
|
|
1310
2071
|
const q = this.query().where(this.#primaryKey, pk);
|
|
1311
2072
|
if (callback)
|
|
1312
2073
|
q.preload(relationName, callback as (q: ModelQuery<BaseEntity>) => void);
|
|
@@ -1334,15 +2095,21 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1334
2095
|
`Relation '${relationName}' not found on ${this.#entityClass.name}`,
|
|
1335
2096
|
);
|
|
1336
2097
|
const relatedClass = relation.target() as new () => BaseEntity;
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
const relatedTable =
|
|
2098
|
+
// Synthesize the related model's @Entity metadata on demand (static `table`
|
|
2099
|
+
// / naming strategy) — a related model referenced ONLY through this relation
|
|
2100
|
+
// may never have been instantiated, so `getEntityMetadata` alone would be
|
|
2101
|
+
// empty and related()/create-through would wrongly fail. Mirrors how the repo
|
|
2102
|
+
// constructor boots its own class (AdonisJS Lucid lazy-boots models).
|
|
2103
|
+
const relatedTable = ensureEntityMetadata(relatedClass).tableName;
|
|
1343
2104
|
const parentPk =
|
|
1344
2105
|
relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
1345
|
-
|
|
2106
|
+
// Read the parent's key LAZILY, at operation time — not once at proxy
|
|
2107
|
+
// creation. Lucid resolves the pivot value when the query runs, so mutating
|
|
2108
|
+
// the parent's (custom local) key between `user.related('roles')` and a later
|
|
2109
|
+
// `.attach()` must target the CURRENT key, never a captured stale one.
|
|
2110
|
+
const readParentId = (): unknown => entity[parentPk];
|
|
2111
|
+
const keyLabel =
|
|
2112
|
+
parentPk === this.#primaryKey ? "primary key" : `key '${parentPk}'`;
|
|
1346
2113
|
const relatedRepo = new BaseRepository<BaseEntity>(relatedClass, this.#db, {
|
|
1347
2114
|
dialect: this.#dialect,
|
|
1348
2115
|
});
|
|
@@ -1362,54 +2129,188 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1362
2129
|
|
|
1363
2130
|
const injectFk = (
|
|
1364
2131
|
data: Record<string, unknown>,
|
|
2132
|
+
fkValue: unknown,
|
|
1365
2133
|
): Record<string, unknown> => ({
|
|
1366
2134
|
...data,
|
|
1367
|
-
[fkCol]:
|
|
1368
|
-
[fkProp]:
|
|
2135
|
+
[fkCol]: fkValue,
|
|
2136
|
+
[fkProp]: fkValue,
|
|
1369
2137
|
});
|
|
1370
2138
|
|
|
2139
|
+
/**
|
|
2140
|
+
* Lucid persists the parent FIRST (inside a managed transaction) so its key
|
|
2141
|
+
* is available, then sets the child FK and writes the child — atomic, rolled
|
|
2142
|
+
* back on any failure. An already-persisted parent skips the save; a
|
|
2143
|
+
* persisted-but-keyless projection is rejected loud. Runs `body` with the
|
|
2144
|
+
* parent's now-guaranteed key and a trx-bound related repo.
|
|
2145
|
+
*/
|
|
2146
|
+
const flushEvents = async (entities: BaseEntity[]): Promise<void> => {
|
|
2147
|
+
for (const e of entities) await this.#dispatchDomainEvents(e);
|
|
2148
|
+
};
|
|
2149
|
+
const withParentSaved = <R>(
|
|
2150
|
+
body: (
|
|
2151
|
+
fkValue: unknown,
|
|
2152
|
+
relRepoTx: BaseRepository<BaseEntity>,
|
|
2153
|
+
trx: TransactionClient,
|
|
2154
|
+
relatedFloors: Map<BaseEntity, number>,
|
|
2155
|
+
) => Promise<R>,
|
|
2156
|
+
): Promise<R> =>
|
|
2157
|
+
transaction(this.#db, async (trx) => {
|
|
2158
|
+
// Snapshot BEFORE the save flips the flag — the parent's events flush
|
|
2159
|
+
// ONLY if WE persisted it here. An already-persisted parent may carry
|
|
2160
|
+
// unrelated in-memory events that belong to whoever saves it; a child
|
|
2161
|
+
// mutation must not emit them as a side effect.
|
|
2162
|
+
const savedParentHere = !entity.$isPersisted;
|
|
2163
|
+
const parentDurable = this.#durableParent ?? this;
|
|
2164
|
+
if (savedParentHere) {
|
|
2165
|
+
// Floor the parent's event queue BEFORE we persist it, so rollback drops
|
|
2166
|
+
// only the events this write queues, keeping any the caller queued
|
|
2167
|
+
// earlier (#8).
|
|
2168
|
+
const parentEventFloor = entity.domainEventCount();
|
|
2169
|
+
// Persist the parent on the SAME trx. Build a BaseEntity-typed repo
|
|
2170
|
+
// for the parent class (mirrors `relatedRepo`) so `save(entity)`
|
|
2171
|
+
// accepts the generic `BaseEntity` without widening `this`.
|
|
2172
|
+
const parentRepoTx = new BaseRepository<BaseEntity>(
|
|
2173
|
+
this.#entityClass,
|
|
2174
|
+
trx,
|
|
2175
|
+
{ dialect: this.#dialect },
|
|
2176
|
+
);
|
|
2177
|
+
parentRepoTx.onDomainEvents = this.onDomainEvents;
|
|
2178
|
+
parentRepoTx.#deferDomainEvents = true;
|
|
2179
|
+
await parentRepoTx.save(entity);
|
|
2180
|
+
// Register the parent's rollback restore IMMEDIATELY after its insert —
|
|
2181
|
+
// the parentPk check just below can throw (a custom `localKey` left unset
|
|
2182
|
+
// after the save), and that throw must still revert the freshly-inserted
|
|
2183
|
+
// parent instead of leaving it lying $isPersisted (same gotcha as
|
|
2184
|
+
// associate(): register the restore before ANY later throwable line).
|
|
2185
|
+
trx.after("rollback", () => {
|
|
2186
|
+
parentDurable.#attachRepoRef(entity);
|
|
2187
|
+
entity.markAsNotPersisted();
|
|
2188
|
+
entity.restoreDomainEventsTo(parentEventFloor);
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
const fkValue = entity[parentPk];
|
|
2192
|
+
if (!isProvidedPk(fkValue)) {
|
|
2193
|
+
throw new AtlasError(
|
|
2194
|
+
"E_MISSING_PRIMARY_KEY",
|
|
2195
|
+
`Cannot use related('${relationName}') on a ${this.#entityClass.name} with no ${keyLabel}.`,
|
|
2196
|
+
{
|
|
2197
|
+
hint: "The parent is an aggregate/alias projection with no key. Select the key or use query().pojo().",
|
|
2198
|
+
},
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
const relTx = relatedRepo.useTransaction(trx);
|
|
2202
|
+
relTx.#deferDomainEvents = true;
|
|
2203
|
+
// Track related rows inserted DIRECTLY through relTx (single create/save;
|
|
2204
|
+
// the batch helpers route through #inManagedTx, which tracks + reverts them
|
|
2205
|
+
// itself on this same trx). A caller-passed related instance we insert here
|
|
2206
|
+
// must, on rollback, revert to $isNew — its row is gone, and keeping
|
|
2207
|
+
// $isPersisted would orphan a later relation write (a M2M pivot-insert
|
|
2208
|
+
// failure AFTER `rel.save(related)` is the canonical trigger) — and drop its
|
|
2209
|
+
// queued events. On commit, re-point its REPO_REF at the durable related repo
|
|
2210
|
+
// (it was bound to the now-finished trx, so refresh()/related() would
|
|
2211
|
+
// otherwise throw "transaction already finished").
|
|
2212
|
+
const relInserts: BaseEntity[] = [];
|
|
2213
|
+
relTx.#insertTracker = relInserts;
|
|
2214
|
+
const relDurable = relatedRepo.#durableParent ?? relatedRepo;
|
|
2215
|
+
// Per-related event floor, populated by a caller-instance write (save):
|
|
2216
|
+
// a fresh child built by create() has floor 0 (clear), but a caller's own
|
|
2217
|
+
// instance passed to save() may carry events queued before the write (#8).
|
|
2218
|
+
const relatedFloors = new Map<BaseEntity, number>();
|
|
2219
|
+
trx.after("commit", () => {
|
|
2220
|
+
for (const r of relInserts) relDurable.#attachRepoRef(r);
|
|
2221
|
+
});
|
|
2222
|
+
trx.after("rollback", () => {
|
|
2223
|
+
for (const r of relInserts) {
|
|
2224
|
+
relDurable.#attachRepoRef(r);
|
|
2225
|
+
r.markAsNotPersisted();
|
|
2226
|
+
r.restoreDomainEventsTo(relatedFloors.get(r) ?? 0);
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
2229
|
+
// Parent COMMIT restore (its rollback restore is registered above, right
|
|
2230
|
+
// after the insert). ONLY if WE persisted it here (`parentRepoTx.save`
|
|
2231
|
+
// flipped it to $isPersisted with REPO_REF bound to the trx repo). Lucid
|
|
2232
|
+
// resets `$trx` on commit → re-point REPO_REF at the durable repo, then
|
|
2233
|
+
// flush the parent's events (a rollback thus publishes nothing). An
|
|
2234
|
+
// already-persisted parent is left untouched: its events belong to whoever
|
|
2235
|
+
// saves it, and its row already exists.
|
|
2236
|
+
if (savedParentHere) {
|
|
2237
|
+
trx.after("commit", () => {
|
|
2238
|
+
parentDurable.#attachRepoRef(entity);
|
|
2239
|
+
return this.#dispatchDomainEvents(entity);
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
return body(fkValue, relTx, trx, relatedFloors);
|
|
2243
|
+
});
|
|
2244
|
+
|
|
1371
2245
|
// Shared "has" proxy methods (create/createMany/save/saveMany +
|
|
1372
|
-
// firstOrCreate/updateOrCreate scoped to this parent's FK).
|
|
2246
|
+
// firstOrCreate/updateOrCreate scoped to this parent's FK). Each persists the
|
|
2247
|
+
// parent first (Lucid parity) and writes the child with the FK set, atomically,
|
|
2248
|
+
// then flushes the child's domain events AFTER the transaction commits.
|
|
1373
2249
|
const hasOps = {
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
2250
|
+
create: (data: Record<string, unknown>) =>
|
|
2251
|
+
withParentSaved(async (fk, rel, trx) => {
|
|
2252
|
+
const child = await rel.create(injectFk(data, fk));
|
|
2253
|
+
trx.after("commit", () => flushEvents([child]));
|
|
2254
|
+
return child;
|
|
2255
|
+
}),
|
|
2256
|
+
createMany: (rows: Array<Record<string, unknown>>) =>
|
|
2257
|
+
withParentSaved(async (fk, rel, _trx) => {
|
|
2258
|
+
// NO wrapper flush: since createMany now runs through #inManagedTx it
|
|
2259
|
+
// ALREADY dispatches the children's events post-commit (like
|
|
2260
|
+
// firstOrCreate/updateOrCreate/saveMany). A second flush would
|
|
2261
|
+
// re-dispatch events the first hook re-queued on a partial sink failure.
|
|
2262
|
+
return rel.createMany(rows.map((r) => injectFk(r, fk)));
|
|
2263
|
+
}),
|
|
2264
|
+
// Scope the search to the parent's FK column so the lookup only sees this
|
|
2265
|
+
// parent's rows; inject the FK into the created/updated row.
|
|
2266
|
+
firstOrCreate: (
|
|
1384
2267
|
search: Record<string, unknown>,
|
|
1385
2268
|
defaults: Record<string, unknown> = {},
|
|
1386
|
-
)
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
2269
|
+
) =>
|
|
2270
|
+
withParentSaved(async (fk, rel, _trx) => {
|
|
2271
|
+
// NO wrapper flush here: unlike create/save, rel.firstOrCreate goes
|
|
2272
|
+
// through #inManagedTx, which ALREADY registers its own post-commit
|
|
2273
|
+
// dispatch for the child. A second flush would re-dispatch events the
|
|
2274
|
+
// first hook re-queued on a partial sink failure → bus duplication.
|
|
2275
|
+
return rel.firstOrCreate(
|
|
2276
|
+
{ ...search, [fkCol]: fk },
|
|
2277
|
+
injectFk(defaults, fk),
|
|
2278
|
+
);
|
|
2279
|
+
}),
|
|
2280
|
+
updateOrCreate: (
|
|
1393
2281
|
search: Record<string, unknown>,
|
|
1394
2282
|
values: Record<string, unknown>,
|
|
1395
|
-
)
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2283
|
+
) =>
|
|
2284
|
+
withParentSaved(async (fk, rel, _trx) => {
|
|
2285
|
+
// NO wrapper flush: rel.updateOrCreate goes through #inManagedTx which
|
|
2286
|
+
// already dispatches the child's events post-commit (see firstOrCreate).
|
|
2287
|
+
return rel.updateOrCreate(
|
|
2288
|
+
{ ...search, [fkCol]: fk },
|
|
2289
|
+
injectFk(values, fk),
|
|
2290
|
+
);
|
|
2291
|
+
}),
|
|
2292
|
+
save: (related: BaseEntity) =>
|
|
2293
|
+
withParentSaved(async (fk, rel, trx, relatedFloors) => {
|
|
2294
|
+
related.setProp(fkCol, fk);
|
|
2295
|
+
related.setProp(fkProp, fk);
|
|
2296
|
+
// Floor BEFORE the write so a rollback keeps events the caller queued on
|
|
2297
|
+
// this instance earlier, dropping only what this save adds (#8).
|
|
2298
|
+
relatedFloors.set(related, related.domainEventCount());
|
|
2299
|
+
await rel.save(related);
|
|
2300
|
+
trx.after("commit", () => flushEvents([related]));
|
|
2301
|
+
}),
|
|
2302
|
+
saveMany: (related: BaseEntity[]) =>
|
|
2303
|
+
withParentSaved(async (fk, rel, _trx) => {
|
|
2304
|
+
for (const r of related) {
|
|
2305
|
+
r.setProp(fkCol, fk);
|
|
2306
|
+
r.setProp(fkProp, fk);
|
|
2307
|
+
}
|
|
2308
|
+
// NO wrapper flush: rel.saveMany now runs through #inManagedTx (it's
|
|
2309
|
+
// all-or-nothing), which ALREADY dispatches these instances' events
|
|
2310
|
+
// post-commit. A second flush would re-dispatch on a partial sink
|
|
2311
|
+
// failure (round-13 double-flush class).
|
|
2312
|
+
return rel.saveMany(related);
|
|
2313
|
+
}),
|
|
1413
2314
|
};
|
|
1414
2315
|
|
|
1415
2316
|
// Scoped query builder (Story 31.9) — pre-applies the FK predicate
|
|
@@ -1425,7 +2326,17 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1425
2326
|
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1426
2327
|
const pivotOther =
|
|
1427
2328
|
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1428
|
-
|
|
2329
|
+
// Resolve the related PK to its DB column (multi-word / columnName),
|
|
2330
|
+
// mirroring the eager-preload fix — a raw property name here targets
|
|
2331
|
+
// the wrong column in the correlated EXISTS.
|
|
2332
|
+
// The related-side key the pivot's otherKey references — the related
|
|
2333
|
+
// PK unless `relatedKey` overrides it (Adonis Lucid `relatedKey`).
|
|
2334
|
+
const relatedPkProp =
|
|
2335
|
+
pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
2336
|
+
const relatedPk =
|
|
2337
|
+
getColumnMetadata(relatedClass).find(
|
|
2338
|
+
(c) => c.propertyKey === relatedPkProp,
|
|
2339
|
+
)?.columnName ?? camelToSnake(relatedPkProp);
|
|
1429
2340
|
// Inline validated quote (same policy as the m2m branch below).
|
|
1430
2341
|
const dialect = this.#dialect;
|
|
1431
2342
|
const quote = (name: string): string => {
|
|
@@ -1434,33 +2345,98 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1434
2345
|
}
|
|
1435
2346
|
return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
|
|
1436
2347
|
};
|
|
2348
|
+
// Table identifiers may be schema-qualified (`schema.table`, e.g. a
|
|
2349
|
+
// Postgres `public.users_roles`) — quote each dotted segment on its own
|
|
2350
|
+
// so it becomes `"schema"."table"`, while EVERY segment still passes the
|
|
2351
|
+
// strict single-identifier guard above (no injection surface). Columns
|
|
2352
|
+
// stay single-segment via `quote`.
|
|
2353
|
+
const quoteTable = (name: string): string =>
|
|
2354
|
+
name.split(".").map(quote).join(".");
|
|
1437
2355
|
// The bound `?` carries the parent PK type (often uuid). A raw `?`
|
|
1438
2356
|
// can't be cast by the structured `casts` mechanism, so emit the
|
|
1439
2357
|
// `::uuid` inline — `whereRaw` rewrites `?`→`$N`, yielding `$N::uuid`.
|
|
1440
2358
|
// Postgres-only; sqlite/mysql coerce. Without it: `pivotFk = $N` is
|
|
1441
2359
|
// `uuid = text`.
|
|
1442
|
-
|
|
2360
|
+
// Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
|
|
2361
|
+
// PK — an m2m with a custom localKey binds `entity[localKey]` into the
|
|
2362
|
+
// pivot FK, so the `::cast` must match that column's type.
|
|
2363
|
+
const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
|
|
1443
2364
|
const ph =
|
|
1444
2365
|
dialect === "postgres" && parentPkCast ? `?::${parentPkCast}` : "?";
|
|
1445
|
-
// EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2366
|
+
// EXISTS (SELECT 1 FROM pivot WHERE pivot.pivotFk = ? AND pivot.pivotOther = related.pk
|
|
2367
|
+
// [AND pivot.col <op> ?]…)
|
|
2368
|
+
// Deferred (not an eager whereRaw): a `.wherePivot()` chained on the
|
|
2369
|
+
// query the proxy hands back must fold into THIS subquery, so we build it
|
|
2370
|
+
// at #buildSpec time with the pivot constraints known then. Identifiers
|
|
2371
|
+
// are validated by `quote`; values bind as params (no injection surface),
|
|
2372
|
+
// so this internal fragment needs no strict-mode bypass.
|
|
2373
|
+
const pivotTable = pivot.pivotTable;
|
|
2374
|
+
q.setPivotExistsBuilder((pivotWheres) => {
|
|
2375
|
+
const base =
|
|
2376
|
+
`EXISTS (SELECT 1 FROM ${quoteTable(pivotTable)} ` +
|
|
2377
|
+
`WHERE ${quoteTable(pivotTable)}.${quote(pivotFk)} = ${ph} ` +
|
|
2378
|
+
`AND ${quoteTable(pivotTable)}.${quote(pivotOther)} = ${quoteTable(relatedTable)}.${quote(relatedPk)}`;
|
|
2379
|
+
const bindings: unknown[] = [readParentId()];
|
|
2380
|
+
let extra = "";
|
|
2381
|
+
for (const w of pivotWheres) {
|
|
2382
|
+
const col = `${quoteTable(pivotTable)}.${quote(w.column)}`;
|
|
2383
|
+
if (w.operator === "IN" || w.operator === "NOT IN") {
|
|
2384
|
+
const vals = Array.isArray(w.value) ? w.value : [w.value];
|
|
2385
|
+
if (vals.length === 0) {
|
|
2386
|
+
// IN () matches nothing; NOT IN () matches everything.
|
|
2387
|
+
if (w.operator === "IN") extra += " AND 1 = 0";
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
extra += ` AND ${col} ${w.operator} (${vals.map(() => "?").join(", ")})`;
|
|
2391
|
+
bindings.push(...vals);
|
|
2392
|
+
} else {
|
|
2393
|
+
extra += ` AND ${col} ${w.operator} ?`;
|
|
2394
|
+
bindings.push(w.value);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return { sql: `${base}${extra})`, bindings };
|
|
1456
2398
|
});
|
|
1457
2399
|
} else if (relation.type === "belongsTo") {
|
|
1458
2400
|
const ownerKey =
|
|
1459
2401
|
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
1460
2402
|
q.where(ownerKey, entity[fkProp] ?? entity[fkCol]);
|
|
2403
|
+
} else if (
|
|
2404
|
+
relation.type === "hasOneThrough" ||
|
|
2405
|
+
relation.type === "hasManyThrough"
|
|
2406
|
+
) {
|
|
2407
|
+
// Lucid's read-only two-hop traversal (verified): the related rows are
|
|
2408
|
+
// reached VIA the intermediate ("through") table, never a direct FK.
|
|
2409
|
+
// related WHERE secondKey IN
|
|
2410
|
+
// (SELECT secondLocal FROM through WHERE firstKey = parent[localKey])
|
|
2411
|
+
// Same key resolution as the eager `#resolveThrough` loader so lazy and
|
|
2412
|
+
// eager agree. Returns a chainable ModelQuery (`.orderBy().limit()` …).
|
|
2413
|
+
if (!relation.through) {
|
|
2414
|
+
throw new Error(
|
|
2415
|
+
`@HasOneThrough/@HasManyThrough '${relationName}' requires a through model`,
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
const throughClass = relation.through() as new () => BaseEntity;
|
|
2419
|
+
const throughRepo = new BaseRepository<BaseEntity>(throughClass, db, {
|
|
2420
|
+
dialect: this.#dialect,
|
|
2421
|
+
});
|
|
2422
|
+
const throughPk = getPrimaryKey(throughClass) ?? "id";
|
|
2423
|
+
const parentLocal =
|
|
2424
|
+
relation.localKey ?? getPrimaryKey(this.#entityClass) ?? "id";
|
|
2425
|
+
const firstKey =
|
|
2426
|
+
relation.firstKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
2427
|
+
const secondKey =
|
|
2428
|
+
relation.secondKey ?? `${camelToSnake(throughClass.name)}_id`;
|
|
2429
|
+
const secondLocal = relation.secondLocalKey ?? throughPk;
|
|
2430
|
+
q.whereIn(
|
|
2431
|
+
secondKey,
|
|
2432
|
+
throughRepo
|
|
2433
|
+
.query()
|
|
2434
|
+
.select(secondLocal)
|
|
2435
|
+
.where(firstKey, entity[parentLocal]),
|
|
2436
|
+
);
|
|
1461
2437
|
} else {
|
|
1462
2438
|
// hasOne / hasMany
|
|
1463
|
-
q.where(fkCol,
|
|
2439
|
+
q.where(fkCol, readParentId());
|
|
1464
2440
|
}
|
|
1465
2441
|
return q;
|
|
1466
2442
|
};
|
|
@@ -1472,10 +2448,24 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1472
2448
|
// the standard TS idiom for widening a generic `this` — safe because
|
|
1473
2449
|
// `T extends BaseEntity`.
|
|
1474
2450
|
const parentRepo = this as BaseRepository<BaseEntity>;
|
|
2451
|
+
// create/save/createMany/saveMany are INVALID for a belongsTo: the FK is on
|
|
2452
|
+
// THIS model, so `...hasOps` would inject the FK into the owner table and
|
|
2453
|
+
// save the current model before it has an owner. Reject them (same throwing
|
|
2454
|
+
// pattern as @HasOne's bulk methods); the only writes are associate /
|
|
2455
|
+
// dissociate.
|
|
2456
|
+
const rejectWrite = async (op: string): Promise<never> => {
|
|
2457
|
+
throw new Error(
|
|
2458
|
+
`related('${relationName}').${op}() is not supported on @BelongsTo — ` +
|
|
2459
|
+
`the foreign key is on this model; use associate() / dissociate().`,
|
|
2460
|
+
);
|
|
2461
|
+
};
|
|
1475
2462
|
const proxy: BelongsToRelationProxy = {
|
|
1476
2463
|
type: "belongsTo",
|
|
1477
|
-
...hasOps,
|
|
1478
2464
|
query: scopedQuery,
|
|
2465
|
+
create: () => rejectWrite("create"),
|
|
2466
|
+
save: () => rejectWrite("save"),
|
|
2467
|
+
createMany: () => rejectWrite("createMany"),
|
|
2468
|
+
saveMany: () => rejectWrite("saveMany"),
|
|
1479
2469
|
async associate(model: BaseEntity) {
|
|
1480
2470
|
if (model === null || model === undefined) {
|
|
1481
2471
|
throw new Error(
|
|
@@ -1484,10 +2474,83 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1484
2474
|
}
|
|
1485
2475
|
const ownerKey =
|
|
1486
2476
|
relation.ownerKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
2477
|
+
// Lucid: persist an unsaved owner FIRST (so a generated key exists),
|
|
2478
|
+
// set the parent FK to the owner's key, then save the parent — all in
|
|
2479
|
+
// ONE transaction (atomic, rolled back on failure). Reject a keyless
|
|
2480
|
+
// owner instead of silently setting the FK to `undefined` (which the
|
|
2481
|
+
// UPDATE would skip → stale/absent association). Events flush post-commit.
|
|
2482
|
+
await transaction(db, async (trx) => {
|
|
2483
|
+
const ownerTx = relatedRepo.useTransaction(trx);
|
|
2484
|
+
ownerTx.#deferDomainEvents = true;
|
|
2485
|
+
// Snapshot BEFORE the save: associate() only persists an unsaved
|
|
2486
|
+
// owner, so an already-persisted owner's pending domain events are
|
|
2487
|
+
// NOT ours to flush (same side-effect fix as withParentSaved).
|
|
2488
|
+
const savedOwnerHere = !model.$isPersisted;
|
|
2489
|
+
const ownerDurable = relatedRepo.#durableParent ?? relatedRepo;
|
|
2490
|
+
// Floor the owner's events before we persist it (#8).
|
|
2491
|
+
const ownerEventFloor = model.domainEventCount();
|
|
2492
|
+
if (savedOwnerHere) {
|
|
2493
|
+
await ownerTx.save(model);
|
|
2494
|
+
// Register the owner's rollback restore IMMEDIATELY after its
|
|
2495
|
+
// insert. The ownerKey check just below AND the parent save later
|
|
2496
|
+
// can BOTH throw after this point, and either must still revert the
|
|
2497
|
+
// freshly-inserted owner (Lucid resets `$trx` on rollback): re-point
|
|
2498
|
+
// its REPO_REF at the durable repo, revert it to $isNew (its row
|
|
2499
|
+
// vanished), and drop its queued events. Registering it here (not
|
|
2500
|
+
// after the checks) is the fix — a throw between the save and a
|
|
2501
|
+
// later registration would leave the owner lying $isPersisted.
|
|
2502
|
+
trx.after("rollback", () => {
|
|
2503
|
+
ownerDurable.#attachRepoRef(model);
|
|
2504
|
+
model.markAsNotPersisted();
|
|
2505
|
+
model.restoreDomainEventsTo(ownerEventFloor);
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
const fkValue = model[ownerKey];
|
|
2509
|
+
if (!isProvidedPk(fkValue)) {
|
|
2510
|
+
throw new AtlasError(
|
|
2511
|
+
"E_MISSING_OWNER_KEY",
|
|
2512
|
+
`Cannot associate('${relationName}'): the owner ${relatedClass.name} has no ${ownerKey} to reference.`,
|
|
2513
|
+
{
|
|
2514
|
+
hint: "Pass an owner whose key is set — a keyless aggregate/alias projection can't be a foreign-key target.",
|
|
2515
|
+
},
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
entity.setProp(fkCol, fkValue);
|
|
2519
|
+
entity.setProp(fkProp, fkValue);
|
|
2520
|
+
const parentTx = parentRepo.useTransaction(trx);
|
|
2521
|
+
parentTx.#deferDomainEvents = true;
|
|
2522
|
+
// Snapshot BEFORE the save flips the flag: the parent reverts on
|
|
2523
|
+
// rollback ONLY if WE inserted it here (a fresh parent). An
|
|
2524
|
+
// already-persisted parent's row survives the rollback.
|
|
2525
|
+
const savedParentHere = !entity.$isPersisted;
|
|
2526
|
+
const parentDurable = parentRepo.#durableParent ?? parentRepo;
|
|
2527
|
+
// Floor the parent's events before its save (#8).
|
|
2528
|
+
const parentEventFloor = entity.domainEventCount();
|
|
2529
|
+
// Register the parent resolution hooks BEFORE the risky parent save —
|
|
2530
|
+
// that save can throw (a beforeUpdate hook, a constraint) AFTER the
|
|
2531
|
+
// owner was inserted, and a throw here must still restore state.
|
|
2532
|
+
trx.after("commit", async () => {
|
|
2533
|
+
// Lucid resets `$trx` on commit → re-point REPO_REF at the durable
|
|
2534
|
+
// repo (else a post-commit refresh() hits the finished trx), then
|
|
2535
|
+
// flush events. The parent is always saved here → always flush its
|
|
2536
|
+
// events. The owner's events flush only if WE saved the owner.
|
|
2537
|
+
parentDurable.#attachRepoRef(entity);
|
|
2538
|
+
if (savedOwnerHere) {
|
|
2539
|
+
ownerDurable.#attachRepoRef(model);
|
|
2540
|
+
await parentRepo.#dispatchDomainEvents(model);
|
|
2541
|
+
}
|
|
2542
|
+
await parentRepo.#dispatchDomainEvents(entity);
|
|
2543
|
+
});
|
|
2544
|
+
trx.after("rollback", () => {
|
|
2545
|
+
// Restore the PARENT (the owner's restore is registered above, right
|
|
2546
|
+
// after its insert). We do NOT revert the parent's FK value — Lucid
|
|
2547
|
+
// never reverts attribute values on rollback.
|
|
2548
|
+
parentDurable.#attachRepoRef(entity);
|
|
2549
|
+
if (savedParentHere) entity.markAsNotPersisted();
|
|
2550
|
+
entity.restoreDomainEventsTo(parentEventFloor);
|
|
2551
|
+
});
|
|
2552
|
+
await parentTx.save(entity);
|
|
2553
|
+
});
|
|
1491
2554
|
},
|
|
1492
2555
|
async dissociate() {
|
|
1493
2556
|
entity.setProp(fkCol, null);
|
|
@@ -1517,16 +2580,20 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1517
2580
|
// every pivot statement (sync's currentIds SELECT, detach DELETE, attach
|
|
1518
2581
|
// INSERT) must carry these explicitly, else `pivotFk = $1` is `uuid = text`.
|
|
1519
2582
|
const pivotKeyCasts: Record<string, string> = {};
|
|
1520
|
-
|
|
2583
|
+
// Cast keys off the RESOLVED parent key (localKey ?? PK), not always the
|
|
2584
|
+
// PK — an m2m with a custom localKey binds `entity[localKey]` into the
|
|
2585
|
+
// pivot FK, so the `::cast` must match that column's type.
|
|
2586
|
+
const parentPkCast = this.#castTypes[this.#dbColumn(parentPk)];
|
|
1521
2587
|
if (parentPkCast) pivotKeyCasts[pivotFk] = parentPkCast;
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
2588
|
+
const relatedPk = pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
2589
|
+
const relatedPkDb =
|
|
2590
|
+
getColumnMetadata(relatedClass).find((c) => c.propertyKey === relatedPk)
|
|
2591
|
+
?.columnName ?? camelToSnake(relatedPk);
|
|
2592
|
+
const relatedPkCast = computeCastTypes(relatedClass)[relatedPkDb];
|
|
1526
2593
|
if (relatedPkCast) pivotKeyCasts[pivotOther] = relatedPkCast;
|
|
1527
2594
|
|
|
1528
2595
|
/**
|
|
1529
|
-
*
|
|
2596
|
+
* Pivot timestamp column names, resolved once from the decorator config.
|
|
1530
2597
|
*
|
|
1531
2598
|
* Three forms supported:
|
|
1532
2599
|
* - `pivotTimestamps: true` → { created_at, updated_at } default names
|
|
@@ -1536,53 +2603,123 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1536
2603
|
* `false` opts a timestamp out; a string overrides the column name;
|
|
1537
2604
|
* `undefined` falls back to the default name.
|
|
1538
2605
|
*/
|
|
1539
|
-
|
|
2606
|
+
let createdCol: string | null = null;
|
|
2607
|
+
let updatedCol: string | null = null;
|
|
2608
|
+
if (tsConfig === true) {
|
|
2609
|
+
createdCol = "created_at";
|
|
2610
|
+
updatedCol = "updated_at";
|
|
2611
|
+
} else if (tsConfig) {
|
|
2612
|
+
createdCol =
|
|
2613
|
+
tsConfig.createdAt === false
|
|
2614
|
+
? null
|
|
2615
|
+
: (tsConfig.createdAt ?? "created_at");
|
|
2616
|
+
updatedCol =
|
|
2617
|
+
tsConfig.updatedAt === false
|
|
2618
|
+
? null
|
|
2619
|
+
: (tsConfig.updatedAt ?? "updated_at");
|
|
2620
|
+
}
|
|
2621
|
+
const tsColumnSet = new Set(
|
|
2622
|
+
[createdCol, updatedCol].filter((c): c is string => c !== null),
|
|
2623
|
+
);
|
|
2624
|
+
// INSERT (attach) stamps both created_at + updated_at; UPDATE (sync's
|
|
2625
|
+
// attribute refresh) bumps only updated_at — Adonis Lucid pivot semantics.
|
|
2626
|
+
const timestampValues = (
|
|
2627
|
+
mode: "insert" | "update",
|
|
2628
|
+
): Record<string, unknown> => {
|
|
1540
2629
|
if (!tsConfig) return {};
|
|
1541
2630
|
const now = new Date().toISOString();
|
|
1542
|
-
let createdCol: string | null;
|
|
1543
|
-
let updatedCol: string | null;
|
|
1544
|
-
if (tsConfig === true) {
|
|
1545
|
-
createdCol = "created_at";
|
|
1546
|
-
updatedCol = "updated_at";
|
|
1547
|
-
} else {
|
|
1548
|
-
createdCol =
|
|
1549
|
-
tsConfig.createdAt === false
|
|
1550
|
-
? null
|
|
1551
|
-
: (tsConfig.createdAt ?? "created_at");
|
|
1552
|
-
updatedCol =
|
|
1553
|
-
tsConfig.updatedAt === false
|
|
1554
|
-
? null
|
|
1555
|
-
: (tsConfig.updatedAt ?? "updated_at");
|
|
1556
|
-
}
|
|
1557
2631
|
const out: Record<string, unknown> = {};
|
|
1558
|
-
if (createdCol) out[createdCol] = now;
|
|
2632
|
+
if (mode === "insert" && createdCol) out[createdCol] = now;
|
|
1559
2633
|
if (updatedCol) out[updatedCol] = now;
|
|
1560
2634
|
return out;
|
|
1561
2635
|
};
|
|
1562
2636
|
|
|
2637
|
+
// Object literal keys are ALWAYS strings, so `sync({ 1: {…} })` /
|
|
2638
|
+
// `attach({ 1: {…} })` arrive with id "1", not 1. Bound as text, a numeric
|
|
2639
|
+
// pivot FK column fails on Postgres (`text` ≠ `integer`, no implicit cast)
|
|
2640
|
+
// and the sync diff mis-compares "1" against the numeric id the DB returns.
|
|
2641
|
+
// Coerce a *canonical* integer back to a number; the round-trip guard
|
|
2642
|
+
// leaves uuid / zero-padded / oversized string keys (`"01234"`, `"abc"`)
|
|
2643
|
+
// untouched so they still bind as text.
|
|
2644
|
+
const canonicalizeId = (id: string | number): string | number => {
|
|
2645
|
+
if (typeof id === "number") return id;
|
|
2646
|
+
return /^-?\d+$/.test(id) &&
|
|
2647
|
+
Number.isSafeInteger(Number(id)) &&
|
|
2648
|
+
String(Number(id)) === id
|
|
2649
|
+
? Number(id)
|
|
2650
|
+
: id;
|
|
2651
|
+
};
|
|
2652
|
+
|
|
1563
2653
|
const normalizeAttach = (
|
|
1564
2654
|
arg: Array<string | number> | Record<string, Record<string, unknown>>,
|
|
1565
2655
|
): Array<{ id: string | number; extras: Record<string, unknown> }> => {
|
|
1566
|
-
if (Array.isArray(arg))
|
|
1567
|
-
|
|
2656
|
+
if (Array.isArray(arg))
|
|
2657
|
+
return arg.map((id) => ({ id: canonicalizeId(id), extras: {} }));
|
|
2658
|
+
return Object.entries(arg).map(([id, extras]) => ({
|
|
2659
|
+
id: canonicalizeId(id),
|
|
2660
|
+
extras,
|
|
2661
|
+
}));
|
|
1568
2662
|
};
|
|
1569
2663
|
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2664
|
+
// Apply a pivot column's `prepare` adapter (model → DB), shared by the
|
|
2665
|
+
// INSERT (attach) and UPDATE (sync) write paths.
|
|
2666
|
+
const encodeExtra = (k: string, raw: unknown): unknown => {
|
|
2667
|
+
const prepare = pivotAdapters?.[k]?.prepare;
|
|
2668
|
+
if (!prepare) return raw;
|
|
2669
|
+
let encoded: unknown;
|
|
2670
|
+
try {
|
|
2671
|
+
// Adonis Lucid signature: (value, attribute, model). A pivot-row
|
|
2672
|
+
// write carries no single model instance.
|
|
2673
|
+
encoded = prepare(raw, k, undefined);
|
|
2674
|
+
} catch (err) {
|
|
2675
|
+
throw wrapAdapterError("prepare", k, err);
|
|
2676
|
+
}
|
|
2677
|
+
assertNotPromise("prepare", k, encoded);
|
|
2678
|
+
return encoded;
|
|
2679
|
+
};
|
|
2680
|
+
|
|
2681
|
+
// Reject an extras key colliding with a reserved pivot column. Without
|
|
2682
|
+
// this guard the FK case would silently override `parentIdValue`
|
|
2683
|
+
// (corrupting the join) and the timestamp case would duplicate the column
|
|
2684
|
+
// (driver-dependent failure or last-wins overwrite).
|
|
2685
|
+
const assertExtraKeyAllowed = (k: string): void => {
|
|
2686
|
+
if (k === pivotFk || k === pivotOther) {
|
|
2687
|
+
throw new Error(
|
|
2688
|
+
`Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`,
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
if (tsColumnSet.has(k)) {
|
|
2692
|
+
throw new Error(
|
|
2693
|
+
`Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`,
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
|
|
2698
|
+
// Narrow an unknown pivot id to a bindable scalar without an `as` cast.
|
|
2699
|
+
const asId = (v: unknown): string | number =>
|
|
2700
|
+
typeof v === "number" ? v : String(v);
|
|
2701
|
+
|
|
2702
|
+
// Current pivot rows for this parent — the other-key plus any attribute
|
|
2703
|
+
// columns the caller needs (so sync() can diff changed pivot rows).
|
|
2704
|
+
// Compiled through the Rust SELECT path so the pivot identifiers go
|
|
2705
|
+
// through `quote_identifier` (rejects anything outside `[A-Za-z0-9_]`),
|
|
2706
|
+
// never the ad-hoc `quote` helper. Runs on `conn` — a transaction inside
|
|
2707
|
+
// sync(), the pool otherwise.
|
|
2708
|
+
const currentPivotRows = async (
|
|
2709
|
+
attrCols: string[],
|
|
2710
|
+
conn: DatabaseConnection = db,
|
|
2711
|
+
): Promise<
|
|
2712
|
+
Array<{ id: string | number; row: Record<string, unknown> }>
|
|
2713
|
+
> => {
|
|
1577
2714
|
const selectSpec = {
|
|
1578
2715
|
kind: "select",
|
|
1579
2716
|
table: pivotTable,
|
|
1580
|
-
select: [pivotOther],
|
|
2717
|
+
select: [pivotOther, ...attrCols],
|
|
1581
2718
|
wheres: [
|
|
1582
2719
|
{
|
|
1583
2720
|
column: pivotFk,
|
|
1584
2721
|
operator: "=",
|
|
1585
|
-
value:
|
|
2722
|
+
value: readParentId(),
|
|
1586
2723
|
type: "and",
|
|
1587
2724
|
},
|
|
1588
2725
|
],
|
|
@@ -1600,19 +2737,27 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1600
2737
|
casts: pivotKeyCasts,
|
|
1601
2738
|
};
|
|
1602
2739
|
const compiled = compileStatementNative(selectSpec, dialect);
|
|
1603
|
-
const rows = await
|
|
2740
|
+
const rows = await conn.query<Record<string, unknown>>(
|
|
1604
2741
|
compiled.statements[0],
|
|
1605
2742
|
compiled.params,
|
|
1606
2743
|
);
|
|
1607
|
-
return rows.map((r) => r[pivotOther]
|
|
2744
|
+
return rows.map((r) => ({ id: asId(r[pivotOther]), row: r }));
|
|
1608
2745
|
};
|
|
1609
2746
|
|
|
1610
2747
|
// Delete via the Rust DELETE compiler so the pivot table + columns get
|
|
1611
2748
|
// `quote_identifier` validation (rejects `"`, `;`, etc.) — safer than
|
|
1612
2749
|
// the previous hand-built SQL with a dumb `"` wrapper.
|
|
1613
|
-
const detach = async (
|
|
2750
|
+
const detach = async (
|
|
2751
|
+
ids?: Array<string | number>,
|
|
2752
|
+
conn: DatabaseConnection = db,
|
|
2753
|
+
): Promise<void> => {
|
|
1614
2754
|
const wheres: Array<Record<string, unknown>> = [
|
|
1615
|
-
{
|
|
2755
|
+
{
|
|
2756
|
+
column: pivotFk,
|
|
2757
|
+
operator: "=",
|
|
2758
|
+
value: readParentId(),
|
|
2759
|
+
type: "and",
|
|
2760
|
+
},
|
|
1616
2761
|
];
|
|
1617
2762
|
if (ids && ids.length > 0) {
|
|
1618
2763
|
wheres.push({
|
|
@@ -1630,62 +2775,32 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1630
2775
|
casts: pivotKeyCasts,
|
|
1631
2776
|
};
|
|
1632
2777
|
const compiled = compileStatementNative(spec, dialect);
|
|
1633
|
-
await
|
|
2778
|
+
await conn.execute(compiled.statements[0], compiled.params);
|
|
1634
2779
|
};
|
|
1635
2780
|
|
|
1636
2781
|
const attach = async (
|
|
1637
2782
|
ids: Array<string | number> | Record<string, Record<string, unknown>>,
|
|
2783
|
+
conn: DatabaseConnection = db,
|
|
2784
|
+
parentFk: unknown = readParentId(),
|
|
1638
2785
|
): Promise<void> => {
|
|
1639
2786
|
const entries = normalizeAttach(ids);
|
|
1640
2787
|
if (entries.length === 0) return;
|
|
1641
|
-
const ts =
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
1645
|
-
// Rust compiler's homogeneity check).
|
|
2788
|
+
const ts = timestampValues("insert");
|
|
2789
|
+
// Union of extra keys across all entries; back-fill missing keys with
|
|
2790
|
+
// `null` so every row in the multi-insert shares the same column set
|
|
2791
|
+
// (required by the Rust compiler's homogeneity check).
|
|
1646
2792
|
const extraKeys = new Set<string>();
|
|
1647
2793
|
for (const e of entries) {
|
|
1648
2794
|
for (const k of Object.keys(e.extras)) extraKeys.add(k);
|
|
1649
2795
|
}
|
|
1650
|
-
|
|
1651
|
-
// this guard, an extras entry named after the FK or a timestamp column
|
|
1652
|
-
// would emit a duplicate column in the INSERT row pair: the FK case
|
|
1653
|
-
// silently overrides `parentIdValue` (corrupting the join); the
|
|
1654
|
-
// timestamp case duplicates the column entirely (driver-dependent
|
|
1655
|
-
// failure or last-wins overwrite).
|
|
1656
|
-
for (const k of extraKeys) {
|
|
1657
|
-
if (k === pivotFk || k === pivotOther) {
|
|
1658
|
-
throw new Error(
|
|
1659
|
-
`Pivot extras key '${k}' collides with the ${k === pivotFk ? "foreignKey" : "otherKey"} column on '${pivotTable}'. Reserved keys MUST NOT appear in attach()/sync() extras.`,
|
|
1660
|
-
);
|
|
1661
|
-
}
|
|
1662
|
-
if (Object.hasOwn(ts, k)) {
|
|
1663
|
-
throw new Error(
|
|
1664
|
-
`Pivot extras key '${k}' collides with a pivotTimestamps column on '${pivotTable}'. Disable the timestamp in the relation options or rename your extra.`,
|
|
1665
|
-
);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
2796
|
+
for (const k of extraKeys) assertExtraKeyAllowed(k);
|
|
1668
2797
|
const rowPairs = entries.map((e) => {
|
|
1669
2798
|
const pairs: Array<[string, unknown]> = [
|
|
1670
|
-
[pivotFk,
|
|
2799
|
+
[pivotFk, parentFk],
|
|
1671
2800
|
[pivotOther, e.id],
|
|
1672
2801
|
];
|
|
1673
|
-
for (const k of extraKeys)
|
|
1674
|
-
|
|
1675
|
-
const prepare = pivotAdapters?.[k]?.prepare;
|
|
1676
|
-
if (!prepare) {
|
|
1677
|
-
pairs.push([k, raw]);
|
|
1678
|
-
continue;
|
|
1679
|
-
}
|
|
1680
|
-
let encoded: unknown;
|
|
1681
|
-
try {
|
|
1682
|
-
encoded = prepare(raw);
|
|
1683
|
-
} catch (err) {
|
|
1684
|
-
throw wrapAdapterError("prepare", k, err);
|
|
1685
|
-
}
|
|
1686
|
-
assertNotPromise("prepare", k, encoded);
|
|
1687
|
-
pairs.push([k, encoded]);
|
|
1688
|
-
}
|
|
2802
|
+
for (const k of extraKeys)
|
|
2803
|
+
pairs.push([k, encodeExtra(k, e.extras[k] ?? null)]);
|
|
1689
2804
|
for (const [k, v] of Object.entries(ts)) pairs.push([k, v]);
|
|
1690
2805
|
return pairs;
|
|
1691
2806
|
});
|
|
@@ -1701,20 +2816,55 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1701
2816
|
casts: pivotCasts,
|
|
1702
2817
|
};
|
|
1703
2818
|
const compiled = compileStatementNative(spec, dialect);
|
|
1704
|
-
await
|
|
2819
|
+
await conn.execute(compiled.statements[0], compiled.params);
|
|
2820
|
+
};
|
|
2821
|
+
|
|
2822
|
+
// Refresh one already-attached pivot row's attributes (sync's update arm,
|
|
2823
|
+
// Adonis Lucid parity): set the provided extras (adapter-encoded) and bump
|
|
2824
|
+
// only updated_at.
|
|
2825
|
+
const updatePivot = async (
|
|
2826
|
+
id: string | number,
|
|
2827
|
+
extras: Record<string, unknown>,
|
|
2828
|
+
conn: DatabaseConnection = db,
|
|
2829
|
+
): Promise<void> => {
|
|
2830
|
+
const ts = timestampValues("update");
|
|
2831
|
+
const set: Array<[string, unknown]> = [];
|
|
2832
|
+
for (const [k, raw] of Object.entries(extras)) {
|
|
2833
|
+
assertExtraKeyAllowed(k);
|
|
2834
|
+
set.push([k, encodeExtra(k, raw ?? null)]);
|
|
2835
|
+
}
|
|
2836
|
+
for (const [k, v] of Object.entries(ts)) set.push([k, v]);
|
|
2837
|
+
if (set.length === 0) return;
|
|
2838
|
+
const casts: Record<string, string> = { ...pivotKeyCasts };
|
|
2839
|
+
for (const k of Object.keys(ts)) casts[k] = "timestamp";
|
|
2840
|
+
const spec = {
|
|
2841
|
+
kind: "update",
|
|
2842
|
+
table: pivotTable,
|
|
2843
|
+
set,
|
|
2844
|
+
wheres: [
|
|
2845
|
+
{
|
|
2846
|
+
column: pivotFk,
|
|
2847
|
+
operator: "=",
|
|
2848
|
+
value: readParentId(),
|
|
2849
|
+
type: "and",
|
|
2850
|
+
},
|
|
2851
|
+
{ column: pivotOther, operator: "=", value: id, type: "and" },
|
|
2852
|
+
],
|
|
2853
|
+
returning: [],
|
|
2854
|
+
casts,
|
|
2855
|
+
};
|
|
2856
|
+
const compiled = compileStatementNative(spec, dialect);
|
|
2857
|
+
await conn.execute(compiled.statements[0], compiled.params);
|
|
1705
2858
|
};
|
|
1706
2859
|
|
|
1707
2860
|
/**
|
|
1708
|
-
* Diff the current pivot state against a target set and apply the
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
* On SQLite this is typically fine because better-sqlite3 serializes
|
|
1717
|
-
* writes per connection; on Postgres/MySQL use `useTransaction` first.
|
|
2861
|
+
* Diff the current pivot state against a target set and apply the minimum
|
|
2862
|
+
* insert / update / delete to converge (Adonis Lucid `sync`): rows missing
|
|
2863
|
+
* from the pivot are attached, already-attached rows whose pivot attributes
|
|
2864
|
+
* changed are updated, and rows absent from the target are detached (unless
|
|
2865
|
+
* `additive`). The read and all three writes run inside ONE managed
|
|
2866
|
+
* transaction — atomic and rolled back on any failure, so a concurrent
|
|
2867
|
+
* writer can't wedge the pivot into a half-synced state.
|
|
1718
2868
|
*/
|
|
1719
2869
|
const sync = async (
|
|
1720
2870
|
target:
|
|
@@ -1722,61 +2872,207 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1722
2872
|
| Record<string, Record<string, unknown>>,
|
|
1723
2873
|
additive = false,
|
|
1724
2874
|
): Promise<void> => {
|
|
1725
|
-
const current = new Set(await currentIds());
|
|
1726
2875
|
const entries = normalizeAttach(target);
|
|
1727
|
-
|
|
1728
|
-
const
|
|
1729
|
-
const
|
|
1730
|
-
|
|
1731
|
-
: [...current].filter((id) => !desired.has(id));
|
|
1732
|
-
if (toDetach.length > 0) await detach(toDetach);
|
|
1733
|
-
if (toAttach.length > 0) {
|
|
1734
|
-
const attachArg: Record<string, Record<string, unknown>> = {};
|
|
1735
|
-
for (const e of toAttach) attachArg[String(e.id)] = e.extras;
|
|
1736
|
-
await attach(attachArg);
|
|
2876
|
+
// Attribute columns to read back so we can detect changed pivot rows.
|
|
2877
|
+
const attrCols = new Set<string>();
|
|
2878
|
+
for (const e of entries) {
|
|
2879
|
+
for (const k of Object.keys(e.extras)) attrCols.add(k);
|
|
1737
2880
|
}
|
|
2881
|
+
const desiredIds = new Set(entries.map((e) => String(e.id)));
|
|
2882
|
+
|
|
2883
|
+
await transaction(db, async (trx) => {
|
|
2884
|
+
const current = await currentPivotRows([...attrCols], trx);
|
|
2885
|
+
const currentById = new Map<
|
|
2886
|
+
string,
|
|
2887
|
+
{ id: string | number; row: Record<string, unknown> }
|
|
2888
|
+
>();
|
|
2889
|
+
for (const c of current) currentById.set(String(c.id), c);
|
|
2890
|
+
|
|
2891
|
+
// Diff by String(id): the DB returns numeric ids for an integer
|
|
2892
|
+
// pivot column while object-form targets carry canonicalized ids —
|
|
2893
|
+
// stringifying both sides keeps the comparison type-agnostic.
|
|
2894
|
+
const toDetach = additive
|
|
2895
|
+
? []
|
|
2896
|
+
: current
|
|
2897
|
+
.filter((c) => !desiredIds.has(String(c.id)))
|
|
2898
|
+
.map((c) => c.id);
|
|
2899
|
+
const toAttach = entries.filter(
|
|
2900
|
+
(e) => !currentById.has(String(e.id)),
|
|
2901
|
+
);
|
|
2902
|
+
const toUpdate = entries.filter((e) => {
|
|
2903
|
+
if (Object.keys(e.extras).length === 0) return false;
|
|
2904
|
+
const cur = currentById.get(String(e.id));
|
|
2905
|
+
if (!cur) return false;
|
|
2906
|
+
// Only rewrite when a provided attribute actually differs — a
|
|
2907
|
+
// no-op sync must not churn rows or bump updated_at. Compare
|
|
2908
|
+
// nullish and empty-string as DISTINCT (a `String(x ?? "")`
|
|
2909
|
+
// collapse would treat `null` and `""` as equal and miss a real
|
|
2910
|
+
// attribute change from one to the other).
|
|
2911
|
+
return Object.keys(e.extras).some((k) => {
|
|
2912
|
+
const stored = cur.row[k];
|
|
2913
|
+
const next = encodeExtra(k, e.extras[k] ?? null);
|
|
2914
|
+
const storedNull = stored === null || stored === undefined;
|
|
2915
|
+
const nextNull = next === null || next === undefined;
|
|
2916
|
+
if (storedNull || nextNull) return storedNull !== nextNull;
|
|
2917
|
+
return String(stored) !== String(next);
|
|
2918
|
+
});
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
if (toDetach.length > 0) await detach(toDetach, trx);
|
|
2922
|
+
for (const e of toUpdate) await updatePivot(e.id, e.extras, trx);
|
|
2923
|
+
if (toAttach.length > 0) {
|
|
2924
|
+
const attachArg: Record<string, Record<string, unknown>> = {};
|
|
2925
|
+
for (const e of toAttach) attachArg[String(e.id)] = e.extras;
|
|
2926
|
+
await attach(attachArg, trx);
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
1738
2929
|
};
|
|
1739
2930
|
|
|
1740
2931
|
// m2m create/save persist the related row THEN insert a pivot row —
|
|
1741
2932
|
// NOT `hasOps.injectFk`, which would write a bogus `<parent>_id` column
|
|
1742
2933
|
// onto the related table and never touch the pivot (silent corruption).
|
|
1743
|
-
|
|
1744
|
-
|
|
2934
|
+
// The whole chain (persist unsaved parent → write related → insert pivot)
|
|
2935
|
+
// runs in ONE transaction via `withParentSaved` (AdonisJS/Lucid parity):
|
|
2936
|
+
// atomic, rolled back on any failure (no orphan related row, no pivot to a
|
|
2937
|
+
// missing parent), with domain events flushed only after commit.
|
|
2938
|
+
const relatedPkProp =
|
|
2939
|
+
pivot.relatedKey ?? getPrimaryKey(relatedClass) ?? "id";
|
|
2940
|
+
const attachRows = (
|
|
2941
|
+
rows: BaseEntity[],
|
|
2942
|
+
trx: TransactionClient,
|
|
2943
|
+
fk: unknown,
|
|
2944
|
+
pivotFor?: (index: number) => Record<string, unknown>,
|
|
2945
|
+
): Promise<void> => {
|
|
1745
2946
|
if (rows.length === 0) return Promise.resolve();
|
|
1746
2947
|
const arg: Record<string, Record<string, unknown>> = {};
|
|
1747
|
-
|
|
1748
|
-
|
|
2948
|
+
rows.forEach((r, i) => {
|
|
2949
|
+
arg[String(r[relatedPkProp])] = pivotFor?.(i) ?? {};
|
|
2950
|
+
});
|
|
2951
|
+
return attach(arg, trx, fk);
|
|
1749
2952
|
};
|
|
2953
|
+
// create/save accept per-row pivot attributes (Adonis Lucid `create(values,
|
|
2954
|
+
// pivotAttributes)` / `save(related, pivotAttributes)`) — written onto the
|
|
2955
|
+
// pivot row alongside the FK/otherKey, in the same transaction.
|
|
1750
2956
|
const m2mOps = {
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
2957
|
+
create: (
|
|
2958
|
+
data: Record<string, unknown>,
|
|
2959
|
+
pivotAttributes?: Record<string, unknown>,
|
|
2960
|
+
): Promise<BaseEntity> =>
|
|
2961
|
+
withParentSaved(async (fk, rel, trx) => {
|
|
2962
|
+
const created = await rel.create(data);
|
|
2963
|
+
await attachRows([created], trx, fk, () => pivotAttributes ?? {});
|
|
2964
|
+
trx.after("commit", () => flushEvents([created]));
|
|
2965
|
+
return created;
|
|
2966
|
+
}),
|
|
2967
|
+
createMany: (
|
|
1757
2968
|
rows: Array<Record<string, unknown>>,
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2969
|
+
pivotAttributes?: Array<Record<string, unknown>>,
|
|
2970
|
+
): Promise<BaseEntity[]> =>
|
|
2971
|
+
withParentSaved(async (fk, rel, trx) => {
|
|
2972
|
+
const created = await rel.createMany(rows);
|
|
2973
|
+
await attachRows(
|
|
2974
|
+
created,
|
|
2975
|
+
trx,
|
|
2976
|
+
fk,
|
|
2977
|
+
(i) => pivotAttributes?.[i] ?? {},
|
|
2978
|
+
);
|
|
2979
|
+
// NO wrapper flush: rel.createMany now self-dispatches via
|
|
2980
|
+
// #inManagedTx (like saveMany). The pivot rows carry no events; a
|
|
2981
|
+
// second flush would double the related rows' events on a partial
|
|
2982
|
+
// sink failure.
|
|
2983
|
+
return created;
|
|
2984
|
+
}),
|
|
2985
|
+
save: (
|
|
2986
|
+
related: BaseEntity,
|
|
2987
|
+
pivotAttributes?: Record<string, unknown>,
|
|
2988
|
+
): Promise<void> =>
|
|
2989
|
+
withParentSaved(async (fk, rel, trx) => {
|
|
2990
|
+
await rel.save(related);
|
|
2991
|
+
await attachRows([related], trx, fk, () => pivotAttributes ?? {});
|
|
2992
|
+
trx.after("commit", () => flushEvents([related]));
|
|
2993
|
+
}),
|
|
2994
|
+
saveMany: (
|
|
2995
|
+
related: BaseEntity[],
|
|
2996
|
+
pivotAttributes?: Array<Record<string, unknown>>,
|
|
2997
|
+
): Promise<BaseEntity[]> =>
|
|
2998
|
+
withParentSaved(async (fk, rel, trx) => {
|
|
2999
|
+
const saved = await rel.saveMany(related);
|
|
3000
|
+
await attachRows(saved, trx, fk, (i) => pivotAttributes?.[i] ?? {});
|
|
3001
|
+
// NO wrapper flush: rel.saveMany self-dispatches via #inManagedTx
|
|
3002
|
+
// (all-or-nothing). A second flush would double on partial failure.
|
|
3003
|
+
return saved;
|
|
3004
|
+
}),
|
|
1772
3005
|
};
|
|
3006
|
+
// attach/detach/sync operate DIRECTLY on the pivot using the parent key —
|
|
3007
|
+
// unlike create/save they never persist the parent (there's no related row
|
|
3008
|
+
// to hang the transaction on). Lucid requires a persisted parent WITH a key
|
|
3009
|
+
// here (every doc example starts from `findOrFail`); without the guard a
|
|
3010
|
+
// keyless/unsaved parent would write a pivot row with a null FK or target a
|
|
3011
|
+
// nonexistent parent. Same seam as delete/refresh: E_MODEL_NOT_PERSISTED on
|
|
3012
|
+
// an unsaved instance, E_MISSING_PRIMARY_KEY on a keyless projection.
|
|
3013
|
+
const guardParent = (op: string): void =>
|
|
3014
|
+
this.#assertPersistedRow(
|
|
3015
|
+
entity,
|
|
3016
|
+
readParentId(),
|
|
3017
|
+
`related('${relationName}').${op}`,
|
|
3018
|
+
// The pivot FK references `parentPk` (localKey ?? PK) — name THAT key
|
|
3019
|
+
// in a missing-key diagnostic, not always 'id'.
|
|
3020
|
+
parentPk,
|
|
3021
|
+
);
|
|
1773
3022
|
const proxy: ManyToManyRelationProxy = {
|
|
1774
3023
|
type: "manyToMany",
|
|
1775
3024
|
...m2mOps,
|
|
1776
3025
|
query: scopedQuery,
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
sync
|
|
3026
|
+
// A query builder on the PIVOT table itself, scoped to this parent
|
|
3027
|
+
// (Adonis Lucid `pivotQuery`) — for reading/updating/deleting pivot
|
|
3028
|
+
// rows directly, beyond attach/detach/sync.
|
|
3029
|
+
pivotQuery: () => {
|
|
3030
|
+
guardParent("pivotQuery()");
|
|
3031
|
+
return new DatabaseQueryBuilder(this.#db, dialect, pivotTable).where(
|
|
3032
|
+
pivotFk,
|
|
3033
|
+
readParentId(),
|
|
3034
|
+
);
|
|
3035
|
+
},
|
|
3036
|
+
// async so the guard throw surfaces as a REJECTED promise — a method
|
|
3037
|
+
// typed `Promise<void>` must never throw synchronously.
|
|
3038
|
+
attach: async (ids) => {
|
|
3039
|
+
guardParent("attach()");
|
|
3040
|
+
return attach(ids);
|
|
3041
|
+
},
|
|
3042
|
+
detach: async (ids) => {
|
|
3043
|
+
guardParent("detach()");
|
|
3044
|
+
return detach(ids);
|
|
3045
|
+
},
|
|
3046
|
+
sync: async (target, additive) => {
|
|
3047
|
+
guardParent("sync()");
|
|
3048
|
+
return sync(target, additive);
|
|
3049
|
+
},
|
|
3050
|
+
};
|
|
3051
|
+
return proxy;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
if (
|
|
3055
|
+
relation.type === "hasOneThrough" ||
|
|
3056
|
+
relation.type === "hasManyThrough"
|
|
3057
|
+
) {
|
|
3058
|
+
// READ-ONLY (Lucid parity, verified): a through relation exposes only
|
|
3059
|
+
// query()/preload. Every write is rejected — the old code fell through to
|
|
3060
|
+
// the hasMany default and wrote to the WRONG table with a bogus direct FK.
|
|
3061
|
+
// To persist, the caller must go through the intermediate model.
|
|
3062
|
+
const rejectWrite = async (op: string): Promise<never> => {
|
|
3063
|
+
throw new Error(
|
|
3064
|
+
`related('${relationName}').${op}() is not supported on ` +
|
|
3065
|
+
`@HasManyThrough/@HasOneThrough — through relations are READ-ONLY ` +
|
|
3066
|
+
`(Lucid parity); persist via the intermediate model.`,
|
|
3067
|
+
);
|
|
3068
|
+
};
|
|
3069
|
+
const proxy: HasManyThroughRelationProxy = {
|
|
3070
|
+
type: relation.type,
|
|
3071
|
+
query: scopedQuery,
|
|
3072
|
+
create: () => rejectWrite("create"),
|
|
3073
|
+
save: () => rejectWrite("save"),
|
|
3074
|
+
createMany: () => rejectWrite("createMany"),
|
|
3075
|
+
saveMany: () => rejectWrite("saveMany"),
|
|
1780
3076
|
};
|
|
1781
3077
|
return proxy;
|
|
1782
3078
|
}
|
|
@@ -1816,11 +3112,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1816
3112
|
|
|
1817
3113
|
async fresh(entity: T): Promise<T> {
|
|
1818
3114
|
const pk = entity[this.#primaryKey];
|
|
1819
|
-
|
|
1820
|
-
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
1821
|
-
[this.#primaryKey]: pk,
|
|
1822
|
-
});
|
|
1823
|
-
}
|
|
3115
|
+
this.#assertPersistedRow(entity, pk, "fresh()");
|
|
1824
3116
|
const found = await this.find(pk as string | number);
|
|
1825
3117
|
if (!found) {
|
|
1826
3118
|
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
@@ -1840,7 +3132,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1840
3132
|
for (const col of this.#columns) {
|
|
1841
3133
|
const value = entity[col];
|
|
1842
3134
|
if (value !== undefined) {
|
|
1843
|
-
row[
|
|
3135
|
+
row[this.#dbColumn(col)] = this.#applyPrepare(col, value, entity);
|
|
1844
3136
|
}
|
|
1845
3137
|
}
|
|
1846
3138
|
return row;
|
|
@@ -1853,11 +3145,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1853
3145
|
for (const [key, value] of Object.entries(data)) {
|
|
1854
3146
|
// Mirror `#plainToRowPairs` — skip undefined so updates can't bind it.
|
|
1855
3147
|
if (value === undefined) continue;
|
|
1856
|
-
|
|
1857
|
-
pairs.push([
|
|
1858
|
-
this.#resolveColumn(key),
|
|
1859
|
-
this.#applyPrepare(propKey, value),
|
|
1860
|
-
]);
|
|
3148
|
+
// `#applyPrepare` normalises the key (property / snake / columnName).
|
|
3149
|
+
pairs.push([this.#resolveColumn(key), this.#applyPrepare(key, value)]);
|
|
1861
3150
|
}
|
|
1862
3151
|
return pairs;
|
|
1863
3152
|
}
|
|
@@ -1892,7 +3181,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1892
3181
|
* adapter rejected — the dev has to bisect across every adapter-tagged
|
|
1893
3182
|
* property to find the culprit.
|
|
1894
3183
|
*/
|
|
1895
|
-
function wrapAdapterError(
|
|
3184
|
+
export function wrapAdapterError(
|
|
1896
3185
|
phase: "prepare" | "consume",
|
|
1897
3186
|
propertyKey: string,
|
|
1898
3187
|
err: unknown,
|
|
@@ -1914,7 +3203,7 @@ function wrapAdapterError(
|
|
|
1914
3203
|
* gives the user a column-annotated error instead of an opaque "Invalid bind
|
|
1915
3204
|
* value" downstream when the unawaited Promise hits the NAPI boundary.
|
|
1916
3205
|
*/
|
|
1917
|
-
function assertNotPromise(
|
|
3206
|
+
export function assertNotPromise(
|
|
1918
3207
|
phase: "prepare" | "consume",
|
|
1919
3208
|
propertyKey: string,
|
|
1920
3209
|
value: unknown,
|