@c9up/atlas 0.1.8 → 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 (76) hide show
  1. package/db.darwin-arm64.node +0 -0
  2. package/db.darwin-x64.node +0 -0
  3. package/db.linux-arm64-gnu.node +0 -0
  4. package/db.linux-x64-gnu.node +0 -0
  5. package/db.win32-x64-msvc.node +0 -0
  6. package/dist/AtlasProvider.d.ts.map +1 -1
  7. package/dist/AtlasProvider.js +9 -1
  8. package/dist/AtlasProvider.js.map +1 -1
  9. package/dist/BaseEntity.d.ts +3 -12
  10. package/dist/BaseEntity.d.ts.map +1 -1
  11. package/dist/BaseEntity.js +20 -4
  12. package/dist/BaseEntity.js.map +1 -1
  13. package/dist/BaseRepository.d.ts.map +1 -1
  14. package/dist/BaseRepository.js +122 -110
  15. package/dist/BaseRepository.js.map +1 -1
  16. package/dist/ModelQuery.d.ts +2 -2
  17. package/dist/ModelQuery.d.ts.map +1 -1
  18. package/dist/ModelQuery.js +62 -17
  19. package/dist/ModelQuery.js.map +1 -1
  20. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  21. package/dist/adapters/NapiDbAdapter.js +7 -5
  22. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  23. package/dist/configure.d.ts.map +1 -1
  24. package/dist/configure.js +5 -6
  25. package/dist/configure.js.map +1 -1
  26. package/dist/decorators/entity.d.ts +1 -1
  27. package/dist/decorators/entity.d.ts.map +1 -1
  28. package/dist/decorators/entity.js +11 -3
  29. package/dist/decorators/entity.js.map +1 -1
  30. package/dist/metadata-keys.d.ts +20 -0
  31. package/dist/metadata-keys.d.ts.map +1 -0
  32. package/dist/metadata-keys.js +13 -0
  33. package/dist/metadata-keys.js.map +1 -0
  34. package/dist/query/native.d.ts +19 -0
  35. package/dist/query/native.d.ts.map +1 -1
  36. package/dist/query/native.js +77 -2
  37. package/dist/query/native.js.map +1 -1
  38. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  39. package/dist/schema/MigrationRunner.js +5 -2
  40. package/dist/schema/MigrationRunner.js.map +1 -1
  41. package/dist/schema/Seeder.d.ts +13 -8
  42. package/dist/schema/Seeder.d.ts.map +1 -1
  43. package/dist/schema/Seeder.js +13 -8
  44. package/dist/schema/Seeder.js.map +1 -1
  45. package/dist/services/db.d.ts +7 -0
  46. package/dist/services/db.d.ts.map +1 -1
  47. package/dist/services/db.js +10 -0
  48. package/dist/services/db.js.map +1 -1
  49. package/dist/testing/Factory.d.ts +6 -7
  50. package/dist/testing/Factory.d.ts.map +1 -1
  51. package/dist/testing/Factory.js +3 -5
  52. package/dist/testing/Factory.js.map +1 -1
  53. package/dist/utils/safePath.d.ts +7 -1
  54. package/dist/utils/safePath.d.ts.map +1 -1
  55. package/dist/utils/safePath.js +24 -1
  56. package/dist/utils/safePath.js.map +1 -1
  57. package/index.darwin-arm64.node +0 -0
  58. package/index.darwin-x64.node +0 -0
  59. package/index.linux-arm64-gnu.node +0 -0
  60. package/index.linux-x64-gnu.node +0 -0
  61. package/index.win32-x64-msvc.node +0 -0
  62. package/package.json +1 -1
  63. package/src/AtlasProvider.ts +9 -1
  64. package/src/BaseEntity.ts +27 -12
  65. package/src/BaseRepository.ts +125 -118
  66. package/src/ModelQuery.ts +66 -17
  67. package/src/adapters/NapiDbAdapter.ts +11 -8
  68. package/src/configure.ts +5 -6
  69. package/src/decorators/entity.ts +12 -4
  70. package/src/metadata-keys.ts +22 -0
  71. package/src/query/native.ts +93 -2
  72. package/src/schema/MigrationRunner.ts +5 -2
  73. package/src/schema/Seeder.ts +14 -12
  74. package/src/services/db.ts +10 -0
  75. package/src/testing/Factory.ts +9 -12
  76. package/src/utils/safePath.ts +27 -2
