@c9up/atlas 0.1.9 → 0.1.11
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/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 +14 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +14 -2
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +3 -12
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +20 -4
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +45 -89
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +2 -2
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +62 -17
- package/dist/ModelQuery.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +14 -1
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +9 -7
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +5 -6
- package/dist/configure.js.map +1 -1
- package/dist/decorators/entity.js +1 -1
- package/dist/decorators/entity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/metadata-keys.d.ts +20 -0
- package/dist/metadata-keys.d.ts.map +1 -0
- package/dist/metadata-keys.js +13 -0
- package/dist/metadata-keys.js.map +1 -0
- package/dist/query/native.d.ts +7 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +10 -1
- package/dist/query/native.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +5 -2
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Seeder.d.ts +13 -8
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +13 -8
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/services/db.d.ts +7 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +10 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/Factory.d.ts +6 -7
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +3 -5
- package/dist/testing/Factory.js.map +1 -1
- package/dist/utils/safePath.d.ts +7 -1
- package/dist/utils/safePath.d.ts.map +1 -1
- package/dist/utils/safePath.js +24 -1
- package/dist/utils/safePath.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 +1 -1
- package/src/AtlasProvider.ts +28 -1
- package/src/BaseEntity.ts +27 -12
- package/src/BaseRepository.ts +50 -98
- package/src/ModelQuery.ts +66 -17
- package/src/adapters/NapiDbAdapter.ts +32 -8
- package/src/configure.ts +5 -6
- package/src/decorators/entity.ts +1 -1
- package/src/index.ts +4 -1
- package/src/metadata-keys.ts +22 -0
- package/src/query/native.ts +12 -1
- package/src/schema/MigrationRunner.ts +5 -2
- package/src/schema/Seeder.ts +14 -12
- package/src/services/db.ts +10 -0
- package/src/testing/Factory.ts +9 -12
- package/src/utils/safePath.ts +27 -2
package/src/BaseRepository.ts
CHANGED
|
@@ -210,6 +210,18 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
210
210
|
options?: { dialect?: AtlasDialect },
|
|
211
211
|
) {
|
|
212
212
|
this.#entityClass = entityClass;
|
|
213
|
+
if (db == null) {
|
|
214
|
+
// A null/undefined connection almost always means IoC constructor
|
|
215
|
+
// injection failed (missing decorator metadata) — fail with a clear
|
|
216
|
+
// message instead of the cryptic `reading 'dialect' of undefined`.
|
|
217
|
+
throw new AtlasError(
|
|
218
|
+
"MISSING_CONNECTION",
|
|
219
|
+
`BaseRepository for '${entityClass.name}' requires a DatabaseConnection (got ${db === null ? "null" : "undefined"}).`,
|
|
220
|
+
{
|
|
221
|
+
hint: "The connection was not injected. Check IoC constructor injection is wired — decorator metadata (emitDecoratorMetadata) or @Inject(token) on the connection parameter.",
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
}
|
|
213
225
|
this.#db = db;
|
|
214
226
|
|
|
215
227
|
// Dialect resolution order: explicit option > connection.dialect > process default.
|
|
@@ -351,15 +363,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
351
363
|
// ─── Finders ──────────────────────────────────────────────
|
|
352
364
|
|
|
353
365
|
async find(id: string | number | bigint): Promise<T | null> {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.#
|
|
358
|
-
const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
|
|
359
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
360
|
-
const row = rows[0];
|
|
361
|
-
if (!row) return null;
|
|
362
|
-
return this.#hydrate(row);
|
|
366
|
+
// Route through the query builder so the read hooks (beforeFind/afterFind)
|
|
367
|
+
// fire and a `beforeFind` hook can mutate the query — the previous direct
|
|
368
|
+
// `#compileSelect` fast path bypassed every read hook silently.
|
|
369
|
+
return this.query().where(this.#primaryKey, id).first();
|
|
363
370
|
}
|
|
364
371
|
|
|
365
372
|
async findOrFail(id: string | number): Promise<T> {
|
|
@@ -373,60 +380,34 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
373
380
|
}
|
|
374
381
|
|
|
375
382
|
async findBy(column: string, value: unknown): Promise<T | null> {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
{ column: col, operator: "=", value, type: "and" },
|
|
379
|
-
];
|
|
380
|
-
this.#appendSoftScope(wheres);
|
|
381
|
-
const { sql, params } = this.#compileSelect({ wheres, limit: 1 });
|
|
382
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
383
|
-
const row = rows[0];
|
|
384
|
-
if (!row) return null;
|
|
385
|
-
return this.#hydrate(row);
|
|
383
|
+
// Through the builder for read-hook parity (see `find`).
|
|
384
|
+
return this.query().where(column, value).first();
|
|
386
385
|
}
|
|
387
386
|
|
|
388
387
|
async all(): Promise<T[]> {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return this
|
|
388
|
+
// Through the builder so beforeFetch/afterFetch fire. The builder applies
|
|
389
|
+
// the soft-delete scope by default, exactly like the old fast path.
|
|
390
|
+
return this.query().exec();
|
|
392
391
|
}
|
|
393
392
|
|
|
394
393
|
async allWithTrashed(): Promise<T[]> {
|
|
395
|
-
return this
|
|
394
|
+
return this.query().withTrashed().exec();
|
|
396
395
|
}
|
|
397
396
|
|
|
398
397
|
async onlyTrashed(): Promise<T[]> {
|
|
399
398
|
if (!this.#softDeletes) return [];
|
|
400
|
-
return this
|
|
401
|
-
wheres: [
|
|
402
|
-
{
|
|
403
|
-
column: "deleted_at",
|
|
404
|
-
operator: "IS NOT NULL",
|
|
405
|
-
value: null,
|
|
406
|
-
type: "and",
|
|
407
|
-
},
|
|
408
|
-
],
|
|
409
|
-
});
|
|
399
|
+
return this.query().onlyTrashed().exec();
|
|
410
400
|
}
|
|
411
401
|
|
|
412
402
|
async where(column: string, value: unknown): Promise<T[]> {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
this
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
// blew up on Postgres/MySQL the moment the app ran against a real driver.
|
|
422
|
-
// Using the PK works on every dialect and matches the user's actual
|
|
423
|
-
// schema — the ordering contract is "most recent first by PK" for
|
|
424
|
-
// `repo.where(col, val)` as a convenience finder.
|
|
425
|
-
const pkCol = camelToSnake(this.#primaryKey);
|
|
426
|
-
return this.#runSelect({
|
|
427
|
-
wheres,
|
|
428
|
-
orderBy: [{ column: pkCol, direction: "desc" }],
|
|
429
|
-
});
|
|
403
|
+
// Order by the primary key (DESC = most recent insert first when the PK is
|
|
404
|
+
// an auto-increment integer or a monotonic UUID). The ordering contract is
|
|
405
|
+
// "most recent first by PK" for `repo.where(col, val)` as a convenience
|
|
406
|
+
// finder. Through the builder for read-hook parity (see `find`).
|
|
407
|
+
return this.query()
|
|
408
|
+
.where(column, value)
|
|
409
|
+
.orderBy(this.#primaryKey, "desc")
|
|
410
|
+
.exec();
|
|
430
411
|
}
|
|
431
412
|
|
|
432
413
|
// ─── Create / Save / Delete ───────────────────────────────
|
|
@@ -450,6 +431,7 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
450
431
|
await this.#insert(entity);
|
|
451
432
|
await fireHooks(this.#entityClass, "afterCreate", entity);
|
|
452
433
|
await fireHooks(this.#entityClass, "afterSave", entity);
|
|
434
|
+
await this.#dispatchDomainEvents(entity);
|
|
453
435
|
return entity;
|
|
454
436
|
}
|
|
455
437
|
|
|
@@ -494,6 +476,17 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
494
476
|
}
|
|
495
477
|
await fireHooks(this.#entityClass, "afterSave", entity);
|
|
496
478
|
|
|
479
|
+
await this.#dispatchDomainEvents(entity);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Flush the entity's accumulated domain events through `onDomainEvents`.
|
|
484
|
+
* On dispatch failure the events are re-queued on the entity and the error
|
|
485
|
+
* propagates, so a caller can retry without losing them. Shared by `save`,
|
|
486
|
+
* `create` and `createMany` — every persistence path that produces a live
|
|
487
|
+
* entity must dispatch, otherwise events silently vanish on batch inserts.
|
|
488
|
+
*/
|
|
489
|
+
async #dispatchDomainEvents(entity: BaseEntity): Promise<void> {
|
|
497
490
|
const events = entity.flushDomainEvents();
|
|
498
491
|
if (events.length > 0 && this.onDomainEvents) {
|
|
499
492
|
try {
|
|
@@ -576,6 +569,9 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
576
569
|
await fireHooks(this.#entityClass, "afterCreate", e);
|
|
577
570
|
await fireHooks(this.#entityClass, "afterSave", e);
|
|
578
571
|
}
|
|
572
|
+
for (const e of entities) {
|
|
573
|
+
await this.#dispatchDomainEvents(e);
|
|
574
|
+
}
|
|
579
575
|
return entities;
|
|
580
576
|
}
|
|
581
577
|
|
|
@@ -908,43 +904,6 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
908
904
|
|
|
909
905
|
// ─── Private helpers ──────────────────────────────────────
|
|
910
906
|
|
|
911
|
-
#compileSelect(opts: {
|
|
912
|
-
wheres?: Array<Record<string, unknown>>;
|
|
913
|
-
orderBy?: Array<Record<string, unknown>>;
|
|
914
|
-
limit?: number;
|
|
915
|
-
}): { sql: string; params: unknown[] } {
|
|
916
|
-
const spec = {
|
|
917
|
-
kind: "select",
|
|
918
|
-
table: this.#tableName,
|
|
919
|
-
select: ["*"],
|
|
920
|
-
wheres: opts.wheres ?? [],
|
|
921
|
-
orderBy: opts.orderBy ?? [],
|
|
922
|
-
groupBy: [],
|
|
923
|
-
having: [],
|
|
924
|
-
limit: opts.limit ?? null,
|
|
925
|
-
offset: null,
|
|
926
|
-
distinct: false,
|
|
927
|
-
ctes: [],
|
|
928
|
-
unions: [],
|
|
929
|
-
// Cast WHERE params on native-typed columns (uuid/timestamp/…) so a
|
|
930
|
-
// `WHERE id = $1` on a uuid PK emits `$1::uuid` — otherwise Postgres
|
|
931
|
-
// rejects it with `operator does not exist: uuid = text`.
|
|
932
|
-
casts: this.#castTypes,
|
|
933
|
-
};
|
|
934
|
-
const compiled = compileStatementNative(spec, this.#dialect);
|
|
935
|
-
return { sql: compiled.statements[0], params: compiled.params };
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
async #runSelect(opts: {
|
|
939
|
-
wheres?: Array<Record<string, unknown>>;
|
|
940
|
-
orderBy?: Array<Record<string, unknown>>;
|
|
941
|
-
limit?: number;
|
|
942
|
-
}): Promise<T[]> {
|
|
943
|
-
const { sql, params } = this.#compileSelect(opts);
|
|
944
|
-
const rows = await this.#db.query<Row>(sql, params);
|
|
945
|
-
return rows.map((r) => this.#hydrate(r));
|
|
946
|
-
}
|
|
947
|
-
|
|
948
907
|
async #runDelete(wheres: Array<Record<string, unknown>>): Promise<void> {
|
|
949
908
|
const compiled = compileStatementNative(
|
|
950
909
|
{ kind: "delete", table: this.#tableName, wheres },
|
|
@@ -1023,17 +982,6 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1023
982
|
return {};
|
|
1024
983
|
}
|
|
1025
984
|
|
|
1026
|
-
#appendSoftScope(wheres: Array<Record<string, unknown>>): void {
|
|
1027
|
-
if (this.#softDeletes) {
|
|
1028
|
-
wheres.push({
|
|
1029
|
-
column: "deleted_at",
|
|
1030
|
-
operator: "IS NULL",
|
|
1031
|
-
value: null,
|
|
1032
|
-
type: "and",
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
985
|
async #insert(entity: T): Promise<void> {
|
|
1038
986
|
// Auto-generate the PK when declared via `@PrimaryKey({ generated })`.
|
|
1039
987
|
this.#applyPrimaryKeyGenerator(entity);
|
|
@@ -1314,6 +1262,10 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1314
1262
|
const relatedRepo = new BaseRepository<BaseEntity>(relatedClass, this.#db, {
|
|
1315
1263
|
dialect: this.#dialect,
|
|
1316
1264
|
});
|
|
1265
|
+
// Propagate the domain-event sink so entities persisted through a relation
|
|
1266
|
+
// proxy (user.related('posts').create(...)) dispatch their events too —
|
|
1267
|
+
// otherwise the related entity's events silently vanish.
|
|
1268
|
+
relatedRepo.onDomainEvents = this.onDomainEvents;
|
|
1317
1269
|
const db = this.#db;
|
|
1318
1270
|
|
|
1319
1271
|
// FK column naming: belongsTo stores the FK on THIS side; has* / m2m on the OTHER side.
|
package/src/ModelQuery.ts
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
import type { BaseEntity } from "./BaseEntity.js";
|
|
11
11
|
import type { DatabaseConnection } from "./BaseRepository.js";
|
|
12
12
|
import {
|
|
13
|
+
getColumnMetadata,
|
|
13
14
|
getEntityMetadata,
|
|
14
15
|
getPrimaryKey,
|
|
15
16
|
getRelationMetadata,
|
|
16
17
|
hasSoftDeletes,
|
|
17
18
|
type RelationMetadata,
|
|
18
19
|
} from "./decorators/entity.js";
|
|
20
|
+
import { fireHooks } from "./decorators/hooks.js";
|
|
19
21
|
import {
|
|
20
22
|
type AtlasDialect,
|
|
21
23
|
compileStatementNative,
|
|
@@ -937,11 +939,15 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
937
939
|
return this;
|
|
938
940
|
}
|
|
939
941
|
|
|
940
|
-
/** Execute and return the first matching entity or null. */
|
|
942
|
+
/** Execute and return the first matching entity or null. Fires beforeFind/afterFind. */
|
|
941
943
|
async first(): Promise<T | null> {
|
|
942
944
|
this.#limit = 1;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
+
await fireHooks(this.#entityClass, "beforeFind", this);
|
|
946
|
+
// Bypass exec() so the multi-row beforeFetch/afterFetch hooks don't ALSO
|
|
947
|
+
// fire — first() is the single-row terminal and owns beforeFind/afterFind.
|
|
948
|
+
const result = (await this.#doExec())[0] ?? null;
|
|
949
|
+
await fireHooks(this.#entityClass, "afterFind", result);
|
|
950
|
+
return result;
|
|
945
951
|
}
|
|
946
952
|
|
|
947
953
|
/** Execute and return the first matching entity or throw. */
|
|
@@ -1035,12 +1041,19 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1035
1041
|
*/
|
|
1036
1042
|
#cachedExec?: Promise<T[]>;
|
|
1037
1043
|
|
|
1038
|
-
/** Execute and return all matching entities, with preloaded relations. */
|
|
1044
|
+
/** Execute and return all matching entities, with preloaded relations. Fires beforeFetch/afterFetch. */
|
|
1039
1045
|
exec(): Promise<T[]> {
|
|
1040
|
-
this.#cachedExec ??= this.#
|
|
1046
|
+
this.#cachedExec ??= this.#execWithFetchHooks();
|
|
1041
1047
|
return this.#cachedExec;
|
|
1042
1048
|
}
|
|
1043
1049
|
|
|
1050
|
+
async #execWithFetchHooks(): Promise<T[]> {
|
|
1051
|
+
await fireHooks(this.#entityClass, "beforeFetch", this);
|
|
1052
|
+
const results = await this.#doExec();
|
|
1053
|
+
await fireHooks(this.#entityClass, "afterFetch", results);
|
|
1054
|
+
return results;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1044
1057
|
async #doExec(): Promise<T[]> {
|
|
1045
1058
|
const { sql, params } = this.toSQL();
|
|
1046
1059
|
const rawRows = await this.#db.query<Record<string, unknown>>(sql, params);
|
|
@@ -1099,12 +1112,28 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1099
1112
|
const relatedMeta = getEntityMetadata(relatedClass);
|
|
1100
1113
|
if (!relatedMeta) return null;
|
|
1101
1114
|
|
|
1115
|
+
// Resolve row keys against declared column metadata, NOT `in entity` —
|
|
1116
|
+
// entities using Adonis' `declare field: T` pattern have no own-properties
|
|
1117
|
+
// on a freshly constructed instance, so `key in entity` is always false and
|
|
1118
|
+
// every column would be silently dropped. Mirrors `BaseRepository.#hydrate`.
|
|
1119
|
+
const relatedPkName = getPrimaryKey(relatedClass) ?? "id";
|
|
1120
|
+
const validColumns = new Set<string>();
|
|
1121
|
+
for (const col of getColumnMetadata(relatedClass)) {
|
|
1122
|
+
validColumns.add(col.propertyKey);
|
|
1123
|
+
validColumns.add(camelToSnake(col.propertyKey));
|
|
1124
|
+
}
|
|
1125
|
+
validColumns.add(relatedPkName);
|
|
1126
|
+
validColumns.add(camelToSnake(relatedPkName));
|
|
1127
|
+
|
|
1102
1128
|
const hydrate = (row: Record<string, unknown>): BaseEntity => {
|
|
1103
1129
|
const entity = new relatedClass();
|
|
1104
1130
|
for (const [key, value] of Object.entries(row)) {
|
|
1105
1131
|
const camelKey = snakeToCamel(key);
|
|
1106
|
-
const targetKey =
|
|
1107
|
-
|
|
1132
|
+
const targetKey = validColumns.has(camelKey)
|
|
1133
|
+
? camelKey
|
|
1134
|
+
: validColumns.has(key)
|
|
1135
|
+
? key
|
|
1136
|
+
: null;
|
|
1108
1137
|
if (targetKey !== null) entity.setProp(targetKey, value);
|
|
1109
1138
|
}
|
|
1110
1139
|
return entity;
|
|
@@ -1351,12 +1380,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1351
1380
|
);
|
|
1352
1381
|
}
|
|
1353
1382
|
const pivot = ctx.relation.pivot;
|
|
1383
|
+
// Default pivot FK = `<model_snake>_id`, derived from the entity CLASS name
|
|
1384
|
+
// (singular by convention), consistent with hasMany/hasOne/belongsTo. Do NOT
|
|
1385
|
+
// singularize the plural TABLE name by stripping a trailing `s` — that breaks
|
|
1386
|
+
// on `status`/`address`/`campus` (→ `statu_id`). Explicit pivot keys win.
|
|
1354
1387
|
const foreignKey =
|
|
1355
|
-
pivot.foreignKey ??
|
|
1356
|
-
`${camelToSnake(this.#tableName.replace(/s$/, ""))}_id`;
|
|
1388
|
+
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1357
1389
|
const otherKey =
|
|
1358
|
-
pivot.otherKey ??
|
|
1359
|
-
`${camelToSnake(ctx.relatedTable.replace(/s$/, ""))}_id`;
|
|
1390
|
+
pivot.otherKey ?? `${camelToSnake(ctx.relatedClass.name)}_id`;
|
|
1360
1391
|
const pk = getPrimaryKey(this.#entityClass) ?? "id";
|
|
1361
1392
|
|
|
1362
1393
|
const ids = entities.map((e) => e[pk]).filter((v) => v != null);
|
|
@@ -1576,12 +1607,12 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1576
1607
|
);
|
|
1577
1608
|
}
|
|
1578
1609
|
const pivot = relation.pivot;
|
|
1610
|
+
// Default pivot FK from the CLASS name (singular), not the plural table
|
|
1611
|
+
// name stripped of a trailing `s` — see the eager loader above.
|
|
1579
1612
|
const foreignKey =
|
|
1580
|
-
pivot.foreignKey ??
|
|
1581
|
-
`${camelToSnake(parentTable.replace(/s$/, ""))}_id`;
|
|
1613
|
+
pivot.foreignKey ?? `${camelToSnake(this.#entityClass.name)}_id`;
|
|
1582
1614
|
const otherKey =
|
|
1583
|
-
pivot.otherKey ??
|
|
1584
|
-
`${camelToSnake(relatedTable.replace(/s$/, ""))}_id`;
|
|
1615
|
+
pivot.otherKey ?? `${camelToSnake(relatedClass.name)}_id`;
|
|
1585
1616
|
const relatedPk = getPrimaryKey(relatedClass) ?? "id";
|
|
1586
1617
|
sub.#pushWhereRaw(
|
|
1587
1618
|
`${q(relatedTable)}.${q(relatedPk)} IN ` +
|
|
@@ -1590,6 +1621,16 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1590
1621
|
);
|
|
1591
1622
|
break;
|
|
1592
1623
|
}
|
|
1624
|
+
default:
|
|
1625
|
+
// hasOneThrough / hasManyThrough build a 2-hop correlated subquery,
|
|
1626
|
+
// which isn't implemented here. Fail loud — falling through would
|
|
1627
|
+
// leave `sub` WITHOUT a join predicate, so whereHas/withCount would
|
|
1628
|
+
// silently match/count EVERY related row.
|
|
1629
|
+
throw new Error(
|
|
1630
|
+
`whereHas/withCount on a '${relation.type}' relation ` +
|
|
1631
|
+
`(${this.#entityClass.name}.${relationName}) is not supported yet. ` +
|
|
1632
|
+
`Use a direct hasMany/belongsTo/manyToMany relation, or filter via a sub-query.`,
|
|
1633
|
+
);
|
|
1593
1634
|
}
|
|
1594
1635
|
return sub;
|
|
1595
1636
|
}
|
|
@@ -1802,6 +1843,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1802
1843
|
async paginate(page: number, perPage: number): Promise<Paginator<T>> {
|
|
1803
1844
|
const p = Math.max(1, Math.floor(page));
|
|
1804
1845
|
const pp = Math.max(1, Math.floor(perPage));
|
|
1846
|
+
// beforePaginate runs BEFORE cloning so a hook mutating the query (e.g. a
|
|
1847
|
+
// tenant scope) propagates into both the COUNT and the data fetch.
|
|
1848
|
+
await fireHooks(this.#entityClass, "beforePaginate", this);
|
|
1805
1849
|
// Parallel COUNT(*) + data fetch
|
|
1806
1850
|
const countQ = this.clone();
|
|
1807
1851
|
countQ.#select = ["COUNT(*) AS count"];
|
|
@@ -1815,7 +1859,10 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1815
1859
|
const dataQ = this.clone();
|
|
1816
1860
|
dataQ.#limit = pp;
|
|
1817
1861
|
dataQ.#offset = (p - 1) * pp;
|
|
1818
|
-
|
|
1862
|
+
// `#doExec` (not `exec`) so the generic beforeFetch/afterFetch don't fire on
|
|
1863
|
+
// top of the paginate hooks — paginate is its own terminal.
|
|
1864
|
+
const items = await dataQ.#doExec();
|
|
1865
|
+
await fireHooks(this.#entityClass, "afterPaginate", items);
|
|
1819
1866
|
return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
|
|
1820
1867
|
}
|
|
1821
1868
|
|
|
@@ -1880,7 +1927,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
1880
1927
|
direction: "asc" as const,
|
|
1881
1928
|
}));
|
|
1882
1929
|
clone.#limit = lim + 1;
|
|
1883
|
-
|
|
1930
|
+
// `#doExec` (not `exec`) — cursorPaginate is an atlas-specific terminal, not a
|
|
1931
|
+
// Lucid hook point; don't fire the generic beforeFetch/afterFetch on its clone.
|
|
1932
|
+
const rows = await clone.#doExec();
|
|
1884
1933
|
const hasMore = rows.length > lim;
|
|
1885
1934
|
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
1886
1935
|
const last = items[items.length - 1] as Record<string, unknown> | undefined;
|
|
@@ -46,10 +46,27 @@ interface NapiModule {
|
|
|
46
46
|
min: number,
|
|
47
47
|
max: number,
|
|
48
48
|
pragmas?: Array<[string, string]>,
|
|
49
|
+
connectRetries?: number,
|
|
50
|
+
connectBackoffMs?: number,
|
|
51
|
+
connectTimeoutMs?: number,
|
|
49
52
|
): Promise<NapiReamDatabase>;
|
|
50
53
|
};
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/** Connection retry / timeout knobs (see {@link createNapiConnection}). */
|
|
57
|
+
export interface ConnectRetryOptions {
|
|
58
|
+
/** Extra attempts if the initial connect fails (default 0 — single attempt). */
|
|
59
|
+
retries?: number;
|
|
60
|
+
/** Base backoff in ms between attempts; grows exponentially, capped at 30s (default 200). */
|
|
61
|
+
backoffMs?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Per-attempt acquire timeout in ms (sqlx `acquire_timeout`). sqlx already
|
|
64
|
+
* retries connection establishment internally up to this window (~30s by
|
|
65
|
+
* default), so lower it to make each retry give up faster.
|
|
66
|
+
*/
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
/**
|
|
54
71
|
* Connect to a database via the Rust NAPI driver.
|
|
55
72
|
*
|
|
@@ -62,13 +79,10 @@ export async function createNapiConnection(
|
|
|
62
79
|
poolMin = 1,
|
|
63
80
|
poolMax = 10,
|
|
64
81
|
pragmas?: Record<string, string | number>,
|
|
82
|
+
retry?: ConnectRetryOptions,
|
|
65
83
|
): Promise<AsyncDatabaseConnection> {
|
|
84
|
+
// Throws with the underlying cause if the binary can't be loaded.
|
|
66
85
|
const native = await loadNativeDb();
|
|
67
|
-
if (!native) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
"[ATLAS] Rust DB driver (atlas-db-napi) not available. Build with: cargo build --release",
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
86
|
|
|
73
87
|
// Validate sqlite pragmas before crossing the NAPI boundary.
|
|
74
88
|
// PRAGMA syntax doesn't take bound parameters — the Rust side will
|
|
@@ -109,6 +123,9 @@ export async function createNapiConnection(
|
|
|
109
123
|
poolMin,
|
|
110
124
|
poolMax,
|
|
111
125
|
pragmaList,
|
|
126
|
+
retry?.retries,
|
|
127
|
+
retry?.backoffMs,
|
|
128
|
+
retry?.timeoutMs,
|
|
112
129
|
);
|
|
113
130
|
|
|
114
131
|
return {
|
|
@@ -149,7 +166,7 @@ export async function createNapiConnection(
|
|
|
149
166
|
// shared with AtlasProvider.
|
|
150
167
|
|
|
151
168
|
/** Load the native DB binding from the prebuilt `.node` binary in the package root. */
|
|
152
|
-
async function loadNativeDb(): Promise<NapiModule
|
|
169
|
+
async function loadNativeDb(): Promise<NapiModule> {
|
|
153
170
|
const platform = process.platform;
|
|
154
171
|
const arch = process.arch;
|
|
155
172
|
// Same naming convention as napi-rs / src/query/native.ts: the build emits
|
|
@@ -172,7 +189,14 @@ async function loadNativeDb(): Promise<NapiModule | null> {
|
|
|
172
189
|
// The binary lives at the package root (../../db.<suffix>.node from src/adapters/)
|
|
173
190
|
const binaryPath = join(here, "..", "..", binaryName);
|
|
174
191
|
return require(binaryPath) as NapiModule;
|
|
175
|
-
} catch {
|
|
176
|
-
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Surface the real cause (missing file, ABI mismatch, dlopen error) — a
|
|
194
|
+
// bare `return null` previously erased it and left the caller throwing a
|
|
195
|
+
// generic "not available" message that was impossible to debug.
|
|
196
|
+
throw new Error(
|
|
197
|
+
`[ATLAS] Failed to load Rust DB driver '${binaryName}' for ${platform}-${arch}. ` +
|
|
198
|
+
"Build with: cargo build --release",
|
|
199
|
+
{ cause: err },
|
|
200
|
+
);
|
|
177
201
|
}
|
|
178
202
|
}
|
package/src/configure.ts
CHANGED
|
@@ -11,24 +11,23 @@ interface Codemods {
|
|
|
11
11
|
export async function configure(codemods: Codemods): Promise<void> {
|
|
12
12
|
await codemods.addProvider("@c9up/atlas/provider");
|
|
13
13
|
await codemods.addEnvVars({
|
|
14
|
-
DB_CONNECTION: "postgres",
|
|
15
14
|
DB_HOST: "localhost",
|
|
16
15
|
DB_PORT: "5432",
|
|
17
16
|
DB_DATABASE: "ream",
|
|
18
17
|
DB_USER: "postgres",
|
|
19
|
-
DB_PASSWORD: "
|
|
18
|
+
DB_PASSWORD: "change-me",
|
|
20
19
|
});
|
|
21
20
|
await codemods.writeFile(
|
|
22
21
|
"config/database.ts",
|
|
23
22
|
`import { defineConfig } from '@c9up/atlas'
|
|
24
23
|
|
|
25
24
|
export default defineConfig({
|
|
26
|
-
|
|
25
|
+
default: 'postgres',
|
|
27
26
|
connections: {
|
|
28
27
|
postgres: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
url:
|
|
29
|
+
process.env.DATABASE_URL ??
|
|
30
|
+
\`postgres://\${process.env.DB_USER ?? 'postgres'}:\${process.env.DB_PASSWORD ?? ''}@\${process.env.DB_HOST ?? 'localhost'}:\${process.env.DB_PORT ?? '5432'}/\${process.env.DB_DATABASE ?? 'ream'}\`,
|
|
32
31
|
},
|
|
33
32
|
},
|
|
34
33
|
})
|
package/src/decorators/entity.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import "reflect-metadata";
|
|
8
8
|
|
|
9
9
|
export { SQLITE_PROD_PRAGMAS } from "./AtlasProvider.js";
|
|
10
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
AsyncDatabaseConnection,
|
|
12
|
+
ConnectRetryOptions,
|
|
13
|
+
} from "./adapters/NapiDbAdapter.js";
|
|
11
14
|
export { createNapiConnection } from "./adapters/NapiDbAdapter.js";
|
|
12
15
|
export type { DomainEvent } from "./BaseEntity.js";
|
|
13
16
|
export { BaseEntity } from "./BaseEntity.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared entity metadata keys + serialize config.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `BaseEntity` so the decorator layer (`decorators/entity.ts`)
|
|
5
|
+
* can read them without importing `BaseEntity` — that import-back formed a
|
|
6
|
+
* runtime cycle `BaseEntity` ↔ `entity` (fallow 2026-06-14). Agnostic: pure
|
|
7
|
+
* `Symbol.for` keys + a plain interface, zero runtime dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Symbol metadata key for the computed-property registry on an entity class. */
|
|
11
|
+
export const COMPUTED_KEY = Symbol.for("atlas:computed");
|
|
12
|
+
|
|
13
|
+
/** Symbol metadata key for the serialize-as / serializer overrides on columns. */
|
|
14
|
+
export const COLUMN_SERIALIZE_KEY = Symbol.for("atlas:columnSerialize");
|
|
15
|
+
|
|
16
|
+
/** Per-column serialization config (populated by @Column options). */
|
|
17
|
+
export interface ColumnSerializeConfig {
|
|
18
|
+
/** Rename this column at toJSON time (e.g. `password` → `passwordHash`). Null = hidden. */
|
|
19
|
+
serializeAs?: string | null;
|
|
20
|
+
/** Transform function applied to the value at toJSON time. */
|
|
21
|
+
serialize?: (value: unknown) => unknown;
|
|
22
|
+
}
|
package/src/query/native.ts
CHANGED
|
@@ -67,6 +67,16 @@ export interface CompiledStatement {
|
|
|
67
67
|
*/
|
|
68
68
|
const castRegistry = new Map<string, Record<string, string>>();
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Drop every registered cast. Called by `AtlasProvider.shutdown()` so a re-boot
|
|
72
|
+
* (tests, hot-reload) starts from a clean registry rather than accumulating casts
|
|
73
|
+
* across runs — two suites declaring the same table with different column types
|
|
74
|
+
* would otherwise pollute each other depending on execution order.
|
|
75
|
+
*/
|
|
76
|
+
export function clearCastRegistry(): void {
|
|
77
|
+
castRegistry.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
/**
|
|
71
81
|
* Register an entity table's cast map (MERGES — a FK cast another entity already
|
|
72
82
|
* published for this table via `registerColumnCast` survives, regardless of
|
|
@@ -143,7 +153,8 @@ export function compileStatementNative(
|
|
|
143
153
|
): CompiledStatement {
|
|
144
154
|
if (!native) {
|
|
145
155
|
throw new Error(
|
|
146
|
-
|
|
156
|
+
"[ATLAS_NAPI_NOT_FOUND] Rust query compiler not available. Build with: cargo build --release",
|
|
157
|
+
loadError !== undefined ? { cause: loadError } : undefined,
|
|
147
158
|
);
|
|
148
159
|
}
|
|
149
160
|
const json = native.compileStatement(
|
|
@@ -312,7 +312,10 @@ export class MigrationRunner {
|
|
|
312
312
|
table: this.#tableName,
|
|
313
313
|
select: ["name"],
|
|
314
314
|
wheres: [{ column: "batch", operator: "=", value: batch, type: "and" }],
|
|
315
|
-
|
|
315
|
+
// Reverse INSERTION order (auto-increment `id`), not name order — a
|
|
316
|
+
// date- or hash-prefixed naming scheme would otherwise roll back in
|
|
317
|
+
// the wrong sequence. `id DESC` is always the inverse of application.
|
|
318
|
+
orderBy: [{ column: "id", direction: "desc" }],
|
|
316
319
|
groupBy: [],
|
|
317
320
|
having: [],
|
|
318
321
|
limit: null,
|
|
@@ -499,7 +502,7 @@ export class MigrationRunner {
|
|
|
499
502
|
/** Load and instantiate a migration class. */
|
|
500
503
|
async #loadMigration(name: string): Promise<Migration> {
|
|
501
504
|
this.#assertSafeName(name);
|
|
502
|
-
assertPathInsideBase(
|
|
505
|
+
await assertPathInsideBase(
|
|
503
506
|
this.#migrationsDir,
|
|
504
507
|
`${name}.ts`,
|
|
505
508
|
"MIGRATION_INVALID",
|
package/src/schema/Seeder.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Seeder — populate the database with default / reference / test data.
|
|
3
3
|
*
|
|
4
|
-
* The canonical pattern:
|
|
4
|
+
* The canonical pattern (idempotent via `upsert`, keyed on a unique column):
|
|
5
5
|
*
|
|
6
6
|
* export default class CountrySeeder extends BaseSeeder {
|
|
7
7
|
* async run() {
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* const countries = new BaseRepository(Country, this.db)
|
|
9
|
+
* // Conflict on isoCode → update name. Safe to re-run.
|
|
10
|
+
* await countries.upsert(
|
|
11
|
+
* [
|
|
12
|
+
* { isoCode: 'FR', name: 'France' },
|
|
13
|
+
* { isoCode: 'IN', name: 'India' },
|
|
14
|
+
* ],
|
|
15
|
+
* ['isoCode'],
|
|
16
|
+
* ['name'],
|
|
17
|
+
* )
|
|
13
18
|
* }
|
|
14
19
|
* }
|
|
15
20
|
*
|
|
@@ -39,7 +44,7 @@ export abstract class BaseSeeder {
|
|
|
39
44
|
this.db = db;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/** The seeder body. Should be idempotent — `
|
|
47
|
+
/** The seeder body. Should be idempotent — `repo.upsert(...)` is the recommended pattern. */
|
|
43
48
|
abstract run(): Promise<void> | void;
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -51,10 +56,7 @@ export const Seeder = BaseSeeder;
|
|
|
51
56
|
* sequentially so ordering is deterministic and side effects are visible to
|
|
52
57
|
* subsequent seeders.
|
|
53
58
|
*/
|
|
54
|
-
export async function runSeeders(
|
|
55
|
-
seeders: BaseSeeder[],
|
|
56
|
-
_db?: DatabaseConnection,
|
|
57
|
-
): Promise<void> {
|
|
59
|
+
export async function runSeeders(seeders: BaseSeeder[]): Promise<void> {
|
|
58
60
|
for (const seeder of seeders) {
|
|
59
61
|
await seeder.run();
|
|
60
62
|
}
|
|
@@ -102,7 +104,7 @@ export async function runSeederDirectory(
|
|
|
102
104
|
const executed: string[] = [];
|
|
103
105
|
for (const file of selected) {
|
|
104
106
|
assertSafeName(file, "E_SEEDER_INVALID", "seeder");
|
|
105
|
-
const resolved = assertPathInsideBase(
|
|
107
|
+
const resolved = await assertPathInsideBase(
|
|
106
108
|
dir,
|
|
107
109
|
file,
|
|
108
110
|
"E_SEEDER_INVALID_PATH",
|
package/src/services/db.ts
CHANGED
|
@@ -22,6 +22,16 @@ export function setDb(connection: AsyncDatabaseConnection): void {
|
|
|
22
22
|
instance = connection;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @internal Unbind the singleton IF it still points at `connection` (called by
|
|
27
|
+
* `AtlasProvider.shutdown()`). Ownership-guarded: when a second provider rebound
|
|
28
|
+
* the singleton, the older provider's shutdown must not clear the newer binding.
|
|
29
|
+
* Without this, `db.*` after shutdown would dereference a closed connection.
|
|
30
|
+
*/
|
|
31
|
+
export function clearDb(connection: AsyncDatabaseConnection): void {
|
|
32
|
+
if (instance === connection) instance = undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
26
36
|
export function getDb(): AsyncDatabaseConnection | undefined {
|
|
27
37
|
return instance;
|