create-lumina-project 1.1.1 → 1.2.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/package.json +2 -2
- package/template/package.json +14 -13
- package/template/scripts/create-migration.ts +457 -16
- package/template/src/database/migrations/20260420050854-create_users.js +68 -0
- package/template/src/database/migrations/20260420050855-create_refresh_tokens.js +60 -0
- package/template/src/utils/Logger.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lumina-project",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"author": "Glenson Ansin",
|
|
5
5
|
"description": "Scaffold a modern Node.js API with Lumina architecture.",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"template"
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"fs-extra": "^11.3.
|
|
15
|
+
"fs-extra": "^11.3.4",
|
|
16
16
|
"kleur": "^4.1.5",
|
|
17
17
|
"ora": "^9.3.0",
|
|
18
18
|
"prompts": "^2.4.2"
|
package/template/package.json
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "server.ts",
|
|
6
|
-
"private": true,
|
|
7
|
-
"type": "module",
|
|
8
6
|
"scripts": {
|
|
9
7
|
"dev": "nodemon --exec tsx server.ts",
|
|
10
8
|
"build": "tsc",
|
|
11
9
|
"start": "node dist/server.js",
|
|
12
10
|
"test": "vitest run",
|
|
13
11
|
"migrate": "tsx scripts/migrate.ts up",
|
|
12
|
+
"migrate:seed": "tsx scripts/migrate.ts up && tsx scripts/seed.ts",
|
|
14
13
|
"migrate:undo": "tsx scripts/migrate.ts down",
|
|
15
14
|
"migrate:reset": "tsx scripts/migrate.ts reset",
|
|
16
15
|
"db:seed": "tsx scripts/seed.ts",
|
|
@@ -22,39 +21,41 @@
|
|
|
22
21
|
"create:factory": "tsx scripts/create-factory.ts",
|
|
23
22
|
"key:generate": "tsx scripts/generate-keys.ts"
|
|
24
23
|
},
|
|
24
|
+
"private": true,
|
|
25
|
+
"type": "module",
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"bcrypt": "^6.0.0",
|
|
27
28
|
"bcryptjs": "^3.0.3",
|
|
28
29
|
"compression": "^1.8.1",
|
|
29
30
|
"cookie-parser": "^1.4.7",
|
|
30
31
|
"cors": "^2.8.6",
|
|
31
|
-
"dotenv": "^17.2
|
|
32
|
+
"dotenv": "^17.4.2",
|
|
32
33
|
"express": "^5.2.1",
|
|
33
|
-
"express-rate-limit": "^8.2
|
|
34
|
+
"express-rate-limit": "^8.3.2",
|
|
34
35
|
"helmet": "^8.1.0",
|
|
35
36
|
"jsonwebtoken": "^9.0.3",
|
|
36
|
-
"multer": "^2.
|
|
37
|
-
"mysql2": "^3.
|
|
38
|
-
"nodemon": "^3.1.
|
|
39
|
-
"pg": "^8.
|
|
37
|
+
"multer": "^2.1.1",
|
|
38
|
+
"mysql2": "^3.22.1",
|
|
39
|
+
"nodemon": "^3.1.14",
|
|
40
|
+
"pg": "^8.20.0",
|
|
40
41
|
"pg-hstore": "^2.3.4",
|
|
41
|
-
"sequelize": "^6.37.
|
|
42
|
+
"sequelize": "^6.37.8",
|
|
42
43
|
"sequelize-cli": "^6.6.5",
|
|
43
44
|
"umzug": "^3.8.2",
|
|
44
45
|
"winston": "^3.19.0",
|
|
45
46
|
"zod": "^4.3.6"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@faker-js/faker": "^10.
|
|
49
|
+
"@faker-js/faker": "^10.4.0",
|
|
49
50
|
"@types/bcrypt": "^6.0.0",
|
|
50
51
|
"@types/compression": "^1.8.1",
|
|
51
52
|
"@types/cookie-parser": "^1.4.10",
|
|
52
53
|
"@types/cors": "^2.8.19",
|
|
53
54
|
"@types/express": "^5.0.6",
|
|
54
55
|
"@types/jsonwebtoken": "^9.0.10",
|
|
55
|
-
"@types/multer": "^2.
|
|
56
|
+
"@types/multer": "^2.1.0",
|
|
56
57
|
"tsx": "^4.21.0",
|
|
57
|
-
"typescript": "^
|
|
58
|
-
"vitest": "^4.
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^4.1.4"
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -2,6 +2,18 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import Logger from '../src/utils/Logger.js';
|
|
4
4
|
|
|
5
|
+
interface ColumnDefinition {
|
|
6
|
+
type: string;
|
|
7
|
+
allowNull?: boolean;
|
|
8
|
+
primaryKey?: boolean;
|
|
9
|
+
autoIncrement?: boolean;
|
|
10
|
+
unique?: boolean;
|
|
11
|
+
defaultValue?: string;
|
|
12
|
+
references?: { model: string; key: string };
|
|
13
|
+
onUpdate?: string;
|
|
14
|
+
onDelete?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
class MigrationGenerator {
|
|
6
18
|
private nameArgument: string;
|
|
7
19
|
private migrationName: string;
|
|
@@ -9,12 +21,14 @@ class MigrationGenerator {
|
|
|
9
21
|
private tableName: string;
|
|
10
22
|
private migrationsDir: string;
|
|
11
23
|
private stubPath: string;
|
|
24
|
+
private modelsDir: string;
|
|
12
25
|
|
|
13
26
|
constructor() {
|
|
14
27
|
this.nameArgument = process.argv[2];
|
|
15
28
|
this.migrationsDir = path.join(process.cwd(), 'src', 'database', 'migrations');
|
|
16
29
|
this.stubPath = path.join(process.cwd(), 'scripts', 'stubs', 'migration.stub');
|
|
17
|
-
|
|
30
|
+
this.modelsDir = path.join(process.cwd(), 'src', 'models');
|
|
31
|
+
|
|
18
32
|
// Initialize properties safely
|
|
19
33
|
this.migrationName = '';
|
|
20
34
|
this.className = '';
|
|
@@ -24,15 +38,19 @@ class MigrationGenerator {
|
|
|
24
38
|
public run(): void {
|
|
25
39
|
if (!this.validateInput()) return;
|
|
26
40
|
|
|
27
|
-
this.
|
|
28
|
-
|
|
41
|
+
if (this.nameArgument.toLowerCase() === 'all') {
|
|
42
|
+
this.generateAllMigrations();
|
|
43
|
+
} else {
|
|
44
|
+
this.parseNameArgument();
|
|
45
|
+
this.createMigrationFile();
|
|
46
|
+
}
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
private validateInput(): boolean {
|
|
32
50
|
if (!this.nameArgument) {
|
|
33
51
|
Logger.error('Please provide a migration name.');
|
|
34
|
-
console.log('\nUsage: npm run
|
|
35
|
-
console.log('Example: npm run
|
|
52
|
+
console.log('\nUsage: npm run create:migration <name> | all');
|
|
53
|
+
console.log('Example: npm run create:migration create_products_table\n');
|
|
36
54
|
process.exit(1);
|
|
37
55
|
}
|
|
38
56
|
return true;
|
|
@@ -57,16 +75,348 @@ class MigrationGenerator {
|
|
|
57
75
|
if (match && match[1]) {
|
|
58
76
|
this.tableName = match[1];
|
|
59
77
|
} else {
|
|
60
|
-
|
|
78
|
+
// Also try without _table suffix: "create_users" -> "users"
|
|
79
|
+
const simpleMatch = this.migrationName.match(/create_(.+)/);
|
|
80
|
+
if (simpleMatch && simpleMatch[1]) {
|
|
81
|
+
this.tableName = simpleMatch[1];
|
|
82
|
+
} else {
|
|
83
|
+
this.tableName = this.migrationName;
|
|
84
|
+
}
|
|
61
85
|
}
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
/**
|
|
65
89
|
* Generates YYYYMMDDHHMMSS format
|
|
66
90
|
*/
|
|
67
|
-
private getTimestamp(): string {
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
private getTimestamp(date: Date = new Date()): string {
|
|
92
|
+
return date.toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attempts to find a matching model file based on the table name.
|
|
97
|
+
* Tries multiple strategies: PascalCase singularization, direct tableName match,
|
|
98
|
+
* and pluralized tableName match to handle inputs like "user", "users", "refresh_tokens".
|
|
99
|
+
* When a model is found, updates this.tableName to match the model's actual tableName.
|
|
100
|
+
*/
|
|
101
|
+
private findModelFile(): string | null {
|
|
102
|
+
if (!fs.existsSync(this.modelsDir)) return null;
|
|
103
|
+
|
|
104
|
+
const files = fs.readdirSync(this.modelsDir).filter(
|
|
105
|
+
f => f.endsWith('.ts') && f !== 'index.ts'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Strategy 1: Singularize table name and PascalCase it -> Model file
|
|
109
|
+
// e.g. "users" -> "User.ts", "refresh_tokens" -> "RefreshToken.ts"
|
|
110
|
+
const singularized = this.singularize(this.tableName);
|
|
111
|
+
const expectedModelName = this.toPascalCase(singularized);
|
|
112
|
+
|
|
113
|
+
const directMatch = files.find(f => f === `${expectedModelName}.ts`);
|
|
114
|
+
if (directMatch) {
|
|
115
|
+
const filePath = path.join(this.modelsDir, directMatch);
|
|
116
|
+
this.syncTableName(filePath);
|
|
117
|
+
return filePath;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Strategy 2: Try PascalCase without singularizing (in case table name is already singular)
|
|
121
|
+
const rawPascal = this.toPascalCase(this.tableName);
|
|
122
|
+
const rawMatch = files.find(f => f === `${rawPascal}.ts`);
|
|
123
|
+
if (rawMatch) {
|
|
124
|
+
const filePath = path.join(this.modelsDir, rawMatch);
|
|
125
|
+
this.syncTableName(filePath);
|
|
126
|
+
return filePath;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Strategy 3: Scan all model files for a matching tableName (try both singular and plural)
|
|
130
|
+
const possibleTableNames = new Set([this.tableName, this.pluralize(this.tableName), singularized]);
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const filePath = path.join(this.modelsDir, file);
|
|
133
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
134
|
+
for (const name of possibleTableNames) {
|
|
135
|
+
if (content.includes(`tableName: '${name}'`)) {
|
|
136
|
+
// Update tableName to match what the model actually uses
|
|
137
|
+
this.tableName = name;
|
|
138
|
+
return filePath;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reads a model file and updates this.tableName to match the model's actual tableName.
|
|
148
|
+
*/
|
|
149
|
+
private syncTableName(filePath: string): void {
|
|
150
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
151
|
+
const tableNameMatch = content.match(/tableName:\s*'(\w+)'/);
|
|
152
|
+
if (tableNameMatch) {
|
|
153
|
+
this.tableName = tableNameMatch[1];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Converts a snake_case string to PascalCase.
|
|
159
|
+
*/
|
|
160
|
+
private toPascalCase(str: string): string {
|
|
161
|
+
return str
|
|
162
|
+
.split('_')
|
|
163
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
164
|
+
.join('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Basic pluralization: adds trailing 's'.
|
|
169
|
+
*/
|
|
170
|
+
private pluralize(word: string): string {
|
|
171
|
+
if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') || word.endsWith('ch') || word.endsWith('sh')) {
|
|
172
|
+
return word + 'es';
|
|
173
|
+
}
|
|
174
|
+
if (word.endsWith('y') && !['a','e','i','o','u'].includes(word[word.length - 2])) {
|
|
175
|
+
return word.slice(0, -1) + 'ies';
|
|
176
|
+
}
|
|
177
|
+
return word + 's';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Basic singularization: removes trailing 's'.
|
|
182
|
+
* Handles common patterns like "ies" -> "y".
|
|
183
|
+
*/
|
|
184
|
+
private singularize(word: string): string {
|
|
185
|
+
if (word.endsWith('ies')) {
|
|
186
|
+
return word.slice(0, -3) + 'y';
|
|
187
|
+
}
|
|
188
|
+
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
|
|
189
|
+
return word.slice(0, -2);
|
|
190
|
+
}
|
|
191
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
192
|
+
return word.slice(0, -1);
|
|
193
|
+
}
|
|
194
|
+
return word;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parses a Sequelize model file to extract column definitions from the init() call
|
|
199
|
+
* and model options (paranoid, timestamps).
|
|
200
|
+
*/
|
|
201
|
+
private parseModelFile(filePath: string): { columns: Map<string, ColumnDefinition>; paranoid: boolean; timestamps: boolean; associations: string[] } {
|
|
202
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
203
|
+
const columns = new Map<string, ColumnDefinition>();
|
|
204
|
+
const associations: string[] = [];
|
|
205
|
+
|
|
206
|
+
// Detect paranoid & timestamps from model options
|
|
207
|
+
const paranoid = /paranoid:\s*true/.test(content);
|
|
208
|
+
const timestamps = /timestamps:\s*true/.test(content) || !/timestamps:\s*false/.test(content);
|
|
209
|
+
|
|
210
|
+
// Extract the init() block - find the column definitions object
|
|
211
|
+
// We look for the pattern: ModelName.init({ ... }, { ... })
|
|
212
|
+
const initMatch = content.match(/\.init\s*\(\s*\{([\s\S]*?)\}\s*,\s*\{/);
|
|
213
|
+
if (!initMatch) {
|
|
214
|
+
return { columns, paranoid, timestamps, associations };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const columnsBlock = initMatch[1];
|
|
218
|
+
|
|
219
|
+
// Parse individual column definitions using a state-machine approach
|
|
220
|
+
this.parseColumnsBlock(columnsBlock, columns);
|
|
221
|
+
|
|
222
|
+
// Parse associations for foreign key references
|
|
223
|
+
this.parseAssociations(content, associations);
|
|
224
|
+
|
|
225
|
+
return { columns, paranoid, timestamps, associations };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parses the columns block from Model.init() to extract column definitions.
|
|
230
|
+
*/
|
|
231
|
+
private parseColumnsBlock(block: string, columns: Map<string, ColumnDefinition>): void {
|
|
232
|
+
// Match each column: `columnName: { ... }`
|
|
233
|
+
const columnRegex = /(\w+)\s*:\s*\{([^}]+)\}/g;
|
|
234
|
+
let match;
|
|
235
|
+
|
|
236
|
+
while ((match = columnRegex.exec(block)) !== null) {
|
|
237
|
+
const columnName = match[1];
|
|
238
|
+
const columnBody = match[2];
|
|
239
|
+
|
|
240
|
+
const colDef: ColumnDefinition = {
|
|
241
|
+
type: this.extractType(columnBody),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Extract allowNull
|
|
245
|
+
const allowNullMatch = columnBody.match(/allowNull:\s*(true|false)/);
|
|
246
|
+
if (allowNullMatch) {
|
|
247
|
+
colDef.allowNull = allowNullMatch[1] === 'true';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Extract primaryKey
|
|
251
|
+
if (/primaryKey:\s*true/.test(columnBody)) {
|
|
252
|
+
colDef.primaryKey = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Extract autoIncrement
|
|
256
|
+
if (/autoIncrement:\s*true/.test(columnBody)) {
|
|
257
|
+
colDef.autoIncrement = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Extract unique
|
|
261
|
+
if (/unique:\s*true/.test(columnBody)) {
|
|
262
|
+
colDef.unique = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Extract defaultValue
|
|
266
|
+
const defaultMatch = columnBody.match(/defaultValue:\s*(.+?)(?:,|\s*$)/);
|
|
267
|
+
if (defaultMatch) {
|
|
268
|
+
const val = defaultMatch[1].trim();
|
|
269
|
+
// Clean trailing commas or spaces
|
|
270
|
+
colDef.defaultValue = val.replace(/,\s*$/, '');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
columns.set(columnName, colDef);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Extracts the DataTypes.XXX type string from a column definition body.
|
|
279
|
+
*/
|
|
280
|
+
private extractType(body: string): string {
|
|
281
|
+
// Match patterns like: DataTypes.STRING, DataTypes.STRING(50), DataTypes.BIGINT
|
|
282
|
+
const typeMatch = body.match(/type:\s*(DataTypes\.\w+(?:\([^)]*\))?)/);
|
|
283
|
+
if (typeMatch) {
|
|
284
|
+
return typeMatch[1];
|
|
285
|
+
}
|
|
286
|
+
return 'DataTypes.STRING';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Parses association methods (belongsTo, hasMany, etc.) for foreign key info.
|
|
291
|
+
*/
|
|
292
|
+
private parseAssociations(content: string, associations: string[]): void {
|
|
293
|
+
// Match belongsTo associations to detect foreign keys
|
|
294
|
+
const belongsToRegex = /belongsTo\s*\(\s*models\.(\w+)\s*,\s*\{[^}]*foreignKey:\s*'(\w+)'/g;
|
|
295
|
+
let match;
|
|
296
|
+
while ((match = belongsToRegex.exec(content)) !== null) {
|
|
297
|
+
associations.push(`${match[2]}:${match[1]}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Generates the migration file content by scanning the model.
|
|
303
|
+
*/
|
|
304
|
+
private generateFromModel(modelPath: string): string {
|
|
305
|
+
const { columns, paranoid, timestamps, associations } = this.parseModelFile(modelPath);
|
|
306
|
+
|
|
307
|
+
// Map of foreignKey -> tableName
|
|
308
|
+
const fkMap = new Map<string, string>();
|
|
309
|
+
for (const assoc of associations) {
|
|
310
|
+
const [fk, modelName] = assoc.split(':');
|
|
311
|
+
// Pluralize the lowercased model name to guess target table
|
|
312
|
+
const targetTable = this.pluralize(modelName.toLowerCase());
|
|
313
|
+
fkMap.set(fk, targetTable);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Build column definitions string
|
|
317
|
+
const lines: string[] = [];
|
|
318
|
+
const indent = ' ';
|
|
319
|
+
|
|
320
|
+
for (const [name, col] of columns) {
|
|
321
|
+
// Skip id — we'll always add it manually with BIGINT for migration
|
|
322
|
+
if (name === 'id') {
|
|
323
|
+
lines.push(`${indent}id: {`);
|
|
324
|
+
lines.push(`${indent} allowNull: false,`);
|
|
325
|
+
lines.push(`${indent} autoIncrement: true,`);
|
|
326
|
+
lines.push(`${indent} primaryKey: true,`);
|
|
327
|
+
lines.push(`${indent} type: DataTypes.BIGINT,`);
|
|
328
|
+
lines.push(`${indent}},`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push(`${indent}${name}: {`);
|
|
333
|
+
lines.push(`${indent} type: ${this.migrationDataType(col.type)},`);
|
|
334
|
+
|
|
335
|
+
if (col.allowNull !== undefined) {
|
|
336
|
+
lines.push(`${indent} allowNull: ${col.allowNull},`);
|
|
337
|
+
}
|
|
338
|
+
if (col.unique) {
|
|
339
|
+
lines.push(`${indent} unique: true,`);
|
|
340
|
+
}
|
|
341
|
+
if (col.defaultValue !== undefined) {
|
|
342
|
+
lines.push(`${indent} defaultValue: ${col.defaultValue},`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (fkMap.has(name)) {
|
|
346
|
+
lines.push(`${indent} references: {`);
|
|
347
|
+
lines.push(`${indent} model: '${fkMap.get(name)}',`);
|
|
348
|
+
lines.push(`${indent} key: 'id',`);
|
|
349
|
+
lines.push(`${indent} },`);
|
|
350
|
+
lines.push(`${indent} onUpdate: 'CASCADE',`);
|
|
351
|
+
lines.push(`${indent} onDelete: 'CASCADE',`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
lines.push(`${indent}},`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Add timestamps
|
|
358
|
+
if (timestamps) {
|
|
359
|
+
lines.push(`${indent}created_at: {`);
|
|
360
|
+
lines.push(`${indent} type: DataTypes.DATE,`);
|
|
361
|
+
lines.push(`${indent} allowNull: false,`);
|
|
362
|
+
lines.push(`${indent} defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),`);
|
|
363
|
+
lines.push(`${indent}},`);
|
|
364
|
+
lines.push(`${indent}updated_at: {`);
|
|
365
|
+
lines.push(`${indent} type: DataTypes.DATE,`);
|
|
366
|
+
lines.push(`${indent} allowNull: false,`);
|
|
367
|
+
lines.push(`${indent} defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),`);
|
|
368
|
+
lines.push(`${indent}},`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add deleted_at for paranoid models
|
|
372
|
+
if (paranoid) {
|
|
373
|
+
lines.push(`${indent}deleted_at: {`);
|
|
374
|
+
lines.push(`${indent} type: DataTypes.DATE,`);
|
|
375
|
+
lines.push(`${indent} allowNull: true,`);
|
|
376
|
+
lines.push(`${indent} defaultValue: null,`);
|
|
377
|
+
lines.push(`${indent}},`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const columnsStr = lines.join('\n');
|
|
381
|
+
|
|
382
|
+
// Build class name from table name: "users" -> "CreateUsersTable"
|
|
383
|
+
const migrationClassName = `Create${this.toPascalCase(this.tableName)}Table`;
|
|
384
|
+
|
|
385
|
+
return `import { DataTypes } from 'sequelize';
|
|
386
|
+
|
|
387
|
+
class ${migrationClassName} {
|
|
388
|
+
/**
|
|
389
|
+
* Run the migrations.
|
|
390
|
+
*/
|
|
391
|
+
async up(queryInterface, Sequelize) {
|
|
392
|
+
await queryInterface.createTable('${this.tableName}', {
|
|
393
|
+
${columnsStr}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Reverse the migrations.
|
|
399
|
+
*/
|
|
400
|
+
async down(queryInterface, Sequelize) {
|
|
401
|
+
await queryInterface.dropTable('${this.tableName}');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export default new ${migrationClassName}();
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Converts model DataTypes to migration-appropriate DataTypes.
|
|
411
|
+
* e.g., DataTypes.INTEGER for id -> DataTypes.BIGINT in migrations.
|
|
412
|
+
* Keeps explicit types as-is.
|
|
413
|
+
*/
|
|
414
|
+
private migrationDataType(type: string): string {
|
|
415
|
+
// Models often use DataTypes.INTEGER for IDs/Foreign Keys, but migrations use BIGINT typically.
|
|
416
|
+
if (type === 'DataTypes.INTEGER') {
|
|
417
|
+
return 'DataTypes.BIGINT';
|
|
418
|
+
}
|
|
419
|
+
return type;
|
|
70
420
|
}
|
|
71
421
|
|
|
72
422
|
private createMigrationFile(): void {
|
|
@@ -76,23 +426,114 @@ class MigrationGenerator {
|
|
|
76
426
|
const fileName = `${timestamp}-create_${this.migrationName}.js`;
|
|
77
427
|
const targetPath = path.join(this.migrationsDir, fileName);
|
|
78
428
|
|
|
79
|
-
// 2.
|
|
80
|
-
|
|
429
|
+
// 2. Try to find and scan the corresponding model
|
|
430
|
+
const modelPath = this.findModelFile();
|
|
431
|
+
let content: string;
|
|
81
432
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
433
|
+
if (modelPath) {
|
|
434
|
+
const modelName = path.basename(modelPath, '.ts');
|
|
435
|
+
Logger.info(`Model found: src/models/${modelName}.ts — scanning columns...`);
|
|
436
|
+
content = this.generateFromModel(modelPath);
|
|
437
|
+
} else {
|
|
438
|
+
// Fall back to stub template
|
|
439
|
+
Logger.info('No matching model found. Using stub template.');
|
|
440
|
+
content = fs.readFileSync(this.stubPath, 'utf-8');
|
|
441
|
+
content = content.replace(/{{ClassName}}/g, this.className);
|
|
442
|
+
content = content.replace(/{{TableName}}/g, this.tableName);
|
|
443
|
+
}
|
|
85
444
|
|
|
86
|
-
//
|
|
445
|
+
// 3. Write File
|
|
87
446
|
fs.writeFileSync(targetPath, content);
|
|
88
447
|
|
|
89
448
|
Logger.info(`Migration Created: src/database/migrations/${fileName}`);
|
|
90
|
-
|
|
449
|
+
|
|
91
450
|
} catch (error) {
|
|
92
451
|
Logger.error('Failed to create migration:', error);
|
|
93
452
|
process.exit(1);
|
|
94
453
|
}
|
|
95
454
|
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Generates migrations for all models in the models directory, considering dependencies (foreign keys).
|
|
458
|
+
*/
|
|
459
|
+
private generateAllMigrations(): void {
|
|
460
|
+
if (!fs.existsSync(this.modelsDir)) {
|
|
461
|
+
Logger.error("Models directory not found.");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 1. Find all model files
|
|
466
|
+
const files = fs.readdirSync(this.modelsDir).filter(
|
|
467
|
+
f => f.endsWith('.ts') && f !== 'index.ts'
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// 2. Parse all models to gather dependencies
|
|
471
|
+
const modelNodes: { file: string, tableName: string, dependencies: string[] }[] = [];
|
|
472
|
+
for (const file of files) {
|
|
473
|
+
const fullPath = path.join(this.modelsDir, file);
|
|
474
|
+
// temporarily set tableName to singularized file name to allow syncTableName to update it
|
|
475
|
+
this.tableName = file.replace('.ts', '').toLowerCase();
|
|
476
|
+
this.syncTableName(fullPath);
|
|
477
|
+
const currentTableName = this.tableName;
|
|
478
|
+
|
|
479
|
+
const { associations } = this.parseModelFile(fullPath);
|
|
480
|
+
const dependencies: string[] = [];
|
|
481
|
+
for (const assoc of associations) {
|
|
482
|
+
const [fk, modelName] = assoc.split(':');
|
|
483
|
+
const targetTable = this.pluralize(modelName.toLowerCase());
|
|
484
|
+
if (targetTable !== currentTableName) { // avoid self-dependencies
|
|
485
|
+
dependencies.push(targetTable);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
modelNodes.push({
|
|
490
|
+
file: fullPath,
|
|
491
|
+
tableName: currentTableName,
|
|
492
|
+
dependencies
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 3. Topological Sort (DFS)
|
|
497
|
+
const sorted: typeof modelNodes = [];
|
|
498
|
+
const visited = new Set<string>();
|
|
499
|
+
const visiting = new Set<string>();
|
|
500
|
+
|
|
501
|
+
const visit = (node: typeof modelNodes[0]) => {
|
|
502
|
+
if (visited.has(node.tableName)) return;
|
|
503
|
+
if (visiting.has(node.tableName)) {
|
|
504
|
+
Logger.warn(`Circular dependency detected involving model: ${node.tableName}. Output order may not be perfect.`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
visiting.add(node.tableName);
|
|
508
|
+
for (const depTable of node.dependencies) {
|
|
509
|
+
const depNode = modelNodes.find(n => n.tableName === depTable);
|
|
510
|
+
if (depNode) visit(depNode);
|
|
511
|
+
}
|
|
512
|
+
visiting.delete(node.tableName);
|
|
513
|
+
visited.add(node.tableName);
|
|
514
|
+
sorted.push(node);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
for (const node of modelNodes) visit(node);
|
|
518
|
+
|
|
519
|
+
// 4. Generate them with incremental timestamps
|
|
520
|
+
let currentTimestamp = new Date();
|
|
521
|
+
for (const node of sorted) {
|
|
522
|
+
this.tableName = node.tableName;
|
|
523
|
+
const timestampStr = this.getTimestamp(currentTimestamp);
|
|
524
|
+
|
|
525
|
+
const fileName = `${timestampStr}-create_${node.tableName}.js`;
|
|
526
|
+
const targetPath = path.join(this.migrationsDir, fileName);
|
|
527
|
+
|
|
528
|
+
Logger.info(`Model found: src/models/${path.basename(node.file)} — scanning columns...`);
|
|
529
|
+
const content = this.generateFromModel(node.file);
|
|
530
|
+
fs.writeFileSync(targetPath, content);
|
|
531
|
+
Logger.info(`Migration Created: src/database/migrations/${fileName}`);
|
|
532
|
+
|
|
533
|
+
// Add 1 second for next file so they execute in order
|
|
534
|
+
currentTimestamp.setSeconds(currentTimestamp.getSeconds() + 1);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
96
537
|
}
|
|
97
538
|
|
|
98
539
|
new MigrationGenerator().run();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
class CreateUsersTable {
|
|
4
|
+
/**
|
|
5
|
+
* Run the migrations.
|
|
6
|
+
*/
|
|
7
|
+
async up(queryInterface, Sequelize) {
|
|
8
|
+
await queryInterface.createTable('users', {
|
|
9
|
+
id: {
|
|
10
|
+
allowNull: false,
|
|
11
|
+
autoIncrement: true,
|
|
12
|
+
primaryKey: true,
|
|
13
|
+
type: DataTypes.BIGINT,
|
|
14
|
+
},
|
|
15
|
+
firstname: {
|
|
16
|
+
type: DataTypes.STRING,
|
|
17
|
+
allowNull: false,
|
|
18
|
+
},
|
|
19
|
+
lastname: {
|
|
20
|
+
type: DataTypes.STRING,
|
|
21
|
+
allowNull: false,
|
|
22
|
+
},
|
|
23
|
+
email: {
|
|
24
|
+
type: DataTypes.STRING,
|
|
25
|
+
allowNull: false,
|
|
26
|
+
unique: true,
|
|
27
|
+
},
|
|
28
|
+
password: {
|
|
29
|
+
type: DataTypes.STRING,
|
|
30
|
+
allowNull: false,
|
|
31
|
+
},
|
|
32
|
+
role: {
|
|
33
|
+
type: DataTypes.STRING,
|
|
34
|
+
allowNull: false,
|
|
35
|
+
defaultValue: 'user',
|
|
36
|
+
},
|
|
37
|
+
avatar: {
|
|
38
|
+
type: DataTypes.TEXT,
|
|
39
|
+
allowNull: true,
|
|
40
|
+
defaultValue: null,
|
|
41
|
+
},
|
|
42
|
+
created_at: {
|
|
43
|
+
type: DataTypes.DATE,
|
|
44
|
+
allowNull: false,
|
|
45
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
46
|
+
},
|
|
47
|
+
updated_at: {
|
|
48
|
+
type: DataTypes.DATE,
|
|
49
|
+
allowNull: false,
|
|
50
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
51
|
+
},
|
|
52
|
+
deleted_at: {
|
|
53
|
+
type: DataTypes.DATE,
|
|
54
|
+
allowNull: true,
|
|
55
|
+
defaultValue: null,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reverse the migrations.
|
|
62
|
+
*/
|
|
63
|
+
async down(queryInterface, Sequelize) {
|
|
64
|
+
await queryInterface.dropTable('users');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default new CreateUsersTable();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
class CreateRefreshTokensTable {
|
|
4
|
+
/**
|
|
5
|
+
* Run the migrations.
|
|
6
|
+
*/
|
|
7
|
+
async up(queryInterface, Sequelize) {
|
|
8
|
+
await queryInterface.createTable('refresh_tokens', {
|
|
9
|
+
id: {
|
|
10
|
+
allowNull: false,
|
|
11
|
+
autoIncrement: true,
|
|
12
|
+
primaryKey: true,
|
|
13
|
+
type: DataTypes.BIGINT,
|
|
14
|
+
},
|
|
15
|
+
user_id: {
|
|
16
|
+
type: DataTypes.BIGINT,
|
|
17
|
+
allowNull: false,
|
|
18
|
+
references: {
|
|
19
|
+
model: 'users',
|
|
20
|
+
key: 'id',
|
|
21
|
+
},
|
|
22
|
+
onUpdate: 'CASCADE',
|
|
23
|
+
onDelete: 'CASCADE',
|
|
24
|
+
},
|
|
25
|
+
token: {
|
|
26
|
+
type: DataTypes.STRING(512),
|
|
27
|
+
allowNull: false,
|
|
28
|
+
unique: true,
|
|
29
|
+
},
|
|
30
|
+
expires_at: {
|
|
31
|
+
type: DataTypes.DATE,
|
|
32
|
+
allowNull: false,
|
|
33
|
+
},
|
|
34
|
+
revoked: {
|
|
35
|
+
type: DataTypes.BOOLEAN,
|
|
36
|
+
allowNull: false,
|
|
37
|
+
defaultValue: false,
|
|
38
|
+
},
|
|
39
|
+
created_at: {
|
|
40
|
+
type: DataTypes.DATE,
|
|
41
|
+
allowNull: false,
|
|
42
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
43
|
+
},
|
|
44
|
+
updated_at: {
|
|
45
|
+
type: DataTypes.DATE,
|
|
46
|
+
allowNull: false,
|
|
47
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reverse the migrations.
|
|
54
|
+
*/
|
|
55
|
+
async down(queryInterface, Sequelize) {
|
|
56
|
+
await queryInterface.dropTable('refresh_tokens');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default new CreateRefreshTokensTable();
|
|
@@ -34,11 +34,23 @@ class Logger {
|
|
|
34
34
|
|
|
35
35
|
// If we're not in production, log to the console with colors
|
|
36
36
|
if (env.NODE_ENV !== 'production') {
|
|
37
|
+
const consoleFormat = winston.format.printf(({ level, message, timestamp, stack, ...meta }) => {
|
|
38
|
+
// Bright black (gray) for timestamp
|
|
39
|
+
const time = `\x1b[90m[${timestamp}]\x1b[39m`;
|
|
40
|
+
|
|
41
|
+
// Sometimes we pass metadata, let's format it if it's not empty, excluding 'timestamp' which is present in meta as well
|
|
42
|
+
const metaString = Object.keys(meta).length ? `\n\x1b[90m${JSON.stringify(meta, null, 2)}\x1b[39m` : '';
|
|
43
|
+
|
|
44
|
+
return `${time} ${level}: ${message}${metaString}${stack ? `\n${stack}` : ''}`;
|
|
45
|
+
});
|
|
46
|
+
|
|
37
47
|
this.logger.add(
|
|
38
48
|
new winston.transports.Console({
|
|
39
49
|
format: winston.format.combine(
|
|
40
50
|
winston.format.colorize(),
|
|
41
|
-
winston.format.
|
|
51
|
+
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
|
52
|
+
winston.format.errors({ stack: true }),
|
|
53
|
+
consoleFormat
|
|
42
54
|
),
|
|
43
55
|
})
|
|
44
56
|
);
|