db-model-router 1.0.2 → 1.0.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/README.md +153 -202
- package/docs/SKILL.md +194 -22
- package/docs/adapters/cockroachdb.md +1 -1
- package/docs/adapters/dynamodb.md +1 -1
- package/docs/adapters/mongodb.md +1 -1
- package/docs/adapters/mssql.md +1 -1
- package/docs/adapters/oracle.md +1 -1
- package/docs/adapters/postgres.md +1 -1
- package/docs/adapters/redis.md +1 -1
- package/docs/adapters/sqlite3.md +1 -1
- package/package.json +3 -5
- package/src/cli/commands/diff.js +114 -0
- package/src/cli/commands/doctor.js +181 -0
- package/src/cli/commands/generate-llm-docs.js +418 -0
- package/src/cli/commands/generate.js +240 -0
- package/src/cli/commands/init.js +153 -0
- package/src/cli/commands/inspect.js +205 -0
- package/src/cli/diff-engine.js +198 -0
- package/src/cli/flags.js +112 -0
- package/src/cli/generate-route.js +237 -2
- package/src/cli/init/dependencies.js +83 -0
- package/src/cli/init/generators.js +782 -0
- package/src/cli/init/prompt.js +159 -0
- package/src/cli/init.js +281 -0
- package/src/cli/main.js +95 -0
- package/src/commons/model.js +5 -6
- package/src/commons/route.js +24 -0
- package/src/schema/schema-parser.js +78 -0
- package/src/schema/schema-printer.js +81 -0
- package/src/schema/schema-to-meta.js +74 -0
- package/src/schema/schema-validator.js +253 -0
- package/src/serve.js +5 -3
- package/docs/README.md +0 -208
- package/src/cli/generate-app.js +0 -359
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const SQL_DATABASES = [
|
|
4
|
+
"mysql",
|
|
5
|
+
"postgres",
|
|
6
|
+
"sqlite3",
|
|
7
|
+
"mssql",
|
|
8
|
+
"cockroachdb",
|
|
9
|
+
"oracle",
|
|
10
|
+
];
|
|
11
|
+
const NOSQL_DATABASES = ["mongodb", "redis", "dynamodb"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format a Date as YYYYMMDDHHMMSS (14-digit string).
|
|
15
|
+
* @param {Date} date
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function migrationTimestamp(date) {
|
|
19
|
+
const y = String(date.getFullYear()).padStart(4, "0");
|
|
20
|
+
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
|
21
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
22
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
23
|
+
const mi = String(date.getMinutes()).padStart(2, "0");
|
|
24
|
+
const s = String(date.getSeconds()).padStart(2, "0");
|
|
25
|
+
return `${y}${mo}${d}${h}${mi}${s}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns true if the database is a SQL database.
|
|
30
|
+
* @param {string} database
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function isSql(database) {
|
|
34
|
+
return SQL_DATABASES.includes(database);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Environment variable config map (DRY: shared by .env and .env.example)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} EnvVarDef
|
|
43
|
+
* @property {string} key - Variable name
|
|
44
|
+
* @property {string} defaultValue - Value for .env
|
|
45
|
+
* @property {string} placeholder - Value for .env.example
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** @type {Record<string, EnvVarDef[]>} */
|
|
49
|
+
const DB_ENV_MAP = {
|
|
50
|
+
mysql: [
|
|
51
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
52
|
+
{ key: "DB_PORT", defaultValue: "3306", placeholder: "3306" },
|
|
53
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
54
|
+
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
55
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
56
|
+
],
|
|
57
|
+
postgres: [
|
|
58
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
59
|
+
{ key: "DB_PORT", defaultValue: "5432", placeholder: "5432" },
|
|
60
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
61
|
+
{ key: "DB_USER", defaultValue: "postgres", placeholder: "your_user" },
|
|
62
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
63
|
+
],
|
|
64
|
+
cockroachdb: [
|
|
65
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
66
|
+
{ key: "DB_PORT", defaultValue: "26257", placeholder: "26257" },
|
|
67
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
68
|
+
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
69
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
70
|
+
],
|
|
71
|
+
sqlite3: [
|
|
72
|
+
{ key: "DB_NAME", defaultValue: "./data.db", placeholder: "./data.db" },
|
|
73
|
+
],
|
|
74
|
+
mongodb: [
|
|
75
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
76
|
+
{ key: "DB_PORT", defaultValue: "27017", placeholder: "27017" },
|
|
77
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
78
|
+
{ key: "DB_USER", defaultValue: "", placeholder: "your_user" },
|
|
79
|
+
{ key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
|
|
80
|
+
],
|
|
81
|
+
mssql: [
|
|
82
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
83
|
+
{ key: "DB_PORT", defaultValue: "1433", placeholder: "1433" },
|
|
84
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
85
|
+
{ key: "DB_USER", defaultValue: "sa", placeholder: "your_user" },
|
|
86
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
87
|
+
],
|
|
88
|
+
oracle: [
|
|
89
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
90
|
+
{ key: "DB_PORT", defaultValue: "1521", placeholder: "1521" },
|
|
91
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
92
|
+
{ key: "DB_USER", defaultValue: "system", placeholder: "your_user" },
|
|
93
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
94
|
+
],
|
|
95
|
+
redis: [
|
|
96
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
97
|
+
{ key: "DB_PORT", defaultValue: "6379", placeholder: "6379" },
|
|
98
|
+
{ key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
|
|
99
|
+
],
|
|
100
|
+
dynamodb: [
|
|
101
|
+
{ key: "AWS_REGION", defaultValue: "us-east-1", placeholder: "us-east-1" },
|
|
102
|
+
{
|
|
103
|
+
key: "AWS_ENDPOINT",
|
|
104
|
+
defaultValue: "http://localhost:8000",
|
|
105
|
+
placeholder: "http://localhost:8000",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "AWS_ACCESS_KEY_ID",
|
|
109
|
+
defaultValue: "local",
|
|
110
|
+
placeholder: "your_access_key",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: "AWS_SECRET_ACCESS_KEY",
|
|
114
|
+
defaultValue: "local",
|
|
115
|
+
placeholder: "your_secret_key",
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const REDIS_SESSION_VARS = [
|
|
121
|
+
{ key: "REDIS_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
122
|
+
{ key: "REDIS_PORT", defaultValue: "6379", placeholder: "6379" },
|
|
123
|
+
{ key: "REDIS_PASS", defaultValue: "", placeholder: "your_password" },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build env file content from the config map.
|
|
128
|
+
* @param {import('./types').InitAnswers} answers
|
|
129
|
+
* @param {'default'|'placeholder'} mode
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function buildEnvContent(answers, mode) {
|
|
133
|
+
const pick = mode === "placeholder" ? "placeholder" : "defaultValue";
|
|
134
|
+
const lines = [];
|
|
135
|
+
lines.push("# Server");
|
|
136
|
+
lines.push("PORT=3000");
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push("# Database");
|
|
139
|
+
|
|
140
|
+
const vars = DB_ENV_MAP[answers.database] || [];
|
|
141
|
+
for (const v of vars) {
|
|
142
|
+
lines.push(`${v.key}=${v[pick]}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Session secret
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push("# Session");
|
|
148
|
+
lines.push(
|
|
149
|
+
`SESSION_SECRET=${mode === "placeholder" ? "your_session_secret" : "change-me"}`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Redis session env vars when session is redis and database is not redis
|
|
153
|
+
if (answers.session === "redis" && answers.database !== "redis") {
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push("# Redis Session");
|
|
156
|
+
for (const v of REDIS_SESSION_VARS) {
|
|
157
|
+
lines.push(`${v.key}=${v[pick]}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push("");
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generate .env file content.
|
|
167
|
+
* @param {import('./types').InitAnswers} answers
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
function generateEnvFile(answers) {
|
|
171
|
+
return buildEnvContent(answers, "default");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate .env.example file content with placeholder values.
|
|
176
|
+
* @param {import('./types').InitAnswers} answers
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function generateEnvExample(answers) {
|
|
180
|
+
return buildEnvContent(answers, "placeholder");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// App.js generator (template-literal based)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Generate the db.connect() block for the selected database.
|
|
189
|
+
* @param {string} database
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
function dbConnectBlock(database) {
|
|
193
|
+
if (database === "dynamodb") {
|
|
194
|
+
return `db.connect({
|
|
195
|
+
region: process.env.AWS_REGION,
|
|
196
|
+
endpoint: process.env.AWS_ENDPOINT,
|
|
197
|
+
credentials: {
|
|
198
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
199
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
200
|
+
},
|
|
201
|
+
});`;
|
|
202
|
+
}
|
|
203
|
+
if (database === "redis") {
|
|
204
|
+
return `db.connect({
|
|
205
|
+
host: process.env.DB_HOST || "localhost",
|
|
206
|
+
port: process.env.DB_PORT || 6379,
|
|
207
|
+
password: process.env.DB_PASS,
|
|
208
|
+
});`;
|
|
209
|
+
}
|
|
210
|
+
if (database === "sqlite3") {
|
|
211
|
+
return `db.connect({
|
|
212
|
+
database: process.env.DB_NAME || "./data.db",
|
|
213
|
+
});`;
|
|
214
|
+
}
|
|
215
|
+
return `db.connect({
|
|
216
|
+
host: process.env.DB_HOST || "localhost",
|
|
217
|
+
port: process.env.DB_PORT,
|
|
218
|
+
database: process.env.DB_NAME,
|
|
219
|
+
user: process.env.DB_USER,
|
|
220
|
+
password: process.env.DB_PASS,
|
|
221
|
+
});`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Generate session middleware block for app.js.
|
|
226
|
+
* @param {import('./types').InitAnswers} answers
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
function sessionBlock(answers) {
|
|
230
|
+
if (answers.session === "redis") {
|
|
231
|
+
const redisConfig =
|
|
232
|
+
answers.database === "redis"
|
|
233
|
+
? ` host: process.env.DB_HOST || "localhost",
|
|
234
|
+
port: process.env.DB_PORT || 6379,
|
|
235
|
+
password: process.env.DB_PASS,`
|
|
236
|
+
: ` host: process.env.REDIS_HOST || "localhost",
|
|
237
|
+
port: process.env.REDIS_PORT || 6379,
|
|
238
|
+
password: process.env.REDIS_PASS,`;
|
|
239
|
+
|
|
240
|
+
return `
|
|
241
|
+
// Session with Redis store
|
|
242
|
+
const redisClient = new Redis({
|
|
243
|
+
${redisConfig}
|
|
244
|
+
});
|
|
245
|
+
app.use(session({
|
|
246
|
+
store: new RedisStore({ client: redisClient }),
|
|
247
|
+
secret: process.env.SESSION_SECRET || "change-me",
|
|
248
|
+
resave: false,
|
|
249
|
+
saveUninitialized: false,
|
|
250
|
+
}));`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const label = answers.session === "database" ? "database" : "in-memory";
|
|
254
|
+
return `
|
|
255
|
+
// Session with ${label} store
|
|
256
|
+
app.use(session({
|
|
257
|
+
secret: process.env.SESSION_SECRET || "change-me",
|
|
258
|
+
resave: false,
|
|
259
|
+
saveUninitialized: false,
|
|
260
|
+
}));`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Generate the app.js file content.
|
|
265
|
+
* @param {import('./types').InitAnswers} answers
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
function generateAppJs(answers) {
|
|
269
|
+
const frameworkPkg =
|
|
270
|
+
answers.framework === "ultimate-express" ? "ultimate-express" : "express";
|
|
271
|
+
|
|
272
|
+
// Imports
|
|
273
|
+
let imports = `const express = require("${frameworkPkg}");
|
|
274
|
+
const { init, db } = require("db-model-router");
|
|
275
|
+
const session = require("express-session");`;
|
|
276
|
+
|
|
277
|
+
if (answers.session === "redis") {
|
|
278
|
+
imports += `\nconst RedisStore = require("connect-redis").default;
|
|
279
|
+
const { Redis } = require("ioredis");`;
|
|
280
|
+
}
|
|
281
|
+
if (answers.rateLimiting) {
|
|
282
|
+
imports += `\nconst rateLimit = require("express-rate-limit");`;
|
|
283
|
+
}
|
|
284
|
+
if (answers.helmet) {
|
|
285
|
+
imports += `\nconst helmet = require("helmet");`;
|
|
286
|
+
}
|
|
287
|
+
imports += `\nconst logger = require("./middleware/logger");`;
|
|
288
|
+
|
|
289
|
+
// Rate limiting block
|
|
290
|
+
const rateLimitBlock = answers.rateLimiting
|
|
291
|
+
? `app.use(rateLimit({
|
|
292
|
+
windowMs: 15 * 60 * 1000,
|
|
293
|
+
max: 100,
|
|
294
|
+
standardHeaders: true,
|
|
295
|
+
legacyHeaders: false,
|
|
296
|
+
}));`
|
|
297
|
+
: "";
|
|
298
|
+
|
|
299
|
+
const helmetBlock = answers.helmet ? `app.use(helmet());` : "";
|
|
300
|
+
|
|
301
|
+
return `${imports}
|
|
302
|
+
|
|
303
|
+
// Load environment variables
|
|
304
|
+
require("dotenv").config();
|
|
305
|
+
|
|
306
|
+
// Initialize database adapter
|
|
307
|
+
init("${answers.database}");
|
|
308
|
+
${dbConnectBlock(answers.database)}
|
|
309
|
+
|
|
310
|
+
const app = express();
|
|
311
|
+
const PORT = process.env.PORT || 3000;
|
|
312
|
+
|
|
313
|
+
// Middleware
|
|
314
|
+
app.use(express.json());
|
|
315
|
+
app.use(express.urlencoded({ extended: true }));
|
|
316
|
+
${helmetBlock ? helmetBlock + "\n" : ""}${rateLimitBlock ? rateLimitBlock + "\n" : ""}${sessionBlock(answers)}
|
|
317
|
+
app.use(logger);
|
|
318
|
+
|
|
319
|
+
// Health check
|
|
320
|
+
app.get("/health", (req, res) => {
|
|
321
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Error handler
|
|
325
|
+
app.use((err, req, res, next) => {
|
|
326
|
+
console.error(err.stack);
|
|
327
|
+
res.status(500).json({ type: "danger", message: "Internal Server Error" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.listen(PORT, () => {
|
|
331
|
+
console.log(\`Server running on port \${PORT}\`);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
module.exports = app;
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Logger middleware generator
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Generate middleware/logger.js content.
|
|
344
|
+
* @param {import('./types').InitAnswers} answers
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
function generateLoggerMiddleware(answers) {
|
|
348
|
+
if (answers.logger) {
|
|
349
|
+
return `const mung = require("express-mung");
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Request/response logger middleware using express-mung.
|
|
353
|
+
* Logs request details, response body, and response time.
|
|
354
|
+
*/
|
|
355
|
+
const logger = mung.json(function transform(body, req, res) {
|
|
356
|
+
const duration = Date.now() - req._startTime;
|
|
357
|
+
const status = res.statusCode;
|
|
358
|
+
const level = status >= 400 ? "WARN" : "INFO";
|
|
359
|
+
console.log(
|
|
360
|
+
\`[\${new Date().toISOString()}] [\${level}] \${req.method} \${req.originalUrl} \${status} \${duration}ms\`,
|
|
361
|
+
);
|
|
362
|
+
console.log(" Request headers:", JSON.stringify(req.headers));
|
|
363
|
+
console.log(" Response body:", JSON.stringify(body));
|
|
364
|
+
return body;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
function startTimer(req, res, next) {
|
|
368
|
+
req._startTime = Date.now();
|
|
369
|
+
next();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = [startTimer, logger];
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return `/**
|
|
377
|
+
* Simple request logger middleware.
|
|
378
|
+
* Logs method, URL, status code, and response time.
|
|
379
|
+
*/
|
|
380
|
+
module.exports = function logger(req, res, next) {
|
|
381
|
+
const start = Date.now();
|
|
382
|
+
const { method, originalUrl } = req;
|
|
383
|
+
|
|
384
|
+
res.on("finish", () => {
|
|
385
|
+
const duration = Date.now() - start;
|
|
386
|
+
const status = res.statusCode;
|
|
387
|
+
const level = status >= 400 ? "WARN" : "INFO";
|
|
388
|
+
console.log(
|
|
389
|
+
\`[\${new Date().toISOString()}] [\${level}] \${method} \${originalUrl} \${status} \${duration}ms\`,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
next();
|
|
394
|
+
};
|
|
395
|
+
`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Migration script generators (Fix 3: migrate.js now checks tracking table)
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Generate migrate.js script content.
|
|
404
|
+
* Checks _migrations tracking table before running each migration.
|
|
405
|
+
* @param {import('./types').InitAnswers} answers
|
|
406
|
+
* @returns {string}
|
|
407
|
+
*/
|
|
408
|
+
function generateMigrateScript(answers) {
|
|
409
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
410
|
+
|
|
411
|
+
if (isNoSql) {
|
|
412
|
+
return `#!/usr/bin/env node
|
|
413
|
+
"use strict";
|
|
414
|
+
|
|
415
|
+
const fs = require("fs");
|
|
416
|
+
const path = require("path");
|
|
417
|
+
const crypto = require("crypto");
|
|
418
|
+
require("dotenv").config();
|
|
419
|
+
|
|
420
|
+
const { init, db } = require("db-model-router");
|
|
421
|
+
|
|
422
|
+
init("${answers.database}");
|
|
423
|
+
|
|
424
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
425
|
+
|
|
426
|
+
async function getExecutedMigrations() {
|
|
427
|
+
try {
|
|
428
|
+
const result = await db.get("_migrations");
|
|
429
|
+
return new Set((result || []).map(r => r.filename));
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return new Set();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function recordMigration(filename, checksum) {
|
|
436
|
+
await db.insert("_migrations", {
|
|
437
|
+
filename,
|
|
438
|
+
executed_at: new Date().toISOString(),
|
|
439
|
+
checksum,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function migrate() {
|
|
444
|
+
const files = fs.readdirSync(migrationsDir)
|
|
445
|
+
.filter(f => f.endsWith(".js"))
|
|
446
|
+
.sort();
|
|
447
|
+
|
|
448
|
+
const executed = await getExecutedMigrations();
|
|
449
|
+
let ran = 0;
|
|
450
|
+
|
|
451
|
+
for (const file of files) {
|
|
452
|
+
if (executed.has(file)) {
|
|
453
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const filePath = path.join(migrationsDir, file);
|
|
457
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
458
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
459
|
+
|
|
460
|
+
const migration = require(filePath);
|
|
461
|
+
console.log(\` Running migration: \${file}\`);
|
|
462
|
+
await migration.up(db);
|
|
463
|
+
await recordMigration(file, checksum);
|
|
464
|
+
console.log(\` Completed: \${file}\`);
|
|
465
|
+
ran++;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (ran === 0) {
|
|
469
|
+
console.log("No pending migrations.");
|
|
470
|
+
} else {
|
|
471
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
472
|
+
}
|
|
473
|
+
process.exit(0);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
migrate().catch(err => {
|
|
477
|
+
console.error("Migration failed:", err);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
});
|
|
480
|
+
`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return `#!/usr/bin/env node
|
|
484
|
+
"use strict";
|
|
485
|
+
|
|
486
|
+
const fs = require("fs");
|
|
487
|
+
const path = require("path");
|
|
488
|
+
const crypto = require("crypto");
|
|
489
|
+
require("dotenv").config();
|
|
490
|
+
|
|
491
|
+
const { init, db } = require("db-model-router");
|
|
492
|
+
|
|
493
|
+
init("${answers.database}");
|
|
494
|
+
|
|
495
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
496
|
+
|
|
497
|
+
async function getExecutedMigrations() {
|
|
498
|
+
try {
|
|
499
|
+
const result = await db.query("SELECT filename FROM _migrations");
|
|
500
|
+
return new Set((result || []).map(r => r.filename));
|
|
501
|
+
} catch (e) {
|
|
502
|
+
// Table may not exist yet (first run)
|
|
503
|
+
return new Set();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function recordMigration(filename, checksum) {
|
|
508
|
+
await db.query(
|
|
509
|
+
"INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
|
|
510
|
+
[filename, checksum]
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function migrate() {
|
|
515
|
+
const files = fs.readdirSync(migrationsDir)
|
|
516
|
+
.filter(f => f.endsWith(".sql"))
|
|
517
|
+
.sort();
|
|
518
|
+
|
|
519
|
+
const executed = await getExecutedMigrations();
|
|
520
|
+
let ran = 0;
|
|
521
|
+
|
|
522
|
+
for (const file of files) {
|
|
523
|
+
if (executed.has(file)) {
|
|
524
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const filePath = path.join(migrationsDir, file);
|
|
528
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
529
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
530
|
+
|
|
531
|
+
console.log(\` Running migration: \${file}\`);
|
|
532
|
+
await db.query(content);
|
|
533
|
+
await recordMigration(file, checksum);
|
|
534
|
+
console.log(\` Completed: \${file}\`);
|
|
535
|
+
ran++;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (ran === 0) {
|
|
539
|
+
console.log("No pending migrations.");
|
|
540
|
+
} else {
|
|
541
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
542
|
+
}
|
|
543
|
+
process.exit(0);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
migrate().catch(err => {
|
|
547
|
+
console.error("Migration failed:", err);
|
|
548
|
+
process.exit(1);
|
|
549
|
+
});
|
|
550
|
+
`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Generate add_migration.js script content.
|
|
555
|
+
* @param {import('./types').InitAnswers} answers
|
|
556
|
+
* @returns {string}
|
|
557
|
+
*/
|
|
558
|
+
function generateAddMigrationScript(answers) {
|
|
559
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
560
|
+
const ext = isNoSql ? "js" : "sql";
|
|
561
|
+
const template = isNoSql
|
|
562
|
+
? `"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`
|
|
563
|
+
: `-- Write your migration SQL here\\n`;
|
|
564
|
+
|
|
565
|
+
return `#!/usr/bin/env node
|
|
566
|
+
"use strict";
|
|
567
|
+
|
|
568
|
+
const fs = require("fs");
|
|
569
|
+
const path = require("path");
|
|
570
|
+
|
|
571
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
572
|
+
|
|
573
|
+
function migrationTimestamp() {
|
|
574
|
+
const now = new Date();
|
|
575
|
+
const y = String(now.getFullYear()).padStart(4, "0");
|
|
576
|
+
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
|
577
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
578
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
579
|
+
const mi = String(now.getMinutes()).padStart(2, "0");
|
|
580
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
581
|
+
return \`\${y}\${mo}\${d}\${h}\${mi}\${s}\`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const name = process.argv[2] || "migration";
|
|
585
|
+
const filename = \`\${migrationTimestamp()}_\${name}.${ext}\`;
|
|
586
|
+
const filePath = path.join(migrationsDir, filename);
|
|
587
|
+
|
|
588
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
589
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fs.writeFileSync(filePath, "${template}");
|
|
593
|
+
console.log(\`Created migration: \${filename}\`);
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Initial migration + session migration generators
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Generate the initial migration file that creates the _migrations tracking table.
|
|
603
|
+
* @param {import('./types').InitAnswers} answers
|
|
604
|
+
* @param {Date} [date]
|
|
605
|
+
* @returns {{ filename: string, content: string }}
|
|
606
|
+
*/
|
|
607
|
+
function generateInitialMigration(answers, date) {
|
|
608
|
+
const ts = migrationTimestamp(date || new Date());
|
|
609
|
+
|
|
610
|
+
if (isSql(answers.database)) {
|
|
611
|
+
let content;
|
|
612
|
+
if (answers.database === "postgres" || answers.database === "cockroachdb") {
|
|
613
|
+
content = `CREATE TABLE IF NOT EXISTS _migrations (
|
|
614
|
+
id SERIAL PRIMARY KEY,
|
|
615
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
616
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
617
|
+
checksum VARCHAR(64) NOT NULL
|
|
618
|
+
);
|
|
619
|
+
`;
|
|
620
|
+
} else if (answers.database === "mssql") {
|
|
621
|
+
content = `CREATE TABLE _migrations (
|
|
622
|
+
id INT IDENTITY(1,1) PRIMARY KEY,
|
|
623
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
624
|
+
executed_at DATETIME DEFAULT GETDATE(),
|
|
625
|
+
checksum VARCHAR(64) NOT NULL
|
|
626
|
+
);
|
|
627
|
+
`;
|
|
628
|
+
} else if (answers.database === "oracle") {
|
|
629
|
+
content = `CREATE TABLE _migrations (
|
|
630
|
+
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
631
|
+
filename VARCHAR2(255) NOT NULL UNIQUE,
|
|
632
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
633
|
+
checksum VARCHAR2(64) NOT NULL
|
|
634
|
+
);
|
|
635
|
+
`;
|
|
636
|
+
} else {
|
|
637
|
+
// mysql, sqlite3
|
|
638
|
+
content = `CREATE TABLE IF NOT EXISTS _migrations (
|
|
639
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
640
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
641
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
642
|
+
checksum VARCHAR(64) NOT NULL
|
|
643
|
+
);
|
|
644
|
+
`;
|
|
645
|
+
}
|
|
646
|
+
return { filename: `${ts}_create_migrations_table.sql`, content };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// NoSQL databases
|
|
650
|
+
let content;
|
|
651
|
+
if (answers.database === "mongodb") {
|
|
652
|
+
content = `"use strict";
|
|
653
|
+
|
|
654
|
+
module.exports = {
|
|
655
|
+
async up(db) {
|
|
656
|
+
await db.createCollection("_migrations");
|
|
657
|
+
await db.collection("_migrations").createIndex({ filename: 1 }, { unique: true });
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
async down(db) {
|
|
661
|
+
await db.collection("_migrations").drop();
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
`;
|
|
665
|
+
} else if (answers.database === "redis") {
|
|
666
|
+
content = `"use strict";
|
|
667
|
+
|
|
668
|
+
module.exports = {
|
|
669
|
+
async up(db) {
|
|
670
|
+
// _migrations hash key will be created on first HSET
|
|
671
|
+
console.log("Redis migration tracking initialized using hash key: _migrations");
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
async down(db) {
|
|
675
|
+
await db.del("_migrations");
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
`;
|
|
679
|
+
} else {
|
|
680
|
+
// dynamodb
|
|
681
|
+
content = `"use strict";
|
|
682
|
+
|
|
683
|
+
module.exports = {
|
|
684
|
+
async up(db) {
|
|
685
|
+
const { CreateTableCommand } = require("@aws-sdk/client-dynamodb");
|
|
686
|
+
await db.send(new CreateTableCommand({
|
|
687
|
+
TableName: "_migrations",
|
|
688
|
+
KeySchema: [{ AttributeName: "filename", KeyType: "HASH" }],
|
|
689
|
+
AttributeDefinitions: [{ AttributeName: "filename", AttributeType: "S" }],
|
|
690
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
691
|
+
}));
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
async down(db) {
|
|
695
|
+
const { DeleteTableCommand } = require("@aws-sdk/client-dynamodb");
|
|
696
|
+
await db.send(new DeleteTableCommand({ TableName: "_migrations" }));
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { filename: `${ts}_create_migrations_table.js`, content };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Generate the session migration file for SQL databases with database session store.
|
|
707
|
+
* @param {import('./types').InitAnswers} answers
|
|
708
|
+
* @param {Date} [date]
|
|
709
|
+
* @returns {{ filename: string, content: string } | null}
|
|
710
|
+
*/
|
|
711
|
+
function generateSessionMigration(answers, date) {
|
|
712
|
+
if (answers.session !== "database" || !isSql(answers.database)) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const ts = migrationTimestamp(date || new Date());
|
|
717
|
+
|
|
718
|
+
let content;
|
|
719
|
+
if (answers.database === "postgres" || answers.database === "cockroachdb") {
|
|
720
|
+
content = `CREATE TABLE IF NOT EXISTS sessions (
|
|
721
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
722
|
+
sess TEXT NOT NULL,
|
|
723
|
+
expired_at TIMESTAMP NOT NULL
|
|
724
|
+
);
|
|
725
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired_at);
|
|
726
|
+
`;
|
|
727
|
+
} else if (answers.database === "mssql") {
|
|
728
|
+
content = `CREATE TABLE sessions (
|
|
729
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
730
|
+
sess TEXT NOT NULL,
|
|
731
|
+
expired_at DATETIME NOT NULL
|
|
732
|
+
);
|
|
733
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
734
|
+
`;
|
|
735
|
+
} else if (answers.database === "oracle") {
|
|
736
|
+
content = `CREATE TABLE sessions (
|
|
737
|
+
sid VARCHAR2(255) PRIMARY KEY,
|
|
738
|
+
sess CLOB NOT NULL,
|
|
739
|
+
expired_at TIMESTAMP NOT NULL
|
|
740
|
+
);
|
|
741
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
742
|
+
`;
|
|
743
|
+
} else {
|
|
744
|
+
// mysql, sqlite3
|
|
745
|
+
content = `CREATE TABLE IF NOT EXISTS sessions (
|
|
746
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
747
|
+
sess TEXT NOT NULL,
|
|
748
|
+
expired_at TIMESTAMP NOT NULL
|
|
749
|
+
);
|
|
750
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
751
|
+
`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return { filename: `${ts}_create_sessions_table.sql`, content };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Generate .gitignore content.
|
|
759
|
+
* @returns {string}
|
|
760
|
+
*/
|
|
761
|
+
function generateGitignore() {
|
|
762
|
+
return `node_modules/
|
|
763
|
+
.env
|
|
764
|
+
*.db
|
|
765
|
+
`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
module.exports = {
|
|
769
|
+
migrationTimestamp,
|
|
770
|
+
isSql,
|
|
771
|
+
generateAppJs,
|
|
772
|
+
generateEnvFile,
|
|
773
|
+
generateEnvExample,
|
|
774
|
+
generateLoggerMiddleware,
|
|
775
|
+
generateMigrateScript,
|
|
776
|
+
generateAddMigrationScript,
|
|
777
|
+
generateInitialMigration,
|
|
778
|
+
generateSessionMigration,
|
|
779
|
+
generateGitignore,
|
|
780
|
+
SQL_DATABASES,
|
|
781
|
+
NOSQL_DATABASES,
|
|
782
|
+
};
|