@@ -56,6 +56,93 @@ export interface CompiledStatement {
56
56
  params: unknown[];
57
57
  }
58
58
 
59
+ /**
60
+ * Per-table Postgres cast hints (snake column → logical type, e.g. `uuid`).
61
+ * Populated once per entity by `BaseRepository` and consulted at the single
62
+ * compile chokepoint below, so EVERY statement built anywhere (the fluent
63
+ * `ModelQuery`, relation loaders, direct repo methods) gets `$N::uuid` casts
64
+ * on its WHERE/SET/value params without threading the cast map through every
65
+ * call site. Postgres-only at the SQL level; the Rust compiler emits casts
66
+ * only on the postgres dialect.
67
+ */
68
+ const castRegistry = new Map<string, Record<string, string>>();
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
+
80
+ /**
81
+ * Register an entity table's cast map (MERGES — a FK cast another entity already
82
+ * published for this table via `registerColumnCast` survives, regardless of
83
+ * which repository is constructed first).
84
+ */
85
+ export function registerTableCasts(
86
+ table: string,
87
+ casts: Record<string, string>,
88
+ ): void {
89
+ if (Object.keys(casts).length === 0) return;
90
+ const existing = castRegistry.get(table);
91
+ castRegistry.set(table, existing ? { ...existing, ...casts } : { ...casts });
92
+ }
93
+
94
+ /**
95
+ * Register a single column's cast on a table. Used by `BaseRepository` to publish
96
+ * relation FK column types (a FK references a typed PK) onto the OTHER table, so
97
+ * eager/lazy relation WHEREs on an untyped uuid FK still get `::uuid`.
98
+ */
99
+ export function registerColumnCast(
100
+ table: string,
101
+ column: string,
102
+ type: string,
103
+ ): void {
104
+ const existing = castRegistry.get(table);
105
+ if (existing) existing[column] = type;
106
+ else castRegistry.set(table, { [column]: type });
107
+ }
108
+
109
+ const CAST_BEARING_KINDS = new Set([
110
+ "select",
111
+ "insert",
112
+ "update",
113
+ "delete",
114
+ "upsert",
115
+ ]);
116
+
117
+ /**
118
+ * Inject the registered cast map for a statement's table. A spec that already
119
+ * carries explicit `casts` for a NON-registered table (e.g. an m2m pivot insert
120
+ * with bespoke `pivotCasts`) is left untouched — only entity tables are in the
121
+ * registry, and their explicit casts are identical to the registered map.
122
+ */
123
+ function isStringRecord(v: unknown): v is Record<string, string> {
124
+ return v !== null && typeof v === "object" && !Array.isArray(v);
125
+ }
126
+
127
+ function withRegistryCasts(spec: object): object {
128
+ if (!("table" in spec) || !("kind" in spec)) return spec;
129
+ const { table, kind } = spec;
130
+ if (
131
+ typeof table !== "string" ||
132
+ typeof kind !== "string" ||
133
+ !CAST_BEARING_KINDS.has(kind)
134
+ ) {
135
+ return spec;
136
+ }
137
+ const registered = castRegistry.get(table);
138
+ if (!registered) return spec;
139
+ // Registered casts are the base; any explicit per-statement casts (e.g. a
140
+ // relation loader hinting a FK column the related entity didn't type) win.
141
+ const explicit =
142
+ "casts" in spec && isStringRecord(spec.casts) ? spec.casts : {};
143
+ return { ...spec, casts: { ...registered, ...explicit } };
144
+ }
145
+
59
146
  /**
60
147
  * Compile any statement (SELECT/INSERT/UPDATE/DELETE/DDL) via the Rust compiler.
61
148
  * `spec` is a tagged object: `{ kind: 'select' | 'insert' | ..., ... }`.
@@ -66,9 +153,13 @@ export function compileStatementNative(
66
153
  ): CompiledStatement {
67
154
  if (!native) {
68
155
  throw new Error(
69
- `[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,
70
158
  );
71
159
  }
72
- const json = native.compileStatement(JSON.stringify(spec), dialect);
160
+ const json = native.compileStatement(
161
+ JSON.stringify(withRegistryCasts(spec)),
162
+ dialect,
163
+ );
73
164
  return JSON.parse(json);
74
165
  }
@@ -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
  }