@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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeder — populate the database with default / reference / test data.
|
|
3
|
+
*
|
|
4
|
+
* The canonical pattern:
|
|
5
|
+
*
|
|
6
|
+
* export default class CountrySeeder extends BaseSeeder {
|
|
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
|
+
* ])
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* @implements MISS-4, Story 32.12
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fsp from "node:fs/promises";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import type { DatabaseConnection } from "../BaseRepository.js";
|
|
22
|
+
import { AtlasError } from "../errors.js";
|
|
23
|
+
import {
|
|
24
|
+
assertPathInsideBase,
|
|
25
|
+
assertSafeName,
|
|
26
|
+
pathExists,
|
|
27
|
+
} from "../utils/safePath.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Base class for all seeders.
|
|
31
|
+
*
|
|
32
|
+
* Subclasses receive the database connection via the constructor so they can
|
|
33
|
+
* build repositories inside `run()` without resorting to globals.
|
|
34
|
+
*/
|
|
35
|
+
export abstract class BaseSeeder {
|
|
36
|
+
protected db: DatabaseConnection;
|
|
37
|
+
|
|
38
|
+
constructor(db: DatabaseConnection) {
|
|
39
|
+
this.db = db;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The seeder body. Should be idempotent — `updateOrCreateMany` is the recommended pattern. */
|
|
43
|
+
abstract run(): Promise<void> | void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Legacy alias kept for code using the previous API. */
|
|
47
|
+
export const Seeder = BaseSeeder;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run a pre-built list of seeder INSTANCES in order. Each `run()` is awaited
|
|
51
|
+
* sequentially so ordering is deterministic and side effects are visible to
|
|
52
|
+
* subsequent seeders.
|
|
53
|
+
*/
|
|
54
|
+
export async function runSeeders(
|
|
55
|
+
seeders: BaseSeeder[],
|
|
56
|
+
_db?: DatabaseConnection,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
for (const seeder of seeders) {
|
|
59
|
+
await seeder.run();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Discover seeder files under `dir`, import them in alphabetical order, build
|
|
65
|
+
* instances with the given DB connection, and run them sequentially.
|
|
66
|
+
*
|
|
67
|
+
* Usage: `await runSeederDirectory('./database/seeders', db)`
|
|
68
|
+
*
|
|
69
|
+
* @param dir Directory containing `.ts` / `.js` seeder files
|
|
70
|
+
* @param db Database connection passed to each seeder constructor
|
|
71
|
+
* @param options Optional filter (`files: ['CountrySeeder']`) to run a subset
|
|
72
|
+
*
|
|
73
|
+
* @implements Story 32.12
|
|
74
|
+
*/
|
|
75
|
+
export async function runSeederDirectory(
|
|
76
|
+
dir: string,
|
|
77
|
+
db: DatabaseConnection,
|
|
78
|
+
options?: { files?: readonly string[] },
|
|
79
|
+
): Promise<string[]> {
|
|
80
|
+
if (!(await pathExists(dir))) {
|
|
81
|
+
throw new AtlasError(
|
|
82
|
+
"E_SEEDER_DIR_NOT_FOUND",
|
|
83
|
+
`Seeder directory not found: ${dir}`,
|
|
84
|
+
{
|
|
85
|
+
hint: "Create the directory or adjust the path passed to runSeederDirectory.",
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const allFiles = (await fsp.readdir(dir))
|
|
91
|
+
.filter(
|
|
92
|
+
(f) => (f.endsWith(".ts") || f.endsWith(".js")) && !f.endsWith(".d.ts"),
|
|
93
|
+
)
|
|
94
|
+
.sort();
|
|
95
|
+
|
|
96
|
+
const selected = options?.files
|
|
97
|
+
? allFiles.filter((f) =>
|
|
98
|
+
options.files?.includes(f.replace(/\.(ts|js)$/, "")),
|
|
99
|
+
)
|
|
100
|
+
: allFiles;
|
|
101
|
+
|
|
102
|
+
const executed: string[] = [];
|
|
103
|
+
for (const file of selected) {
|
|
104
|
+
assertSafeName(file, "E_SEEDER_INVALID", "seeder");
|
|
105
|
+
const resolved = assertPathInsideBase(
|
|
106
|
+
dir,
|
|
107
|
+
file,
|
|
108
|
+
"E_SEEDER_INVALID_PATH",
|
|
109
|
+
"Seeder",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// `pathToFileURL` is required on Windows where bare `C:\…` paths
|
|
113
|
+
// trip ESM's ERR_UNSUPPORTED_ESM_URL_SCHEME on dynamic import.
|
|
114
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
115
|
+
const SeederClass = mod.default;
|
|
116
|
+
if (!SeederClass || typeof SeederClass !== "function") {
|
|
117
|
+
throw new AtlasError(
|
|
118
|
+
"E_SEEDER_INVALID",
|
|
119
|
+
`Seeder ${file} must export a default class extending BaseSeeder`,
|
|
120
|
+
{
|
|
121
|
+
hint: "Example: `export default class UserSeeder extends BaseSeeder { async run() { ... } }`",
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const instance: BaseSeeder = new SeederClass(db);
|
|
127
|
+
await instance.run();
|
|
128
|
+
executed.push(file.replace(/\.(ts|js)$/, ""));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return executed;
|
|
132
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TableBuilder — fluent column/index builder used inside `Schema.createTable()`.
|
|
3
|
+
*
|
|
4
|
+
* Builds an in-memory column/index spec, then delegates SQL generation to the
|
|
5
|
+
* Rust compiler via `compileStatementNative`. No SQL strings are produced in TS.
|
|
6
|
+
*
|
|
7
|
+
* @implements FR34
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AtlasDialect,
|
|
12
|
+
compileStatementNative,
|
|
13
|
+
getAtlasDialect,
|
|
14
|
+
} from "../query/native.js";
|
|
15
|
+
import {
|
|
16
|
+
type ColumnDefinition,
|
|
17
|
+
type ColumnType,
|
|
18
|
+
type IndexDefinition,
|
|
19
|
+
TYPE_KIND_MAP,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** Table builder — used inside `schema.createTable(name, callback)`. */
|
|
23
|
+
export class TableBuilder {
|
|
24
|
+
readonly tableName: string;
|
|
25
|
+
#columns: ColumnDefinition[] = [];
|
|
26
|
+
#indexes: IndexDefinition[] = [];
|
|
27
|
+
#currentColumn?: ColumnDefinition;
|
|
28
|
+
|
|
29
|
+
constructor(tableName: string) {
|
|
30
|
+
this.tableName = tableName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Column types ─────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
uuid(name: string): this {
|
|
36
|
+
return this.#addColumn(name, "uuid");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
string(name: string, length = 255): this {
|
|
40
|
+
this.#addColumn(name, "string");
|
|
41
|
+
if (this.#currentColumn) this.#currentColumn.length = length;
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
text(name: string): this {
|
|
46
|
+
return this.#addColumn(name, "text");
|
|
47
|
+
}
|
|
48
|
+
integer(name: string): this {
|
|
49
|
+
return this.#addColumn(name, "integer");
|
|
50
|
+
}
|
|
51
|
+
bigInteger(name: string): this {
|
|
52
|
+
return this.#addColumn(name, "bigInteger");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
decimal(name: string, precision = 10, scale = 2): this {
|
|
56
|
+
this.#addColumn(name, "decimal");
|
|
57
|
+
if (this.#currentColumn) {
|
|
58
|
+
this.#currentColumn.precision = precision;
|
|
59
|
+
this.#currentColumn.scale = scale;
|
|
60
|
+
}
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
boolean(name: string): this {
|
|
65
|
+
return this.#addColumn(name, "boolean");
|
|
66
|
+
}
|
|
67
|
+
date(name: string): this {
|
|
68
|
+
return this.#addColumn(name, "date");
|
|
69
|
+
}
|
|
70
|
+
timestamp(name: string): this {
|
|
71
|
+
return this.#addColumn(name, "timestamp");
|
|
72
|
+
}
|
|
73
|
+
json(name: string): this {
|
|
74
|
+
return this.#addColumn(name, "json");
|
|
75
|
+
}
|
|
76
|
+
binary(name: string): this {
|
|
77
|
+
return this.#addColumn(name, "binary");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Shortcuts ────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* UUID primary key with Postgres-only `gen_random_uuid()` default.
|
|
84
|
+
*
|
|
85
|
+
* **Portability warning — DO NOT use in framework-shipped migration templates.**
|
|
86
|
+
*
|
|
87
|
+
* `gen_random_uuid()` is a Postgres-13+ built-in. SQLite and MySQL do NOT
|
|
88
|
+
* provide a function by that name; calling `id()` in a migration that
|
|
89
|
+
* runs on those dialects fails at `migrations:run` with a "no such
|
|
90
|
+
* function" error. The helper is retained for user-app migrations where
|
|
91
|
+
* the target dialect is known to be Postgres.
|
|
92
|
+
*
|
|
93
|
+
* In framework-shipped templates, write the column explicitly and supply
|
|
94
|
+
* the UUID at INSERT time:
|
|
95
|
+
*
|
|
96
|
+
* t.uuid('id').primary() // no DEFAULT
|
|
97
|
+
* // and at insert: db.insert({ id: crypto.randomUUID(), ... })
|
|
98
|
+
*
|
|
99
|
+
* See `AUDIT-migration-templates.md` (shipped at the package root) for
|
|
100
|
+
* the full audit and the escape-hatch procedure if a future story makes
|
|
101
|
+
* the helper dialect-aware.
|
|
102
|
+
*/
|
|
103
|
+
id(): this {
|
|
104
|
+
return this.uuid("id").primary().defaultTo("gen_random_uuid()");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `created_at` + `updated_at` columns with Postgres-only `DEFAULT NOW()`.
|
|
109
|
+
*
|
|
110
|
+
* **Portability warning — DO NOT use in framework-shipped migration templates.**
|
|
111
|
+
*
|
|
112
|
+
* `NOW()` is a Postgres/MySQL function — SQLite does NOT recognise it
|
|
113
|
+
* (SQLite accepts `CURRENT_TIMESTAMP`, not `NOW()`). Calling
|
|
114
|
+
* `timestamps()` in a migration that runs on SQLite fails at
|
|
115
|
+
* `migrations:run` with a "no such function" error. The helper is
|
|
116
|
+
* retained for user-app migrations where the target dialect is known to
|
|
117
|
+
* be Postgres or MySQL.
|
|
118
|
+
*
|
|
119
|
+
* In framework-shipped templates, write the columns explicitly without a
|
|
120
|
+
* DEFAULT and supply the value at INSERT/UPSERT time:
|
|
121
|
+
*
|
|
122
|
+
* t.timestamp('created_at').notNullable() // no DEFAULT
|
|
123
|
+
* t.timestamp('updated_at').notNullable()
|
|
124
|
+
* // and at insert: db.insert({ created_at: new Date().toISOString(), ... })
|
|
125
|
+
*
|
|
126
|
+
* See `AUDIT-migration-templates.md` (shipped at the package root) for
|
|
127
|
+
* the full audit and the escape-hatch procedure if a future story makes
|
|
128
|
+
* the helper dialect-aware.
|
|
129
|
+
*/
|
|
130
|
+
timestamps(): this {
|
|
131
|
+
this.timestamp("created_at").notNullable().defaultTo("NOW()");
|
|
132
|
+
this.timestamp("updated_at").notNullable().defaultTo("NOW()");
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Column modifiers ─────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
primary(): this {
|
|
139
|
+
if (this.#currentColumn) this.#currentColumn.primary = true;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
notNullable(): this {
|
|
144
|
+
if (this.#currentColumn) this.#currentColumn.nullable = false;
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
nullable(): this {
|
|
149
|
+
if (this.#currentColumn) this.#currentColumn.nullable = true;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
unique(): this {
|
|
154
|
+
if (this.#currentColumn) this.#currentColumn.unique = true;
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
defaultTo(value: string): this {
|
|
159
|
+
if (this.#currentColumn) this.#currentColumn.defaultValue = value;
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
references(table: string, column = "id"): this {
|
|
164
|
+
if (this.#currentColumn) this.#currentColumn.references = { table, column };
|
|
165
|
+
return this;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Indexes ──────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
index(columns: string | string[], name?: string): this {
|
|
171
|
+
const cols = Array.isArray(columns) ? columns : [columns];
|
|
172
|
+
this.#indexes.push({
|
|
173
|
+
name: name ?? `idx_${this.tableName}_${cols.join("_")}`,
|
|
174
|
+
columns: cols,
|
|
175
|
+
unique: false,
|
|
176
|
+
});
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
uniqueIndex(columns: string | string[], name?: string): this {
|
|
181
|
+
const cols = Array.isArray(columns) ? columns : [columns];
|
|
182
|
+
this.#indexes.push({
|
|
183
|
+
name: name ?? `idx_${this.tableName}_${cols.join("_")}_unique`,
|
|
184
|
+
columns: cols,
|
|
185
|
+
unique: true,
|
|
186
|
+
});
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Accessors ────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
getColumns(): ColumnDefinition[] {
|
|
193
|
+
return [...this.#columns];
|
|
194
|
+
}
|
|
195
|
+
getIndexes(): IndexDefinition[] {
|
|
196
|
+
return [...this.#indexes];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Compile to SQL statements via the Rust compiler. */
|
|
200
|
+
toStatements(dialect: AtlasDialect = getAtlasDialect()): string[] {
|
|
201
|
+
const spec = {
|
|
202
|
+
kind: "createTable",
|
|
203
|
+
table: this.tableName,
|
|
204
|
+
columns: this.#columns.map((c) => ({
|
|
205
|
+
name: c.name,
|
|
206
|
+
kind: TYPE_KIND_MAP[c.type],
|
|
207
|
+
length: c.length ?? null,
|
|
208
|
+
precision: c.precision ?? null,
|
|
209
|
+
scale: c.scale ?? null,
|
|
210
|
+
nullable: c.nullable,
|
|
211
|
+
primary: c.primary,
|
|
212
|
+
unique: c.unique,
|
|
213
|
+
default: c.defaultValue ?? null,
|
|
214
|
+
references: c.references ?? null,
|
|
215
|
+
})),
|
|
216
|
+
indexes: this.#indexes.map((i) => ({
|
|
217
|
+
name: i.name,
|
|
218
|
+
columns: i.columns,
|
|
219
|
+
unique: i.unique,
|
|
220
|
+
})),
|
|
221
|
+
ifNotExists: false,
|
|
222
|
+
};
|
|
223
|
+
return compileStatementNative(spec, dialect).statements;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#addColumn(name: string, type: ColumnType): this {
|
|
227
|
+
const col: ColumnDefinition = {
|
|
228
|
+
name,
|
|
229
|
+
type,
|
|
230
|
+
nullable: true,
|
|
231
|
+
primary: false,
|
|
232
|
+
unique: false,
|
|
233
|
+
};
|
|
234
|
+
this.#columns.push(col);
|
|
235
|
+
this.#currentColumn = col;
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared schema types — column type names, definitions, and the
|
|
3
|
+
* mapping from logical TS types to the Rust ColumnTypeKind.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ColumnType =
|
|
7
|
+
| "string"
|
|
8
|
+
| "text"
|
|
9
|
+
| "integer"
|
|
10
|
+
| "bigInteger"
|
|
11
|
+
| "decimal"
|
|
12
|
+
| "boolean"
|
|
13
|
+
| "date"
|
|
14
|
+
| "timestamp"
|
|
15
|
+
| "uuid"
|
|
16
|
+
| "json"
|
|
17
|
+
| "binary";
|
|
18
|
+
|
|
19
|
+
/** Maps our logical type names to the ColumnTypeKind expected by the Rust compiler. */
|
|
20
|
+
export const TYPE_KIND_MAP: Record<ColumnType, string> = {
|
|
21
|
+
string: "string",
|
|
22
|
+
text: "text",
|
|
23
|
+
integer: "integer",
|
|
24
|
+
bigInteger: "bigInteger",
|
|
25
|
+
decimal: "decimal",
|
|
26
|
+
boolean: "boolean",
|
|
27
|
+
date: "date",
|
|
28
|
+
timestamp: "timestamp",
|
|
29
|
+
uuid: "uuid",
|
|
30
|
+
json: "json",
|
|
31
|
+
binary: "binary",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ColumnDefinition {
|
|
35
|
+
name: string;
|
|
36
|
+
type: ColumnType;
|
|
37
|
+
length?: number;
|
|
38
|
+
precision?: number;
|
|
39
|
+
scale?: number;
|
|
40
|
+
nullable: boolean;
|
|
41
|
+
primary: boolean;
|
|
42
|
+
unique: boolean;
|
|
43
|
+
defaultValue?: string;
|
|
44
|
+
references?: { table: string; column: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface IndexDefinition {
|
|
48
|
+
name: string;
|
|
49
|
+
columns: string[];
|
|
50
|
+
unique: boolean;
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `db` singleton — Adonis Lucid–style ergonomic access to the
|
|
3
|
+
* configured database connection.
|
|
4
|
+
*
|
|
5
|
+
* import db from '@c9up/atlas/services/db'
|
|
6
|
+
*
|
|
7
|
+
* const rows = await db.query('SELECT * FROM users WHERE id = ?', [id])
|
|
8
|
+
*
|
|
9
|
+
* Populated by `AtlasProvider.boot()`. The instance is whatever the
|
|
10
|
+
* `database.connections[default]` config block resolves to (typically
|
|
11
|
+
* a `NapiDbAdapter` wrapping the Rust sqlite driver, but apps can
|
|
12
|
+
* swap in a custom `AsyncDatabaseConnection` through the provider's
|
|
13
|
+
* container hooks).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
|
|
17
|
+
|
|
18
|
+
let instance: AsyncDatabaseConnection | undefined;
|
|
19
|
+
|
|
20
|
+
/** @internal Bind the singleton (called by AtlasProvider). */
|
|
21
|
+
export function setDb(connection: AsyncDatabaseConnection): void {
|
|
22
|
+
instance = connection;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
26
|
+
export function getDb(): AsyncDatabaseConnection | undefined {
|
|
27
|
+
return instance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const db: AsyncDatabaseConnection = new Proxy({} as AsyncDatabaseConnection, {
|
|
31
|
+
get(_target, prop) {
|
|
32
|
+
if (!instance) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"[atlas] db singleton accessed before AtlasProvider.boot() ran. " +
|
|
35
|
+
"Check that `@c9up/atlas/provider` is listed in your reamrc.ts " +
|
|
36
|
+
"providers and that `config/database.ts` defines at least one " +
|
|
37
|
+
"connection.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const value = Reflect.get(instance, prop, instance);
|
|
41
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export default db;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database cleanup utilities for testing.
|
|
3
|
+
*
|
|
4
|
+
* @implements MISS-19
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
|
|
8
|
+
import { compileStatementNative, getAtlasDialect } from "../query/native.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wrap a test in a savepoint that is rolled back after.
|
|
12
|
+
*/
|
|
13
|
+
export async function useTransaction(
|
|
14
|
+
db: AsyncDatabaseConnection,
|
|
15
|
+
): Promise<() => Promise<void>> {
|
|
16
|
+
await db.execute("SAVEPOINT test_savepoint");
|
|
17
|
+
return async () => {
|
|
18
|
+
await db.execute("ROLLBACK TO SAVEPOINT test_savepoint");
|
|
19
|
+
await db.execute("RELEASE SAVEPOINT test_savepoint");
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Truncate all user tables (excludes _migrations and internal tables).
|
|
25
|
+
*
|
|
26
|
+
* The SELECT on `sqlite_master` is SQLite-specific introspection and is kept
|
|
27
|
+
* as raw SQL intentionally — it's not a user query. The resulting DELETEs
|
|
28
|
+
* go through the Rust compiler.
|
|
29
|
+
*/
|
|
30
|
+
export async function truncateAll(db: AsyncDatabaseConnection): Promise<void> {
|
|
31
|
+
// SQL LIKE `_` is a single-char wildcard, not a literal underscore.
|
|
32
|
+
// `NOT LIKE '_%'` without `ESCAPE` matches NOTHING (every non-empty name
|
|
33
|
+
// matches `_%`). The `ESCAPE '\'` clause makes `\_` a literal underscore,
|
|
34
|
+
// so the exclusion targets only names starting with `_` (the convention
|
|
35
|
+
// for framework-private tables, including `_migrations`).
|
|
36
|
+
const tables = await db.query(
|
|
37
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' AND name NOT LIKE 'sqlite_%'",
|
|
38
|
+
);
|
|
39
|
+
const dialect = getAtlasDialect();
|
|
40
|
+
for (const row of tables) {
|
|
41
|
+
const name = row.name;
|
|
42
|
+
if (typeof name !== "string") continue;
|
|
43
|
+
const compiled = compileStatementNative(
|
|
44
|
+
{ kind: "delete", table: name, wheres: [] },
|
|
45
|
+
dialect,
|
|
46
|
+
);
|
|
47
|
+
await db.execute(compiled.statements[0], compiled.params);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Factory — Lucid-compatible factory surface for generating test data.
|
|
3
|
+
*
|
|
4
|
+
* Supports named states (variations), relations (nested `with`), many-to-many
|
|
5
|
+
* pivot attributes, stubbing (build without persisting), and ad-hoc merges.
|
|
6
|
+
*
|
|
7
|
+
* const UserFactory = factory(User, () => ({ email: `user-${Date.now()}@test.com`, name: 'Test' }))
|
|
8
|
+
* .state('admin', (u) => { u.role = 'admin' })
|
|
9
|
+
*
|
|
10
|
+
* const user = await UserFactory.create(db)
|
|
11
|
+
* const users = await UserFactory.apply('admin').createMany(5, db)
|
|
12
|
+
* const draft = UserFactory.merge({ name: 'Alice' }).makeStubbed()
|
|
13
|
+
*
|
|
14
|
+
* @implements MISS-18, Story 32.13
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { BaseEntity } from "../BaseEntity.js";
|
|
18
|
+
import { BaseRepository, type DatabaseConnection } from "../BaseRepository.js";
|
|
19
|
+
|
|
20
|
+
type EntityConstructor<T extends BaseEntity> = new () => T;
|
|
21
|
+
|
|
22
|
+
/** A named state — mutates an in-progress data object in place. */
|
|
23
|
+
type StateFn<D> = (data: D) => void;
|
|
24
|
+
|
|
25
|
+
export interface FactoryBuilder<T extends BaseEntity> {
|
|
26
|
+
/** Override specific fields for the next call (reset after consumption). */
|
|
27
|
+
merge(overrides: Partial<Record<string, unknown>>): FactoryBuilder<T>;
|
|
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
|
+
*/
|
|
34
|
+
state(name: string, fn: StateFn<Record<string, unknown>>): FactoryBuilder<T>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Activate one or more declared states for the next call. Multiple applies
|
|
38
|
+
* compose (all applied states fire, in order).
|
|
39
|
+
*/
|
|
40
|
+
apply(...stateNames: string[]): FactoryBuilder<T>;
|
|
41
|
+
|
|
42
|
+
/** Create and persist a single entity (fires lifecycle hooks via `repo.create`). */
|
|
43
|
+
create(db: DatabaseConnection): Promise<T>;
|
|
44
|
+
|
|
45
|
+
/** Create and persist multiple entities (fires hooks per row). */
|
|
46
|
+
createMany(count: number, db: DatabaseConnection): Promise<T[]>;
|
|
47
|
+
|
|
48
|
+
/** Build the data object without persisting and without instantiating an entity. */
|
|
49
|
+
make(): Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
/** Build multiple data objects without persisting. */
|
|
52
|
+
makeMany(count: number): Record<string, unknown>[];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build an entity INSTANCE without persisting it (Lucid's `makeStubbed`).
|
|
56
|
+
* Useful when you need a `new User()` object but want to avoid the DB.
|
|
57
|
+
*/
|
|
58
|
+
makeStubbed(): T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Define a model factory.
|
|
63
|
+
*
|
|
64
|
+
* const UserFactory = factory(User, () => ({
|
|
65
|
+
* email: `user-${Date.now()}@test.com`,
|
|
66
|
+
* name: 'Test User',
|
|
67
|
+
* }))
|
|
68
|
+
*/
|
|
69
|
+
export function factory<T extends BaseEntity>(
|
|
70
|
+
entityClass: EntityConstructor<T>,
|
|
71
|
+
defaults: () => Record<string, unknown>,
|
|
72
|
+
): FactoryBuilder<T> {
|
|
73
|
+
// Persistent state — lives across calls.
|
|
74
|
+
const states = new Map<string, StateFn<Record<string, unknown>>>();
|
|
75
|
+
// Transient state — resets after every `make`/`create`.
|
|
76
|
+
let pendingOverrides: Partial<Record<string, unknown>> = {};
|
|
77
|
+
let pendingStates: string[] = [];
|
|
78
|
+
|
|
79
|
+
const buildData = (): Record<string, unknown> => {
|
|
80
|
+
const data: Record<string, unknown> = {
|
|
81
|
+
...defaults(),
|
|
82
|
+
...pendingOverrides,
|
|
83
|
+
};
|
|
84
|
+
for (const name of pendingStates) {
|
|
85
|
+
const fn = states.get(name);
|
|
86
|
+
if (!fn)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Factory state '${name}' is not defined on ${entityClass.name}Factory`,
|
|
89
|
+
);
|
|
90
|
+
fn(data);
|
|
91
|
+
}
|
|
92
|
+
return data;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const resetPending = (): void => {
|
|
96
|
+
pendingOverrides = {};
|
|
97
|
+
pendingStates = [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const builder: FactoryBuilder<T> = {
|
|
101
|
+
merge(overrides) {
|
|
102
|
+
pendingOverrides = { ...pendingOverrides, ...overrides };
|
|
103
|
+
return builder;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
state(name, fn) {
|
|
107
|
+
states.set(name, fn);
|
|
108
|
+
return builder;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
apply(...names) {
|
|
112
|
+
pendingStates.push(...names);
|
|
113
|
+
return builder;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
make() {
|
|
117
|
+
const data = buildData();
|
|
118
|
+
resetPending();
|
|
119
|
+
return data;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
makeMany(count) {
|
|
123
|
+
// Re-evaluate defaults for each row so `Date.now()` / faker generate distinct values.
|
|
124
|
+
const rows: Record<string, unknown>[] = [];
|
|
125
|
+
const capturedOverrides = pendingOverrides;
|
|
126
|
+
const capturedStates = pendingStates;
|
|
127
|
+
for (let i = 0; i < count; i++) {
|
|
128
|
+
pendingOverrides = capturedOverrides;
|
|
129
|
+
pendingStates = capturedStates;
|
|
130
|
+
rows.push(buildData());
|
|
131
|
+
}
|
|
132
|
+
resetPending();
|
|
133
|
+
return rows;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
makeStubbed() {
|
|
137
|
+
const data = buildData();
|
|
138
|
+
resetPending();
|
|
139
|
+
const entity = new entityClass();
|
|
140
|
+
for (const [key, value] of Object.entries(data)) {
|
|
141
|
+
entity.setProp(key, value);
|
|
142
|
+
}
|
|
143
|
+
return entity;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async create(db) {
|
|
147
|
+
const data = builder.make();
|
|
148
|
+
const repo = new BaseRepository(entityClass, db);
|
|
149
|
+
return repo.create(data);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async createMany(count, db) {
|
|
153
|
+
const rows = builder.makeMany(count);
|
|
154
|
+
const repo = new BaseRepository(entityClass, db);
|
|
155
|
+
const created: T[] = [];
|
|
156
|
+
for (const data of rows) {
|
|
157
|
+
created.push(await repo.create(data));
|
|
158
|
+
}
|
|
159
|
+
return created;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return builder;
|
|
164
|
+
}
|