db-model-router 1.0.0 → 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.
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { parseSchema } = require("../../schema/schema-parser");
6
+ const { schemaToModelMeta } = require("../../schema/schema-to-meta");
7
+ const { generateModelFile } = require("../generate-model");
8
+ const {
9
+ generateRouteFile,
10
+ generateChildRouteFile,
11
+ generateRoutesIndexFile,
12
+ generateTestFile,
13
+ generateChildTestFile,
14
+ } = require("../generate-route");
15
+ const { generateOpenAPISpec } = require("../generate-openapi");
16
+ const { generateLlmsTxt, generateLlmMd } = require("./generate-llm-docs");
17
+
18
+ /**
19
+ * Generate command handler for the unified CLI.
20
+ *
21
+ * Reads a schema file, converts to ModelMeta[], and generates
22
+ * models, routes, tests, and OpenAPI spec files.
23
+ *
24
+ * Supported flags:
25
+ * --from Path to schema file (default: dbmr.schema.json)
26
+ * --models Generate only model files
27
+ * --routes Generate only route files (including child routes and index)
28
+ * --openapi Generate only OpenAPI spec
29
+ * --tests Generate only test files
30
+ * --dry-run Report planned files without writing
31
+ * --json Output JSON result via ctx
32
+ *
33
+ * When no artifact flags are provided, all artifact types are generated.
34
+ *
35
+ * @param {object} args - Parsed key-value args
36
+ * @param {object} flags - Universal flags: { yes, json, dryRun, noInstall, help }
37
+ * @param {import('../flags').OutputContext} ctx - Output context
38
+ */
39
+ async function generate(args, flags, ctx) {
40
+ const schemaPath = path.resolve(args.from || "dbmr.schema.json");
41
+
42
+ if (!fs.existsSync(schemaPath)) {
43
+ const msg = `Schema file not found: ${args.from || "dbmr.schema.json"}`;
44
+ if (flags.json) {
45
+ ctx.result({ error: true, code: "SCHEMA_NOT_FOUND", message: msg });
46
+ } else {
47
+ ctx.log(`Error: ${msg}`);
48
+ }
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ let schema;
54
+ try {
55
+ const raw = fs.readFileSync(schemaPath, "utf8");
56
+ schema = parseSchema(raw);
57
+ } catch (err) {
58
+ const msg = `Schema parse error: ${err.message}`;
59
+ if (flags.json) {
60
+ ctx.result({
61
+ error: true,
62
+ code: "SCHEMA_VALIDATION",
63
+ message: msg,
64
+ errors: err.errors || [],
65
+ });
66
+ } else {
67
+ ctx.log(`Error: ${msg}`);
68
+ }
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+
73
+ const meta = schemaToModelMeta(schema);
74
+ const relationships = schema.relationships || [];
75
+ const tableNames = meta.map((m) => m.table).sort();
76
+
77
+ // Determine which artifact types to generate
78
+ const hasArtifactFlag =
79
+ args.models === true ||
80
+ args.routes === true ||
81
+ args.openapi === true ||
82
+ args.tests === true ||
83
+ args["llm-docs"] === true;
84
+
85
+ const genModels = !hasArtifactFlag || args.models === true;
86
+ const genRoutes = !hasArtifactFlag || args.routes === true;
87
+ const genOpenapi = !hasArtifactFlag || args.openapi === true;
88
+ const genTests = !hasArtifactFlag || args.tests === true;
89
+ const genLlmDocs = !hasArtifactFlag || args["llm-docs"] === true;
90
+
91
+ const modelsRelPath = "../models";
92
+ const baseDir = process.cwd();
93
+
94
+ // Collect all planned files: { relPath, content }
95
+ const planned = [];
96
+
97
+ // --- Model files ---
98
+ if (genModels) {
99
+ for (const m of meta) {
100
+ planned.push({
101
+ relPath: `models/${m.table}.js`,
102
+ content: generateModelFile(m),
103
+ });
104
+ }
105
+ }
106
+
107
+ // --- Route files ---
108
+ if (genRoutes) {
109
+ // One route per table
110
+ for (const m of meta) {
111
+ planned.push({
112
+ relPath: `routes/${m.table}.js`,
113
+ content: generateRouteFile(m.table, modelsRelPath),
114
+ });
115
+ }
116
+
117
+ // Child route files (one per relationship)
118
+ for (const rel of relationships) {
119
+ planned.push({
120
+ relPath: `routes/${rel.child}_child_of_${rel.parent}.js`,
121
+ content: generateChildRouteFile(
122
+ rel.child,
123
+ rel.parent,
124
+ rel.foreignKey,
125
+ modelsRelPath,
126
+ ),
127
+ });
128
+ }
129
+
130
+ // Routes index file
131
+ planned.push({
132
+ relPath: "routes/index.js",
133
+ content: generateRoutesIndexFile(tableNames, relationships),
134
+ });
135
+ }
136
+
137
+ // --- OpenAPI spec ---
138
+ if (genOpenapi) {
139
+ planned.push({
140
+ relPath: "openapi.json",
141
+ content: JSON.stringify(generateOpenAPISpec(meta), null, 2) + "\n",
142
+ });
143
+ }
144
+
145
+ // --- Test files ---
146
+ if (genTests) {
147
+ for (const m of meta) {
148
+ planned.push({
149
+ relPath: `test/${m.table}.test.js`,
150
+ content: generateTestFile(m.table, m.primary_key),
151
+ });
152
+ }
153
+
154
+ // Child test files (one per relationship)
155
+ for (const rel of relationships) {
156
+ const childMeta = meta.find((m) => m.table === rel.child);
157
+ const pk = childMeta ? childMeta.primary_key : "id";
158
+ planned.push({
159
+ relPath: `test/${rel.child}_child_of_${rel.parent}.test.js`,
160
+ content: generateChildTestFile(
161
+ rel.child,
162
+ rel.parent,
163
+ rel.foreignKey,
164
+ pk,
165
+ ),
166
+ });
167
+ }
168
+ }
169
+
170
+ // --- LLM docs ---
171
+ if (genLlmDocs) {
172
+ planned.push({
173
+ relPath: "llms.txt",
174
+ content: generateLlmsTxt(),
175
+ });
176
+ planned.push({
177
+ relPath: "docs/llm.md",
178
+ content: generateLlmMd(),
179
+ });
180
+ }
181
+
182
+ // --- Process planned files ---
183
+ const results = [];
184
+
185
+ for (const { relPath, content } of planned) {
186
+ const fullPath = path.join(baseDir, relPath);
187
+
188
+ if (flags.dryRun) {
189
+ results.push({ path: relPath, status: "planned" });
190
+ continue;
191
+ }
192
+
193
+ // Check if file exists and content matches (skip-unchanged)
194
+ if (fs.existsSync(fullPath)) {
195
+ const existing = fs.readFileSync(fullPath, "utf8");
196
+ if (existing === content) {
197
+ results.push({ path: relPath, status: "unchanged" });
198
+ ctx.log(` unchanged ${relPath}`);
199
+ continue;
200
+ }
201
+ // File exists but content differs — overwrite
202
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
203
+ fs.writeFileSync(fullPath, content, "utf8");
204
+ results.push({ path: relPath, status: "overwritten" });
205
+ ctx.log(` overwritten ${relPath}`);
206
+ } else {
207
+ // File does not exist — create
208
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
209
+ fs.writeFileSync(fullPath, content, "utf8");
210
+ results.push({ path: relPath, status: "created" });
211
+ ctx.log(` created ${relPath}`);
212
+ }
213
+ }
214
+
215
+ // --- Output ---
216
+ if (flags.dryRun) {
217
+ if (flags.json) {
218
+ ctx.result({ files: results });
219
+ } else {
220
+ ctx.log("Dry run — the following files would be generated:");
221
+ for (const r of results) {
222
+ ctx.log(` ${r.path}`);
223
+ }
224
+ ctx.log(`\n${results.length} file(s) planned.`);
225
+ }
226
+ } else if (flags.json) {
227
+ ctx.result({ files: results });
228
+ } else {
229
+ const created = results.filter((r) => r.status === "created").length;
230
+ const overwritten = results.filter(
231
+ (r) => r.status === "overwritten",
232
+ ).length;
233
+ const unchanged = results.filter((r) => r.status === "unchanged").length;
234
+ ctx.log(
235
+ `\nDone. ${created} created, ${overwritten} overwritten, ${unchanged} unchanged.`,
236
+ );
237
+ }
238
+ }
239
+
240
+ module.exports = generate;
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { parseSchema } = require("../../schema/schema-parser");
6
+ const {
7
+ generateFiles,
8
+ updatePackageJson,
9
+ runInstall,
10
+ printSummary,
11
+ ensurePackageJson,
12
+ } = require("../init");
13
+ const { promptUser } = require("../init/prompt");
14
+
15
+ /**
16
+ * Default answers used when --yes is provided and no schema is available.
17
+ */
18
+ const DEFAULT_ANSWERS = {
19
+ framework: "express",
20
+ database: "postgres",
21
+ session: "memory",
22
+ rateLimiting: false,
23
+ helmet: false,
24
+ logger: false,
25
+ };
26
+
27
+ /**
28
+ * Init command handler for the unified CLI.
29
+ *
30
+ * Scaffolds a new project from a schema file or interactively.
31
+ *
32
+ * @param {object} args - Parsed positional/key-value args (e.g. { from, framework, database })
33
+ * @param {object} flags - Universal flags: { yes, json, dryRun, noInstall, help }
34
+ * @param {import('../flags').OutputContext} ctx - Output context for --json support
35
+ */
36
+ async function init(args, flags, ctx) {
37
+ let answers;
38
+
39
+ if (args.from) {
40
+ // --from points to a schema file: read adapter/framework from it
41
+ const schemaPath = path.resolve(args.from);
42
+ if (!fs.existsSync(schemaPath)) {
43
+ throw new Error(`Schema file not found: ${args.from}`);
44
+ }
45
+ const raw = fs.readFileSync(schemaPath, "utf8");
46
+ const schema = parseSchema(raw);
47
+
48
+ answers = {
49
+ framework: schema.framework,
50
+ database: schema.adapter,
51
+ session: (schema.options && schema.options.session) || "memory",
52
+ rateLimiting: !!(schema.options && schema.options.rateLimiting),
53
+ helmet: !!(schema.options && schema.options.helmet),
54
+ logger: !!(schema.options && schema.options.logger),
55
+ };
56
+ } else if (flags.yes) {
57
+ // --yes with no schema: use defaults, but allow CLI overrides
58
+ answers = Object.assign({}, DEFAULT_ANSWERS);
59
+ if (args.framework) answers.framework = args.framework;
60
+ if (args.database) answers.database = args.database;
61
+ } else {
62
+ // Interactive: build prefilled from CLI args, prompt for the rest
63
+ const prefilled = {};
64
+ if (args.framework) prefilled.framework = args.framework;
65
+ if (args.database) prefilled.database = args.database;
66
+ answers = await promptUser(prefilled);
67
+ }
68
+
69
+ // --dry-run: report planned files without writing
70
+ if (flags.dryRun) {
71
+ const planned = planFiles(answers);
72
+ if (flags.json) {
73
+ ctx.result({
74
+ files: planned,
75
+ dependencies: { installed: false },
76
+ actions: ["dry-run"],
77
+ });
78
+ } else {
79
+ ctx.log("Dry run — the following files would be created:");
80
+ for (const f of planned) {
81
+ ctx.log(` ${f}`);
82
+ }
83
+ ctx.log("\nNo files were written.");
84
+ }
85
+ return;
86
+ }
87
+
88
+ // Ensure package.json exists
89
+ ensurePackageJson();
90
+
91
+ // Generate project files
92
+ const generated = generateFiles(answers);
93
+
94
+ // Update package.json with deps and scripts
95
+ updatePackageJson(answers);
96
+
97
+ // npm install (unless --no-install)
98
+ const installed = !flags.noInstall;
99
+ if (installed) {
100
+ runInstall();
101
+ }
102
+
103
+ // Output
104
+ const allFiles = [
105
+ ...generated.files,
106
+ ...generated.migrationFiles.map((m) => `migrations/${m}`),
107
+ ];
108
+
109
+ if (flags.json) {
110
+ ctx.result({
111
+ files: allFiles,
112
+ dependencies: { installed },
113
+ actions: installed ? ["scaffolded", "installed"] : ["scaffolded"],
114
+ });
115
+ } else {
116
+ printSummary(generated);
117
+ if (!installed) {
118
+ ctx.log(
119
+ "\nSkipped npm install (--no-install). Run `npm install` manually.",
120
+ );
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Compute the list of files that would be created (for --dry-run).
127
+ * This mirrors the file list from generateFiles() without writing anything.
128
+ *
129
+ * @param {object} answers
130
+ * @returns {string[]}
131
+ */
132
+ function planFiles(answers) {
133
+ const { isSql } = require("../init/generators");
134
+ const files = [
135
+ "app.js",
136
+ ".env",
137
+ ".env.example",
138
+ "middleware/logger.js",
139
+ "migrate.js",
140
+ "add_migration.js",
141
+ ".gitignore",
142
+ "migrations/<timestamp>_create_migrations_table" +
143
+ (isSql(answers.database) ? ".sql" : ".js"),
144
+ ];
145
+
146
+ if (answers.session === "database" && isSql(answers.database)) {
147
+ files.push("migrations/<timestamp>_create_sessions_table.sql");
148
+ }
149
+
150
+ return files;
151
+ }
152
+
153
+ module.exports = init;
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { printSchema } = require("../../schema/schema-printer");
6
+ const {
7
+ introspectMySQL,
8
+ introspectPostgres,
9
+ introspectSQLite3,
10
+ introspectMSSQL,
11
+ introspectOracle,
12
+ introspectCockroachDB,
13
+ } = require("../generate-model");
14
+
15
+ /**
16
+ * Map of adapter names to their introspection functions.
17
+ * Each value is an async function(db) => ModelMeta[].
18
+ */
19
+ const INTROSPECT_MAP = {
20
+ mysql: introspectMySQL,
21
+ postgres: introspectPostgres,
22
+ sqlite3: introspectSQLite3,
23
+ mssql: introspectMSSQL,
24
+ oracle: introspectOracle,
25
+ cockroachdb: introspectCockroachDB,
26
+ };
27
+
28
+ /**
29
+ * Convert a ModelMeta array (from introspection) into a ParsedSchema object.
30
+ * This is the reverse of schemaToModelMeta.
31
+ *
32
+ * @param {string} adapter - The database adapter name
33
+ * @param {string} framework - The framework name (default: "express")
34
+ * @param {Array<{table, structure, primary_key, unique, option}>} models
35
+ * @returns {object} ParsedSchema
36
+ */
37
+ function modelMetaToSchema(adapter, framework, models) {
38
+ const tables = {};
39
+
40
+ for (const m of models) {
41
+ const columns = {};
42
+
43
+ // Re-add columns from structure
44
+ for (const [col, rule] of Object.entries(m.structure)) {
45
+ columns[col] = rule;
46
+ }
47
+
48
+ const pk = m.primary_key || "id";
49
+ const unique = m.unique && m.unique.length > 0 ? [...m.unique] : [pk];
50
+
51
+ const opt = m.option || {};
52
+ const softDelete = opt.safeDelete || null;
53
+ const timestamps = {
54
+ created_at: opt.created_at || null,
55
+ modified_at: opt.modified_at || null,
56
+ };
57
+
58
+ tables[m.table] = {
59
+ name: m.table,
60
+ columns,
61
+ pk,
62
+ unique,
63
+ softDelete,
64
+ timestamps,
65
+ };
66
+ }
67
+
68
+ return {
69
+ adapter,
70
+ framework: framework || "express",
71
+ tables,
72
+ relationships: [],
73
+ options: {},
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Inspect command handler for the unified CLI.
79
+ *
80
+ * Connects to a live database, introspects its structure, converts to
81
+ * ParsedSchema, prints via schema-printer, and writes to file.
82
+ *
83
+ * Supported flags:
84
+ * --type Database adapter type (required)
85
+ * --env Path to .env file for connection params
86
+ * --out Output file path (default: dbmr.schema.json)
87
+ * --tables Comma-separated list of tables to include
88
+ * --json Output schema to stdout as JSON (no file write)
89
+ * --dry-run Output schema to stdout without writing file
90
+ *
91
+ * @param {object} args - Parsed key-value args
92
+ * @param {object} flags - Universal flags: { yes, json, dryRun, noInstall, help }
93
+ * @param {import('../flags').OutputContext} ctx - Output context
94
+ */
95
+ async function inspect(args, flags, ctx) {
96
+ const adapterType = args.type;
97
+ if (!adapterType || !INTROSPECT_MAP[adapterType]) {
98
+ const supported = Object.keys(INTROSPECT_MAP).join(", ");
99
+ const msg = adapterType
100
+ ? `Unsupported --type "${adapterType}". Supported: ${supported}`
101
+ : `Missing required --type flag. Supported: ${supported}`;
102
+ if (flags.json) {
103
+ ctx.result({ error: true, code: "INVALID_TYPE", message: msg });
104
+ } else {
105
+ ctx.log(`Error: ${msg}`);
106
+ }
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
111
+ // Load .env file if --env provided
112
+ if (args.env) {
113
+ require("dotenv").config({ path: path.resolve(args.env) });
114
+ }
115
+
116
+ // Connect to database
117
+ let db;
118
+ try {
119
+ const restRouter = require("../../index.js");
120
+ restRouter.init(adapterType);
121
+ db = restRouter.db;
122
+
123
+ const config = {
124
+ host: process.env.DB_HOST || "localhost",
125
+ port: process.env.DB_PORT,
126
+ database: process.env.DB_NAME,
127
+ user: process.env.DB_USER,
128
+ password: process.env.DB_PASS,
129
+ filename: process.env.DB_NAME,
130
+ server: process.env.DB_HOST || "localhost",
131
+ options: { encrypt: false, trustServerCertificate: true },
132
+ };
133
+
134
+ db.connect(config);
135
+ } catch (err) {
136
+ const msg = `Database connection failed: ${err.message}`;
137
+ if (flags.json) {
138
+ ctx.result({ error: true, code: "CONNECTION_FAILED", message: msg });
139
+ } else {
140
+ ctx.log(`Error: ${msg}`);
141
+ }
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ // Introspect
147
+ let models;
148
+ try {
149
+ const introspectFn = INTROSPECT_MAP[adapterType];
150
+ models = await introspectFn(db);
151
+ } catch (err) {
152
+ const msg = `Introspection failed: ${err.message}`;
153
+ if (flags.json) {
154
+ ctx.result({ error: true, code: "INTROSPECTION_FAILED", message: msg });
155
+ } else {
156
+ ctx.log(`Error: ${msg}`);
157
+ }
158
+ process.exitCode = 1;
159
+ // Disconnect
160
+ if (db.disconnect) await db.disconnect();
161
+ else if (db.close) db.close();
162
+ return;
163
+ }
164
+
165
+ // Disconnect
166
+ try {
167
+ if (db.disconnect) await db.disconnect();
168
+ else if (db.close) db.close();
169
+ } catch (_) {
170
+ // ignore disconnect errors
171
+ }
172
+
173
+ // Filter by --tables if provided
174
+ if (args.tables) {
175
+ const allowed = new Set(args.tables.split(",").map((s) => s.trim()));
176
+ models = models.filter((m) => allowed.has(m.table));
177
+ }
178
+
179
+ // Convert ModelMeta[] → ParsedSchema
180
+ const schema = modelMetaToSchema(adapterType, "express", models);
181
+
182
+ // Print via schema-printer
183
+ const output = printSchema(schema);
184
+
185
+ // Determine output path
186
+ const outPath = args.out || "dbmr.schema.json";
187
+
188
+ if (flags.json) {
189
+ // --json: output schema to stdout, no file write
190
+ ctx.result({ schema: JSON.parse(output), writtenTo: null });
191
+ } else if (flags.dryRun) {
192
+ // --dry-run: output schema to stdout, no file write
193
+ ctx.log(output);
194
+ ctx.log(`Would write to: ${outPath}`);
195
+ } else {
196
+ // Write to file
197
+ const resolvedPath = path.resolve(outPath);
198
+ fs.writeFileSync(resolvedPath, output, "utf8");
199
+ ctx.log(`Schema written to ${outPath}`);
200
+ ctx.log(output);
201
+ }
202
+ }
203
+
204
+ module.exports = inspect;
205
+ module.exports.modelMetaToSchema = modelMetaToSchema;