stabilize-orm 1.1.3 → 1.1.4
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/README.md +209 -155
- package/bun.lock +61 -0
- package/cache.ts +88 -17
- package/cli/stabilize-cli.ts +300 -432
- package/client.ts +153 -170
- package/decorators.ts +70 -4
- package/dist/cli/stabilize-cli.js +3093 -86
- package/dist/index.js +3023 -26
- package/index.ts +90 -51
- package/logger.ts +76 -75
- package/migrations.ts +157 -65
- package/package.json +5 -2
- package/query-builder.ts +103 -13
- package/repository.ts +447 -287
- package/types.ts +58 -29
package/cli/stabilize-cli.ts
CHANGED
|
@@ -1,542 +1,408 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* @file stabilize-cli.ts
|
|
4
|
+
* @description The command-line interface for the Stabilize ORM.
|
|
5
|
+
* @author ElectronSz
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import 'reflect-metadata';
|
|
3
9
|
|
|
4
10
|
import { program } from "commander";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
Stabilize,
|
|
8
|
-
runMigrations,
|
|
9
|
-
ModelKey
|
|
10
|
-
} from "../";
|
|
11
|
-
import { LogLevel, type DBConfig, type LoggerConfig, DBType } from "../types";
|
|
11
|
+
import { generateMigration, runMigrations, Stabilize, ModelKey, DBClient } from "../";
|
|
12
|
+
import { LogLevel, type DBConfig, type LoggerConfig, DBType, type Migration } from "../types";
|
|
12
13
|
import * as fs from "fs/promises";
|
|
13
14
|
import * as path from "path";
|
|
14
15
|
import { glob } from "glob";
|
|
16
|
+
import readline from "readline";
|
|
15
17
|
|
|
16
|
-
// ---
|
|
18
|
+
// --- CLI UI Toolkit ---
|
|
17
19
|
const C = {
|
|
18
|
-
RESET: "\x1b[0m",
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
GREEN: "\x1b[32m",
|
|
23
|
-
YELLOW: "\x1b[33m",
|
|
24
|
-
BLUE: "\x1b[34m",
|
|
25
|
-
MAGENTA: "\x1b[35m",
|
|
26
|
-
CYAN: "\x1b[36m",
|
|
27
|
-
WHITE: "\x1b[37m",
|
|
28
|
-
BG_GREEN: "\x1b[42m\x1b[30m",
|
|
29
|
-
BG_RED: "\x1b[41m\x1b[37m",
|
|
30
|
-
BG_YELLOW: "\x1b[43m\x1b[30m",
|
|
20
|
+
RESET: "\x1b[0m", BRIGHT: "\x1b[1m", DIM: "\x1b[2m",
|
|
21
|
+
RED: "\x1b[31m", GREEN: "\x1b[32m", YELLOW: "\x1b[33m", BLUE: "\x1b[34m",
|
|
22
|
+
MAGENTA: "\x1b[35m", CYAN: "\x1b[36m", WHITE: "\x1b[37m",
|
|
23
|
+
BG_GREEN: "\x1b[42m\x1b[30m", BG_RED: "\x1b[41m\x1b[37m", BG_YELLOW: "\x1b[43m\x1b[30m",
|
|
31
24
|
};
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
26
|
+
const CLILogger = {
|
|
27
|
+
info: (message: string) => console.log(`${C.BLUE}ℹ${C.RESET} ${message}`),
|
|
28
|
+
success: (message: string) => console.log(`${C.GREEN}✔${C.RESET} ${C.GREEN}${message}${C.RESET}`),
|
|
29
|
+
warn: (message: string) => console.log(`${C.YELLOW}⚠${C.RESET} ${message}`),
|
|
30
|
+
error: (message: string, details?: string) => {
|
|
31
|
+
console.error(`\n${C.BG_RED} ERROR ${C.RESET} ${C.RED}${message}${C.RESET}`);
|
|
32
|
+
if (details) console.error(`${C.DIM}${details}${C.RESET}`);
|
|
33
|
+
console.log();
|
|
34
|
+
},
|
|
35
|
+
panic: (error: Error, command: string) => {
|
|
36
|
+
CLILogger.error(`A fatal error occurred in the '${command}' command.`, error.stack);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const spinner = {
|
|
42
|
+
chars: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
43
|
+
interval: 80,
|
|
44
|
+
_timer: null as Timer | null,
|
|
45
|
+
start: (message: string) => {
|
|
46
|
+
let i = 0;
|
|
47
|
+
process.stdout.write("\n");
|
|
48
|
+
spinner._timer = setInterval(() => {
|
|
49
|
+
process.stdout.write(`\r${C.CYAN}${spinner.chars[i++ % spinner.chars.length]}${C.RESET} ${message}`);
|
|
50
|
+
}, spinner.interval);
|
|
51
|
+
},
|
|
52
|
+
stop: (success: boolean, message: string) => {
|
|
53
|
+
if (spinner._timer) clearInterval(spinner._timer);
|
|
54
|
+
process.stdout.write(`\r${success ? `${C.GREEN}✔` : `${C.RED}✖`} ${message}\n\n`);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// --- Core CLI Logic ---
|
|
59
|
+
|
|
60
|
+
async function loadConfig(configPath: string): Promise<{ config: DBConfig; orm: Stabilize }> {
|
|
61
|
+
try {
|
|
62
|
+
const absoluteConfigPath = path.resolve(process.cwd(), configPath);
|
|
63
|
+
const configModule = await import(absoluteConfigPath);
|
|
64
|
+
const config: DBConfig = configModule.default || configModule;
|
|
65
|
+
const logLevelKey = program.opts().logLevel as keyof typeof LogLevel;
|
|
66
|
+
const loggerConfig: LoggerConfig = { level: LogLevel[logLevelKey] };
|
|
67
|
+
const orm = new Stabilize(config, { enabled: false, ttl: 60 }, loggerConfig);
|
|
68
|
+
return { config, orm };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new Error(`Failed to load database configuration from '${configPath}'. Make sure the file exists and is valid.`);
|
|
78
71
|
}
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
filePath: path.resolve(process.cwd(), "logs/stabilize.log"),
|
|
90
|
-
maxFileSize: 5 * 1024 * 1024,
|
|
91
|
-
maxFiles: 3,
|
|
92
|
-
};
|
|
93
|
-
const orm = new Stabilize(
|
|
94
|
-
config,
|
|
95
|
-
{ enabled: false, ttl: 60 },
|
|
96
|
-
loggerConfig,
|
|
97
|
-
);
|
|
98
|
-
return { config, loggerConfig, orm };
|
|
74
|
+
async function confirm(question: string): Promise<boolean> {
|
|
75
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
rl.question(`${C.YELLOW}⚠${C.RESET} ${question} ${C.DIM}(y/N)${C.RESET} `, (answer) => {
|
|
78
|
+
rl.close();
|
|
79
|
+
resolve(answer.toLowerCase() === 'y');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
99
82
|
}
|
|
100
83
|
|
|
101
84
|
// --------------------------------------------------------------------------------------------------
|
|
102
85
|
// COMMAND: GENERATE
|
|
103
86
|
// --------------------------------------------------------------------------------------------------
|
|
104
|
-
|
|
105
87
|
program
|
|
106
88
|
.command("generate <type> <name>")
|
|
107
|
-
.description("Generate a model, migration, or seed file")
|
|
108
|
-
.option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
|
|
89
|
+
.description("Generate a new model, migration, or seed file.")
|
|
109
90
|
.action(async (type: string, name: string) => {
|
|
91
|
+
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
110
92
|
try {
|
|
111
93
|
if (type === "migration") {
|
|
112
94
|
const modelPath = path.resolve(process.cwd(), "models", `${name}.ts`);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
} catch (err: any) {
|
|
119
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Failed to import model: ${modelPath}. Ensure file exists and is valid. Details: ${err.message}`);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const tableName = Reflect.getMetadata(ModelKey, modelClass);
|
|
123
|
-
console.log("Errors are calculated", tableName)
|
|
124
|
-
if (!tableName) {
|
|
125
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Model class '${name}' in ${modelPath} is not decorated with @Model or metadata is missing.`);
|
|
126
|
-
console.log(`${C.YELLOW} TIP ${C.RESET} - Ensure @Model('${name.toLowerCase()}s') is on the class.\n - Regenerate with 'generate model ${name}'.\n - Import 'reflect-metadata' in your model file or entry point if needed.`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
// Determine dbType
|
|
130
|
-
const dbType = (await loadConfig("config/database.ts")).config.type ?? DBType.SQLite;
|
|
131
|
-
let migration: any;
|
|
132
|
-
try {
|
|
133
|
-
migration = await generateMigration(modelClass, `create_${name.toLowerCase()}`);
|
|
134
|
-
} catch (genErr: any) {
|
|
135
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Migration generation failed: ${genErr.message}`);
|
|
136
|
-
if (genErr.message.includes('decorated') || genErr.message.includes('Model')) {
|
|
137
|
-
console.log(`${C.YELLOW} TIP ${C.RESET} Ensure reflect-metadata is installed and imported globally.`);
|
|
138
|
-
}
|
|
139
|
-
return;
|
|
95
|
+
const { config } = await loadConfig("config/database.ts");
|
|
96
|
+
const modelModule = await import(modelPath).catch(() => { throw new Error(`Model file not found at '${modelPath}'.`); });
|
|
97
|
+
const modelClass = modelModule[capitalizedName];
|
|
98
|
+
if (!modelClass || !Reflect.getMetadata(ModelKey, modelClass)) {
|
|
99
|
+
throw new Error(`Class '${capitalizedName}' in '${modelPath}' is not a valid @Model.`);
|
|
140
100
|
}
|
|
101
|
+
const migration = await generateMigration(modelClass, `create_${name}_table`, config.type);
|
|
141
102
|
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
142
103
|
await fs.mkdir(migrationDir, { recursive: true });
|
|
143
104
|
const timestamp = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const migration: Migration = {
|
|
150
|
-
name: '${migrationFileName}',
|
|
151
|
-
up: async (client: any) => {
|
|
152
|
-
${migration.up.map((q: string) => `await client.query(\`${q}\`);`).join("\n ")}
|
|
153
|
-
},
|
|
154
|
-
down: async (client: any) => {
|
|
155
|
-
${migration.down.map((q: string) => `await client.query(\`${q}\`);`).join("\n ")}
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
export default migration;
|
|
160
|
-
`.trim();
|
|
161
|
-
await fs.writeFile(migrationFile, migrationContent + "\n");
|
|
162
|
-
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Migration generated: ${C.GREEN}${migrationFile}${C.RESET}`);
|
|
163
|
-
|
|
105
|
+
const fileName = `${timestamp}_create_${name}_table.json`;
|
|
106
|
+
migration.name = path.basename(fileName, ".json");
|
|
107
|
+
const filePath = path.join(migrationDir, fileName);
|
|
108
|
+
await fs.writeFile(filePath, JSON.stringify(migration, null, 2));
|
|
109
|
+
CLILogger.success(`Migration generated: ${filePath}`);
|
|
164
110
|
} else if (type === "model") {
|
|
165
111
|
const modelDir = path.resolve(process.cwd(), "models");
|
|
166
112
|
await fs.mkdir(modelDir, { recursive: true });
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
import 'reflect-metadata';
|
|
170
|
-
import { Model, Column,
|
|
171
|
-
|
|
172
|
-
@Model('${name.toLowerCase()}s')
|
|
173
|
-
export class ${
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
await fs.writeFile(modelFile, modelContent);
|
|
190
|
-
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Model generated: ${C.GREEN}${modelFile}${C.RESET}`);
|
|
191
|
-
|
|
113
|
+
const filePath = path.join(modelDir, `${name}.ts`);
|
|
114
|
+
const content = `
|
|
115
|
+
import 'reflect-metadata';
|
|
116
|
+
import { Model, Column, DataTypes } from 'stabilize-orm';
|
|
117
|
+
|
|
118
|
+
@Model('${name.toLowerCase()}s')
|
|
119
|
+
export class ${capitalizedName} {
|
|
120
|
+
@Column({ type: DataTypes.INTEGER, name: 'id' })
|
|
121
|
+
id!: number;
|
|
122
|
+
|
|
123
|
+
@Column({ type: DataTypes.STRING, length: 100 })
|
|
124
|
+
name!: string;
|
|
125
|
+
|
|
126
|
+
@Column({ type: DataTypes.DATETIME, name: 'created_at' })
|
|
127
|
+
createdAt!: Date;
|
|
128
|
+
|
|
129
|
+
@Column({ type: DataTypes.DATETIME, name: 'updated_at' })
|
|
130
|
+
updatedAt!: Date;
|
|
131
|
+
}
|
|
132
|
+
`.trim() + "\n";
|
|
133
|
+
await fs.writeFile(filePath, content);
|
|
134
|
+
CLILogger.success(`Model generated: ${filePath}`);
|
|
192
135
|
} else if (type === "seed") {
|
|
193
136
|
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
194
137
|
await fs.mkdir(seedDir, { recursive: true });
|
|
195
138
|
const timestamp = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
import { Stabilize } from 'stabilize-orm';
|
|
200
|
-
import { ${
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export async function rollback(orm: Stabilize) {
|
|
220
|
-
const repo = orm.getRepository(${name});
|
|
221
|
-
const entities = await repo.find().execute(orm.client);
|
|
222
|
-
const ids = entities.map((e: any) => e.id).filter(Boolean);
|
|
223
|
-
if (ids.length > 0) {
|
|
224
|
-
await repo.bulkDelete(ids, { batchSize: 100 });
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
await orm.client.query(
|
|
228
|
-
\`DELETE FROM seed_history WHERE name = ?\`,
|
|
229
|
-
['${seedFileName}']
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
`.trim() + "\n";
|
|
233
|
-
await fs.writeFile(seedFile, seedContent);
|
|
234
|
-
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Seed generated: ${C.GREEN}${seedFile}${C.RESET}`);
|
|
235
|
-
|
|
139
|
+
const fileName = `${timestamp}_${name}.ts`;
|
|
140
|
+
const filePath = path.join(seedDir, fileName);
|
|
141
|
+
const content = `
|
|
142
|
+
import { Stabilize } from 'stabilize-orm';
|
|
143
|
+
import { ${capitalizedName} } from './models/${name}';
|
|
144
|
+
|
|
145
|
+
export const dependencies: string[] = [];
|
|
146
|
+
|
|
147
|
+
export async function seed(orm: Stabilize): Promise<void> {
|
|
148
|
+
const repo = orm.getRepository(${capitalizedName});
|
|
149
|
+
await repo.bulkCreate([
|
|
150
|
+
{ name: '${capitalizedName} One' },
|
|
151
|
+
{ name: '${capitalizedName} Two' },
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function rollback(orm: Stabilize): Promise<void> {
|
|
156
|
+
await orm.client.query('DELETE FROM ${name.toLowerCase()}s WHERE name LIKE ?', ['${capitalizedName} %']);
|
|
157
|
+
}
|
|
158
|
+
`.trim() + "\n";
|
|
159
|
+
await fs.writeFile(filePath, content);
|
|
160
|
+
CLILogger.success(`Seed generated: ${filePath}`);
|
|
236
161
|
} else {
|
|
237
|
-
|
|
162
|
+
CLILogger.error(`Invalid type '${type}'. Use 'model', 'migration', or 'seed'.`);
|
|
238
163
|
}
|
|
239
|
-
} catch (error
|
|
240
|
-
|
|
164
|
+
} catch (error) {
|
|
165
|
+
CLILogger.panic(error as Error, "generate");
|
|
241
166
|
}
|
|
242
167
|
});
|
|
243
168
|
|
|
244
169
|
// --------------------------------------------------------------------------------------------------
|
|
245
|
-
//
|
|
170
|
+
// COMMANDS: MIGRATE & ROLLBACK
|
|
246
171
|
// --------------------------------------------------------------------------------------------------
|
|
247
|
-
|
|
248
172
|
program
|
|
249
173
|
.command("migrate")
|
|
250
|
-
.description("Apply all pending migrations")
|
|
174
|
+
.description("Apply all pending database migrations.")
|
|
251
175
|
.option("-c, --config <path>", "Path to database config file", "config/database.ts")
|
|
252
|
-
.option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
|
|
253
176
|
.action(async (options) => {
|
|
254
177
|
let orm: Stabilize | null = null;
|
|
255
178
|
try {
|
|
256
179
|
const { orm: loadedOrm, config } = await loadConfig(options.config);
|
|
257
180
|
orm = loadedOrm;
|
|
258
|
-
const dbType = config.type ?? DBType.SQLite;
|
|
259
|
-
await orm.client.query(getMigrationsTableSQL(dbType));
|
|
260
|
-
|
|
261
181
|
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
262
|
-
const migrationFiles = (await glob(`${migrationDir}/*.
|
|
263
|
-
const migrations =
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const applied = await orm.client.query(`SELECT name FROM migrations WHERE name = ?`, [migrationName]);
|
|
267
|
-
if (applied.length > 0) continue;
|
|
268
|
-
let migrationModule;
|
|
269
|
-
try {
|
|
270
|
-
migrationModule = await import(file);
|
|
271
|
-
} catch (err) {
|
|
272
|
-
console.error(`Failed to load migration ${file}:`, err);
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
const migration = migrationModule.default || migrationModule;
|
|
276
|
-
if (!migration || typeof migration.up !== "function") {
|
|
277
|
-
console.warn(`Invalid migration format in ${file}. Skipping.`);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
migrations.push({ name: migrationName, up: migration.up });
|
|
281
|
-
}
|
|
182
|
+
const migrationFiles = (await glob(`${migrationDir}/*.json`)).sort();
|
|
183
|
+
const migrations: Migration[] = await Promise.all(
|
|
184
|
+
migrationFiles.map(async file => JSON.parse(await fs.readFile(file, 'utf-8')))
|
|
185
|
+
);
|
|
282
186
|
if (migrations.length === 0) {
|
|
283
|
-
|
|
284
|
-
await orm.close();
|
|
187
|
+
CLILogger.warn("No migration files found.");
|
|
285
188
|
return;
|
|
286
189
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
[mig.name, new Date().toISOString()]
|
|
295
|
-
);
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} All migrations applied successfully.`);
|
|
299
|
-
await orm.close();
|
|
300
|
-
|
|
301
|
-
} catch (error: any) {
|
|
302
|
-
console.error(`${C.BG_RED} FATAL ${C.RESET} Migration failed:`, error.message);
|
|
190
|
+
spinner.start("Applying migrations...");
|
|
191
|
+
await runMigrations(config, migrations);
|
|
192
|
+
spinner.stop(true, "All pending migrations applied.");
|
|
193
|
+
} catch (error) {
|
|
194
|
+
spinner.stop(false, "Migration process failed.");
|
|
195
|
+
CLILogger.panic(error as Error, "migrate");
|
|
196
|
+
} finally {
|
|
303
197
|
if (orm) await orm.close();
|
|
304
|
-
process.exit(1);
|
|
305
198
|
}
|
|
306
199
|
});
|
|
307
200
|
|
|
308
|
-
// --------------------------------------------------------------------------------------------------
|
|
309
|
-
// COMMAND: MIGRATE:ROLLBACK
|
|
310
|
-
// --------------------------------------------------------------------------------------------------
|
|
311
|
-
|
|
312
201
|
program
|
|
313
202
|
.command("migrate:rollback")
|
|
314
|
-
.description("
|
|
203
|
+
.description("Roll back the most recently applied migration.")
|
|
315
204
|
.option("-c, --config <path>", "Path to database config file", "config/database.ts")
|
|
316
|
-
.option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
|
|
317
205
|
.action(async (options) => {
|
|
318
206
|
let orm: Stabilize | null = null;
|
|
319
207
|
try {
|
|
320
|
-
const { orm: loadedOrm
|
|
208
|
+
const { orm: loadedOrm } = await loadConfig(options.config);
|
|
321
209
|
orm = loadedOrm;
|
|
322
|
-
|
|
323
|
-
await orm.client.query(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
`SELECT name FROM migrations ORDER BY applied_at DESC LIMIT 1`
|
|
327
|
-
);
|
|
328
|
-
if (latest.length === 0) {
|
|
329
|
-
console.log(`${C.YELLOW} WARNING ${C.RESET} No migrations to rollback.`);
|
|
330
|
-
await orm.close();
|
|
210
|
+
spinner.start("Rolling back last migration...");
|
|
211
|
+
const [latest] = await orm.client.query<{ name: string }>(`SELECT name FROM migrations ORDER BY applied_at DESC, name DESC LIMIT 1`);
|
|
212
|
+
if (!latest) {
|
|
213
|
+
spinner.stop(false, "No migrations to roll back.");
|
|
331
214
|
return;
|
|
332
215
|
}
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
} catch (err) {
|
|
339
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Migration file not found: ${migrationFile}`);
|
|
340
|
-
await orm.close();
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
const migration = migrationModule.default || migrationModule;
|
|
344
|
-
if (typeof migration.down !== "function") {
|
|
345
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Migration ${migrationName} missing down function.`);
|
|
346
|
-
await orm.close();
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
console.log(`${C.BLUE} INFO ${C.RESET} Rolling back: ${C.YELLOW}${migrationName}${C.RESET}`);
|
|
350
|
-
await orm.transaction(async () => {
|
|
351
|
-
await migration.down(orm?.client);
|
|
352
|
-
await orm?.client.query(`DELETE FROM migrations WHERE name = ?`, [migrationName]);
|
|
216
|
+
const migrationFile = path.resolve(process.cwd(), "migrations", `${latest.name}.json`);
|
|
217
|
+
const migration: Migration = JSON.parse(await fs.readFile(migrationFile, 'utf-8'));
|
|
218
|
+
await orm.transaction(async (txClient) => {
|
|
219
|
+
for (const query of migration.down) await txClient.query(query);
|
|
220
|
+
await txClient.query(`DELETE FROM migrations WHERE name = ?`, [latest.name]);
|
|
353
221
|
});
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
222
|
+
spinner.stop(true, `Rolled back: ${latest.name}`);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
spinner.stop(false, "Rollback failed.");
|
|
225
|
+
CLILogger.panic(error as Error, "migrate:rollback");
|
|
226
|
+
} finally {
|
|
359
227
|
if (orm) await orm.close();
|
|
360
|
-
process.exit(1);
|
|
361
228
|
}
|
|
362
229
|
});
|
|
363
230
|
|
|
364
231
|
// --------------------------------------------------------------------------------------------------
|
|
365
|
-
//
|
|
232
|
+
// COMMANDS: SEED & ROLLBACK
|
|
366
233
|
// --------------------------------------------------------------------------------------------------
|
|
367
|
-
|
|
368
234
|
program
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
let orm: Stabilize | null = null;
|
|
375
|
-
try {
|
|
376
|
-
const { orm: loadedOrm, config } = await loadConfig(options.config);
|
|
377
|
-
orm = loadedOrm;
|
|
378
|
-
const dbType = config.type ?? DBType.SQLite;
|
|
379
|
-
await orm.client.query(getSeedHistoryTableSQL(dbType));
|
|
380
|
-
|
|
381
|
-
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
382
|
-
const seedFiles = await glob(`${seedDir}/*.ts`);
|
|
383
|
-
if (seedFiles.length === 0) {
|
|
384
|
-
console.log(`${C.YELLOW} WARNING ${C.RESET} No seed files found.`);
|
|
385
|
-
await orm.close();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const seedGraph = new Map<string, { file: string; dependencies: string[] }>();
|
|
389
|
-
for (const file of seedFiles) {
|
|
390
|
-
const seedName = path.basename(file, ".ts");
|
|
391
|
-
let mod;
|
|
235
|
+
.command("seed")
|
|
236
|
+
.description("Run all pending seed files.")
|
|
237
|
+
.option("-c, --config <path>", "Path to db config file", "config/database.ts")
|
|
238
|
+
.action(async (options) => {
|
|
239
|
+
let orm: Stabilize | null = null;
|
|
392
240
|
try {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
241
|
+
const { orm: loadedOrm } = await loadConfig(options.config);
|
|
242
|
+
orm = loadedOrm;
|
|
243
|
+
await orm.client.query(`CREATE TABLE IF NOT EXISTS seed_history (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
|
244
|
+
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
245
|
+
const seedFiles = await glob(`${seedDir}/*.ts`);
|
|
246
|
+
const graph = new Map<string, { file: string; dependencies: string[] }>();
|
|
247
|
+
for (const file of seedFiles) {
|
|
248
|
+
const name = path.basename(file, ".ts");
|
|
249
|
+
const mod = await import(file);
|
|
250
|
+
graph.set(name, { file, dependencies: mod.dependencies || [] });
|
|
251
|
+
}
|
|
252
|
+
const orderedSeeds = topologicalSort(graph);
|
|
253
|
+
const appliedSeeds = new Set((await orm.client.query<{ name: string }>(`SELECT name FROM seed_history`)).map(r => r.name));
|
|
254
|
+
const pendingSeeds = orderedSeeds.filter(s => !appliedSeeds.has(s));
|
|
255
|
+
if (pendingSeeds.length === 0) {
|
|
256
|
+
CLILogger.warn("No pending seeds to run.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
spinner.start(`Running ${pendingSeeds.length} seed(s)...`);
|
|
260
|
+
for (const seedName of pendingSeeds) {
|
|
261
|
+
const mod = await import(graph.get(seedName)!.file);
|
|
262
|
+
await orm.transaction(async (txClient) => {
|
|
263
|
+
const txOrm = new Stabilize(orm!.client.config, { enabled: false, ttl: 60 }, { level: LogLevel.Error }, txClient);
|
|
264
|
+
await mod.seed(txOrm);
|
|
265
|
+
await txClient.query(`INSERT INTO seed_history (name, applied_at) VALUES (?, ?)`, [seedName, new Date().toISOString()]);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
spinner.stop(true, `Successfully applied ${pendingSeeds.length} seed(s).`);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
spinner.stop(false, "Seeding process failed.");
|
|
271
|
+
CLILogger.panic(error as Error, "seed");
|
|
272
|
+
} finally {
|
|
273
|
+
if (orm) await orm.close();
|
|
397
274
|
}
|
|
398
|
-
|
|
399
|
-
file,
|
|
400
|
-
dependencies: mod.dependencies || [],
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
const orderedSeeds = topologicalSort(seedGraph);
|
|
275
|
+
});
|
|
404
276
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
277
|
+
program
|
|
278
|
+
.command("seed:rollback")
|
|
279
|
+
.description("Roll back the most recently applied seed.")
|
|
280
|
+
.option("-c, --config <path>", "Path to db config file", "config/database.ts")
|
|
281
|
+
.action(async (options) => {
|
|
282
|
+
let orm: Stabilize | null = null;
|
|
283
|
+
try {
|
|
284
|
+
const { orm: loadedOrm } = await loadConfig(options.config);
|
|
285
|
+
orm = loadedOrm;
|
|
286
|
+
spinner.start("Rolling back last seed...");
|
|
287
|
+
const [latest] = await orm!.client.query<{ name: string }>(`SELECT name FROM seed_history ORDER BY applied_at DESC, name DESC LIMIT 1`);
|
|
288
|
+
if (!latest) {
|
|
289
|
+
spinner.stop(false, "No seeds to roll back.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const seedFile = path.resolve(process.cwd(), "seeds", `${latest.name}.ts`);
|
|
293
|
+
const mod = await import(seedFile);
|
|
294
|
+
if (typeof mod.rollback !== 'function') {
|
|
295
|
+
throw new Error(`Rollback function not found in '${latest.name}.ts'`);
|
|
296
|
+
}
|
|
297
|
+
await orm.transaction(async (txClient) => {
|
|
298
|
+
const txOrm = new Stabilize(orm!.client.config, { enabled: false, ttl: 60 }, { level: LogLevel.Error });
|
|
299
|
+
await mod.rollback(txOrm);
|
|
300
|
+
await txClient.query(`DELETE FROM seed_history WHERE name = ?`, [latest.name]);
|
|
301
|
+
});
|
|
302
|
+
spinner.stop(true, `Rolled back seed: ${latest.name}`);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
spinner.stop(false, "Seed rollback failed.");
|
|
305
|
+
CLILogger.panic(error as Error, "seed:rollback");
|
|
306
|
+
} finally {
|
|
307
|
+
if (orm) await orm.close();
|
|
425
308
|
}
|
|
426
|
-
|
|
427
|
-
console.log(`\n${C.BG_GREEN} SUCCESS ${C.RESET} Seeding complete. Applied: ${appliedCount}`);
|
|
428
|
-
await orm.close();
|
|
429
|
-
|
|
430
|
-
} catch (error: any) {
|
|
431
|
-
console.error(`${C.BG_RED} FATAL ${C.RESET} Seeding failed:`, error.message);
|
|
432
|
-
if (orm) await orm.close();
|
|
433
|
-
process.exit(1);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
309
|
+
});
|
|
436
310
|
|
|
437
311
|
// --------------------------------------------------------------------------------------------------
|
|
438
|
-
//
|
|
312
|
+
// COMMANDS: DB & STATUS
|
|
439
313
|
// --------------------------------------------------------------------------------------------------
|
|
440
|
-
|
|
441
314
|
program
|
|
442
|
-
.command("
|
|
443
|
-
.description("
|
|
444
|
-
.option("-c, --config <path>", "Path to
|
|
445
|
-
.option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
|
|
315
|
+
.command("db:drop")
|
|
316
|
+
.description("Drop the database. USE WITH CAUTION.")
|
|
317
|
+
.option("-c, --config <path>", "Path to db config file", "config/database.ts")
|
|
446
318
|
.action(async (options) => {
|
|
447
|
-
let orm: Stabilize | null = null;
|
|
448
319
|
try {
|
|
449
|
-
const {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const latest = await orm.client.query(`SELECT name FROM seed_history ORDER BY applied_at DESC LIMIT 1`);
|
|
455
|
-
if (latest.length === 0) {
|
|
456
|
-
console.log(`${C.YELLOW} WARNING ${C.RESET} No seeds to rollback.`);
|
|
457
|
-
await orm.close();
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
const seedName = latest[0].name;
|
|
461
|
-
const seedFile = path.resolve(process.cwd(), "seeds", `${seedName}.ts`);
|
|
462
|
-
let mod;
|
|
463
|
-
try {
|
|
464
|
-
mod = await import(seedFile);
|
|
465
|
-
} catch (err) {
|
|
466
|
-
console.error(`${C.BG_RED} ERROR ${C.RESET} Seed file not found: ${seedFile}`);
|
|
467
|
-
await orm.close();
|
|
320
|
+
const { config } = await loadConfig(options.config);
|
|
321
|
+
const dbName = new URL(config.connectionString).pathname.substring(1);
|
|
322
|
+
if (!await confirm(`This will permanently delete the '${dbName}' database. Are you sure?`)) {
|
|
323
|
+
CLILogger.warn("Database drop cancelled.");
|
|
468
324
|
return;
|
|
469
325
|
}
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
326
|
+
spinner.start(`Dropping database '${dbName}'...`);
|
|
327
|
+
if (config.type === DBType.SQLite) {
|
|
328
|
+
await fs.unlink(dbName).catch(() => {});
|
|
329
|
+
} else {
|
|
330
|
+
const adminConfig = { ...config };
|
|
331
|
+
const url = new URL(adminConfig.connectionString);
|
|
332
|
+
url.pathname = config.type === DBType.Postgres ? '/postgres' : '';
|
|
333
|
+
adminConfig.connectionString = url.toString();
|
|
334
|
+
const adminOrm = new Stabilize(adminConfig);
|
|
335
|
+
await adminOrm.client.query(`DROP DATABASE IF EXISTS "${dbName}"`);
|
|
336
|
+
await adminOrm.close();
|
|
475
337
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
await orm.close();
|
|
481
|
-
|
|
482
|
-
} catch (error: any) {
|
|
483
|
-
console.error(`${C.BG_RED} FATAL ${C.RESET} Rollback failed:`, error.message);
|
|
484
|
-
if (orm) await orm.close();
|
|
485
|
-
process.exit(1);
|
|
338
|
+
spinner.stop(true, `Database '${dbName}' dropped successfully.`);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
spinner.stop(false, "Failed to drop database.");
|
|
341
|
+
CLILogger.panic(error as Error, "db:drop");
|
|
486
342
|
}
|
|
487
343
|
});
|
|
488
344
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
345
|
+
program
|
|
346
|
+
.command("db:reset")
|
|
347
|
+
.description("Drop, create, migrate, and seed the database.")
|
|
348
|
+
.option("-c, --config <path>", "Path to db config file", "config/database.ts")
|
|
349
|
+
.action(async (options) => {
|
|
350
|
+
CLILogger.warn("This command will destroy and rebuild your database.");
|
|
351
|
+
try {
|
|
352
|
+
await program.commands.find(c => c.name() === 'db:drop')?.parseAsync(process.argv, { from: 'user' });
|
|
353
|
+
CLILogger.info("Creating database... (This is handled automatically by the driver on first connection)");
|
|
354
|
+
await program.commands.find(c => c.name() === 'migrate')?.parseAsync(process.argv, { from: 'user' });
|
|
355
|
+
await program.commands.find(c => c.name() === 'seed')?.parseAsync(process.argv, { from: 'user' });
|
|
356
|
+
CLILogger.success("Database reset complete.");
|
|
357
|
+
} catch (error) {
|
|
358
|
+
CLILogger.panic(error as Error, "db:reset");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
492
361
|
|
|
493
362
|
program
|
|
494
363
|
.command("status")
|
|
495
|
-
.description("
|
|
496
|
-
.option("-c, --config <path>", "Path to
|
|
497
|
-
.option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
|
|
364
|
+
.description("Show the status of all migrations and seeds.")
|
|
365
|
+
.option("-c, --config <path>", "Path to db config file", "config/database.ts")
|
|
498
366
|
.action(async (options) => {
|
|
499
367
|
let orm: Stabilize | null = null;
|
|
500
368
|
try {
|
|
501
|
-
const { orm: loadedOrm
|
|
369
|
+
const { orm: loadedOrm } = await loadConfig(options.config);
|
|
502
370
|
orm = loadedOrm;
|
|
503
|
-
|
|
504
|
-
await orm.client.query(
|
|
505
|
-
await orm.client.query(getSeedHistoryTableSQL(dbType));
|
|
371
|
+
await orm.client.query(`CREATE TABLE IF NOT EXISTS migrations (name TEXT PRIMARY KEY, applied_at TEXT)`);
|
|
372
|
+
await orm.client.query(`CREATE TABLE IF NOT EXISTS seed_history (name TEXT PRIMARY KEY, applied_at TEXT)`);
|
|
506
373
|
|
|
507
|
-
|
|
508
|
-
const migrationFiles = (await glob(`${migrationDir}/*.ts`)).map(f => path.basename(f, ".ts")).sort();
|
|
509
|
-
console.log(`\n${C.BRIGHT}Migration Status:${C.RESET}`);
|
|
374
|
+
console.log(`\n${C.BRIGHT}Migration Status${C.RESET}`);
|
|
510
375
|
console.log(`---------------------------------`);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
376
|
+
const migrationFiles = (await glob(`migrations/*.json`)).map(f => path.basename(f, ".json")).sort();
|
|
377
|
+
const appliedMigrations = new Set((await orm.client.query<{ name: string }>(`SELECT name FROM migrations`)).map(r => r.name));
|
|
378
|
+
migrationFiles.forEach(name => {
|
|
379
|
+
const status = appliedMigrations.has(name) ? `${C.BG_GREEN} APPLIED ${C.RESET}` : `${C.BG_YELLOW} PENDING ${C.RESET}`;
|
|
514
380
|
console.log(`${status} ${C.WHITE}${name}${C.RESET}`);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
console.log(`\n${C.BRIGHT}Seed Status
|
|
381
|
+
});
|
|
382
|
+
if (migrationFiles.length === 0) console.log(C.DIM + " No migration files found." + C.RESET);
|
|
383
|
+
|
|
384
|
+
console.log(`\n${C.BRIGHT}Seed Status${C.RESET}`);
|
|
519
385
|
console.log(`---------------------------------`);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
386
|
+
const seedFiles = (await glob(`seeds/*.ts`)).map(f => path.basename(f, ".ts")).sort();
|
|
387
|
+
const appliedSeeds = new Set((await orm.client.query<{ name: string }>(`SELECT name FROM seed_history`)).map(r => r.name));
|
|
388
|
+
seedFiles.forEach(name => {
|
|
389
|
+
const status = appliedSeeds.has(name) ? `${C.BG_GREEN} APPLIED ${C.RESET}` : `${C.BG_YELLOW} PENDING ${C.RESET}`;
|
|
523
390
|
console.log(`${status} ${C.WHITE}${name}${C.RESET}`);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
391
|
+
});
|
|
392
|
+
if (seedFiles.length === 0) console.log(C.DIM + " No seed files found." + C.RESET);
|
|
393
|
+
console.log();
|
|
394
|
+
|
|
395
|
+
} catch (error) {
|
|
396
|
+
CLILogger.panic(error as Error, "status");
|
|
397
|
+
} finally {
|
|
528
398
|
if (orm) await orm.close();
|
|
529
|
-
process.exit(1);
|
|
530
399
|
}
|
|
531
400
|
});
|
|
532
401
|
|
|
533
402
|
// --------------------------------------------------------------------------------------------------
|
|
534
|
-
// TOPOLOGICAL SORT HELPER
|
|
403
|
+
// TOPOLOGICAL SORT HELPER for seeding
|
|
535
404
|
// --------------------------------------------------------------------------------------------------
|
|
536
|
-
|
|
537
|
-
function topologicalSort(
|
|
538
|
-
graph: Map<string, { file: string; dependencies: string[] }>,
|
|
539
|
-
): string[] {
|
|
405
|
+
function topologicalSort(graph: Map<string, { file: string; dependencies: string[] }>): string[] {
|
|
540
406
|
const result: string[] = [];
|
|
541
407
|
const visited = new Set<string>();
|
|
542
408
|
const temp = new Set<string>();
|
|
@@ -546,7 +412,7 @@ function topologicalSort(
|
|
|
546
412
|
temp.add(node);
|
|
547
413
|
const deps = graph.get(node)?.dependencies || [];
|
|
548
414
|
for (const dep of deps) {
|
|
549
|
-
if (!graph.has(dep)) throw new Error(`Dependency ${dep} not found for ${node}`);
|
|
415
|
+
if (!graph.has(dep)) throw new Error(`Dependency '${dep}' not found for seed '${node}'`);
|
|
550
416
|
visit(dep);
|
|
551
417
|
}
|
|
552
418
|
temp.delete(node);
|
|
@@ -560,4 +426,6 @@ function topologicalSort(
|
|
|
560
426
|
return result;
|
|
561
427
|
}
|
|
562
428
|
|
|
563
|
-
program
|
|
429
|
+
program
|
|
430
|
+
.option("-l, --log-level <level>", "Global log level", "Info")
|
|
431
|
+
.parse(process.argv);
|