stabilize-orm 1.1.2 → 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.
@@ -1,542 +1,408 @@
1
1
  #!/usr/bin/env bun
2
- import 'reflect-metadata'; // MUST BE FIRST — Enables decorator metadata reflection
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
- generateMigration,
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
- // --- ANSI Color and Styling Helpers ---
18
+ // --- CLI UI Toolkit ---
17
19
  const C = {
18
- RESET: "\x1b[0m",
19
- BRIGHT: "\x1b[1m",
20
- DIM: "\x1b[2m",
21
- RED: "\x1b[31m",
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
- // DB-aware migrations table
34
- function getMigrationsTableSQL(dbType: DBType) {
35
- switch (dbType) {
36
- case DBType.Postgres:
37
- return `CREATE TABLE IF NOT EXISTS migrations (
38
- id SERIAL PRIMARY KEY,
39
- name TEXT NOT NULL UNIQUE,
40
- applied_at TIMESTAMP NOT NULL
41
- )`;
42
- case DBType.MySQL:
43
- return `CREATE TABLE IF NOT EXISTS migrations (
44
- id INT AUTO_INCREMENT PRIMARY KEY,
45
- name VARCHAR(255) NOT NULL UNIQUE,
46
- applied_at DATETIME NOT NULL
47
- )`;
48
- case DBType.SQLite:
49
- default:
50
- return `CREATE TABLE IF NOT EXISTS migrations (
51
- id INTEGER PRIMARY KEY AUTOINCREMENT,
52
- name TEXT NOT NULL UNIQUE,
53
- applied_at TEXT NOT NULL
54
- )`;
55
- }
56
- }
57
- function getSeedHistoryTableSQL(dbType: DBType) {
58
- switch (dbType) {
59
- case DBType.Postgres:
60
- return `CREATE TABLE IF NOT EXISTS seed_history (
61
- id SERIAL PRIMARY KEY,
62
- name TEXT NOT NULL UNIQUE,
63
- applied_at TIMESTAMP NOT NULL
64
- )`;
65
- case DBType.MySQL:
66
- return `CREATE TABLE IF NOT EXISTS seed_history (
67
- id INT AUTO_INCREMENT PRIMARY KEY,
68
- name VARCHAR(255) NOT NULL UNIQUE,
69
- applied_at DATETIME NOT NULL
70
- )`;
71
- case DBType.SQLite:
72
- default:
73
- return `CREATE TABLE IF NOT EXISTS seed_history (
74
- id INTEGER PRIMARY KEY AUTOINCREMENT,
75
- name TEXT NOT NULL UNIQUE,
76
- applied_at TEXT NOT NULL
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
- // Helper function to load configuration
82
- async function loadConfig(configPath: string): Promise<{ config: DBConfig; loggerConfig: LoggerConfig; orm: Stabilize }> {
83
- const absoluteConfigPath = path.resolve(process.cwd(), configPath);
84
- const configModule = await import(absoluteConfigPath);
85
- const config: DBConfig = configModule.default || configModule;
86
- const logLevel = program.opts().logLevel as LogLevel || "info";
87
- const loggerConfig: LoggerConfig = {
88
- level: logLevel,
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
- let modelClass: any;
114
- try {
115
- const modelModule = await import(modelPath);
116
- modelClass = Object.values(modelModule).find(val => typeof val === "function" && val.prototype);
117
- if (!modelClass) throw new Error("No class exported in model file.");
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 migrationFileName = `${timestamp}_${name.toLowerCase()}`;
145
- const migrationFile = path.join(migrationDir, `${migrationFileName}.ts`);
146
- const migrationContent = `
147
- import { Migration } from 'stabilize-orm/src/types';
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 modelFile = path.join(modelDir, `${name}.ts`);
168
- const modelContent = `
169
- import 'reflect-metadata'; // Required for decorators
170
- import { Model, Column, Required } from 'stabilize-orm';
171
-
172
- @Model('${name.toLowerCase()}s')
173
- export class ${name} {
174
- @Column('id', 'TEXT', { primaryKey: true })
175
- @Required()
176
- id: string = crypto.randomUUID();
177
-
178
- @Column('name', 'TEXT')
179
- @Required()
180
- name?: string;
181
-
182
- @Column('created_at', 'TEXT')
183
- createdAt?: string;
184
-
185
- @Column('updated_at', 'TEXT')
186
- updatedAt?: string;
187
- }
188
- `.trim() + "\n";
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 seedFileName = `${timestamp}_${name.toLowerCase()}`;
197
- const seedFile = path.join(seedDir, `${seedFileName}.ts`);
198
- const seedContent = `
199
- import { Stabilize } from 'stabilize-orm';
200
- import { ${name} } from '../models/${name}';
201
- import { randomUUID } from 'crypto';
202
-
203
- export const dependencies: string[] = [];
204
-
205
- // Use UUIDs in seed data
206
- export async function seed(orm: Stabilize) {
207
- const repo = orm.getRepository(${name});
208
- await repo.bulkCreate([
209
- { id: randomUUID(), name: '${name} 1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
210
- { id: randomUUID(), name: '${name} 2', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
211
- ], { batchSize: 100 });
212
-
213
- await orm.client.query(
214
- \`INSERT INTO seed_history (name, applied_at) VALUES (?, ?)\`,
215
- ['${seedFileName}', new Date().toISOString()]
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
- console.error(`${C.BG_RED} ERROR ${C.RESET} Invalid type. Use ${C.YELLOW}"model", "migration", or "seed"${C.RESET}.`);
162
+ CLILogger.error(`Invalid type '${type}'. Use 'model', 'migration', or 'seed'.`);
238
163
  }
239
- } catch (error: any) {
240
- console.error(`${C.BG_RED} FATAL ${C.RESET} Error generating file:`, error.message);
164
+ } catch (error) {
165
+ CLILogger.panic(error as Error, "generate");
241
166
  }
242
167
  });
243
168
 
244
169
  // --------------------------------------------------------------------------------------------------
245
- // COMMAND: MIGRATE
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}/*.ts`)).sort();
263
- const migrations = [];
264
- for (const file of migrationFiles) {
265
- const migrationName = path.basename(file, ".ts");
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
- console.log(`${C.YELLOW} WARNING ${C.RESET} No pending migrations found.`);
284
- await orm.close();
187
+ CLILogger.warn("No migration files found.");
285
188
  return;
286
189
  }
287
- console.log(`${C.BLUE} INFO ${C.RESET} Applying ${C.BRIGHT}${migrations.length}${C.RESET} migration(s)...`);
288
- for (const mig of migrations) {
289
- console.log(` Applying: ${C.CYAN}${mig.name}${C.RESET}`);
290
- await orm.transaction(async () => {
291
- await mig.up(orm?.client);
292
- await orm?.client.query(
293
- `INSERT INTO migrations (name, applied_at) VALUES (?, ?)`,
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("Rollback the most recently applied migration")
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, config } = await loadConfig(options.config);
208
+ const { orm: loadedOrm } = await loadConfig(options.config);
321
209
  orm = loadedOrm;
322
- const dbType = config.type ?? DBType.SQLite;
323
- await orm.client.query(getMigrationsTableSQL(dbType));
324
-
325
- const latest = await orm.client.query(
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 migrationName = latest[0].name;
334
- const migrationFile = path.resolve(process.cwd(), "migrations", `${migrationName}.ts`);
335
- let migrationModule;
336
- try {
337
- migrationModule = await import(migrationFile);
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
- console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Rolled back: ${C.GREEN}${migrationName}${C.RESET}`);
355
- await orm.close();
356
-
357
- } catch (error: any) {
358
- console.error(`${C.BG_RED} FATAL ${C.RESET} Rollback failed:`, error.message);
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
- // COMMAND: SEED
232
+ // COMMANDS: SEED & ROLLBACK
366
233
  // --------------------------------------------------------------------------------------------------
367
-
368
234
  program
369
- .command("seed")
370
- .description("Run seed files to populate the database")
371
- .option("-c, --config <path>", "Path to database config file", "config/database.ts")
372
- .option("-l, --log-level <level>", "Log level (error, warn, info, debug)", "info")
373
- .action(async (options) => {
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
- mod = await import(file);
394
- } catch (err) {
395
- console.warn(`Failed to load seed ${file}:`, err);
396
- continue;
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
- seedGraph.set(seedName, {
399
- file,
400
- dependencies: mod.dependencies || [],
401
- });
402
- }
403
- const orderedSeeds = topologicalSort(seedGraph);
275
+ });
404
276
 
405
- let appliedCount = 0;
406
- for (const seedName of orderedSeeds) {
407
- const { file } = seedGraph.get(seedName)!;
408
- const alreadyApplied = await orm.client.query(`SELECT 1 FROM seed_history WHERE name = ?`, [seedName]);
409
- if (alreadyApplied.length > 0) {
410
- console.log(` ${C.DIM}Skipped:${C.RESET} ${seedName}`);
411
- continue;
412
- }
413
- console.log(` ${C.BRIGHT}Running:${C.RESET} ${C.MAGENTA}${seedName}${C.RESET}`);
414
- const mod = await import(file);
415
- const seedFn = mod.seed || mod.default;
416
- if (typeof seedFn === "function") {
417
- await seedFn(orm);
418
- await orm.client.query(
419
- `INSERT INTO seed_history (name, applied_at) VALUES (?, ?)`,
420
- [seedName, new Date().toISOString()]
421
- );
422
- appliedCount++;
423
- } else {
424
- console.error(`${C.BG_RED} ERROR ${C.RESET} Invalid seed export in ${file}`);
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
- // COMMAND: SEED:ROLLBACK
312
+ // COMMANDS: DB & STATUS
439
313
  // --------------------------------------------------------------------------------------------------
440
-
441
314
  program
442
- .command("seed:rollback")
443
- .description("Rollback the most recently applied seed")
444
- .option("-c, --config <path>", "Path to database config file", "config/database.ts")
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 { orm: loadedOrm, config } = await loadConfig(options.config);
450
- orm = loadedOrm;
451
- const dbType = config.type ?? DBType.SQLite;
452
- await orm.client.query(getSeedHistoryTableSQL(dbType));
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
- const rollbackFn = mod.rollback;
471
- if (typeof rollbackFn !== "function") {
472
- console.error(`${C.BG_RED} ERROR ${C.RESET} Missing rollback function in ${seedName}`);
473
- await orm.close();
474
- return;
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
- console.log(`${C.BLUE} INFO ${C.RESET} Rolling back seed: ${C.YELLOW}${seedName}${C.RESET}`);
477
- await rollbackFn(orm);
478
- await orm.client.query(`DELETE FROM seed_history WHERE name = ?`, [seedName]);
479
- console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Seed rolled back: ${C.GREEN}${seedName}${C.RESET}`);
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
- // COMMAND: STATUS
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("Display status of migrations and seeds")
496
- .option("-c, --config <path>", "Path to database config file", "config/database.ts")
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, config } = await loadConfig(options.config);
369
+ const { orm: loadedOrm } = await loadConfig(options.config);
502
370
  orm = loadedOrm;
503
- const dbType = config.type ?? DBType.SQLite;
504
- await orm.client.query(getMigrationsTableSQL(dbType));
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
- const migrationDir = path.resolve(process.cwd(), "migrations");
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
- for (const name of migrationFiles) {
512
- const res = await orm.client.query(`SELECT 1 FROM migrations WHERE name = ?`, [name]);
513
- const status = res.length > 0 ? `${C.BG_GREEN} APPLIED ${C.RESET}` : `${C.BG_YELLOW} PENDING ${C.RESET}`;
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
- const seedDir = path.resolve(process.cwd(), "seeds");
517
- const seedFiles = (await glob(`${seedDir}/*.ts`)).map(f => path.basename(f, ".ts")).sort();
518
- console.log(`\n${C.BRIGHT}Seed Status:${C.RESET}`);
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
- for (const name of seedFiles) {
521
- const res = await orm.client.query(`SELECT 1 FROM seed_history WHERE name = ?`, [name]);
522
- const status = res.length > 0 ? `${C.BG_GREEN} APPLIED ${C.RESET}` : `${C.BG_YELLOW} PENDING ${C.RESET}`;
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
- await orm.close();
526
- } catch (error: any) {
527
- console.error(`${C.BG_RED} FATAL ${C.RESET} Status check failed:`, error.message);
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.parse(process.argv);
429
+ program
430
+ .option("-l, --log-level <level>", "Global log level", "Info")
431
+ .parse(process.argv);