stabilize-orm 1.0.8 → 1.1.0
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/.eslintrc.json +10 -0
- package/.github/workflows/ci-cd.yml +73 -0
- package/LICENSE +21 -0
- package/bun.lock +600 -0
- package/cache.ts +110 -0
- package/cli/stabilize-cli.ts +594 -0
- package/client.ts +258 -0
- package/decorators.ts +99 -0
- package/index.ts +143 -0
- package/logger.ts +126 -0
- package/migrations.ts +81 -0
- package/package.json +3 -15
- package/query-builder.ts +96 -0
- package/repository.ts +565 -0
- package/tests/migrations.test.ts +141 -0
- package/tsconfig.json +32 -0
- package/types.ts +76 -0
package/cache.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Redis from "ioredis";
|
|
2
|
+
import { type CacheConfig, type CacheStats } from "./types";
|
|
3
|
+
import { ConsoleLogger, type Logger } from "./logger";
|
|
4
|
+
|
|
5
|
+
export class Cache {
|
|
6
|
+
private redis: Redis | null = null;
|
|
7
|
+
private ttl: number;
|
|
8
|
+
private prefix: string;
|
|
9
|
+
private strategy: "cache-aside" | "write-through";
|
|
10
|
+
private logger: Logger;
|
|
11
|
+
private hits: number = 0;
|
|
12
|
+
private misses: number = 0;
|
|
13
|
+
|
|
14
|
+
constructor(config: CacheConfig, logger: Logger = new ConsoleLogger()) {
|
|
15
|
+
this.ttl = config.ttl;
|
|
16
|
+
this.prefix = config.cachePrefix || "cache:";
|
|
17
|
+
this.strategy = config.strategy || "cache-aside";
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
|
|
20
|
+
if (config.enabled && config.redisUrl) {
|
|
21
|
+
this.redis = new Redis(config.redisUrl, { lazyConnect: true });
|
|
22
|
+
this.redis.on("error", (error) => this.logger.logError(error));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getStrategy() {
|
|
27
|
+
return this.strategy;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get<T>(key: string): Promise<T | null> {
|
|
31
|
+
if (!this.redis) return null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const data = await this.redis.get(this.prefix + key);
|
|
35
|
+
if (data) {
|
|
36
|
+
this.hits++;
|
|
37
|
+
this.logger.logDebug(`Cache hit for key: ${key}`);
|
|
38
|
+
return JSON.parse(data) as T;
|
|
39
|
+
}
|
|
40
|
+
this.misses++;
|
|
41
|
+
this.logger.logDebug(`Cache miss for key: ${key}`);
|
|
42
|
+
return null;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.logger.logError(error as Error);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async set<T>(key: string, value: T, ttl: number = this.ttl): Promise<void> {
|
|
50
|
+
if (!this.redis) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await this.redis.set(this.prefix + key, JSON.stringify(value), "EX", ttl);
|
|
54
|
+
this.logger.logDebug(`Cache set for key: ${key}`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.logger.logError(error as Error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async invalidate(keys: string[]): Promise<void> {
|
|
61
|
+
if (!this.redis) return;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const pipeline = this.redis.pipeline();
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
pipeline.del(this.prefix + key);
|
|
67
|
+
this.logger.logDebug(`Cache invalidated for key: ${key}`);
|
|
68
|
+
}
|
|
69
|
+
await pipeline.exec();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
this.logger.logError(error as Error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async invalidatePattern(pattern: string): Promise<void> {
|
|
76
|
+
if (!this.redis) return;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const keys = await this.redis.keys(this.prefix + pattern);
|
|
80
|
+
if (keys.length > 0) {
|
|
81
|
+
const pipeline = this.redis.pipeline();
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
pipeline.del(key);
|
|
84
|
+
this.logger.logDebug(`Cache invalidated for pattern: ${pattern}`);
|
|
85
|
+
}
|
|
86
|
+
await pipeline.exec();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
this.logger.logError(error as Error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getStats(): Promise<CacheStats> {
|
|
94
|
+
if (!this.redis) return { hits: 0, misses: 0, keys: 0 };
|
|
95
|
+
try {
|
|
96
|
+
const keys = await this.redis.keys(this.prefix + "*");
|
|
97
|
+
return { hits: this.hits, misses: this.misses, keys: keys.length };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.logger.logError(error as Error);
|
|
100
|
+
return { hits: this.hits, misses: this.misses, keys: 0 };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async disconnect(): Promise<void> {
|
|
105
|
+
if (this.redis) {
|
|
106
|
+
await this.redis.quit();
|
|
107
|
+
this.logger.logInfo("Redis connection closed");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
generateMigration,
|
|
5
|
+
Stabilize,
|
|
6
|
+
DBType,
|
|
7
|
+
type DBConfig,
|
|
8
|
+
LogLevel,
|
|
9
|
+
type LoggerConfig,
|
|
10
|
+
runMigrations, // Added runMigrations import
|
|
11
|
+
} from "../src";
|
|
12
|
+
import * as fs from "fs/promises";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { glob } from "glob";
|
|
15
|
+
|
|
16
|
+
// --- ANSI Color and Styling Helpers ---
|
|
17
|
+
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", // Black text on Green background
|
|
29
|
+
BG_RED: "\x1b[41m\x1b[37m", // White text on Red background
|
|
30
|
+
BG_YELLOW: "\x1b[43m\x1b[30m",// Black text on Yellow background
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
program.version("1.0.5").description("Stabilize ORM CLI");
|
|
35
|
+
|
|
36
|
+
// Helper function to load configuration
|
|
37
|
+
async function loadConfig(configPath: string): Promise<{ config: DBConfig, loggerConfig: LoggerConfig, orm: Stabilize }> {
|
|
38
|
+
const absoluteConfigPath = path.resolve(process.cwd(), configPath);
|
|
39
|
+
const configModule = await import(absoluteConfigPath);
|
|
40
|
+
const config: DBConfig = configModule.default || configModule;
|
|
41
|
+
|
|
42
|
+
const logLevel = program.opts().logLevel as LogLevel;
|
|
43
|
+
|
|
44
|
+
const loggerConfig: LoggerConfig = {
|
|
45
|
+
level: logLevel,
|
|
46
|
+
filePath: path.resolve(process.cwd(), "logs/stabilize.log"),
|
|
47
|
+
maxFileSize: 5 * 1024 * 1024,
|
|
48
|
+
maxFiles: 3,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const orm = new Stabilize(
|
|
52
|
+
config,
|
|
53
|
+
{ enabled: false, ttl: 60 },
|
|
54
|
+
loggerConfig,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return { config, loggerConfig, orm };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --------------------------------------------------------------------------------------------------
|
|
61
|
+
// COMMAND: GENERATE
|
|
62
|
+
// --------------------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
program
|
|
65
|
+
.command("generate <type> <name>")
|
|
66
|
+
.description("Generate a model, migration, or seed file")
|
|
67
|
+
.option(
|
|
68
|
+
"-l, --log-level <level>",
|
|
69
|
+
"Log level (error, warn, info, debug)",
|
|
70
|
+
"info",
|
|
71
|
+
)
|
|
72
|
+
.action(async (type: string, name: string) => {
|
|
73
|
+
try {
|
|
74
|
+
if (type === "migration") {
|
|
75
|
+
const modelPath = path.resolve(process.cwd(), "models", `${name}.ts`);
|
|
76
|
+
const modelModule = await import(modelPath);
|
|
77
|
+
const model = Object.values(modelModule)[0] as new (
|
|
78
|
+
...args: any[]
|
|
79
|
+
) => any;
|
|
80
|
+
const migration = await generateMigration(
|
|
81
|
+
model,
|
|
82
|
+
`create_${name.toLowerCase()}`,
|
|
83
|
+
);
|
|
84
|
+
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
85
|
+
await fs.mkdir(migrationDir, { recursive: true });
|
|
86
|
+
const timestamp = new Date().toISOString().replace(/[-:T.]/g, "");
|
|
87
|
+
const migrationFile = path.join(
|
|
88
|
+
migrationDir,
|
|
89
|
+
`${timestamp}_${name.toLowerCase()}.ts`,
|
|
90
|
+
);
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
migrationFile,
|
|
93
|
+
`import { Migration } from 'stabilize-orm/src/types';\n\nconst migration: Migration = ${JSON.stringify(migration, null, 2)};\n\nexport default migration;`,
|
|
94
|
+
);
|
|
95
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Migration generated: ${C.GREEN}${migrationFile}${C.RESET}`);
|
|
96
|
+
|
|
97
|
+
} else if (type === "model") {
|
|
98
|
+
const modelDir = path.resolve(process.cwd(), "models");
|
|
99
|
+
await fs.mkdir(modelDir, { recursive: true });
|
|
100
|
+
const modelFile = path.join(modelDir, `${name}.ts`);
|
|
101
|
+
const modelContent = `
|
|
102
|
+
import { Model, Column, Required } from 'stabilize-orm';
|
|
103
|
+
|
|
104
|
+
@Model('${name.toLowerCase()}s')
|
|
105
|
+
export class ${name} {
|
|
106
|
+
@Column('id', 'INTEGER')
|
|
107
|
+
id?: number;
|
|
108
|
+
|
|
109
|
+
@Column('name', 'TEXT')
|
|
110
|
+
@Required()
|
|
111
|
+
name: string;
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
await fs.writeFile(modelFile, modelContent);
|
|
115
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Model generated: ${C.GREEN}${modelFile}${C.RESET}`);
|
|
116
|
+
|
|
117
|
+
} else if (type === "seed") {
|
|
118
|
+
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
119
|
+
await fs.mkdir(seedDir, { recursive: true });
|
|
120
|
+
const timestamp = new Date().toISOString().replace(/[-:T.]/g, "");
|
|
121
|
+
const seedFile = path.join(
|
|
122
|
+
seedDir,
|
|
123
|
+
`${timestamp}_${name.toLowerCase()}.ts`,
|
|
124
|
+
);
|
|
125
|
+
const seedContent = `
|
|
126
|
+
import { Stabilize } from 'stabilize-orm';
|
|
127
|
+
import { ${name} } from '../models/${name}'; // Assuming model path
|
|
128
|
+
|
|
129
|
+
export const dependencies = [];
|
|
130
|
+
|
|
131
|
+
export async function seed(orm: Stabilize) {
|
|
132
|
+
const repo = orm.getRepository(${name});
|
|
133
|
+
await repo.bulkCreate([
|
|
134
|
+
{ name: '${name} 1' },
|
|
135
|
+
{ name: '${name} 2' },
|
|
136
|
+
], { batchSize: 100 });
|
|
137
|
+
|
|
138
|
+
await orm['client'].query(
|
|
139
|
+
\`INSERT INTO seed_history (name, applied_at) VALUES (?, ?)\`,
|
|
140
|
+
['${timestamp}_${name.toLowerCase()}', new Date().toISOString()]
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function rollback(orm: Stabilize) {
|
|
145
|
+
const repo = orm.getRepository(${name});
|
|
146
|
+
// NOTE: This rollback logic assumes your entities have an 'id' field for deletion.
|
|
147
|
+
const entities = await repo.find().execute(orm['client']);
|
|
148
|
+
await repo.bulkDelete(entities.map(e => e.id!), { batchSize: 100 });
|
|
149
|
+
|
|
150
|
+
await orm['client'].query(
|
|
151
|
+
\`DELETE FROM seed_history WHERE name = ?\`,
|
|
152
|
+
['${timestamp}_${name.toLowerCase()}']
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
`;
|
|
156
|
+
await fs.writeFile(seedFile, seedContent);
|
|
157
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Seed generated: ${C.GREEN}${seedFile}${C.RESET}`);
|
|
158
|
+
} else {
|
|
159
|
+
console.error(`${C.BG_RED} ERROR ${C.RESET} Invalid type. Use ${C.YELLOW}"model", "migration", or "seed"${C.RESET}.`);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Error generating file:`, error);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
// --------------------------------------------------------------------------------------------------
|
|
168
|
+
// COMMAND: MIGRATE
|
|
169
|
+
// --------------------------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
program
|
|
172
|
+
.command("migrate")
|
|
173
|
+
.description("Apply all pending migrations")
|
|
174
|
+
.option(
|
|
175
|
+
"-c, --config <path>",
|
|
176
|
+
"Path to database config file",
|
|
177
|
+
"config/database.ts",
|
|
178
|
+
)
|
|
179
|
+
.option(
|
|
180
|
+
"-l, --log-level <level>",
|
|
181
|
+
"Log level (error, warn, info, debug)",
|
|
182
|
+
"info",
|
|
183
|
+
)
|
|
184
|
+
.action(async (options) => {
|
|
185
|
+
let orm: Stabilize | null = null;
|
|
186
|
+
try {
|
|
187
|
+
const { config } = await loadConfig(options.config);
|
|
188
|
+
|
|
189
|
+
// Re-initialize ORM with correct logging configuration
|
|
190
|
+
orm = new Stabilize(config, { enabled: false, ttl: 60 }, { level: options.logLevel as LogLevel });
|
|
191
|
+
|
|
192
|
+
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
193
|
+
const migrationFiles = await glob(`${migrationDir}/*.ts`);
|
|
194
|
+
|
|
195
|
+
const migrations = [];
|
|
196
|
+
for (const file of migrationFiles.sort()) {
|
|
197
|
+
const migrationModule = await import(file);
|
|
198
|
+
// Assuming migration files export a default object with { up: string[], down: string[] }
|
|
199
|
+
migrations.push(migrationModule.default || migrationModule);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (migrations.length === 0) {
|
|
203
|
+
console.log(`${C.YELLOW} WARNING ${C.RESET} No migration files found in migrations/ directory.`);
|
|
204
|
+
await orm.close();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`${C.BLUE} INFO ${C.RESET} Running ${C.BRIGHT}${migrations.length}${C.RESET} migration files...`);
|
|
209
|
+
|
|
210
|
+
// runMigrations handles applying only the unapplied ones.
|
|
211
|
+
await runMigrations(config, migrations);
|
|
212
|
+
|
|
213
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Migrations completed successfully.`);
|
|
214
|
+
await orm.close();
|
|
215
|
+
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Migration failed:`, error);
|
|
218
|
+
if (orm) await orm.close();
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
// --------------------------------------------------------------------------------------------------
|
|
225
|
+
// COMMAND: MIGRATE:ROLLBACK (One step back)
|
|
226
|
+
// --------------------------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
program
|
|
229
|
+
.command("migrate:rollback")
|
|
230
|
+
.description("Rollback the most recently applied migration")
|
|
231
|
+
.option(
|
|
232
|
+
"-c, --config <path>",
|
|
233
|
+
"Path to database config file",
|
|
234
|
+
"config/database.ts",
|
|
235
|
+
)
|
|
236
|
+
.option(
|
|
237
|
+
"-l, --log-level <level>",
|
|
238
|
+
"Log level (error, warn, info, debug)",
|
|
239
|
+
"info",
|
|
240
|
+
)
|
|
241
|
+
.action(async (options) => {
|
|
242
|
+
let orm: Stabilize | null = null;
|
|
243
|
+
try {
|
|
244
|
+
const { config } = await loadConfig(options.config);
|
|
245
|
+
orm = new Stabilize(config, { enabled: false, ttl: 60 }, { level: options.logLevel as LogLevel });
|
|
246
|
+
|
|
247
|
+
// 1. Get the last applied migration record from the DB
|
|
248
|
+
const latestApplied = await orm["client"].query<{ name: string }>(
|
|
249
|
+
`SELECT name FROM migrations ORDER BY applied_at DESC LIMIT 1`,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (latestApplied.length === 0) {
|
|
253
|
+
console.log(`${C.YELLOW} WARNING ${C.RESET} No migrations to rollback.`);
|
|
254
|
+
await orm.close();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const migrationName = latestApplied[0]!.name;
|
|
259
|
+
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
260
|
+
const migrationFiles = await glob(`${migrationDir}/*.ts`);
|
|
261
|
+
|
|
262
|
+
// 2. Find the corresponding file
|
|
263
|
+
const migrationFile = migrationFiles.find(f => path.basename(f, '.ts') === migrationName);
|
|
264
|
+
|
|
265
|
+
if (!migrationFile) {
|
|
266
|
+
console.error(`${C.BG_RED} ERROR ${C.RESET} Migration file for ${migrationName} not found.`);
|
|
267
|
+
await orm.close();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Load the rollback (down) query
|
|
272
|
+
const migrationModule = await import(migrationFile);
|
|
273
|
+
const migration = migrationModule.default || migrationModule;
|
|
274
|
+
const downQueries: string[] = migration.down;
|
|
275
|
+
|
|
276
|
+
if (!downQueries || downQueries.length === 0) {
|
|
277
|
+
console.error(`${C.BG_RED} ERROR ${C.RESET} Migration ${migrationName} does not contain a 'down' array for rollback.`);
|
|
278
|
+
await orm.close();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log(`${C.BLUE} INFO ${C.RESET} Rolling back migration: ${C.YELLOW}${migrationName}${C.RESET}`);
|
|
283
|
+
|
|
284
|
+
// 4. Run the down queries inside a transaction (best practice)
|
|
285
|
+
await orm.transaction(async () => {
|
|
286
|
+
for (const query of downQueries) {
|
|
287
|
+
await orm!['client'].query(query, []);
|
|
288
|
+
}
|
|
289
|
+
// 5. Remove the migration record from the history table
|
|
290
|
+
await orm!["client"].query(
|
|
291
|
+
`DELETE FROM migrations WHERE name = ?`,
|
|
292
|
+
[migrationName],
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Rolled back migration: ${C.GREEN}${migrationName}${C.RESET}`);
|
|
297
|
+
await orm.close();
|
|
298
|
+
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Migration rollback failed:`, error);
|
|
301
|
+
if (orm) await orm.close();
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
// --------------------------------------------------------------------------------------------------
|
|
308
|
+
// COMMAND: SEED
|
|
309
|
+
// --------------------------------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
program
|
|
312
|
+
.command("seed")
|
|
313
|
+
.description("Run seed files to populate the database")
|
|
314
|
+
.option(
|
|
315
|
+
"-c, --config <path>",
|
|
316
|
+
"Path to database config file",
|
|
317
|
+
"config/database.ts",
|
|
318
|
+
)
|
|
319
|
+
.option(
|
|
320
|
+
"-l, --log-level <level>",
|
|
321
|
+
"Log level (error, warn, info, debug)",
|
|
322
|
+
"info",
|
|
323
|
+
)
|
|
324
|
+
.action(async (options) => {
|
|
325
|
+
let orm: Stabilize | null = null;
|
|
326
|
+
try {
|
|
327
|
+
const { config } = await loadConfig(options.config);
|
|
328
|
+
orm = new Stabilize(config, { enabled: false, ttl: 60 }, { level: options.logLevel as LogLevel });
|
|
329
|
+
|
|
330
|
+
await orm["client"].query(`
|
|
331
|
+
CREATE TABLE IF NOT EXISTS seed_history (
|
|
332
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
333
|
+
name TEXT NOT NULL,
|
|
334
|
+
applied_at TEXT NOT NULL
|
|
335
|
+
)
|
|
336
|
+
`);
|
|
337
|
+
|
|
338
|
+
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
339
|
+
const seedFiles = await glob(`${seedDir}/*.ts`);
|
|
340
|
+
if (seedFiles.length === 0) {
|
|
341
|
+
console.log(`${C.YELLOW} WARNING ${C.RESET} No seed files found in seeds/ directory.`);
|
|
342
|
+
await orm.close();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const seedGraph = new Map<
|
|
347
|
+
string,
|
|
348
|
+
{ file: string; dependencies: string[] }
|
|
349
|
+
>();
|
|
350
|
+
for (const file of seedFiles) {
|
|
351
|
+
const seedName = path.basename(file, ".ts");
|
|
352
|
+
const seedModule = await import(file);
|
|
353
|
+
seedGraph.set(seedName, {
|
|
354
|
+
file,
|
|
355
|
+
dependencies: seedModule.dependencies || [],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const orderedSeeds = topologicalSort(seedGraph);
|
|
360
|
+
|
|
361
|
+
console.log(`${C.BLUE} INFO ${C.RESET} Running ${C.BRIGHT}${orderedSeeds.length}${C.RESET} seed files (sorted by dependency)...`);
|
|
362
|
+
let seedsApplied = 0;
|
|
363
|
+
for (const seedName of orderedSeeds) {
|
|
364
|
+
const { file } = seedGraph.get(seedName)!;
|
|
365
|
+
const applied = await orm["client"].query<{ id: number }>(
|
|
366
|
+
`SELECT id FROM seed_history WHERE name = ?`,
|
|
367
|
+
[seedName],
|
|
368
|
+
);
|
|
369
|
+
if (applied.length > 0) {
|
|
370
|
+
console.log(` ${C.DIM}Skipping already applied seed:${C.RESET} ${seedName}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
console.log(` ${C.BRIGHT}Executing seed:${C.RESET} ${C.MAGENTA}${seedName}${C.RESET}`);
|
|
375
|
+
const seedModule = await import(file);
|
|
376
|
+
const seedFn = seedModule.default || seedModule.seed;
|
|
377
|
+
if (typeof seedFn === "function") {
|
|
378
|
+
await seedFn(orm);
|
|
379
|
+
seedsApplied++;
|
|
380
|
+
} else {
|
|
381
|
+
console.error(
|
|
382
|
+
`${C.BG_RED} ERROR ${C.RESET} Seed file ${file} must export a default function or a function named 'seed'.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log(`\n${C.BG_GREEN} SUCCESS ${C.RESET} Seeding completed. ${C.GREEN}${seedsApplied} new seeds applied.${C.RESET}`);
|
|
388
|
+
await orm.close();
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Seeding failed:`, error);
|
|
391
|
+
if (orm) await orm.close();
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --------------------------------------------------------------------------------------------------
|
|
397
|
+
// COMMAND: SEED:ROLLBACK (One step back)
|
|
398
|
+
// --------------------------------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
program
|
|
401
|
+
.command("seed:rollback")
|
|
402
|
+
.description("Rollback the most recently applied seed")
|
|
403
|
+
.option(
|
|
404
|
+
"-c, --config <path>",
|
|
405
|
+
"Path to database config file",
|
|
406
|
+
"config/database.ts",
|
|
407
|
+
)
|
|
408
|
+
.option(
|
|
409
|
+
"-l, --log-level <level>",
|
|
410
|
+
"Log level (error, warn, info, debug)",
|
|
411
|
+
"info",
|
|
412
|
+
)
|
|
413
|
+
.action(async (options) => {
|
|
414
|
+
let orm: Stabilize | null = null;
|
|
415
|
+
try {
|
|
416
|
+
const { config } = await loadConfig(options.config);
|
|
417
|
+
orm = new Stabilize(config, { enabled: false, ttl: 60 }, { level: options.logLevel as LogLevel });
|
|
418
|
+
|
|
419
|
+
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
420
|
+
const seedFiles = await glob(`${seedDir}/*.ts`);
|
|
421
|
+
const seedGraph = new Map<
|
|
422
|
+
string,
|
|
423
|
+
{ file: string; dependencies: string[] }
|
|
424
|
+
>();
|
|
425
|
+
for (const file of seedFiles) {
|
|
426
|
+
const seedName = path.basename(file, ".ts");
|
|
427
|
+
const seedModule = await import(file);
|
|
428
|
+
seedGraph.set(seedName, {
|
|
429
|
+
file,
|
|
430
|
+
dependencies: seedModule.dependencies || [],
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const latestSeed = await orm["client"].query<{ name: string }>(
|
|
435
|
+
`SELECT name FROM seed_history ORDER BY applied_at DESC LIMIT 1`,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
if (latestSeed.length === 0) {
|
|
439
|
+
console.log(`${C.YELLOW} WARNING ${C.RESET} No seeds to rollback.`);
|
|
440
|
+
await orm.close();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const seedName = latestSeed[0]!.name;
|
|
445
|
+
const seedFile = seedGraph.get(seedName)?.file;
|
|
446
|
+
if (!seedFile) {
|
|
447
|
+
console.error(`${C.BG_RED} ERROR ${C.RESET} Seed file for ${seedName} not found.`);
|
|
448
|
+
await orm.close();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
console.log(`${C.BLUE} INFO ${C.RESET} Rolling back seed: ${C.YELLOW}${seedName}${C.RESET}`);
|
|
453
|
+
const seedModule = await import(seedFile);
|
|
454
|
+
const rollbackFn = seedModule.rollback;
|
|
455
|
+
|
|
456
|
+
if (typeof rollbackFn === "function") {
|
|
457
|
+
await rollbackFn(orm);
|
|
458
|
+
console.log(`${C.BG_GREEN} SUCCESS ${C.RESET} Rolled back seed: ${C.GREEN}${seedName}${C.RESET}`);
|
|
459
|
+
} else {
|
|
460
|
+
console.error(
|
|
461
|
+
`${C.BG_RED} ERROR ${C.RESET} Seed file ${seedFile} must export a 'rollback' function.`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await orm.close();
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Seed rollback failed:`, error);
|
|
468
|
+
if (orm) await orm.close();
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
// --------------------------------------------------------------------------------------------------
|
|
475
|
+
// COMMAND: STATUS
|
|
476
|
+
// --------------------------------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
program
|
|
479
|
+
.command("status")
|
|
480
|
+
.description("Display status of migrations and seeds")
|
|
481
|
+
.option(
|
|
482
|
+
"-c, --config <path>",
|
|
483
|
+
"Path to database config file",
|
|
484
|
+
"config/database.ts",
|
|
485
|
+
)
|
|
486
|
+
.option(
|
|
487
|
+
"-l, --log-level <level>",
|
|
488
|
+
"Log level (error, warn, info, debug)",
|
|
489
|
+
"info",
|
|
490
|
+
)
|
|
491
|
+
.action(async (options) => {
|
|
492
|
+
let orm: Stabilize | null = null;
|
|
493
|
+
try {
|
|
494
|
+
const { config } = await loadConfig(options.config);
|
|
495
|
+
orm = new Stabilize(config, { enabled: false, ttl: 60 }, { level: options.logLevel as LogLevel });
|
|
496
|
+
|
|
497
|
+
const migrationDir = path.resolve(process.cwd(), "migrations");
|
|
498
|
+
const migrationFiles = await glob(`${migrationDir}/*.ts`);
|
|
499
|
+
|
|
500
|
+
// Ensure migrations table exists for query purposes
|
|
501
|
+
await orm["client"].query(`
|
|
502
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
503
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
504
|
+
name TEXT NOT NULL,
|
|
505
|
+
applied_at TEXT NOT NULL
|
|
506
|
+
)
|
|
507
|
+
`);
|
|
508
|
+
|
|
509
|
+
console.log(`\n${C.BRIGHT}Migration Status:${C.RESET}`);
|
|
510
|
+
console.log(`---------------------------------`);
|
|
511
|
+
for (const file of migrationFiles.sort()) {
|
|
512
|
+
const migrationName = path.basename(file, ".ts");
|
|
513
|
+
const applied = await orm["client"].query<{ name: string }>(
|
|
514
|
+
`SELECT name FROM migrations WHERE name = ?`,
|
|
515
|
+
[migrationName],
|
|
516
|
+
);
|
|
517
|
+
const status = applied.length > 0
|
|
518
|
+
? `${C.BG_GREEN} APPLIED ${C.RESET}`
|
|
519
|
+
: `${C.BG_YELLOW} PENDING ${C.RESET}`;
|
|
520
|
+
|
|
521
|
+
console.log(`${status} ${C.WHITE}${migrationName}${C.RESET}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Ensure seed_history table exists for query purposes
|
|
525
|
+
await orm["client"].query(`
|
|
526
|
+
CREATE TABLE IF NOT EXISTS seed_history (
|
|
527
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
528
|
+
name TEXT NOT NULL,
|
|
529
|
+
applied_at TEXT NOT NULL
|
|
530
|
+
)
|
|
531
|
+
`);
|
|
532
|
+
|
|
533
|
+
const seedDir = path.resolve(process.cwd(), "seeds");
|
|
534
|
+
const seedFiles = await glob(`${seedDir}/*.ts`);
|
|
535
|
+
console.log(`\n${C.BRIGHT}Seed Status:${C.RESET}`);
|
|
536
|
+
console.log(`---------------------------------`);
|
|
537
|
+
for (const file of seedFiles.sort()) {
|
|
538
|
+
const seedName = path.basename(file, ".ts");
|
|
539
|
+
const applied = await orm["client"].query<{ name: string }>(
|
|
540
|
+
`SELECT name FROM seed_history WHERE name = ?`,
|
|
541
|
+
[seedName],
|
|
542
|
+
);
|
|
543
|
+
const status = applied.length > 0
|
|
544
|
+
? `${C.BG_GREEN} APPLIED ${C.RESET}`
|
|
545
|
+
: `${C.BG_YELLOW} PENDING ${C.RESET}`;
|
|
546
|
+
|
|
547
|
+
console.log(`${status} ${C.WHITE}${seedName}${C.RESET}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
await orm.close();
|
|
551
|
+
} catch (error) {
|
|
552
|
+
console.error(`${C.BG_RED} FATAL ${C.RESET} Status check failed:`, error);
|
|
553
|
+
if (orm) await orm.close();
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
// --------------------------------------------------------------------------------------------------
|
|
560
|
+
// TOPOLOGICAL SORT HELPER
|
|
561
|
+
// --------------------------------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
function topologicalSort(
|
|
564
|
+
graph: Map<string, { file: string; dependencies: string[] }>,
|
|
565
|
+
): string[] {
|
|
566
|
+
const result: string[] = [];
|
|
567
|
+
const visited = new Set<string>();
|
|
568
|
+
const temp = new Set<string>();
|
|
569
|
+
|
|
570
|
+
function visit(node: string) {
|
|
571
|
+
if (temp.has(node))
|
|
572
|
+
throw new Error(`Circular dependency detected at ${node}`);
|
|
573
|
+
if (!visited.has(node)) {
|
|
574
|
+
temp.add(node);
|
|
575
|
+
const { dependencies } = graph.get(node)!;
|
|
576
|
+
for (const dep of dependencies) {
|
|
577
|
+
if (!graph.has(dep))
|
|
578
|
+
throw new Error(`Dependency ${dep} not found for ${node}`);
|
|
579
|
+
visit(dep);
|
|
580
|
+
}
|
|
581
|
+
temp.delete(node);
|
|
582
|
+
visited.add(node);
|
|
583
|
+
result.push(node);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
for (const node of graph.keys()) {
|
|
588
|
+
if (!visited.has(node)) visit(node);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
program.parse(process.argv);
|