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