@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.
- 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.map +1 -1
- package/dist/AtlasProvider.js +9 -1
- 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 +122 -110
- 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.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +7 -5
- 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.d.ts +1 -1
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +11 -3
- package/dist/decorators/entity.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 +19 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +77 -2
- 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 +9 -1
- package/src/BaseEntity.ts +27 -12
- package/src/BaseRepository.ts +125 -118
- package/src/ModelQuery.ts +66 -17
- package/src/adapters/NapiDbAdapter.ts +11 -8
- package/src/configure.ts +5 -6
- package/src/decorators/entity.ts +12 -4
- package/src/metadata-keys.ts +22 -0
- package/src/query/native.ts +93 -2
- 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/query/native.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/src/testing/Factory.ts
CHANGED
|
@@ -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
|
|
38
|
-
* compose (all
|
|
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
|
|
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();
|
package/src/utils/safePath.ts
CHANGED
|
@@ -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
|
}
|