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