db-model-router 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1791 @@
1
+ "use strict";
2
+
3
+ const SQL_DATABASES = [
4
+ "mysql",
5
+ "mariadb",
6
+ "postgres",
7
+ "sqlite3",
8
+ "mssql",
9
+ "cockroachdb",
10
+ "oracle",
11
+ ];
12
+ const NOSQL_DATABASES = ["mongodb", "redis", "dynamodb"];
13
+
14
+ /**
15
+ * Format a Date as YYYYMMDDHHMMSS (14-digit string).
16
+ * @param {Date} date
17
+ * @returns {string}
18
+ */
19
+ function migrationTimestamp(date) {
20
+ const y = String(date.getFullYear()).padStart(4, "0");
21
+ const mo = String(date.getMonth() + 1).padStart(2, "0");
22
+ const d = String(date.getDate()).padStart(2, "0");
23
+ const h = String(date.getHours()).padStart(2, "0");
24
+ const mi = String(date.getMinutes()).padStart(2, "0");
25
+ const s = String(date.getSeconds()).padStart(2, "0");
26
+ return `${y}${mo}${d}${h}${mi}${s}`;
27
+ }
28
+
29
+ /**
30
+ * Returns true if the database is a SQL database.
31
+ * @param {string} database
32
+ * @returns {boolean}
33
+ */
34
+ function isSql(database) {
35
+ return SQL_DATABASES.includes(database);
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Environment variable config map (DRY: shared by .env and .env.example)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * @typedef {Object} EnvVarDef
44
+ * @property {string} key - Variable name
45
+ * @property {string} defaultValue - Value for .env
46
+ * @property {string} placeholder - Value for .env.example
47
+ */
48
+
49
+ /** @type {Record<string, EnvVarDef[]>} */
50
+ const DB_ENV_MAP = {
51
+ mysql: [
52
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
53
+ { key: "DB_PORT", defaultValue: "3306", placeholder: "3306" },
54
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
55
+ { key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
56
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
57
+ ],
58
+ mariadb: [
59
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
60
+ { key: "DB_PORT", defaultValue: "3306", placeholder: "3306" },
61
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
62
+ { key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
63
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
64
+ ],
65
+ postgres: [
66
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
67
+ { key: "DB_PORT", defaultValue: "5432", placeholder: "5432" },
68
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
69
+ { key: "DB_USER", defaultValue: "postgres", placeholder: "your_user" },
70
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
71
+ ],
72
+ cockroachdb: [
73
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
74
+ { key: "DB_PORT", defaultValue: "26257", placeholder: "26257" },
75
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
76
+ { key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
77
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
78
+ ],
79
+ sqlite3: [
80
+ {
81
+ key: "DB_NAME",
82
+ defaultValue: "./data/data.db",
83
+ placeholder: "./data/data.db",
84
+ },
85
+ ],
86
+ mongodb: [
87
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
88
+ { key: "DB_PORT", defaultValue: "27017", placeholder: "27017" },
89
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
90
+ { key: "DB_USER", defaultValue: "", placeholder: "your_user" },
91
+ { key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
92
+ ],
93
+ mssql: [
94
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
95
+ { key: "DB_PORT", defaultValue: "1433", placeholder: "1433" },
96
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
97
+ { key: "DB_USER", defaultValue: "sa", placeholder: "your_user" },
98
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
99
+ ],
100
+ oracle: [
101
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
102
+ { key: "DB_PORT", defaultValue: "1521", placeholder: "1521" },
103
+ { key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
104
+ { key: "DB_USER", defaultValue: "system", placeholder: "your_user" },
105
+ { key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
106
+ ],
107
+ redis: [
108
+ { key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
109
+ { key: "DB_PORT", defaultValue: "6379", placeholder: "6379" },
110
+ { key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
111
+ ],
112
+ dynamodb: [
113
+ { key: "AWS_REGION", defaultValue: "us-east-1", placeholder: "us-east-1" },
114
+ {
115
+ key: "AWS_ENDPOINT",
116
+ defaultValue: "http://localhost:8000",
117
+ placeholder: "http://localhost:8000",
118
+ },
119
+ {
120
+ key: "AWS_ACCESS_KEY_ID",
121
+ defaultValue: "local",
122
+ placeholder: "your_access_key",
123
+ },
124
+ {
125
+ key: "AWS_SECRET_ACCESS_KEY",
126
+ defaultValue: "local",
127
+ placeholder: "your_secret_key",
128
+ },
129
+ ],
130
+ };
131
+
132
+ const REDIS_SESSION_VARS = [
133
+ { key: "REDIS_HOST", defaultValue: "localhost", placeholder: "localhost" },
134
+ { key: "REDIS_PORT", defaultValue: "6379", placeholder: "6379" },
135
+ { key: "REDIS_PASS", defaultValue: "", placeholder: "your_password" },
136
+ ];
137
+
138
+ /**
139
+ * Generate a random alphanumeric password.
140
+ * @param {number} [length=24]
141
+ * @returns {string}
142
+ */
143
+ function randomPassword(length) {
144
+ const chars =
145
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
146
+ const len = length || 24;
147
+ let result = "";
148
+ const crypto = require("crypto");
149
+ const bytes = crypto.randomBytes(len);
150
+ for (let i = 0; i < len; i++) {
151
+ result += chars[bytes[i] % chars.length];
152
+ }
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Build env file content from the config map.
158
+ * @param {import('./types').InitAnswers} answers
159
+ * @param {'default'|'placeholder'} mode
160
+ * @param {object} [secrets] - generated secrets to keep in sync with docker-compose
161
+ * @param {string} [secrets.dbPass] - database password
162
+ * @param {string} [secrets.redisPass] - redis session password
163
+ * @param {string} [secrets.sessionSecret] - session secret
164
+ * @returns {string}
165
+ */
166
+ function buildEnvContent(answers, mode, secrets) {
167
+ const s = secrets || {};
168
+ const pick = mode === "placeholder" ? "placeholder" : "defaultValue";
169
+ const lines = [];
170
+ lines.push("# Server");
171
+ lines.push("PORT=3000");
172
+ lines.push("");
173
+ lines.push("# Database");
174
+
175
+ const vars = DB_ENV_MAP[answers.database] || [];
176
+ for (const v of vars) {
177
+ // Override password with generated secret in default mode
178
+ if (mode === "default" && v.key === "DB_PASS" && s.dbPass) {
179
+ lines.push(`${v.key}=${s.dbPass}`);
180
+ } else {
181
+ lines.push(`${v.key}=${v[pick]}`);
182
+ }
183
+ }
184
+
185
+ // Session secret
186
+ lines.push("");
187
+ lines.push("# Session");
188
+ lines.push(
189
+ `SESSION_SECRET=${mode === "placeholder" ? "your_session_secret" : s.sessionSecret || "change-me"}`,
190
+ );
191
+
192
+ // Redis session env vars when session is redis and database is not redis
193
+ if (answers.session === "redis" && answers.database !== "redis") {
194
+ lines.push("");
195
+ lines.push("# Redis Session");
196
+ for (const v of REDIS_SESSION_VARS) {
197
+ if (mode === "default" && v.key === "REDIS_PASS" && s.redisPass) {
198
+ lines.push(`${v.key}=${s.redisPass}`);
199
+ } else {
200
+ lines.push(`${v.key}=${v[pick]}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ // Logging
206
+ if (answers.logger) {
207
+ lines.push("");
208
+ lines.push("# Logging");
209
+ lines.push(
210
+ `APP_NAME=${mode === "placeholder" ? "your_app_name" : "my-app"}`,
211
+ );
212
+ lines.push(`LOG_LEVEL=${mode === "placeholder" ? "info" : "info"}`);
213
+ // LOKI_HOST: empty by default, set a URL to enable Loki transport
214
+ if (answers.loki && mode === "default") {
215
+ lines.push("LOKI_HOST=http://localhost:3100");
216
+ } else {
217
+ lines.push(
218
+ `LOKI_HOST=${mode === "placeholder" ? "http://your-loki-host:3100" : ""}`,
219
+ );
220
+ }
221
+ }
222
+
223
+ lines.push("");
224
+ return lines.join("\n");
225
+ }
226
+
227
+ /**
228
+ * Generate .env file content.
229
+ * @param {import('./types').InitAnswers} answers
230
+ * @param {object} [secrets] - generated secrets
231
+ * @returns {string}
232
+ */
233
+ function generateEnvFile(answers, secrets) {
234
+ return buildEnvContent(answers, "default", secrets);
235
+ }
236
+
237
+ /**
238
+ * Generate .env.example file content with placeholder values.
239
+ * @param {import('./types').InitAnswers} answers
240
+ * @returns {string}
241
+ */
242
+ function generateEnvExample(answers) {
243
+ return buildEnvContent(answers, "placeholder");
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // App.js generator (template-literal based)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Generate the db.connect() block for the selected database.
252
+ * @param {string} database
253
+ * @returns {string}
254
+ */
255
+ function dbConnectBlock(database) {
256
+ if (database === "dynamodb") {
257
+ return `db.connect({
258
+ region: process.env.AWS_REGION,
259
+ endpoint: process.env.AWS_ENDPOINT,
260
+ credentials: {
261
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
262
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
263
+ },
264
+ });`;
265
+ }
266
+ if (database === "redis") {
267
+ return `db.connect({
268
+ host: process.env.DB_HOST || "localhost",
269
+ port: process.env.DB_PORT || 6379,
270
+ password: process.env.DB_PASS,
271
+ });`;
272
+ }
273
+ if (database === "sqlite3") {
274
+ return `db.connect({
275
+ database: process.env.DB_NAME || "./data/data.db",
276
+ });`;
277
+ }
278
+ return `db.connect({
279
+ host: process.env.DB_HOST || "localhost",
280
+ port: process.env.DB_PORT,
281
+ database: process.env.DB_NAME,
282
+ user: process.env.DB_USER,
283
+ password: process.env.DB_PASS,
284
+ });`;
285
+ }
286
+
287
+ /**
288
+ * Return just the connect config properties (indented, no wrapper).
289
+ * Used by generateDbModule where the caller controls the object name.
290
+ * @param {string} database
291
+ * @returns {string}
292
+ */
293
+ function dbConnectArgs(database) {
294
+ if (database === "dynamodb") {
295
+ return ` region: process.env.AWS_REGION,
296
+ endpoint: process.env.AWS_ENDPOINT,
297
+ credentials: {
298
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
299
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
300
+ },
301
+ `;
302
+ }
303
+ if (database === "redis") {
304
+ return ` host: process.env.DB_HOST || "localhost",
305
+ port: process.env.DB_PORT || 6379,
306
+ password: process.env.DB_PASS,
307
+ `;
308
+ }
309
+ if (database === "sqlite3") {
310
+ return ` database: process.env.DB_NAME || "./data/data.db",
311
+ `;
312
+ }
313
+ return ` host: process.env.DB_HOST || "localhost",
314
+ port: process.env.DB_PORT,
315
+ database: process.env.DB_NAME,
316
+ user: process.env.DB_USER,
317
+ password: process.env.DB_PASS,
318
+ `;
319
+ }
320
+
321
+ /**
322
+ * Generate session middleware block for app.js.
323
+ * @param {import('./types').InitAnswers} answers
324
+ * @returns {string}
325
+ */
326
+ function sessionBlock(answers) {
327
+ if (answers.session === "redis") {
328
+ const redisConfig =
329
+ answers.database === "redis"
330
+ ? ` host: process.env.DB_HOST || "localhost",
331
+ port: process.env.DB_PORT || 6379,
332
+ password: process.env.DB_PASS,`
333
+ : ` host: process.env.REDIS_HOST || "localhost",
334
+ port: process.env.REDIS_PORT || 6379,
335
+ password: process.env.REDIS_PASS,`;
336
+
337
+ return `
338
+ // Session with Redis store
339
+ const redisClient = new Redis({
340
+ ${redisConfig}
341
+ });
342
+ app.use(session({
343
+ store: new RedisStore({ client: redisClient }),
344
+ secret: process.env.SESSION_SECRET || "change-me",
345
+ resave: false,
346
+ saveUninitialized: false,
347
+ }));`;
348
+ }
349
+
350
+ const label = answers.session === "database" ? "database" : "in-memory";
351
+ return `
352
+ // Session with ${label} store
353
+ app.use(session({
354
+ secret: process.env.SESSION_SECRET || "change-me",
355
+ resave: false,
356
+ saveUninitialized: false,
357
+ }));`;
358
+ }
359
+
360
+ /**
361
+ * Generate the app.js file content.
362
+ * @param {import('./types').InitAnswers} answers
363
+ * @returns {string}
364
+ */
365
+ function generateAppJs(answers) {
366
+ const frameworkPkg =
367
+ answers.framework === "ultimate-express" ? "ultimate-express" : "express";
368
+
369
+ // Imports
370
+ let imports = `const express = require("${frameworkPkg}");
371
+ const { init, db } = require("db-model-router");
372
+ const session = require("express-session");`;
373
+
374
+ if (answers.session === "redis") {
375
+ imports += `\nconst RedisStore = require("connect-redis").default;
376
+ const { Redis } = require("ioredis");`;
377
+ }
378
+ if (answers.rateLimiting) {
379
+ imports += `\nconst rateLimit = require("express-rate-limit");`;
380
+ }
381
+ if (answers.helmet) {
382
+ imports += `\nconst helmet = require("helmet");`;
383
+ }
384
+ imports += `\nconst logger = require("./middleware/logger");`;
385
+
386
+ // Rate limiting block
387
+ const rateLimitBlock = answers.rateLimiting
388
+ ? `app.use(rateLimit({
389
+ windowMs: 15 * 60 * 1000,
390
+ max: 100,
391
+ standardHeaders: true,
392
+ legacyHeaders: false,
393
+ }));`
394
+ : "";
395
+
396
+ const helmetBlock = answers.helmet ? `app.use(helmet());` : "";
397
+
398
+ return `${imports}
399
+
400
+ // Load environment variables
401
+ require("dotenv").config();
402
+
403
+ // Initialize database adapter
404
+ init("${answers.database}");
405
+ ${dbConnectBlock(answers.database)}
406
+
407
+ const app = express();
408
+ const PORT = process.env.PORT || 3000;
409
+
410
+ // Middleware
411
+ app.use(express.json());
412
+ app.use(express.urlencoded({ extended: true }));
413
+ ${helmetBlock ? helmetBlock + "\n" : ""}${rateLimitBlock ? rateLimitBlock + "\n" : ""}${sessionBlock(answers)}
414
+ app.use(logger);
415
+
416
+ // Health check
417
+ app.get("/health", (req, res) => {
418
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
419
+ });
420
+
421
+ // Error handler
422
+ app.use((err, req, res, next) => {
423
+ console.error(err.stack);
424
+ res.status(500).json({ type: "danger", message: "Internal Server Error" });
425
+ });
426
+
427
+ app.listen(PORT, () => {
428
+ console.log(\`Server running on port \${PORT}\`);
429
+ });
430
+
431
+ module.exports = app;
432
+ `;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Logger middleware generator
437
+ // ---------------------------------------------------------------------------
438
+
439
+ /**
440
+ * Generate middleware/logger.js content.
441
+ * When logger is enabled, uses Winston with winston-loki transport for Grafana.
442
+ * @param {import('./types').InitAnswers} answers
443
+ * @returns {string}
444
+ */
445
+ function generateLoggerMiddleware(answers) {
446
+ if (answers.logger) {
447
+ return `import winston from "winston";
448
+
449
+ /**
450
+ * Winston logger with Console transport.
451
+ * If LOKI_HOST is set in .env, adds a Loki transport for Grafana visualization.
452
+ */
453
+ const transports = [
454
+ new winston.transports.Console({
455
+ format: winston.format.combine(
456
+ winston.format.colorize(),
457
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
458
+ const metaStr = Object.keys(meta).length > 1
459
+ ? " " + JSON.stringify(meta)
460
+ : "";
461
+ return \`[\${timestamp}] [\${level}] \${message}\${metaStr}\`;
462
+ }),
463
+ ),
464
+ }),
465
+ ];
466
+
467
+ // Add Loki transport only when LOKI_HOST is configured
468
+ if (process.env.LOKI_HOST) {
469
+ const { default: LokiTransport } = await import("winston-loki");
470
+ transports.push(
471
+ new LokiTransport({
472
+ host: process.env.LOKI_HOST,
473
+ labels: { app: process.env.APP_NAME || "app" },
474
+ json: true,
475
+ onConnectionError: (err) => console.error("Loki connection error:", err),
476
+ }),
477
+ );
478
+ }
479
+
480
+ const logger = winston.createLogger({
481
+ level: process.env.LOG_LEVEL || "info",
482
+ format: winston.format.combine(
483
+ winston.format.timestamp(),
484
+ winston.format.json(),
485
+ ),
486
+ defaultMeta: { service: process.env.APP_NAME || "app" },
487
+ transports,
488
+ });
489
+
490
+ /**
491
+ * Express middleware that logs every request/response.
492
+ */
493
+ function requestLogger(req, res, next) {
494
+ const start = Date.now();
495
+
496
+ res.on("finish", () => {
497
+ const duration = Date.now() - start;
498
+ const level = res.statusCode >= 400 ? "warn" : "info";
499
+ logger.log({
500
+ level,
501
+ message: \`\${req.method} \${req.originalUrl} \${res.statusCode} \${duration}ms\`,
502
+ method: req.method,
503
+ url: req.originalUrl,
504
+ status: res.statusCode,
505
+ duration,
506
+ });
507
+ });
508
+
509
+ next();
510
+ }
511
+
512
+ requestLogger.logger = logger;
513
+ export default requestLogger;
514
+ `;
515
+ }
516
+
517
+ return `/**
518
+ * Simple request logger middleware.
519
+ * Logs method, URL, status code, and response time.
520
+ */
521
+ export default function logger(req, res, next) {
522
+ const start = Date.now();
523
+ const { method, originalUrl } = req;
524
+
525
+ res.on("finish", () => {
526
+ const duration = Date.now() - start;
527
+ const status = res.statusCode;
528
+ const level = status >= 400 ? "WARN" : "INFO";
529
+ console.log(
530
+ \`[\${new Date().toISOString()}] [\${level}] \${method} \${originalUrl} \${status} \${duration}ms\`,
531
+ );
532
+ });
533
+
534
+ next();
535
+ }
536
+ `;
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Migration script generators (Fix 3: migrate.js now checks tracking table)
541
+ // ---------------------------------------------------------------------------
542
+
543
+ /**
544
+ * Generate migrate.js script content.
545
+ * Checks _migrations tracking table before running each migration.
546
+ * @param {import('./types').InitAnswers} answers
547
+ * @returns {string}
548
+ */
549
+ function generateMigrateScript(answers) {
550
+ const isNoSql = NOSQL_DATABASES.includes(answers.database);
551
+
552
+ if (isNoSql) {
553
+ return `#!/usr/bin/env node
554
+ "use strict";
555
+
556
+ const fs = require("fs");
557
+ const path = require("path");
558
+ const crypto = require("crypto");
559
+ require("dotenv").config();
560
+
561
+ const { init, db } = require("db-model-router");
562
+
563
+ init("${answers.database}");
564
+
565
+ const migrationsDir = path.join(__dirname, "migrations");
566
+
567
+ async function getExecutedMigrations() {
568
+ try {
569
+ const result = await db.get("_migrations");
570
+ return new Set((result || []).map(r => r.filename));
571
+ } catch (e) {
572
+ return new Set();
573
+ }
574
+ }
575
+
576
+ async function recordMigration(filename, checksum) {
577
+ await db.insert("_migrations", {
578
+ filename,
579
+ executed_at: new Date().toISOString(),
580
+ checksum,
581
+ });
582
+ }
583
+
584
+ async function migrate() {
585
+ const files = fs.readdirSync(migrationsDir)
586
+ .filter(f => f.endsWith(".js"))
587
+ .sort();
588
+
589
+ const executed = await getExecutedMigrations();
590
+ let ran = 0;
591
+
592
+ for (const file of files) {
593
+ if (executed.has(file)) {
594
+ console.log(\` Skipping (already executed): \${file}\`);
595
+ continue;
596
+ }
597
+ const filePath = path.join(migrationsDir, file);
598
+ const content = fs.readFileSync(filePath, "utf8");
599
+ const checksum = crypto.createHash("md5").update(content).digest("hex");
600
+
601
+ const migration = require(filePath);
602
+ console.log(\` Running migration: \${file}\`);
603
+ await migration.up(db);
604
+ await recordMigration(file, checksum);
605
+ console.log(\` Completed: \${file}\`);
606
+ ran++;
607
+ }
608
+
609
+ if (ran === 0) {
610
+ console.log("No pending migrations.");
611
+ } else {
612
+ console.log(\`\\n\${ran} migration(s) complete.\`);
613
+ }
614
+ process.exit(0);
615
+ }
616
+
617
+ migrate().catch(err => {
618
+ console.error("Migration failed:", err);
619
+ process.exit(1);
620
+ });
621
+ `;
622
+ }
623
+
624
+ return `#!/usr/bin/env node
625
+ "use strict";
626
+
627
+ const fs = require("fs");
628
+ const path = require("path");
629
+ const crypto = require("crypto");
630
+ require("dotenv").config();
631
+
632
+ const { init, db } = require("db-model-router");
633
+
634
+ init("${answers.database}");
635
+
636
+ const migrationsDir = path.join(__dirname, "migrations");
637
+
638
+ async function getExecutedMigrations() {
639
+ try {
640
+ const result = await db.query("SELECT filename FROM _migrations");
641
+ return new Set((result || []).map(r => r.filename));
642
+ } catch (e) {
643
+ // Table may not exist yet (first run)
644
+ return new Set();
645
+ }
646
+ }
647
+
648
+ async function recordMigration(filename, checksum) {
649
+ await db.query(
650
+ "INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
651
+ [filename, checksum]
652
+ );
653
+ }
654
+
655
+ async function migrate() {
656
+ const files = fs.readdirSync(migrationsDir)
657
+ .filter(f => f.endsWith(".sql"))
658
+ .sort();
659
+
660
+ const executed = await getExecutedMigrations();
661
+ let ran = 0;
662
+
663
+ for (const file of files) {
664
+ if (executed.has(file)) {
665
+ console.log(\` Skipping (already executed): \${file}\`);
666
+ continue;
667
+ }
668
+ const filePath = path.join(migrationsDir, file);
669
+ const content = fs.readFileSync(filePath, "utf8");
670
+ const checksum = crypto.createHash("md5").update(content).digest("hex");
671
+
672
+ console.log(\` Running migration: \${file}\`);
673
+ await db.query(content);
674
+ await recordMigration(file, checksum);
675
+ console.log(\` Completed: \${file}\`);
676
+ ran++;
677
+ }
678
+
679
+ if (ran === 0) {
680
+ console.log("No pending migrations.");
681
+ } else {
682
+ console.log(\`\\n\${ran} migration(s) complete.\`);
683
+ }
684
+ process.exit(0);
685
+ }
686
+
687
+ migrate().catch(err => {
688
+ console.error("Migration failed:", err);
689
+ process.exit(1);
690
+ });
691
+ `;
692
+ }
693
+
694
+ /**
695
+ * Generate add_migration.js script content.
696
+ * @param {import('./types').InitAnswers} answers
697
+ * @returns {string}
698
+ */
699
+ function generateAddMigrationScript(answers) {
700
+ const isNoSql = NOSQL_DATABASES.includes(answers.database);
701
+ const ext = isNoSql ? "js" : "sql";
702
+ const template = isNoSql
703
+ ? `"use strict";\\n\\nmodule.exports = {\\n async up(db) {\\n // Write your migration here\\n },\\n\\n async down(db) {\\n // Write your rollback here\\n },\\n};\\n`
704
+ : `-- Write your migration SQL here\\n`;
705
+
706
+ return `#!/usr/bin/env node
707
+ "use strict";
708
+
709
+ const fs = require("fs");
710
+ const path = require("path");
711
+
712
+ const migrationsDir = path.join(__dirname, "migrations");
713
+
714
+ function migrationTimestamp() {
715
+ const now = new Date();
716
+ const y = String(now.getFullYear()).padStart(4, "0");
717
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
718
+ const d = String(now.getDate()).padStart(2, "0");
719
+ const h = String(now.getHours()).padStart(2, "0");
720
+ const mi = String(now.getMinutes()).padStart(2, "0");
721
+ const s = String(now.getSeconds()).padStart(2, "0");
722
+ return \`\${y}\${mo}\${d}\${h}\${mi}\${s}\`;
723
+ }
724
+
725
+ const name = process.argv[2] || "migration";
726
+ const filename = \`\${migrationTimestamp()}_\${name}.${ext}\`;
727
+ const filePath = path.join(migrationsDir, filename);
728
+
729
+ if (!fs.existsSync(migrationsDir)) {
730
+ fs.mkdirSync(migrationsDir, { recursive: true });
731
+ }
732
+
733
+ fs.writeFileSync(filePath, "${template}");
734
+ console.log(\`Created migration: \${filename}\`);
735
+ `;
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Initial migration + session migration generators
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /**
743
+ * Generate the initial migration file that creates the _migrations tracking table.
744
+ * @param {import('./types').InitAnswers} answers
745
+ * @param {Date} [date]
746
+ * @returns {{ filename: string, content: string }}
747
+ */
748
+ function generateInitialMigration(answers, date) {
749
+ const ts = migrationTimestamp(date || new Date());
750
+
751
+ if (isSql(answers.database)) {
752
+ let content;
753
+ if (answers.database === "postgres" || answers.database === "cockroachdb") {
754
+ content = `CREATE TABLE IF NOT EXISTS _migrations (
755
+ id SERIAL PRIMARY KEY,
756
+ filename VARCHAR(255) NOT NULL UNIQUE,
757
+ executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
758
+ checksum VARCHAR(64) NOT NULL
759
+ );
760
+ `;
761
+ } else if (answers.database === "mssql") {
762
+ content = `CREATE TABLE _migrations (
763
+ id INT IDENTITY(1,1) PRIMARY KEY,
764
+ filename VARCHAR(255) NOT NULL UNIQUE,
765
+ executed_at DATETIME DEFAULT GETDATE(),
766
+ checksum VARCHAR(64) NOT NULL
767
+ );
768
+ `;
769
+ } else if (answers.database === "oracle") {
770
+ content = `CREATE TABLE _migrations (
771
+ id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
772
+ filename VARCHAR2(255) NOT NULL UNIQUE,
773
+ executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
774
+ checksum VARCHAR2(64) NOT NULL
775
+ );
776
+ `;
777
+ } else {
778
+ // mysql, sqlite3
779
+ content = `CREATE TABLE IF NOT EXISTS _migrations (
780
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
781
+ filename VARCHAR(255) NOT NULL UNIQUE,
782
+ executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
783
+ checksum VARCHAR(64) NOT NULL
784
+ );
785
+ `;
786
+ }
787
+ return { filename: `${ts}_create_migrations_table.sql`, content };
788
+ }
789
+
790
+ // NoSQL databases
791
+ let content;
792
+ if (answers.database === "mongodb") {
793
+ content = `export async function up(db) {
794
+ await db.createCollection("_migrations");
795
+ await db.collection("_migrations").createIndex({ filename: 1 }, { unique: true });
796
+ }
797
+
798
+ export async function down(db) {
799
+ await db.collection("_migrations").drop();
800
+ }
801
+ `;
802
+ } else if (answers.database === "redis") {
803
+ content = `export async function up(db) {
804
+ // _migrations hash key will be created on first HSET
805
+ console.log("Redis migration tracking initialized using hash key: _migrations");
806
+ }
807
+
808
+ export async function down(db) {
809
+ await db.del("_migrations");
810
+ }
811
+ `;
812
+ } else {
813
+ // dynamodb
814
+ content = `import { CreateTableCommand, DeleteTableCommand } from "@aws-sdk/client-dynamodb";
815
+
816
+ export async function up(db) {
817
+ await db.send(new CreateTableCommand({
818
+ TableName: "_migrations",
819
+ KeySchema: [{ AttributeName: "filename", KeyType: "HASH" }],
820
+ AttributeDefinitions: [{ AttributeName: "filename", AttributeType: "S" }],
821
+ BillingMode: "PAY_PER_REQUEST",
822
+ }));
823
+ }
824
+
825
+ export async function down(db) {
826
+ await db.send(new DeleteTableCommand({ TableName: "_migrations" }));
827
+ }
828
+ `;
829
+ }
830
+
831
+ return { filename: `${ts}_create_migrations_table.js`, content };
832
+ }
833
+
834
+ /**
835
+ * Generate the session migration file for SQL databases with database session store.
836
+ * @param {import('./types').InitAnswers} answers
837
+ * @param {Date} [date]
838
+ * @returns {{ filename: string, content: string } | null}
839
+ */
840
+ function generateSessionMigration(answers, date) {
841
+ if (answers.session !== "database" || !isSql(answers.database)) {
842
+ return null;
843
+ }
844
+
845
+ const ts = migrationTimestamp(date || new Date());
846
+
847
+ let content;
848
+ if (answers.database === "postgres" || answers.database === "cockroachdb") {
849
+ content = `CREATE TABLE IF NOT EXISTS sessions (
850
+ sid VARCHAR(255) PRIMARY KEY,
851
+ sess TEXT NOT NULL,
852
+ expired_at TIMESTAMP NOT NULL
853
+ );
854
+ CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired_at);
855
+ `;
856
+ } else if (answers.database === "mssql") {
857
+ content = `CREATE TABLE sessions (
858
+ sid VARCHAR(255) PRIMARY KEY,
859
+ sess TEXT NOT NULL,
860
+ expired_at DATETIME NOT NULL
861
+ );
862
+ CREATE INDEX idx_sessions_expired ON sessions(expired_at);
863
+ `;
864
+ } else if (answers.database === "oracle") {
865
+ content = `CREATE TABLE sessions (
866
+ sid VARCHAR2(255) PRIMARY KEY,
867
+ sess CLOB NOT NULL,
868
+ expired_at TIMESTAMP NOT NULL
869
+ );
870
+ CREATE INDEX idx_sessions_expired ON sessions(expired_at);
871
+ `;
872
+ } else {
873
+ // mysql, sqlite3
874
+ content = `CREATE TABLE IF NOT EXISTS sessions (
875
+ sid VARCHAR(255) PRIMARY KEY,
876
+ sess TEXT NOT NULL,
877
+ expired_at TIMESTAMP NOT NULL
878
+ );
879
+ CREATE INDEX idx_sessions_expired ON sessions(expired_at);
880
+ `;
881
+ }
882
+
883
+ return { filename: `${ts}_create_sessions_table.sql`, content };
884
+ }
885
+
886
+ // ---------------------------------------------------------------------------
887
+ // Docker Compose generator
888
+ // ---------------------------------------------------------------------------
889
+
890
+ /**
891
+ * Docker image and config for each supported database.
892
+ */
893
+ const DOCKER_DB_MAP = {
894
+ mysql: {
895
+ image: "mysql:latest",
896
+ port: "3306:3306",
897
+ env: (secrets) => ({
898
+ MYSQL_ROOT_PASSWORD: secrets.dbPass,
899
+ MYSQL_DATABASE: "my_app",
900
+ }),
901
+ volumes: ["./data/mysql:/var/lib/mysql"],
902
+ },
903
+ mariadb: {
904
+ image: "mariadb:latest",
905
+ port: "3306:3306",
906
+ env: (secrets) => ({
907
+ MARIADB_ROOT_PASSWORD: secrets.dbPass,
908
+ MARIADB_DATABASE: "my_app",
909
+ }),
910
+ volumes: ["./data/mariadb:/var/lib/mysql"],
911
+ },
912
+ postgres: {
913
+ image: "postgres:alpine",
914
+ port: "5432:5432",
915
+ env: (secrets) => ({
916
+ POSTGRES_USER: "postgres",
917
+ POSTGRES_PASSWORD: secrets.dbPass,
918
+ POSTGRES_DB: "my_app",
919
+ }),
920
+ volumes: ["./data/postgres:/var/lib/postgresql/data"],
921
+ },
922
+ cockroachdb: {
923
+ image: "cockroachdb/cockroach:latest",
924
+ port: "26257:26257",
925
+ command: "start-single-node --insecure",
926
+ env: () => ({}),
927
+ volumes: ["./data/cockroachdb:/cockroach/cockroach-data"],
928
+ },
929
+ mongodb: {
930
+ image: "mongo:latest",
931
+ port: "27017:27017",
932
+ env: (secrets) => ({
933
+ MONGO_INITDB_ROOT_USERNAME: "root",
934
+ MONGO_INITDB_ROOT_PASSWORD: secrets.dbPass,
935
+ MONGO_INITDB_DATABASE: "my_app",
936
+ }),
937
+ volumes: ["./data/mongodb:/data/db"],
938
+ },
939
+ mssql: {
940
+ image: "mcr.microsoft.com/mssql/server:latest",
941
+ port: "1433:1433",
942
+ env: (secrets) => ({
943
+ ACCEPT_EULA: "Y",
944
+ MSSQL_SA_PASSWORD: secrets.dbPass,
945
+ }),
946
+ volumes: ["./data/mssql:/var/opt/mssql"],
947
+ },
948
+ oracle: {
949
+ image: "gvenzl/oracle-xe:latest",
950
+ port: "1521:1521",
951
+ env: (secrets) => ({
952
+ ORACLE_PASSWORD: secrets.dbPass,
953
+ APP_USER: "system",
954
+ APP_USER_PASSWORD: secrets.dbPass,
955
+ }),
956
+ volumes: ["./data/oracle:/opt/oracle/oradata"],
957
+ },
958
+ redis: {
959
+ image: "redis:alpine",
960
+ port: "6379:6379",
961
+ command: null, // set dynamically if password
962
+ env: () => ({}),
963
+ volumes: ["./data/redis:/data"],
964
+ },
965
+ dynamodb: {
966
+ image: "amazon/dynamodb-local:latest",
967
+ port: "8000:8000",
968
+ env: () => ({}),
969
+ volumes: [],
970
+ },
971
+ };
972
+
973
+ /**
974
+ * CloudBeaver JDBC driver IDs and URL templates per database.
975
+ */
976
+ const CLOUDBEAVER_DB_MAP = {
977
+ mysql: {
978
+ provider: "mysql",
979
+ driver: "mysql8",
980
+ urlTemplate: (host, port, dbName) =>
981
+ `jdbc:mysql://${host}:${port}/${dbName}`,
982
+ },
983
+ mariadb: {
984
+ provider: "mysql",
985
+ driver: "mariaDB",
986
+ urlTemplate: (host, port, dbName) =>
987
+ `jdbc:mariadb://${host}:${port}/${dbName}`,
988
+ },
989
+ postgres: {
990
+ provider: "postgresql",
991
+ driver: "postgres-jdbc",
992
+ urlTemplate: (host, port, dbName) =>
993
+ `jdbc:postgresql://${host}:${port}/${dbName}`,
994
+ },
995
+ cockroachdb: {
996
+ provider: "postgresql",
997
+ driver: "postgres-jdbc",
998
+ urlTemplate: (host, port, dbName) =>
999
+ `jdbc:postgresql://${host}:${port}/${dbName}`,
1000
+ },
1001
+ mssql: {
1002
+ provider: "sqlserver",
1003
+ driver: "mssql_jdbc_ms_new",
1004
+ urlTemplate: (host, port, dbName) =>
1005
+ `jdbc:sqlserver://${host}:${port};databaseName=${dbName};trustServerCertificate=true`,
1006
+ },
1007
+ oracle: {
1008
+ provider: "oracle",
1009
+ driver: "oracle_thin",
1010
+ urlTemplate: (host, port, dbName) =>
1011
+ `jdbc:oracle:thin:@${host}:${port}/${dbName}`,
1012
+ },
1013
+ mongodb: {
1014
+ provider: "mongodb",
1015
+ driver: "mongodb",
1016
+ urlTemplate: (host, port, dbName) => `mongodb://${host}:${port}/${dbName}`,
1017
+ },
1018
+ };
1019
+
1020
+ /**
1021
+ * Generate CloudBeaver data-sources.json for auto-connecting to the project database.
1022
+ * @param {import('./types').InitAnswers} answers
1023
+ * @param {object} secrets
1024
+ * @returns {string|null}
1025
+ */
1026
+ function generateCloudBeaverDataSources(answers, secrets) {
1027
+ const cbDb = CLOUDBEAVER_DB_MAP[answers.database];
1028
+ if (!cbDb) return null;
1029
+
1030
+ const dbConfig = DOCKER_DB_MAP[answers.database];
1031
+ if (!dbConfig) return null;
1032
+
1033
+ const host = answers.database; // service name in docker-compose
1034
+ const port = dbConfig.port.split(":")[1];
1035
+ const dbName = "my_app";
1036
+
1037
+ // Determine user/pass based on adapter
1038
+ let user = "root";
1039
+ let pass = secrets.dbPass;
1040
+ if (answers.database === "postgres" || answers.database === "cockroachdb")
1041
+ user = "postgres";
1042
+ if (answers.database === "mssql") user = "sa";
1043
+ if (answers.database === "oracle") user = "system";
1044
+ if (answers.database === "mongodb") user = "root";
1045
+
1046
+ const connId = `${answers.database}-project-db`;
1047
+ const url = cbDb.urlTemplate(host, port, dbName);
1048
+
1049
+ const config = {
1050
+ folders: {},
1051
+ connections: {
1052
+ [connId]: {
1053
+ provider: cbDb.provider,
1054
+ driver: cbDb.driver,
1055
+ name: `${answers.database} - my_app`,
1056
+ "save-password": true,
1057
+ configuration: {
1058
+ host: host,
1059
+ port: port,
1060
+ database: dbName,
1061
+ url: url,
1062
+ configurationType: "MANUAL",
1063
+ type: "dev",
1064
+ auth: "native",
1065
+ userName: user,
1066
+ userPassword: pass,
1067
+ },
1068
+ },
1069
+ },
1070
+ };
1071
+
1072
+ return JSON.stringify(config, null, 2) + "\n";
1073
+ }
1074
+
1075
+ /**
1076
+ * Generate docker-compose.yml content.
1077
+ * @param {import('./types').InitAnswers} answers
1078
+ * @param {object} secrets - { dbPass, redisPass }
1079
+ * @returns {string|null} null if no Docker service needed (e.g. sqlite3)
1080
+ */
1081
+ function generateDockerCompose(answers, secrets) {
1082
+ // sqlite3 runs in-process, no Docker needed
1083
+ if (answers.database === "sqlite3") return null;
1084
+
1085
+ const dbConfig = DOCKER_DB_MAP[answers.database];
1086
+ if (!dbConfig) return null;
1087
+
1088
+ const services = {};
1089
+
1090
+ // --- Primary database service ---
1091
+ const dbService = {
1092
+ container_name: `${answers.database}_db`,
1093
+ image: dbConfig.image,
1094
+ ports: [dbConfig.port],
1095
+ restart: "unless-stopped",
1096
+ };
1097
+
1098
+ const envVars = dbConfig.env(secrets);
1099
+ if (Object.keys(envVars).length > 0) {
1100
+ dbService.environment = envVars;
1101
+ }
1102
+ if (dbConfig.command) {
1103
+ dbService.command = dbConfig.command;
1104
+ }
1105
+ // Redis with password
1106
+ if (answers.database === "redis" && secrets.dbPass) {
1107
+ dbService.command = `redis-server --requirepass ${secrets.dbPass}`;
1108
+ }
1109
+ if (dbConfig.volumes && dbConfig.volumes.length > 0) {
1110
+ dbService.volumes = dbConfig.volumes;
1111
+ }
1112
+
1113
+ services[answers.database] = dbService;
1114
+
1115
+ // --- Redis session service (if session=redis and db is not already redis) ---
1116
+ if (answers.session === "redis" && answers.database !== "redis") {
1117
+ const redisService = {
1118
+ container_name: "redis_session",
1119
+ image: "redis:alpine",
1120
+ ports: ["6379:6379"],
1121
+ restart: "unless-stopped",
1122
+ };
1123
+ if (secrets.redisPass) {
1124
+ redisService.command = `redis-server --requirepass ${secrets.redisPass}`;
1125
+ }
1126
+ redisService.volumes = ["./data/redis:/data"];
1127
+ services["redis"] = redisService;
1128
+ }
1129
+
1130
+ // --- CloudBeaver service (for SQL/MongoDB databases) ---
1131
+ const hasCbSupport = !!CLOUDBEAVER_DB_MAP[answers.database];
1132
+ if (hasCbSupport) {
1133
+ services["cloudbeaver"] = {
1134
+ container_name: "cloudbeaver",
1135
+ image: "dbeaver/cloudbeaver:latest",
1136
+ ports: ["8978:8978"],
1137
+ restart: "unless-stopped",
1138
+ environment: {
1139
+ CB_SERVER_NAME: "CloudBeaver",
1140
+ CB_ADMIN_NAME: "cbadmin",
1141
+ CB_ADMIN_PASSWORD: secrets.dbPass,
1142
+ },
1143
+ volumes: [
1144
+ "./data/cloudbeaver:/opt/cloudbeaver/workspace",
1145
+ "./.cloudbeaver/data-sources.json:/opt/cloudbeaver/workspace/GlobalConfiguration/.dbeaver/data-sources.json:ro",
1146
+ ],
1147
+ depends_on: [answers.database],
1148
+ };
1149
+ }
1150
+
1151
+ // --- Loki + Grafana (when logger + loki are enabled) ---
1152
+ if (answers.loki) {
1153
+ services["loki"] = {
1154
+ container_name: "loki",
1155
+ image: "grafana/loki:latest",
1156
+ ports: ["3100:3100"],
1157
+ restart: "unless-stopped",
1158
+ command: "-config.file=/etc/loki/local-config.yaml",
1159
+ volumes: ["./data/loki:/loki"],
1160
+ };
1161
+
1162
+ services["grafana"] = {
1163
+ container_name: "grafana",
1164
+ image: "grafana/grafana:latest",
1165
+ ports: ["3001:3000"],
1166
+ restart: "unless-stopped",
1167
+ environment: {
1168
+ GF_SECURITY_ADMIN_USER: "admin",
1169
+ GF_SECURITY_ADMIN_PASSWORD: secrets.dbPass,
1170
+ GF_AUTH_ANONYMOUS_ENABLED: "true",
1171
+ },
1172
+ volumes: [
1173
+ "./data/grafana:/var/lib/grafana",
1174
+ "./.grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro",
1175
+ ],
1176
+ depends_on: ["loki"],
1177
+ };
1178
+ }
1179
+
1180
+ // --- Build YAML manually (no dependency needed) ---
1181
+ const lines = [];
1182
+ lines.push("services:");
1183
+
1184
+ for (const [name, svc] of Object.entries(services)) {
1185
+ lines.push(` ${name}:`);
1186
+ lines.push(` container_name: ${svc.container_name}`);
1187
+ lines.push(` image: ${svc.image}`);
1188
+ if (svc.command) {
1189
+ lines.push(` command: ${svc.command}`);
1190
+ }
1191
+ if (svc.ports && svc.ports.length > 0) {
1192
+ lines.push(" ports:");
1193
+ for (const p of svc.ports) {
1194
+ lines.push(` - "${p}"`);
1195
+ }
1196
+ }
1197
+ if (svc.environment && Object.keys(svc.environment).length > 0) {
1198
+ lines.push(" environment:");
1199
+ for (const [k, v] of Object.entries(svc.environment)) {
1200
+ lines.push(` ${k}: "${v}"`);
1201
+ }
1202
+ }
1203
+ if (svc.volumes && svc.volumes.length > 0) {
1204
+ lines.push(" volumes:");
1205
+ for (const v of svc.volumes) {
1206
+ lines.push(` - ${v}`);
1207
+ }
1208
+ }
1209
+ if (svc.depends_on && svc.depends_on.length > 0) {
1210
+ lines.push(" depends_on:");
1211
+ for (const d of svc.depends_on) {
1212
+ lines.push(` - ${d}`);
1213
+ }
1214
+ }
1215
+ lines.push(` restart: unless-stopped`);
1216
+ lines.push("");
1217
+ }
1218
+
1219
+ return lines.join("\n");
1220
+ }
1221
+
1222
+ /**
1223
+ * Generate Dockerfile for the project.
1224
+ * Uses multi-stage build with node:alpine for a lean production image.
1225
+ * @param {import('./types').InitAnswers} answers
1226
+ * @param {string} [outputDir] - relative output directory for source files
1227
+ * @returns {string}
1228
+ */
1229
+ function generateDockerfile(answers, outputDir) {
1230
+ const copyDirs = ["commons", "middleware", "route", "migrations"]
1231
+ .map((d) => {
1232
+ const src = outputDir ? `${outputDir}/${d}` : d;
1233
+ return `COPY ${src}/ ./${src}/`;
1234
+ })
1235
+ .join("\n");
1236
+
1237
+ return `FROM node:alpine
1238
+
1239
+ WORKDIR /app
1240
+
1241
+ # Install dependencies
1242
+ COPY package*.json ./
1243
+ RUN npm ci --omit=dev
1244
+
1245
+ # Copy application files
1246
+ COPY app.js ./
1247
+ ${copyDirs}
1248
+
1249
+ # Expose port
1250
+ EXPOSE 3000
1251
+
1252
+ # Start the application
1253
+ CMD ["node", "app.js"]
1254
+ `;
1255
+ }
1256
+
1257
+ /**
1258
+ * Generate Grafana datasource provisioning file for auto-connecting Loki.
1259
+ * @returns {string}
1260
+ */
1261
+ function generateGrafanaDatasources() {
1262
+ return `apiVersion: 1
1263
+
1264
+ datasources:
1265
+ - name: Loki
1266
+ type: loki
1267
+ access: proxy
1268
+ url: http://loki:3100
1269
+ isDefault: true
1270
+ editable: false
1271
+ `;
1272
+ }
1273
+
1274
+ /**
1275
+ * Generate .dockerignore content.
1276
+ * @returns {string}
1277
+ */
1278
+ function generateDockerignore() {
1279
+ return `node_modules
1280
+ npm-debug.log
1281
+ .env
1282
+ .env.example
1283
+ .git
1284
+ .gitignore
1285
+ data
1286
+ `;
1287
+ }
1288
+
1289
+ /**
1290
+ * Generate .gitignore content.
1291
+ * @returns {string}
1292
+ */
1293
+ function generateGitignore() {
1294
+ return `node_modules/
1295
+ .env
1296
+ *.db
1297
+ data/
1298
+ .cloudbeaver/
1299
+ `;
1300
+ }
1301
+
1302
+ // ---------------------------------------------------------------------------
1303
+ // Commons: session.js generator
1304
+ // ---------------------------------------------------------------------------
1305
+
1306
+ /**
1307
+ * Generate commons/session.js — session configuration module.
1308
+ * @param {import('./types').InitAnswers} answers
1309
+ * @returns {string}
1310
+ */
1311
+ function generateSessionJs(answers) {
1312
+ let imports = `import session from "express-session";\n`;
1313
+
1314
+ if (answers.session === "redis") {
1315
+ imports += `import { RedisStore } from "connect-redis";\nimport ioredis from "ioredis";\n\nconst { Redis } = ioredis;\n`;
1316
+ }
1317
+
1318
+ let storeSetup = "";
1319
+ let storeOption = "";
1320
+
1321
+ if (answers.session === "redis") {
1322
+ const redisConfig =
1323
+ answers.database === "redis"
1324
+ ? ` host: process.env.DB_HOST || "localhost",\n port: process.env.DB_PORT || 6379,\n password: process.env.DB_PASS,`
1325
+ : ` host: process.env.REDIS_HOST || "localhost",\n port: process.env.REDIS_PORT || 6379,\n password: process.env.REDIS_PASS,`;
1326
+
1327
+ storeSetup = `\nconst redisClient = new Redis({\n${redisConfig}\n});\n`;
1328
+ storeOption = `\n store: new RedisStore({ client: redisClient }),`;
1329
+ }
1330
+
1331
+ return `${imports}${storeSetup}
1332
+ /**
1333
+ * Configure and return session middleware.
1334
+ * Session store: ${answers.session}
1335
+ */
1336
+ export default function configureSession() {
1337
+ return session({${storeOption}
1338
+ secret: process.env.SESSION_SECRET || "change-me",
1339
+ resave: false,
1340
+ saveUninitialized: false,
1341
+ });
1342
+ }
1343
+ `;
1344
+ }
1345
+
1346
+ // ---------------------------------------------------------------------------
1347
+ // Commons: migrate.js generator (standalone script)
1348
+ // ---------------------------------------------------------------------------
1349
+
1350
+ /**
1351
+ * Generate commons/migrate.js — migration runner module.
1352
+ * Works as both an importable module and a standalone script.
1353
+ * @param {import('./types').InitAnswers} answers
1354
+ * @param {string} [outputDir] - relative output directory
1355
+ * @returns {string}
1356
+ */
1357
+ function generateMigrateModule(answers, outputDir) {
1358
+ const isNoSql = NOSQL_DATABASES.includes(answers.database);
1359
+ // commons/migrate.js and migrations/ are sibling dirs inside the same outputDir
1360
+ const migrationsRel = "../migrations";
1361
+
1362
+ if (isNoSql) {
1363
+ return `#!/usr/bin/env node
1364
+ import fs from "fs";
1365
+ import path from "path";
1366
+ import crypto from "crypto";
1367
+ import { fileURLToPath } from "url";
1368
+
1369
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1370
+
1371
+ /**
1372
+ * Run all pending migrations from the migrations directory.
1373
+ * @param {object} db - db-model-router db instance
1374
+ * @param {string} migrationsDir - absolute path to migrations folder
1375
+ */
1376
+ export default async function runMigrations(db, migrationsDir) {
1377
+ const files = fs.readdirSync(migrationsDir)
1378
+ .filter(f => f.endsWith(".js"))
1379
+ .sort();
1380
+
1381
+ let executed;
1382
+ try {
1383
+ const result = await db.get("_migrations");
1384
+ executed = new Set((result || []).map(r => r.filename));
1385
+ } catch (e) {
1386
+ executed = new Set();
1387
+ }
1388
+
1389
+ let ran = 0;
1390
+ for (const file of files) {
1391
+ if (executed.has(file)) {
1392
+ console.log(\` Skipping (already executed): \${file}\`);
1393
+ continue;
1394
+ }
1395
+ const filePath = path.join(migrationsDir, file);
1396
+ const content = fs.readFileSync(filePath, "utf8");
1397
+ const checksum = crypto.createHash("md5").update(content).digest("hex");
1398
+
1399
+ const migration = await import(filePath);
1400
+ console.log(\` Running migration: \${file}\`);
1401
+ await migration.up(db);
1402
+ await db.insert("_migrations", {
1403
+ filename: file,
1404
+ executed_at: new Date().toISOString(),
1405
+ checksum,
1406
+ });
1407
+ console.log(\` Completed: \${file}\`);
1408
+ ran++;
1409
+ }
1410
+
1411
+ if (ran === 0) {
1412
+ console.log("No pending migrations.");
1413
+ } else {
1414
+ console.log(\`\\n\${ran} migration(s) complete.\`);
1415
+ }
1416
+ }
1417
+
1418
+ // Run as standalone script
1419
+ const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
1420
+ if (isMain) {
1421
+ await import("dotenv/config");
1422
+ const pkg = await import("db-model-router");
1423
+ const mod = pkg.default || pkg;
1424
+ mod.init("${answers.database}");
1425
+ const migrationsDir = path.join(__dirname, "${migrationsRel}");
1426
+ runMigrations(mod.db, migrationsDir)
1427
+ .then(() => process.exit(0))
1428
+ .catch(err => { console.error("Migration failed:", err); process.exit(1); });
1429
+ }
1430
+ `;
1431
+ }
1432
+
1433
+ return `#!/usr/bin/env node
1434
+ import fs from "fs";
1435
+ import path from "path";
1436
+ import crypto from "crypto";
1437
+ import { fileURLToPath } from "url";
1438
+
1439
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1440
+
1441
+ /**
1442
+ * Run all pending SQL migrations from the migrations directory.
1443
+ * @param {object} db - db-model-router db instance
1444
+ * @param {string} migrationsDir - absolute path to migrations folder
1445
+ */
1446
+ export default async function runMigrations(db, migrationsDir) {
1447
+ const files = fs.readdirSync(migrationsDir)
1448
+ .filter(f => f.endsWith(".sql"))
1449
+ .sort();
1450
+
1451
+ let executed;
1452
+ try {
1453
+ const result = await db.query("SELECT filename FROM _migrations");
1454
+ executed = new Set((result || []).map(r => r.filename));
1455
+ } catch (e) {
1456
+ executed = new Set();
1457
+ }
1458
+
1459
+ let ran = 0;
1460
+ for (const file of files) {
1461
+ if (executed.has(file)) {
1462
+ console.log(\` Skipping (already executed): \${file}\`);
1463
+ continue;
1464
+ }
1465
+ const filePath = path.join(migrationsDir, file);
1466
+ const content = fs.readFileSync(filePath, "utf8");
1467
+ const checksum = crypto.createHash("md5").update(content).digest("hex");
1468
+
1469
+ console.log(\` Running migration: \${file}\`);
1470
+ await db.query(content);
1471
+ await db.query(
1472
+ "INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
1473
+ [file, checksum]
1474
+ );
1475
+ console.log(\` Completed: \${file}\`);
1476
+ ran++;
1477
+ }
1478
+
1479
+ if (ran === 0) {
1480
+ console.log("No pending migrations.");
1481
+ } else {
1482
+ console.log(\`\\n\${ran} migration(s) complete.\`);
1483
+ }
1484
+ }
1485
+
1486
+ // Run as standalone script
1487
+ const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
1488
+ if (isMain) {
1489
+ await import("dotenv/config");
1490
+ const pkg = await import("db-model-router");
1491
+ const mod = pkg.default || pkg;
1492
+ mod.init("${answers.database}");
1493
+ const migrationsDir = path.join(__dirname, "${migrationsRel}");
1494
+ runMigrations(mod.db, migrationsDir)
1495
+ .then(() => process.exit(0))
1496
+ .catch(err => { console.error("Migration failed:", err); process.exit(1); });
1497
+ }
1498
+ `;
1499
+ }
1500
+
1501
+ // ---------------------------------------------------------------------------
1502
+ // Commons: add_migration.js generator (standalone script)
1503
+ // ---------------------------------------------------------------------------
1504
+
1505
+ /**
1506
+ * Generate commons/add_migration.js — migration creation helper module.
1507
+ * Works as both an importable module and a standalone script.
1508
+ * @param {import('./types').InitAnswers} answers
1509
+ * @param {string} [outputDir] - relative output directory
1510
+ * @returns {string}
1511
+ */
1512
+ function generateAddMigrationModule(answers, outputDir) {
1513
+ const isNoSql = NOSQL_DATABASES.includes(answers.database);
1514
+ const ext = isNoSql ? "js" : "sql";
1515
+ const template = isNoSql
1516
+ ? `export async function up(db) {\\n // Write your migration here\\n}\\n\\nexport async function down(db) {\\n // Write your rollback here\\n}\\n`
1517
+ : `-- Write your migration SQL here\\n`;
1518
+ const migrationsRel = "../migrations";
1519
+
1520
+ return `#!/usr/bin/env node
1521
+ import fs from "fs";
1522
+ import path from "path";
1523
+ import { fileURLToPath } from "url";
1524
+
1525
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1526
+
1527
+ /**
1528
+ * Create a new timestamped migration file.
1529
+ * @param {string} migrationsDir - absolute path to migrations folder
1530
+ * @param {string} [name] - migration name (default: "migration")
1531
+ * @returns {string} the created filename
1532
+ */
1533
+ export default function addMigration(migrationsDir, name) {
1534
+ const migrationName = name || "migration";
1535
+ const now = new Date();
1536
+ const y = String(now.getFullYear()).padStart(4, "0");
1537
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
1538
+ const d = String(now.getDate()).padStart(2, "0");
1539
+ const h = String(now.getHours()).padStart(2, "0");
1540
+ const mi = String(now.getMinutes()).padStart(2, "0");
1541
+ const s = String(now.getSeconds()).padStart(2, "0");
1542
+ const ts = \`\${y}\${mo}\${d}\${h}\${mi}\${s}\`;
1543
+
1544
+ const filename = \`\${ts}_\${migrationName}.${ext}\`;
1545
+ const filePath = path.join(migrationsDir, filename);
1546
+
1547
+ if (!fs.existsSync(migrationsDir)) {
1548
+ fs.mkdirSync(migrationsDir, { recursive: true });
1549
+ }
1550
+
1551
+ fs.writeFileSync(filePath, "${template}");
1552
+ console.log(\`Created migration: \${filename}\`);
1553
+ return filename;
1554
+ }
1555
+
1556
+ // Run as standalone script
1557
+ const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
1558
+ if (isMain) {
1559
+ const migrationsDir = path.join(__dirname, "${migrationsRel}");
1560
+ const name = process.argv[2] || "migration";
1561
+ addMigration(migrationsDir, name);
1562
+ }
1563
+ `;
1564
+ }
1565
+
1566
+ // ---------------------------------------------------------------------------
1567
+ // Commons: security.js generator (helmet + header overrides)
1568
+ // ---------------------------------------------------------------------------
1569
+
1570
+ /**
1571
+ * Generate commons/security.js — helmet and custom header security middleware.
1572
+ * @param {import('./types').InitAnswers} answers
1573
+ * @returns {string}
1574
+ */
1575
+ function generateSecurityJs(answers) {
1576
+ let imports = "";
1577
+ if (answers.helmet) {
1578
+ imports += `import helmet from "helmet";\n`;
1579
+ }
1580
+ if (answers.rateLimiting) {
1581
+ imports += `import rateLimit from "express-rate-limit";\n`;
1582
+ }
1583
+
1584
+ return `${imports}
1585
+ /**
1586
+ * Apply security middleware to the Express app.
1587
+ * Includes: ${answers.helmet ? "Helmet, " : ""}${answers.rateLimiting ? "rate limiting, " : ""}custom security headers.
1588
+ * @param {import("express").Application} app
1589
+ */
1590
+ export default function applySecurity(app) {
1591
+ ${answers.helmet ? ` // Helmet — sets various HTTP headers for security\n app.use(helmet());\n` : " // Helmet is not enabled. Install and enable via --helmet flag.\n"}
1592
+ ${answers.rateLimiting ? ` // Rate limiting\n app.use(rateLimit({\n windowMs: 15 * 60 * 1000,\n max: 100,\n standardHeaders: true,\n legacyHeaders: false,\n }));\n` : ""}
1593
+ // Custom security headers (override or extend as needed)
1594
+ app.use((req, res, next) => {
1595
+ res.setHeader("X-Content-Type-Options", "nosniff");
1596
+ res.setHeader("X-Frame-Options", "DENY");
1597
+ res.setHeader("X-XSS-Protection", "1; mode=block");
1598
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1599
+ res.removeHeader("X-Powered-By");
1600
+ next();
1601
+ });
1602
+ }
1603
+ `;
1604
+ }
1605
+
1606
+ // ---------------------------------------------------------------------------
1607
+ // Route: health.js generator
1608
+ // ---------------------------------------------------------------------------
1609
+
1610
+ /**
1611
+ * Generate route/health.js — health check route.
1612
+ * @returns {string}
1613
+ */
1614
+ function generateHealthRoute() {
1615
+ return `import express from "express";
1616
+
1617
+ const router = express.Router();
1618
+
1619
+ /**
1620
+ * GET /health
1621
+ * Returns server health status, uptime, memory, and database connectivity.
1622
+ */
1623
+ router.get("/", async (req, res) => {
1624
+ const health = {
1625
+ status: "ok",
1626
+ timestamp: new Date().toISOString(),
1627
+ uptime: process.uptime(),
1628
+ memory: process.memoryUsage(),
1629
+ db: { connected: false },
1630
+ };
1631
+
1632
+ try {
1633
+ if (global.db && typeof global.db.query === "function") {
1634
+ await global.db.query("SELECT NOW()");
1635
+ health.db.connected = true;
1636
+ } else if (global.db && typeof global.db.get === "function") {
1637
+ // NoSQL adapters (mongodb, redis, dynamodb)
1638
+ health.db.connected = true;
1639
+ }
1640
+ } catch (err) {
1641
+ health.status = "degraded";
1642
+ health.db.error = err.message;
1643
+ }
1644
+
1645
+ const statusCode = health.status === "ok" ? 200 : 503;
1646
+ res.status(statusCode).json(health);
1647
+ });
1648
+
1649
+ export default router;
1650
+ `;
1651
+ }
1652
+
1653
+ // ---------------------------------------------------------------------------
1654
+ // Route: index.js generator — mounts all route modules
1655
+ // ---------------------------------------------------------------------------
1656
+
1657
+ /**
1658
+ * Generate route/index.js — central route mounting file.
1659
+ * @returns {string}
1660
+ */
1661
+ function generateRouteIndexFile() {
1662
+ return `import express from "express";
1663
+ import healthRoute from "./health.js";
1664
+
1665
+ const router = express.Router();
1666
+
1667
+ router.use("/health", healthRoute);
1668
+
1669
+ export default router;
1670
+ `;
1671
+ }
1672
+
1673
+ // ---------------------------------------------------------------------------
1674
+ // Commons: db.js generator — database init, connect, and global.db
1675
+ // ---------------------------------------------------------------------------
1676
+
1677
+ /**
1678
+ * Generate commons/db.js — database initialization and connection module.
1679
+ * Sets global.db so the db instance is accessible across the application.
1680
+ * @param {import('./types').InitAnswers} answers
1681
+ * @returns {string}
1682
+ */
1683
+ function generateDbModule(answers) {
1684
+ return `import "dotenv/config";
1685
+ import dbModelRouter from "db-model-router";
1686
+
1687
+ // Initialize database adapter
1688
+ dbModelRouter.init("${answers.database}");
1689
+
1690
+ // Connect to database
1691
+ dbModelRouter.db.connect({
1692
+ ${dbConnectArgs(answers.database)}});
1693
+
1694
+ // Make db available globally across the application
1695
+ const db = dbModelRouter.db;
1696
+ global.db = db;
1697
+
1698
+ export { db };
1699
+ export default db;
1700
+ `;
1701
+ }
1702
+
1703
+ // ---------------------------------------------------------------------------
1704
+ // Updated app.js generator — links commons and route/index
1705
+ // ---------------------------------------------------------------------------
1706
+
1707
+ /**
1708
+ * Generate the app.js file content (v2 — uses commons modules and route/index).
1709
+ * @param {import('./types').InitAnswers} answers
1710
+ * @param {string} [outputDir] - relative output directory for source files (e.g. "backend")
1711
+ * @returns {string}
1712
+ */
1713
+ function generateAppJsV2(answers, outputDir) {
1714
+ const frameworkPkg =
1715
+ answers.framework === "ultimate-express" ? "ultimate-express" : "express";
1716
+
1717
+ const commonsPrefix = outputDir ? `./${outputDir}/commons` : "./commons";
1718
+ const routePrefix = outputDir ? `./${outputDir}/route` : "./route";
1719
+ const middlewarePrefix = outputDir
1720
+ ? `./${outputDir}/middleware`
1721
+ : "./middleware";
1722
+
1723
+ return `import express from "${frameworkPkg}";
1724
+ import "${commonsPrefix}/db.js";
1725
+ import configureSession from "${commonsPrefix}/session.js";
1726
+ import applySecurity from "${commonsPrefix}/security.js";
1727
+ import logger from "${middlewarePrefix}/logger.js";
1728
+ import route from "${routePrefix}/index.js";
1729
+
1730
+ const app = express();
1731
+ const PORT = process.env.PORT || 3000;
1732
+
1733
+ // Middleware
1734
+ app.use(express.json());
1735
+ app.use(express.urlencoded({ extended: true }));
1736
+
1737
+ // Security (helmet, rate limiting, custom headers)
1738
+ applySecurity(app);
1739
+
1740
+ // Session
1741
+ app.use(configureSession());
1742
+
1743
+ // Logger
1744
+ app.use(logger);
1745
+
1746
+ // Routes
1747
+ app.use(route);
1748
+
1749
+ // Error handler
1750
+ app.use((err, req, res, next) => {
1751
+ console.error(err.stack);
1752
+ res.status(500).json({ type: "danger", message: "Internal Server Error" });
1753
+ });
1754
+
1755
+ app.listen(PORT, () => {
1756
+ console.log(\`Server running on port \${PORT}\`);
1757
+ });
1758
+
1759
+ export default app;
1760
+ `;
1761
+ }
1762
+
1763
+ module.exports = {
1764
+ migrationTimestamp,
1765
+ isSql,
1766
+ randomPassword,
1767
+ generateAppJs,
1768
+ generateAppJsV2,
1769
+ generateEnvFile,
1770
+ generateEnvExample,
1771
+ generateLoggerMiddleware,
1772
+ generateMigrateScript,
1773
+ generateAddMigrationScript,
1774
+ generateInitialMigration,
1775
+ generateSessionMigration,
1776
+ generateGitignore,
1777
+ generateDockerfile,
1778
+ generateDockerignore,
1779
+ generateGrafanaDatasources,
1780
+ generateDockerCompose,
1781
+ generateCloudBeaverDataSources,
1782
+ generateSessionJs,
1783
+ generateMigrateModule,
1784
+ generateAddMigrationModule,
1785
+ generateSecurityJs,
1786
+ generateHealthRoute,
1787
+ generateRouteIndexFile,
1788
+ generateDbModule,
1789
+ SQL_DATABASES,
1790
+ NOSQL_DATABASES,
1791
+ };