@c9up/atlas 0.1.3
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/LICENSE +21 -0
- package/README.md +35 -0
- 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/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 +69 -0
- package/scripts/copy-napi.mjs +86 -0
- package/src/AtlasProvider.ts +297 -0
- package/src/BaseEntity.ts +585 -0
- package/src/BaseRepository.ts +1694 -0
- package/src/ModelQuery.ts +2293 -0
- package/src/Transaction.ts +83 -0
- package/src/adapters/NapiDbAdapter.ts +178 -0
- package/src/config.ts +7 -0
- package/src/configure.ts +37 -0
- package/src/decorators/entity.ts +532 -0
- package/src/decorators/hooks.ts +169 -0
- package/src/decorators/scope.ts +44 -0
- package/src/errors.ts +111 -0
- package/src/index.ts +114 -0
- package/src/naming/NamingStrategy.ts +106 -0
- package/src/query/QueryBuilder.ts +422 -0
- package/src/query/native.ts +74 -0
- package/src/schema/Migration.ts +81 -0
- package/src/schema/MigrationRunner.ts +532 -0
- package/src/schema/Schema.ts +78 -0
- package/src/schema/SchemaBuilder.ts +14 -0
- package/src/schema/Seeder.ts +132 -0
- package/src/schema/TableBuilder.ts +238 -0
- package/src/schema/types.ts +51 -0
- package/src/services/db.ts +45 -0
- package/src/testing/DatabaseCleanup.ts +49 -0
- package/src/testing/Factory.ts +164 -0
- package/src/testing/TestDatabase.ts +81 -0
- package/src/testing/index.ts +3 -0
- package/src/utils/casing.ts +11 -0
- package/src/utils/dialectFromUrl.ts +16 -0
- package/src/utils/identifier.ts +35 -0
- package/src/utils/safePath.ts +59 -0
- package/src/utils/transactionBrand.ts +10 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction — wraps database operations in BEGIN/COMMIT/ROLLBACK.
|
|
3
|
+
*
|
|
4
|
+
* @implements MISS-1
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import type { DatabaseConnection } from "./BaseRepository.js";
|
|
9
|
+
import {
|
|
10
|
+
isTransactionClient,
|
|
11
|
+
TRANSACTION_BRAND,
|
|
12
|
+
} from "./utils/transactionBrand.js";
|
|
13
|
+
|
|
14
|
+
export interface TransactionClient extends DatabaseConnection {
|
|
15
|
+
commit(): Promise<void>;
|
|
16
|
+
rollback(): Promise<void>;
|
|
17
|
+
readonly isNested: boolean;
|
|
18
|
+
readonly [TRANSACTION_BRAND]: true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function transaction<T>(
|
|
22
|
+
db: DatabaseConnection,
|
|
23
|
+
callback: (trx: TransactionClient) => Promise<T> | T,
|
|
24
|
+
): Promise<T> {
|
|
25
|
+
if (isTransactionClient(db)) {
|
|
26
|
+
const name = `sp_${randomBytes(6).toString("hex")}`;
|
|
27
|
+
await db.execute(`SAVEPOINT ${name}`, []);
|
|
28
|
+
|
|
29
|
+
const trx: TransactionClient = {
|
|
30
|
+
execute: db.execute.bind(db),
|
|
31
|
+
query: db.query.bind(db),
|
|
32
|
+
async commit() {
|
|
33
|
+
await db.execute(`RELEASE SAVEPOINT ${name}`, []);
|
|
34
|
+
},
|
|
35
|
+
async rollback() {
|
|
36
|
+
await db.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
|
|
37
|
+
},
|
|
38
|
+
isNested: true,
|
|
39
|
+
[TRANSACTION_BRAND]: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await callback(trx);
|
|
44
|
+
await trx.commit();
|
|
45
|
+
return result;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
try {
|
|
48
|
+
await trx.rollback();
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort */
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await db.execute("BEGIN", []);
|
|
57
|
+
|
|
58
|
+
const trx: TransactionClient = {
|
|
59
|
+
execute: db.execute.bind(db),
|
|
60
|
+
query: db.query.bind(db),
|
|
61
|
+
async commit() {
|
|
62
|
+
await db.execute("COMMIT", []);
|
|
63
|
+
},
|
|
64
|
+
async rollback() {
|
|
65
|
+
await db.execute("ROLLBACK", []);
|
|
66
|
+
},
|
|
67
|
+
isNested: false,
|
|
68
|
+
[TRANSACTION_BRAND]: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const result = await callback(trx);
|
|
73
|
+
await trx.commit();
|
|
74
|
+
return result;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
await trx.rollback();
|
|
78
|
+
} catch {
|
|
79
|
+
/* best-effort */
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NapiDbAdapter — bridges the Rust atlas-db NAPI binding to Atlas.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dialectFromUrl } from "../utils/dialectFromUrl.js";
|
|
6
|
+
|
|
7
|
+
/** One `(sql, params)` pair passed to `runInTransaction`. */
|
|
8
|
+
export interface BatchStatement {
|
|
9
|
+
sql: string;
|
|
10
|
+
params?: unknown[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Async database connection backed by Rust (sqlx). */
|
|
14
|
+
export interface AsyncDatabaseConnection {
|
|
15
|
+
/** The dialect this connection targets — derived from the URL scheme at connect time. */
|
|
16
|
+
readonly dialect: "sqlite" | "postgres" | "mysql";
|
|
17
|
+
query<T = Record<string, unknown>>(
|
|
18
|
+
sql: string,
|
|
19
|
+
params?: unknown[],
|
|
20
|
+
): Promise<T[]>;
|
|
21
|
+
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
|
|
22
|
+
/**
|
|
23
|
+
* Run every statement in `batch` atomically inside a single sqlx transaction.
|
|
24
|
+
* Either every statement commits or none do — used by MigrationRunner to
|
|
25
|
+
* wrap `up()` / `down()` SQL together with the `_migrations` bookkeeping.
|
|
26
|
+
*/
|
|
27
|
+
runInTransaction(batch: readonly BatchStatement[]): Promise<number>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
ping(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Shape of the NAPI ReamDatabase class. */
|
|
33
|
+
interface NapiReamDatabase {
|
|
34
|
+
query(sql: string, paramsJson: string): Promise<string>;
|
|
35
|
+
execute(sql: string, paramsJson: string): Promise<number>;
|
|
36
|
+
runInTransaction(batchJson: string): Promise<number>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
ping(): Promise<void>;
|
|
39
|
+
poolSize(): number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface NapiModule {
|
|
43
|
+
ReamDatabase: {
|
|
44
|
+
connect(
|
|
45
|
+
url: string,
|
|
46
|
+
min: number,
|
|
47
|
+
max: number,
|
|
48
|
+
pragmas?: Array<[string, string]>,
|
|
49
|
+
): Promise<NapiReamDatabase>;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Connect to a database via the Rust NAPI driver.
|
|
55
|
+
*
|
|
56
|
+
* const db = await createNapiConnection('sqlite:data/app.db')
|
|
57
|
+
* const db = await createNapiConnection('postgres://user:pass@host/db')
|
|
58
|
+
* const db = await createNapiConnection('mysql://user:pass@host/db')
|
|
59
|
+
*/
|
|
60
|
+
export async function createNapiConnection(
|
|
61
|
+
url: string,
|
|
62
|
+
poolMin = 1,
|
|
63
|
+
poolMax = 10,
|
|
64
|
+
pragmas?: Record<string, string | number>,
|
|
65
|
+
): Promise<AsyncDatabaseConnection> {
|
|
66
|
+
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
|
+
|
|
73
|
+
// Validate sqlite pragmas before crossing the NAPI boundary.
|
|
74
|
+
// PRAGMA syntax doesn't take bound parameters — the Rust side will
|
|
75
|
+
// interpolate the (key, value) pair into the statement, so the
|
|
76
|
+
// alphabet is locked to `[A-Za-z0-9_]+` on both sides. Failing
|
|
77
|
+
// here gives a clearer error than a Rust panic in `after_connect`.
|
|
78
|
+
const dialect = dialectFromUrl(url);
|
|
79
|
+
if (dialect === "sqlite" && pragmas) {
|
|
80
|
+
const safe = /^[A-Za-z0-9_]+$/;
|
|
81
|
+
for (const [key, value] of Object.entries(pragmas)) {
|
|
82
|
+
if (!safe.test(key)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[ATLAS] Invalid sqlite pragma key: ${JSON.stringify(key)}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!safe.test(String(value))) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[ATLAS] Invalid sqlite pragma value for '${key}': ${JSON.stringify(value)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Pragmas are pushed THROUGH the NAPI boundary so the Rust side can
|
|
95
|
+
// wire them into `SqliteConnectOptions::pragma()`. That's the only
|
|
96
|
+
// path that guarantees every connection sqlx opens for the pool
|
|
97
|
+
// starts in the requested journal_mode / synchronous state — running
|
|
98
|
+
// PRAGMA from the query path can silently no-op when the first
|
|
99
|
+
// pooled connection already claimed the journal in another mode.
|
|
100
|
+
const pragmaList: Array<[string, string]> | undefined =
|
|
101
|
+
dialect === "sqlite" && pragmas
|
|
102
|
+
? Object.entries(pragmas).map(
|
|
103
|
+
([k, v]) => [k, String(v)] as [string, string],
|
|
104
|
+
)
|
|
105
|
+
: undefined;
|
|
106
|
+
|
|
107
|
+
const db = await native.ReamDatabase.connect(
|
|
108
|
+
url,
|
|
109
|
+
poolMin,
|
|
110
|
+
poolMax,
|
|
111
|
+
pragmaList,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
dialect,
|
|
116
|
+
async query<T = Record<string, unknown>>(
|
|
117
|
+
sql: string,
|
|
118
|
+
params: unknown[] = [],
|
|
119
|
+
): Promise<T[]> {
|
|
120
|
+
const json = await db.query(sql, JSON.stringify(params));
|
|
121
|
+
return JSON.parse(json) as T[];
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async execute(
|
|
125
|
+
sql: string,
|
|
126
|
+
params: unknown[] = [],
|
|
127
|
+
): Promise<{ rowsAffected: number }> {
|
|
128
|
+
const affected = await db.execute(sql, JSON.stringify(params));
|
|
129
|
+
return { rowsAffected: affected };
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async runInTransaction(batch: readonly BatchStatement[]): Promise<number> {
|
|
133
|
+
// Rust side expects `[[sql, params], ...]`
|
|
134
|
+
const payload = batch.map((s) => [s.sql, s.params ?? []]);
|
|
135
|
+
return db.runInTransaction(JSON.stringify(payload));
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async close(): Promise<void> {
|
|
139
|
+
await db.close();
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async ping(): Promise<void> {
|
|
143
|
+
await db.ping();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// dialectFromUrl moved to ../utils/dialectFromUrl.ts — single source of truth
|
|
149
|
+
// shared with AtlasProvider.
|
|
150
|
+
|
|
151
|
+
/** Load the native DB binding from the prebuilt `.node` binary in the package root. */
|
|
152
|
+
async function loadNativeDb(): Promise<NapiModule | null> {
|
|
153
|
+
const platform = process.platform;
|
|
154
|
+
const arch = process.arch;
|
|
155
|
+
// Same naming convention as napi-rs / src/query/native.ts: the build emits
|
|
156
|
+
// `db.win32-x64-msvc.node` and `db.linux-x64-gnu.node`, so win32 needs the
|
|
157
|
+
// `-msvc` ABI tag and linux the `-gnu` one — a bare `win32-x64` misses the file.
|
|
158
|
+
const suffix =
|
|
159
|
+
platform === "linux"
|
|
160
|
+
? `${platform}-${arch}-gnu`
|
|
161
|
+
: platform === "win32"
|
|
162
|
+
? `${platform}-${arch}-msvc`
|
|
163
|
+
: `${platform}-${arch}`;
|
|
164
|
+
const binaryName = `db.${suffix}.node`;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const { createRequire } = await import("node:module");
|
|
168
|
+
const { fileURLToPath } = await import("node:url");
|
|
169
|
+
const { dirname, join } = await import("node:path");
|
|
170
|
+
const require = createRequire(import.meta.url);
|
|
171
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
172
|
+
// The binary lives at the package root (../../db.<suffix>.node from src/adapters/)
|
|
173
|
+
const binaryPath = join(here, "..", "..", binaryName);
|
|
174
|
+
return require(binaryPath) as NapiModule;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/config.ts
ADDED
package/src/configure.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface Codemods {
|
|
2
|
+
addProvider(importPath: string): Promise<void>;
|
|
3
|
+
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
4
|
+
writeFile(
|
|
5
|
+
filePath: string,
|
|
6
|
+
content: string,
|
|
7
|
+
options?: { force?: boolean },
|
|
8
|
+
): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function configure(codemods: Codemods): Promise<void> {
|
|
12
|
+
await codemods.addProvider("@c9up/atlas/provider");
|
|
13
|
+
await codemods.addEnvVars({
|
|
14
|
+
DB_CONNECTION: "postgres",
|
|
15
|
+
DB_HOST: "localhost",
|
|
16
|
+
DB_PORT: "5432",
|
|
17
|
+
DB_DATABASE: "ream",
|
|
18
|
+
DB_USER: "postgres",
|
|
19
|
+
DB_PASSWORD: "secret",
|
|
20
|
+
});
|
|
21
|
+
await codemods.writeFile(
|
|
22
|
+
"config/database.ts",
|
|
23
|
+
`import { defineConfig } from '@c9up/atlas'
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
connection: process.env.DB_CONNECTION ?? 'postgres',
|
|
27
|
+
connections: {
|
|
28
|
+
postgres: {
|
|
29
|
+
host: process.env.DB_HOST ?? 'localhost',
|
|
30
|
+
port: Number(process.env.DB_PORT ?? '5432'),
|
|
31
|
+
database: process.env.DB_DATABASE ?? 'ream',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
`,
|
|
36
|
+
);
|
|
37
|
+
}
|