db-model-router 1.0.4 → 1.0.6

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.
Files changed (46) hide show
  1. package/README.md +110 -16
  2. package/TODO.md +15 -0
  3. package/dbmr.schema.json +333 -0
  4. package/docker-compose.yml +1 -1
  5. package/package.json +8 -7
  6. package/scripts/demo-create.js +47 -0
  7. package/skill/SKILL.md +464 -0
  8. package/skill/references/cockroachdb.md +49 -0
  9. package/skill/references/dynamodb.md +53 -0
  10. package/skill/references/mongodb.md +56 -0
  11. package/skill/references/mssql.md +55 -0
  12. package/skill/references/oracle.md +52 -0
  13. package/skill/references/postgres.md +50 -0
  14. package/skill/references/redis.md +53 -0
  15. package/skill/references/sqlite3.md +43 -0
  16. package/src/cli/commands/generate.js +95 -31
  17. package/src/cli/commands/help.js +12 -7
  18. package/src/cli/commands/init.js +2 -2
  19. package/src/cli/commands/inspect.js +1 -0
  20. package/src/cli/diff-engine.js +54 -23
  21. package/src/cli/generate-db-manager.js +1573 -0
  22. package/src/cli/generate-docs-route.js +31 -0
  23. package/src/cli/generate-migration.js +356 -0
  24. package/src/cli/generate-model.js +9 -4
  25. package/src/cli/generate-openapi.js +40 -13
  26. package/src/cli/generate-route.js +55 -27
  27. package/src/cli/init/dependencies.js +3 -0
  28. package/src/cli/init/generators.js +37 -31
  29. package/src/cli/init.js +8 -8
  30. package/src/cli/main.js +2 -2
  31. package/src/cockroachdb/db.js +90 -59
  32. package/src/commons/route.js +20 -20
  33. package/src/commons/validator.js +58 -1
  34. package/src/dynamodb/db.js +50 -27
  35. package/src/mongodb/db.js +1 -0
  36. package/src/mssql/db.js +89 -61
  37. package/src/mysql/db.js +1 -0
  38. package/src/oracle/db.js +1 -0
  39. package/src/postgres/db.js +61 -41
  40. package/src/redis/db.js +1 -0
  41. package/src/schema/schema-parser.js +43 -1
  42. package/src/schema/schema-printer.js +7 -0
  43. package/src/schema/schema-validator.js +17 -0
  44. package/src/sqlite3/db.js +12 -0
  45. package/docs/SKILL.md +0 -419
  46. package/src/cli/commands/generate-llm-docs.js +0 -418
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Generate a routes/docs.js file that serves Swagger UI for the OpenAPI spec.
5
+ * Uses swagger-ui-express to mount at /docs.
6
+ *
7
+ * @returns {string}
8
+ */
9
+ function generateDocsRoute() {
10
+ return `import express from "express";
11
+ import swaggerUi from "swagger-ui-express";
12
+ import { readFileSync } from "fs";
13
+ import { dirname, join } from "path";
14
+ import { fileURLToPath } from "url";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const spec = JSON.parse(readFileSync(join(__dirname, "../openapi.json"), "utf8"));
18
+
19
+ const router = express.Router();
20
+
21
+ router.use("/", swaggerUi.serve);
22
+ router.get("/", swaggerUi.setup(spec, {
23
+ customSiteTitle: "API Documentation",
24
+ customCss: ".swagger-ui .topbar { display: none }",
25
+ }));
26
+
27
+ export default router;
28
+ `;
29
+ }
30
+
31
+ module.exports = { generateDocsRoute };
@@ -0,0 +1,356 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Generate database migration SQL/JS from parsed schema tables.
5
+ *
6
+ * Produces CREATE TABLE statements (or equivalent) for each table,
7
+ * with proper column types, constraints, and indexes per adapter.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Column type mapping per adapter
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Map a dbmr column rule to a SQL column type for the given adapter.
16
+ *
17
+ * @param {string} rule - e.g. "required|string", "auto_increment", "integer"
18
+ * @param {string} adapter - e.g. "postgres", "mysql", "sqlite3"
19
+ * @returns {{ sqlType: string, nullable: boolean, isAutoIncrement: boolean }}
20
+ */
21
+ function mapColumnType(rule, adapter) {
22
+ const parts = rule.split("|");
23
+ const isRequired = parts.includes("required");
24
+ const baseType = parts.filter((p) => p !== "required")[0] || "string";
25
+
26
+ let sqlType;
27
+ let isAutoIncrement = false;
28
+
29
+ switch (baseType) {
30
+ case "auto_increment":
31
+ isAutoIncrement = true;
32
+ sqlType = autoIncrementType(adapter);
33
+ break;
34
+ case "string":
35
+ sqlType = stringType(adapter);
36
+ break;
37
+ case "integer":
38
+ sqlType = integerType(adapter);
39
+ break;
40
+ case "numeric":
41
+ sqlType = numericType(adapter);
42
+ break;
43
+ case "boolean":
44
+ sqlType = booleanType(adapter);
45
+ break;
46
+ case "datetime":
47
+ sqlType = datetimeType(adapter);
48
+ break;
49
+ case "object":
50
+ sqlType = jsonType(adapter);
51
+ break;
52
+ default:
53
+ sqlType = stringType(adapter);
54
+ }
55
+
56
+ return {
57
+ sqlType,
58
+ nullable: !isRequired && !isAutoIncrement,
59
+ isAutoIncrement,
60
+ };
61
+ }
62
+
63
+ function autoIncrementType(adapter) {
64
+ switch (adapter) {
65
+ case "postgres":
66
+ case "cockroachdb":
67
+ return "SERIAL";
68
+ case "mssql":
69
+ return "INT IDENTITY(1,1)";
70
+ case "oracle":
71
+ return "NUMBER GENERATED BY DEFAULT AS IDENTITY";
72
+ case "sqlite3":
73
+ return "INTEGER";
74
+ default:
75
+ // mysql, mariadb
76
+ return "INT AUTO_INCREMENT";
77
+ }
78
+ }
79
+
80
+ function stringType(adapter) {
81
+ if (adapter === "oracle") return "VARCHAR2(255)";
82
+ return "VARCHAR(255)";
83
+ }
84
+
85
+ function integerType(adapter) {
86
+ if (adapter === "oracle") return "NUMBER(10)";
87
+ return "INTEGER";
88
+ }
89
+
90
+ function numericType(adapter) {
91
+ if (adapter === "oracle") return "NUMBER(12,2)";
92
+ if (adapter === "mssql") return "DECIMAL(12,2)";
93
+ return "DECIMAL(12,2)";
94
+ }
95
+
96
+ function booleanType(adapter) {
97
+ if (adapter === "oracle") return "NUMBER(1)";
98
+ if (adapter === "mssql") return "BIT";
99
+ if (adapter === "mysql" || adapter === "mariadb") return "TINYINT(1)";
100
+ return "BOOLEAN";
101
+ }
102
+
103
+ function datetimeType(adapter) {
104
+ if (adapter === "mssql") return "DATETIME";
105
+ return "TIMESTAMP";
106
+ }
107
+
108
+ function jsonType(adapter) {
109
+ if (adapter === "postgres" || adapter === "cockroachdb") return "JSONB";
110
+ if (adapter === "oracle") return "CLOB";
111
+ if (adapter === "mssql") return "NVARCHAR(MAX)";
112
+ if (adapter === "sqlite3") return "TEXT";
113
+ return "JSON";
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // SQL migration generators
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Generate a CREATE TABLE SQL statement for a single table.
122
+ *
123
+ * @param {string} tableName
124
+ * @param {object} tableDef - parsed table definition from schema
125
+ * @param {string} adapter
126
+ * @returns {string}
127
+ */
128
+ function generateCreateTableSQL(tableName, tableDef, adapter) {
129
+ const lines = [];
130
+ const pk = tableDef.pk;
131
+
132
+ for (const [colName, rule] of Object.entries(tableDef.columns)) {
133
+ const { sqlType, nullable, isAutoIncrement } = mapColumnType(rule, adapter);
134
+ let line = ` ${quoteIdent(colName, adapter)} ${sqlType}`;
135
+
136
+ if (colName === pk) {
137
+ line += " PRIMARY KEY";
138
+ if (isAutoIncrement && adapter === "sqlite3") {
139
+ // SQLite needs AUTOINCREMENT after PRIMARY KEY for INTEGER type
140
+ line = ` ${quoteIdent(colName, adapter)} INTEGER PRIMARY KEY AUTOINCREMENT`;
141
+ }
142
+ } else {
143
+ if (!nullable) line += " NOT NULL";
144
+ }
145
+
146
+ // Default values for timestamps
147
+ if (tableDef.timestamps) {
148
+ if (
149
+ colName === tableDef.timestamps.created_at ||
150
+ colName === tableDef.timestamps.modified_at
151
+ ) {
152
+ line += defaultTimestamp(adapter);
153
+ }
154
+ }
155
+
156
+ // Default for boolean softDelete column
157
+ if (tableDef.softDelete && colName === tableDef.softDelete) {
158
+ line += defaultBoolean(adapter);
159
+ }
160
+
161
+ lines.push(line);
162
+ }
163
+
164
+ // Unique constraints (excluding PK which is already PRIMARY KEY)
165
+ if (tableDef.unique && tableDef.unique.length > 0) {
166
+ const uniqueCols = tableDef.unique.filter((c) => c !== pk);
167
+ for (const col of uniqueCols) {
168
+ lines.push(` UNIQUE (${quoteIdent(col, adapter)})`);
169
+ }
170
+ }
171
+
172
+ const createPrefix =
173
+ adapter === "oracle" || adapter === "mssql"
174
+ ? `CREATE TABLE ${quoteIdent(tableName, adapter)}`
175
+ : `CREATE TABLE IF NOT EXISTS ${quoteIdent(tableName, adapter)}`;
176
+
177
+ return `${createPrefix} (\n${lines.join(",\n")}\n);\n`;
178
+ }
179
+
180
+ /**
181
+ * Quote an identifier based on adapter.
182
+ */
183
+ function quoteIdent(name, adapter) {
184
+ // Most adapters are fine without quoting for simple names
185
+ // but we keep it safe for reserved words
186
+ if (adapter === "mssql") return `[${name}]`;
187
+ if (adapter === "oracle") return `"${name.toUpperCase()}"`;
188
+ return name;
189
+ }
190
+
191
+ /**
192
+ * Default timestamp expression per adapter.
193
+ */
194
+ function defaultTimestamp(adapter) {
195
+ switch (adapter) {
196
+ case "mssql":
197
+ return " DEFAULT GETDATE()";
198
+ case "oracle":
199
+ return " DEFAULT CURRENT_TIMESTAMP";
200
+ case "sqlite3":
201
+ return " DEFAULT CURRENT_TIMESTAMP";
202
+ default:
203
+ return " DEFAULT CURRENT_TIMESTAMP";
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Default boolean false expression per adapter.
209
+ */
210
+ function defaultBoolean(adapter) {
211
+ if (adapter === "oracle" || adapter === "mssql") return " DEFAULT 0";
212
+ if (adapter === "mysql" || adapter === "mariadb") return " DEFAULT 0";
213
+ return " DEFAULT FALSE";
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // NoSQL migration generators
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Generate a MongoDB migration JS file for a single table (collection).
222
+ */
223
+ function generateMongoDBMigration(tableName, tableDef) {
224
+ const uniqueCols = (tableDef.unique || []).filter((c) => c !== tableDef.pk);
225
+ const indexLines = uniqueCols
226
+ .map(
227
+ (col) =>
228
+ ` await db.collection("${tableName}").createIndex({ ${col}: 1 }, { unique: true });`,
229
+ )
230
+ .join("\n");
231
+
232
+ return `"use strict";
233
+
234
+ module.exports = {
235
+ async up(db) {
236
+ await db.createCollection("${tableName}");
237
+ ${indexLines ? indexLines + "\n" : ""} },
238
+
239
+ async down(db) {
240
+ await db.collection("${tableName}").drop();
241
+ },
242
+ };
243
+ `;
244
+ }
245
+
246
+ /**
247
+ * Generate a DynamoDB migration JS file for a single table.
248
+ */
249
+ function generateDynamoDBMigration(tableName, tableDef) {
250
+ const pk = tableDef.pk;
251
+ return `import { CreateTableCommand, DeleteTableCommand } from "@aws-sdk/client-dynamodb";
252
+
253
+ export async function up(db) {
254
+ await db.send(new CreateTableCommand({
255
+ TableName: "${tableName}",
256
+ KeySchema: [{ AttributeName: "${pk}", KeyType: "HASH" }],
257
+ AttributeDefinitions: [{ AttributeName: "${pk}", AttributeType: "N" }],
258
+ BillingMode: "PAY_PER_REQUEST",
259
+ }));
260
+ }
261
+
262
+ export async function down(db) {
263
+ await db.send(new DeleteTableCommand({ TableName: "${tableName}" }));
264
+ }
265
+ `;
266
+ }
267
+
268
+ /**
269
+ * Generate a Redis migration JS file (Redis doesn't need table creation,
270
+ * but we create a placeholder for consistency).
271
+ */
272
+ function generateRedisMigration(tableName) {
273
+ return `"use strict";
274
+
275
+ module.exports = {
276
+ async up(db) {
277
+ // Redis is schema-less. This migration is a placeholder.
278
+ // Data for "${tableName}" will be stored as hash keys: ${tableName}:<id>
279
+ console.log("Redis: ${tableName} collection ready (schema-less).");
280
+ },
281
+
282
+ async down(db) {
283
+ // Warning: this deletes ALL keys matching the pattern
284
+ // In production, use SCAN instead of KEYS
285
+ const keys = await db.keys("${tableName}:*");
286
+ if (keys.length > 0) {
287
+ await db.del(...keys);
288
+ }
289
+ },
290
+ };
291
+ `;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Public API
296
+ // ---------------------------------------------------------------------------
297
+
298
+ const SQL_ADAPTERS = [
299
+ "mysql",
300
+ "mariadb",
301
+ "postgres",
302
+ "sqlite3",
303
+ "mssql",
304
+ "cockroachdb",
305
+ "oracle",
306
+ ];
307
+
308
+ /**
309
+ * Generate migration file content for all tables in the schema.
310
+ *
311
+ * For SQL adapters: produces a single .sql file with all CREATE TABLE statements.
312
+ * For NoSQL adapters: produces one .js file per table.
313
+ *
314
+ * @param {object} schema - parsed schema from parseSchema()
315
+ * @returns {Array<{ filename: string, content: string }>}
316
+ */
317
+ function generateMigrationFiles(schema) {
318
+ const adapter = schema.adapter;
319
+ const tables = schema.tables;
320
+ const tableNames = Object.keys(tables).sort();
321
+
322
+ if (SQL_ADAPTERS.includes(adapter)) {
323
+ // Single SQL file with all CREATE TABLE statements
324
+ const statements = [];
325
+ for (const name of tableNames) {
326
+ statements.push(generateCreateTableSQL(name, tables[name], adapter));
327
+ }
328
+ const content = statements.join("\n");
329
+ return [{ filename: `create_tables.sql`, content }];
330
+ }
331
+
332
+ // NoSQL: one file per table
333
+ const files = [];
334
+ for (const name of tableNames) {
335
+ let content;
336
+ if (adapter === "mongodb") {
337
+ content = generateMongoDBMigration(name, tables[name]);
338
+ } else if (adapter === "dynamodb") {
339
+ content = generateDynamoDBMigration(name, tables[name]);
340
+ } else if (adapter === "redis") {
341
+ content = generateRedisMigration(name);
342
+ } else {
343
+ // Fallback
344
+ content = `// Migration for ${name}\n`;
345
+ }
346
+ files.push({ filename: `create_${name}.js`, content });
347
+ }
348
+
349
+ return files;
350
+ }
351
+
352
+ module.exports = {
353
+ generateMigrationFiles,
354
+ generateCreateTableSQL,
355
+ mapColumnType,
356
+ };
@@ -544,7 +544,9 @@ function generateModelFile(m) {
544
544
  if (opt.modified_at) parts.push(`modified_at: "${opt.modified_at}"`);
545
545
  optionStr = `\n { ${parts.join(", ")} },`;
546
546
  }
547
- return `const { db, model } = require("db-model-router");
547
+ return `import dbModelRouter from "db-model-router";
548
+
549
+ const { db, model } = dbModelRouter;
548
550
 
549
551
  const ${varName} = model(
550
552
  db,
@@ -554,7 +556,7 @@ const ${varName} = model(
554
556
  ${uniqueStr},${optionStr}
555
557
  );
556
558
 
557
- module.exports = ${varName};
559
+ export default ${varName};
558
560
  `;
559
561
  }
560
562
 
@@ -563,11 +565,14 @@ function generateIndexFile(models) {
563
565
  let exports = "";
564
566
  for (const m of models) {
565
567
  const varName = safeVarName(m.table);
566
- imports += `const ${varName} = require("./${m.table}");\n`;
568
+ imports += `import ${varName} from "./${m.table}.js";\n`;
567
569
  exports += ` ${varName},\n`;
568
570
  }
569
571
  return `${imports}
570
- module.exports = {
572
+ export {
573
+ ${exports}};
574
+
575
+ export default {
571
576
  ${exports}};
572
577
  `;
573
578
  }
@@ -5,6 +5,13 @@ function generateOpenAPISpec(models, options = {}) {
5
5
  const basePath = options.basePath || "/api";
6
6
  const title = options.title || "REST Router API";
7
7
  const version = options.version || "1.0.0";
8
+ const relationships = options.relationships || [];
9
+
10
+ // Build a lookup: child table -> { parent, foreignKey }
11
+ const childMap = {};
12
+ for (const rel of relationships) {
13
+ childMap[rel.child] = { parent: rel.parent, foreignKey: rel.foreignKey };
14
+ }
8
15
 
9
16
  const spec = {
10
17
  openapi: "3.0.3",
@@ -39,14 +46,34 @@ function generateOpenAPISpec(models, options = {}) {
39
46
  };
40
47
 
41
48
  const ref = { $ref: `#/components/schemas/${schemaName}` };
42
- const prefix = `${basePath}/${m.table}`;
49
+
50
+ // Determine path prefix: nested under parent if this is a child table
51
+ let prefix;
52
+ const isChild = !!childMap[m.table];
53
+ let fkParam = null;
54
+ if (isChild) {
55
+ const { parent, foreignKey } = childMap[m.table];
56
+ prefix = `${basePath}/${parent}/{${foreignKey}}/${m.table}`;
57
+ fkParam = {
58
+ name: foreignKey,
59
+ in: "path",
60
+ required: true,
61
+ schema: { type: "string" },
62
+ description: `${capitalize(parent)} foreign key`,
63
+ };
64
+ } else {
65
+ prefix = `${basePath}/${m.table}`;
66
+ }
67
+
68
+ // Helper: prepend FK path param for child routes
69
+ const withFk = (params) => (fkParam ? [fkParam, ...params] : params);
43
70
 
44
71
  // GET / — list
45
72
  spec.paths[`${prefix}/`] = {
46
73
  get: {
47
74
  tags: [tag],
48
75
  summary: `List ${m.table}`,
49
- parameters: [
76
+ parameters: withFk([
50
77
  {
51
78
  name: "page",
52
79
  in: "query",
@@ -69,7 +96,7 @@ function generateOpenAPISpec(models, options = {}) {
69
96
  in: "query",
70
97
  schema: { type: "string", enum: ["json", "csv", "xml"] },
71
98
  },
72
- ],
99
+ ]),
73
100
  responses: {
74
101
  200: {
75
102
  description: "Success",
@@ -141,7 +168,7 @@ function generateOpenAPISpec(models, options = {}) {
141
168
  get: {
142
169
  tags: [tag],
143
170
  summary: `Get ${m.table} by ${pk}`,
144
- parameters: [
171
+ parameters: withFk([
145
172
  { name: pk, in: "path", required: true, schema: { type: "string" } },
146
173
  { name: "select_columns", in: "query", schema: { type: "string" } },
147
174
  {
@@ -149,7 +176,7 @@ function generateOpenAPISpec(models, options = {}) {
149
176
  in: "query",
150
177
  schema: { type: "string", enum: ["json", "csv", "xml"] },
151
178
  },
152
- ],
179
+ ]),
153
180
  responses: {
154
181
  200: {
155
182
  description: "Success",
@@ -161,9 +188,9 @@ function generateOpenAPISpec(models, options = {}) {
161
188
  post: {
162
189
  tags: [tag],
163
190
  summary: `Insert a ${m.table}`,
164
- parameters: [
191
+ parameters: withFk([
165
192
  { name: pk, in: "path", required: true, schema: { type: "string" } },
166
- ],
193
+ ]),
167
194
  requestBody: { content: { "application/json": { schema: ref } } },
168
195
  responses: {
169
196
  200: {
@@ -175,9 +202,9 @@ function generateOpenAPISpec(models, options = {}) {
175
202
  put: {
176
203
  tags: [tag],
177
204
  summary: `Update a ${m.table}`,
178
- parameters: [
205
+ parameters: withFk([
179
206
  { name: pk, in: "path", required: true, schema: { type: "string" } },
180
- ],
207
+ ]),
181
208
  requestBody: { content: { "application/json": { schema: ref } } },
182
209
  responses: {
183
210
  200: {
@@ -190,9 +217,9 @@ function generateOpenAPISpec(models, options = {}) {
190
217
  patch: {
191
218
  tags: [tag],
192
219
  summary: `Partial update a ${m.table}`,
193
- parameters: [
220
+ parameters: withFk([
194
221
  { name: pk, in: "path", required: true, schema: { type: "string" } },
195
- ],
222
+ ]),
196
223
  requestBody: {
197
224
  content: { "application/json": { schema: { type: "object" } } },
198
225
  },
@@ -207,9 +234,9 @@ function generateOpenAPISpec(models, options = {}) {
207
234
  delete: {
208
235
  tags: [tag],
209
236
  summary: `Delete a ${m.table}`,
210
- parameters: [
237
+ parameters: withFk([
211
238
  { name: pk, in: "path", required: true, schema: { type: "string" } },
212
- ],
239
+ ]),
213
240
  responses: {
214
241
  200: { description: "Deleted" },
215
242
  404: { description: "Not Found" },