@c9up/atlas 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/db.win32-x64-msvc.node +0 -0
  2. package/dist/AtlasProvider.d.ts.map +1 -1
  3. package/dist/AtlasProvider.js +9 -1
  4. package/dist/AtlasProvider.js.map +1 -1
  5. package/dist/BaseEntity.d.ts +3 -12
  6. package/dist/BaseEntity.d.ts.map +1 -1
  7. package/dist/BaseEntity.js +20 -4
  8. package/dist/BaseEntity.js.map +1 -1
  9. package/dist/BaseRepository.d.ts.map +1 -1
  10. package/dist/BaseRepository.js +37 -89
  11. package/dist/BaseRepository.js.map +1 -1
  12. package/dist/ModelQuery.d.ts +2 -2
  13. package/dist/ModelQuery.d.ts.map +1 -1
  14. package/dist/ModelQuery.js +62 -17
  15. package/dist/ModelQuery.js.map +1 -1
  16. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  17. package/dist/adapters/NapiDbAdapter.js +7 -5
  18. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  19. package/dist/configure.d.ts.map +1 -1
  20. package/dist/configure.js +5 -6
  21. package/dist/configure.js.map +1 -1
  22. package/dist/decorators/entity.js +1 -1
  23. package/dist/decorators/entity.js.map +1 -1
  24. package/dist/metadata-keys.d.ts +20 -0
  25. package/dist/metadata-keys.d.ts.map +1 -0
  26. package/dist/metadata-keys.js +13 -0
  27. package/dist/metadata-keys.js.map +1 -0
  28. package/dist/query/native.d.ts +7 -0
  29. package/dist/query/native.d.ts.map +1 -1
  30. package/dist/query/native.js +10 -1
  31. package/dist/query/native.js.map +1 -1
  32. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  33. package/dist/schema/MigrationRunner.js +5 -2
  34. package/dist/schema/MigrationRunner.js.map +1 -1
  35. package/dist/schema/Seeder.d.ts +13 -8
  36. package/dist/schema/Seeder.d.ts.map +1 -1
  37. package/dist/schema/Seeder.js +13 -8
  38. package/dist/schema/Seeder.js.map +1 -1
  39. package/dist/services/db.d.ts +7 -0
  40. package/dist/services/db.d.ts.map +1 -1
  41. package/dist/services/db.js +10 -0
  42. package/dist/services/db.js.map +1 -1
  43. package/dist/testing/Factory.d.ts +6 -7
  44. package/dist/testing/Factory.d.ts.map +1 -1
  45. package/dist/testing/Factory.js +3 -5
  46. package/dist/testing/Factory.js.map +1 -1
  47. package/dist/utils/safePath.d.ts +7 -1
  48. package/dist/utils/safePath.d.ts.map +1 -1
  49. package/dist/utils/safePath.js +24 -1
  50. package/dist/utils/safePath.js.map +1 -1
  51. package/index.darwin-arm64.node +0 -0
  52. package/index.darwin-x64.node +0 -0
  53. package/index.linux-arm64-gnu.node +0 -0
  54. package/index.linux-x64-gnu.node +0 -0
  55. package/index.win32-x64-msvc.node +0 -0
  56. package/package.json +1 -1
  57. package/src/AtlasProvider.ts +9 -1
  58. package/src/BaseEntity.ts +27 -12
  59. package/src/BaseRepository.ts +38 -98
  60. package/src/ModelQuery.ts +66 -17
  61. package/src/adapters/NapiDbAdapter.ts +11 -8
  62. package/src/configure.ts +5 -6
  63. package/src/decorators/entity.ts +1 -1
  64. package/src/metadata-keys.ts +22 -0
  65. package/src/query/native.ts +12 -1
  66. package/src/schema/MigrationRunner.ts +5 -2
  67. package/src/schema/Seeder.ts +14 -12
  68. package/src/services/db.ts +10 -0
  69. package/src/testing/Factory.ts +9 -12
  70. package/src/utils/safePath.ts +27 -2
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
- const results = await this.exec();
944
- return results[0] ?? null;
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.#doExec();
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
- camelKey in entity ? camelKey : key in entity ? key : null;
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
- const items = await dataQ.exec();
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
- const rows = await clone.exec();
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;
@@ -63,12 +63,8 @@ export async function createNapiConnection(
63
63
  poolMax = 10,
64
64
  pragmas?: Record<string, string | number>,
65
65
  ): Promise<AsyncDatabaseConnection> {
66
+ // Throws with the underlying cause if the binary can't be loaded.
66
67
  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
68
 
73
69
  // Validate sqlite pragmas before crossing the NAPI boundary.
74
70
  // PRAGMA syntax doesn't take bound parameters — the Rust side will
@@ -149,7 +145,7 @@ export async function createNapiConnection(
149
145
  // shared with AtlasProvider.
150
146
 
151
147
  /** Load the native DB binding from the prebuilt `.node` binary in the package root. */
152
- async function loadNativeDb(): Promise<NapiModule | null> {
148
+ async function loadNativeDb(): Promise<NapiModule> {
153
149
  const platform = process.platform;
154
150
  const arch = process.arch;
155
151
  // Same naming convention as napi-rs / src/query/native.ts: the build emits
@@ -172,7 +168,14 @@ async function loadNativeDb(): Promise<NapiModule | null> {
172
168
  // The binary lives at the package root (../../db.<suffix>.node from src/adapters/)
173
169
  const binaryPath = join(here, "..", "..", binaryName);
174
170
  return require(binaryPath) as NapiModule;
175
- } catch {
176
- return null;
171
+ } catch (err) {
172
+ // Surface the real cause (missing file, ABI mismatch, dlopen error) — a
173
+ // bare `return null` previously erased it and left the caller throwing a
174
+ // generic "not available" message that was impossible to debug.
175
+ throw new Error(
176
+ `[ATLAS] Failed to load Rust DB driver '${binaryName}' for ${platform}-${arch}. ` +
177
+ "Build with: cargo build --release",
178
+ { cause: err },
179
+ );
177
180
  }
178
181
  }
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: "secret",
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
- connection: process.env.DB_CONNECTION ?? 'postgres',
25
+ default: 'postgres',
27
26
  connections: {
28
27
  postgres: {
29
- host: process.env.DB_HOST ?? 'localhost',
30
- port: Number(process.env.DB_PORT ?? '5432'),
31
- database: process.env.DB_DATABASE ?? 'ream',
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
  })
@@ -9,7 +9,7 @@ import {
9
9
  COLUMN_SERIALIZE_KEY,
10
10
  COMPUTED_KEY,
11
11
  type ColumnSerializeConfig,
12
- } from "../BaseEntity.js";
12
+ } from "../metadata-keys.js";
13
13
 
14
14
  const ENTITY_KEY = Symbol("atlas:entity");
15
15
  const COLUMNS_KEY = Symbol("atlas:columns");
@@ -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
+ }
@@ -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
- `[ATLAS_NAPI_NOT_FOUND] Rust query compiler not available: ${loadError ?? "binary not found"}`,
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
- orderBy: [{ column: "name", direction: "desc" }],
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",
@@ -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
- * // Idempotent via updateOrCreateMany safe to re-run.
9
- * await Country.updateOrCreateMany('isoCode', [
10
- * { isoCode: 'FR', name: 'France' },
11
- * { isoCode: 'IN', name: 'India' },
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 — `updateOrCreateMany` is the recommended pattern. */
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",
@@ -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;
@@ -26,16 +26,15 @@ export interface FactoryBuilder<T extends BaseEntity> {
26
26
  /** Override specific fields for the next call (reset after consumption). */
27
27
  merge(overrides: Partial<Record<string, unknown>>): FactoryBuilder<T>;
28
28
 
29
- /**
30
- * Declare a named variation of this factory. States are stored on the
31
- * factory itself and don't mutate the caller — `apply()` returns a child
32
- * builder with the state active.
33
- */
29
+ /** Declare a named variation of this factory, stored on the factory's state map. */
34
30
  state(name: string, fn: StateFn<Record<string, unknown>>): FactoryBuilder<T>;
35
31
 
36
32
  /**
37
- * Activate one or more declared states for the next call. Multiple applies
38
- * compose (all applied states fire, in order).
33
+ * Activate one or more declared states for the NEXT build. Multiple applies
34
+ * compose (all fire, in order). NOTE: this mutates the builder's shared
35
+ * pending state and returns the SAME builder for chaining — there is no
36
+ * isolated child builder. The pending set is reset after each make/create
37
+ * (audit 2026-06-13). `merge()` behaves the same way.
39
38
  */
40
39
  apply(...stateNames: string[]): FactoryBuilder<T>;
41
40
 
@@ -120,13 +119,11 @@ export function factory<T extends BaseEntity>(
120
119
  },
121
120
 
122
121
  makeMany(count) {
123
- // Re-evaluate defaults for each row so `Date.now()` / faker generate distinct values.
122
+ // Re-evaluate defaults for each row so `Date.now()` / faker generate
123
+ // distinct values. `buildData()` reads (never mutates) the pending
124
+ // overrides/states, so they stay stable across iterations on their own.
124
125
  const rows: Record<string, unknown>[] = [];
125
- const capturedOverrides = pendingOverrides;
126
- const capturedStates = pendingStates;
127
126
  for (let i = 0; i < count; i++) {
128
- pendingOverrides = capturedOverrides;
129
- pendingStates = capturedStates;
130
127
  rows.push(buildData());
131
128
  }
132
129
  resetPending();
@@ -40,13 +40,19 @@ export function assertSafeName(
40
40
  * Resolve `fileName` inside `baseDir` and throw if the resulting path escapes
41
41
  * the base — guards against symlink / `../` traversal attacks when loading
42
42
  * migration or seeder files dynamically.
43
+ *
44
+ * Two layers: (1) a logical `path.resolve` check that catches `../` walks, and
45
+ * (2) a `realpath` check that follows symlinks — a symlink FILE sitting inside
46
+ * the base but pointing at `/etc/passwd` passes the logical check yet is caught
47
+ * here. When the file doesn't exist yet (ENOENT), only the logical check
48
+ * applies and the caller's own existence check produces the not-found error.
43
49
  */
44
- export function assertPathInsideBase(
50
+ export async function assertPathInsideBase(
45
51
  baseDir: string,
46
52
  fileName: string,
47
53
  errorCode: string,
48
54
  kind: string,
49
- ): string {
55
+ ): Promise<string> {
50
56
  const resolved = path.resolve(baseDir, fileName);
51
57
  const base = path.resolve(baseDir);
52
58
  if (!resolved.startsWith(base + path.sep) && resolved !== base) {
@@ -55,5 +61,24 @@ export function assertPathInsideBase(
55
61
  `${kind} path escapes directory: ${fileName}`,
56
62
  );
57
63
  }
64
+ // Symlink-aware check: resolve real targets and re-verify containment.
65
+ try {
66
+ const realBase = await fsp.realpath(base);
67
+ const realResolved = await fsp.realpath(resolved);
68
+ if (
69
+ !realResolved.startsWith(realBase + path.sep) &&
70
+ realResolved !== realBase
71
+ ) {
72
+ throw new AtlasError(
73
+ errorCode,
74
+ `${kind} path escapes directory via symlink: ${fileName}`,
75
+ );
76
+ }
77
+ } catch (err) {
78
+ // File not yet created — the logical check above stands; let the caller's
79
+ // existence check report the missing file. Re-throw real traversal errors.
80
+ if (err instanceof AtlasError) throw err;
81
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
82
+ }
58
83
  return resolved;
59
84
  }