db-model-router 1.0.4 → 1.0.5

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 (92) hide show
  1. package/README.md +110 -16
  2. package/TODO.md +14 -0
  3. package/dbmr.schema.json +333 -0
  4. package/demo/.dockerignore +7 -0
  5. package/demo/.env.example +13 -0
  6. package/demo/Dockerfile +20 -0
  7. package/demo/app.js +37 -0
  8. package/demo/commons/add_migration.js +43 -0
  9. package/demo/commons/db.js +17 -0
  10. package/demo/commons/migrate.js +65 -0
  11. package/demo/commons/security.js +30 -0
  12. package/demo/commons/session.js +13 -0
  13. package/demo/dbmr.schema.json +362 -0
  14. package/demo/docs/llm.md +197 -0
  15. package/demo/llms.txt +70 -0
  16. package/demo/middleware/logger.js +67 -0
  17. package/demo/migrations/20260430155808_create_migrations_table.sql +6 -0
  18. package/demo/migrations/20260430155809_create_tables.sql +207 -0
  19. package/demo/models/addresses.js +22 -0
  20. package/demo/models/cart_items.js +18 -0
  21. package/demo/models/carts.js +16 -0
  22. package/demo/models/categories.js +20 -0
  23. package/demo/models/coupons.js +23 -0
  24. package/demo/models/order_items.js +21 -0
  25. package/demo/models/orders.js +25 -0
  26. package/demo/models/payments.js +21 -0
  27. package/demo/models/product_images.js +18 -0
  28. package/demo/models/product_reviews.js +20 -0
  29. package/demo/models/product_variants.js +20 -0
  30. package/demo/models/products.js +30 -0
  31. package/demo/models/shipments.js +19 -0
  32. package/demo/models/users.js +19 -0
  33. package/demo/models/wishlists.js +15 -0
  34. package/demo/openapi.json +5872 -0
  35. package/demo/package-lock.json +2810 -0
  36. package/demo/package.json +34 -0
  37. package/demo/routes/addresses.js +6 -0
  38. package/demo/routes/carts/cart_items.js +7 -0
  39. package/demo/routes/carts.js +6 -0
  40. package/demo/routes/categories.js +6 -0
  41. package/demo/routes/coupons.js +6 -0
  42. package/demo/routes/docs.js +18 -0
  43. package/demo/routes/health.js +35 -0
  44. package/demo/routes/index.js +39 -0
  45. package/demo/routes/orders/order_items.js +7 -0
  46. package/demo/routes/orders/payments.js +7 -0
  47. package/demo/routes/orders/shipments.js +7 -0
  48. package/demo/routes/orders.js +6 -0
  49. package/demo/routes/products/product_images.js +7 -0
  50. package/demo/routes/products/product_reviews.js +7 -0
  51. package/demo/routes/products/product_variants.js +7 -0
  52. package/demo/routes/products.js +6 -0
  53. package/demo/routes/users.js +6 -0
  54. package/demo/routes/wishlists.js +6 -0
  55. package/docker-compose.yml +1 -1
  56. package/package.json +8 -7
  57. package/scripts/demo-create.js +47 -0
  58. package/skill/SKILL.md +464 -0
  59. package/skill/references/cockroachdb.md +49 -0
  60. package/skill/references/dynamodb.md +53 -0
  61. package/skill/references/mongodb.md +56 -0
  62. package/skill/references/mssql.md +55 -0
  63. package/skill/references/oracle.md +52 -0
  64. package/skill/references/postgres.md +50 -0
  65. package/skill/references/redis.md +53 -0
  66. package/skill/references/sqlite3.md +43 -0
  67. package/src/cli/commands/generate.js +58 -17
  68. package/src/cli/commands/help.js +11 -6
  69. package/src/cli/commands/init.js +2 -2
  70. package/src/cli/commands/inspect.js +1 -0
  71. package/src/cli/diff-engine.js +52 -22
  72. package/src/cli/generate-docs-route.js +31 -0
  73. package/src/cli/generate-migration.js +356 -0
  74. package/src/cli/generate-route.js +52 -24
  75. package/src/cli/init/dependencies.js +3 -0
  76. package/src/cli/init/generators.js +1 -1
  77. package/src/cli/init.js +8 -8
  78. package/src/cockroachdb/db.js +90 -59
  79. package/src/commons/route.js +20 -20
  80. package/src/commons/validator.js +58 -1
  81. package/src/dynamodb/db.js +50 -27
  82. package/src/mongodb/db.js +1 -0
  83. package/src/mssql/db.js +89 -61
  84. package/src/mysql/db.js +1 -0
  85. package/src/oracle/db.js +1 -0
  86. package/src/postgres/db.js +61 -41
  87. package/src/redis/db.js +1 -0
  88. package/src/schema/schema-parser.js +43 -1
  89. package/src/schema/schema-printer.js +7 -0
  90. package/src/schema/schema-validator.js +17 -0
  91. package/src/sqlite3/db.js +1 -0
  92. package/docs/SKILL.md +0 -419
@@ -0,0 +1,52 @@
1
+ # Oracle Adapter
2
+
3
+ Uses [oracledb](https://www.npmjs.com/package/oracledb) (Oracle Instant Client required).
4
+
5
+ ## Connection
6
+
7
+ ```js
8
+ const { init, db, model, route } = require("db-model-router");
9
+ init("oracle");
10
+
11
+ db.connect({
12
+ host: "localhost",
13
+ port: 1521,
14
+ database: "XEPDB1",
15
+ user: "system",
16
+ password: "oracle",
17
+ });
18
+ ```
19
+
20
+ ## Environment Variables
21
+
22
+ ```env
23
+ ORACLE_HOST=localhost
24
+ ORACLE_PORT=1521
25
+ ORACLE_DB=XEPDB1
26
+ ORACLE_USER=system
27
+ ORACLE_PASSWORD=oracle
28
+ ```
29
+
30
+ ## Notes
31
+
32
+ - Requires Oracle Instant Client installed on the host
33
+ - Uses connection pooling with session callbacks for NLS date format
34
+ - MySQL-style `?` placeholders are auto-translated to `:1, :2, ...`
35
+ - Includes a SQL translator that converts MySQL DDL/DML to Oracle syntax
36
+ - `MERGE INTO ... USING DUAL` is used for upsert operations
37
+ - `RETURNING ... INTO :pk_out` is used to retrieve auto-generated IDs
38
+ - Oracle reserved words in column names are auto-quoted
39
+ - `CLOB` values are fetched as strings
40
+
41
+ ## Table Creation
42
+
43
+ ```sql
44
+ CREATE TABLE users (
45
+ id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
46
+ name VARCHAR2(255) NOT NULL,
47
+ email VARCHAR2(255) NOT NULL,
48
+ age NUMBER NOT NULL
49
+ );
50
+ ```
51
+
52
+ [← Back to main docs](../../README.md)
@@ -0,0 +1,50 @@
1
+ # PostgreSQL Adapter
2
+
3
+ Uses the [pg](https://www.npmjs.com/package/pg) driver with connection pooling.
4
+
5
+ ## Connection
6
+
7
+ ```js
8
+ const { init, db, model, route } = require("db-model-router");
9
+ init("postgres");
10
+
11
+ db.connect({
12
+ host: "localhost",
13
+ port: 5432,
14
+ user: "postgres",
15
+ password: "password",
16
+ database: "my_app",
17
+ connectionLimit: 50, // pool max
18
+ dateStrings: false, // set true to return dates as strings
19
+ });
20
+ ```
21
+
22
+ ## Environment Variables
23
+
24
+ ```env
25
+ PG_HOST=localhost
26
+ PG_PORT=5432
27
+ PG_USER=postgres
28
+ PG_PASSWORD=password
29
+ PG_DB=test_db
30
+ ```
31
+
32
+ ## Notes
33
+
34
+ - Supports MySQL-style `?` placeholders in raw `db.query()` calls — they are auto-translated to `$1, $2, ...`
35
+ - `SERIAL` / `BIGSERIAL` primary keys are auto-detected via `pg_index`
36
+ - `ON CONFLICT` is used for upsert operations
37
+ - Includes a SQL translator layer that converts common MySQL DDL/DML to PostgreSQL syntax
38
+
39
+ ## Table Creation
40
+
41
+ ```sql
42
+ CREATE TABLE users (
43
+ id SERIAL PRIMARY KEY,
44
+ name VARCHAR NOT NULL,
45
+ email VARCHAR NOT NULL,
46
+ age INTEGER NOT NULL
47
+ );
48
+ ```
49
+
50
+ [← Back to main docs](../../README.md)
@@ -0,0 +1,53 @@
1
+ # Redis Adapter
2
+
3
+ Uses [ioredis](https://www.npmjs.com/package/ioredis). Records are stored as Redis hashes with key pattern `{table}:{id}`.
4
+
5
+ ## Connection
6
+
7
+ ```js
8
+ const { init, db, model, route } = require("db-model-router");
9
+ init("redis");
10
+
11
+ db.connect({
12
+ host: "localhost",
13
+ port: 6379,
14
+ password: "", // optional
15
+ db: 0, // Redis DB index, optional
16
+ primaryKey: "id", // field used as the hash key suffix
17
+ });
18
+ ```
19
+
20
+ ## Environment Variables
21
+
22
+ ```env
23
+ REDIS_HOST=localhost
24
+ REDIS_PORT=6379
25
+ ```
26
+
27
+ ## Notes
28
+
29
+ - Each record is a Redis hash at key `{table}:{primaryKey}`
30
+ - Auto-incrementing IDs use `INCR {table}:__seq`
31
+ - All filtering, sorting, and pagination happen in-memory (full SCAN)
32
+ - Redis stores all values as strings — numeric coercion is applied on read
33
+ - Nested objects are JSON-serialized into hash fields
34
+ - Best suited for small-to-medium datasets where Redis is already in the stack
35
+
36
+ ## Model Definition
37
+
38
+ ```js
39
+ const users = model(
40
+ db,
41
+ "users",
42
+ {
43
+ id: "string",
44
+ name: "required|string",
45
+ email: "required|string",
46
+ age: "required|integer",
47
+ },
48
+ "id",
49
+ ["id"],
50
+ );
51
+ ```
52
+
53
+ [← Back to main docs](../../README.md)
@@ -0,0 +1,43 @@
1
+ # SQLite3 Adapter
2
+
3
+ Uses [better-sqlite3](https://www.npmjs.com/package/better-sqlite3) for synchronous, high-performance SQLite access.
4
+
5
+ ## Connection
6
+
7
+ ```js
8
+ const { init, db, model, route } = require("db-model-router");
9
+ init("sqlite3");
10
+
11
+ db.connect({ database: "./data.db" });
12
+ // or in-memory:
13
+ db.connect({ database: ":memory:" });
14
+ ```
15
+
16
+ ## Options
17
+
18
+ | Option | Description |
19
+ | --------------- | --------------------------- |
20
+ | `database` | File path or `:memory:` |
21
+ | `readonly` | Open in read-only mode |
22
+ | `fileMustExist` | Throw if file doesn't exist |
23
+
24
+ ## Notes
25
+
26
+ - All operations are synchronous (wrapped to match the async model API)
27
+ - WAL journal mode is enabled by default for better concurrency
28
+ - Uses `INSERT OR IGNORE` for conflict handling
29
+ - `ON CONFLICT ... DO UPDATE SET` for upsert
30
+ - No Docker container needed — runs in-process
31
+
32
+ ## Table Creation
33
+
34
+ ```sql
35
+ CREATE TABLE users (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ name TEXT NOT NULL,
38
+ email TEXT NOT NULL,
39
+ age INTEGER NOT NULL
40
+ );
41
+ ```
42
+
43
+ [← Back to main docs](../../README.md)
@@ -14,21 +14,26 @@ const {
14
14
  } = require("../generate-route");
15
15
  const { generateOpenAPISpec } = require("../generate-openapi");
16
16
  const { generateLlmsTxt, generateLlmMd } = require("./generate-llm-docs");
17
+ const { generateMigrationFiles } = require("../generate-migration");
18
+ const { generateDocsRoute } = require("../generate-docs-route");
19
+ const { migrationTimestamp } = require("../init/generators");
17
20
 
18
21
  /**
19
22
  * Generate command handler for the unified CLI.
20
23
  *
21
24
  * Reads a schema file, converts to ModelMeta[], and generates
22
- * models, routes, tests, and OpenAPI spec files.
25
+ * models, routes, tests, OpenAPI spec, migrations, and docs route.
23
26
  *
24
27
  * 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
28
+ * --from Path to schema file (default: dbmr.schema.json)
29
+ * --models Generate only model files
30
+ * --routes Generate only route files (including child routes and index)
31
+ * --openapi Generate only OpenAPI spec + docs route
32
+ * --tests Generate only test files
33
+ * --migrations Generate only migration files
34
+ * --llm-docs Generate only LLM documentation
35
+ * --dry-run Report planned files without writing
36
+ * --json Output JSON result via ctx
32
37
  *
33
38
  * When no artifact flags are provided, all artifact types are generated.
34
39
  *
@@ -80,12 +85,14 @@ async function generate(args, flags, ctx) {
80
85
  args.routes === true ||
81
86
  args.openapi === true ||
82
87
  args.tests === true ||
88
+ args.migrations === true ||
83
89
  args["llm-docs"] === true;
84
90
 
85
91
  const genModels = !hasArtifactFlag || args.models === true;
86
92
  const genRoutes = !hasArtifactFlag || args.routes === true;
87
93
  const genOpenapi = !hasArtifactFlag || args.openapi === true;
88
94
  const genTests = !hasArtifactFlag || args.tests === true;
95
+ const genMigrations = !hasArtifactFlag || args.migrations === true;
89
96
  const genLlmDocs = !hasArtifactFlag || args["llm-docs"] === true;
90
97
 
91
98
  const modelsRelPath = "../models";
@@ -106,57 +113,91 @@ async function generate(args, flags, ctx) {
106
113
 
107
114
  // --- Route files ---
108
115
  if (genRoutes) {
109
- // One route per table
116
+ // Collect child tables to skip generating top-level route files for them
117
+ const nestedChildren = new Set();
118
+ for (const rel of relationships) {
119
+ nestedChildren.add(rel.child);
120
+ }
121
+
122
+ // One route per top-level table (skip children)
110
123
  for (const m of meta) {
124
+ if (nestedChildren.has(m.table)) continue;
111
125
  planned.push({
112
126
  relPath: `routes/${m.table}.js`,
113
127
  content: generateRouteFile(m.table, modelsRelPath),
114
128
  });
115
129
  }
116
130
 
117
- // Child route files (one per relationship)
131
+ // Child route files in subfolders: routes/<parent>/<child>.js
118
132
  for (const rel of relationships) {
119
133
  planned.push({
120
- relPath: `routes/${rel.child}_child_of_${rel.parent}.js`,
134
+ relPath: `routes/${rel.parent}/${rel.child}.js`,
121
135
  content: generateChildRouteFile(
122
136
  rel.child,
123
137
  rel.parent,
124
138
  rel.foreignKey,
125
- modelsRelPath,
139
+ `../../models`,
126
140
  ),
127
141
  });
128
142
  }
129
143
 
130
- // Routes index file
144
+ // Routes index file (include docs route when openapi is being generated)
131
145
  planned.push({
132
146
  relPath: "routes/index.js",
133
- content: generateRoutesIndexFile(tableNames, relationships),
147
+ content: generateRoutesIndexFile(tableNames, relationships, {
148
+ includeDocs: genOpenapi,
149
+ }),
134
150
  });
135
151
  }
136
152
 
137
- // --- OpenAPI spec ---
153
+ // --- OpenAPI spec + docs route ---
138
154
  if (genOpenapi) {
139
155
  planned.push({
140
156
  relPath: "openapi.json",
141
157
  content: JSON.stringify(generateOpenAPISpec(meta), null, 2) + "\n",
142
158
  });
159
+
160
+ // Generate Swagger UI docs route
161
+ planned.push({
162
+ relPath: "routes/docs.js",
163
+ content: generateDocsRoute(),
164
+ });
165
+ }
166
+
167
+ // --- Migration files ---
168
+ if (genMigrations) {
169
+ const migrationFiles = generateMigrationFiles(schema);
170
+ const ts = migrationTimestamp(new Date());
171
+ for (const mf of migrationFiles) {
172
+ planned.push({
173
+ relPath: `migrations/${ts}_${mf.filename}`,
174
+ content: mf.content,
175
+ });
176
+ }
143
177
  }
144
178
 
145
179
  // --- Test files ---
146
180
  if (genTests) {
181
+ // Collect child tables to skip generating top-level test files for them
182
+ const nestedChildrenForTests = new Set();
183
+ for (const rel of relationships) {
184
+ nestedChildrenForTests.add(rel.child);
185
+ }
186
+
147
187
  for (const m of meta) {
188
+ if (nestedChildrenForTests.has(m.table)) continue;
148
189
  planned.push({
149
190
  relPath: `test/${m.table}.test.js`,
150
191
  content: generateTestFile(m.table, m.primary_key),
151
192
  });
152
193
  }
153
194
 
154
- // Child test files (one per relationship)
195
+ // Child test files in subfolders: test/<parent>/<child>.test.js
155
196
  for (const rel of relationships) {
156
197
  const childMeta = meta.find((m) => m.table === rel.child);
157
198
  const pk = childMeta ? childMeta.primary_key : "id";
158
199
  planned.push({
159
- relPath: `test/${rel.child}_child_of_${rel.parent}.test.js`,
200
+ relPath: `test/${rel.parent}/${rel.child}.test.js`,
160
201
  content: generateChildTestFile(
161
202
  rel.child,
162
203
  rel.parent,
@@ -8,7 +8,7 @@ const COMMAND_HELP = {
8
8
  init: `Usage: db-model-router init [options]
9
9
 
10
10
  Scaffold a new project from a schema file or interactively.
11
- Creates app.js, .env, commons/, route/, middleware/, and migrations/.
11
+ Creates app.js, .env, commons/, routes/, middleware/, and migrations/.
12
12
 
13
13
  Options:
14
14
  --from <path> Read adapter, framework, and options from a schema file
@@ -18,7 +18,7 @@ Options:
18
18
  --db <name> Alias for --database
19
19
  --session <type> Session store: memory, redis, database
20
20
  --output <dir> Directory for backend source files (relative to cwd).
21
- package.json and app.js stay in root; commons/, route/,
21
+ package.json and app.js stay in root; commons/, routes/,
22
22
  middleware/, and migrations/ go inside this folder.
23
23
  --rateLimiting Enable rate limiting (default: yes)
24
24
  --helmet Enable Helmet security headers (default: yes)
@@ -39,8 +39,8 @@ Generated files:
39
39
  <output>/commons/add_migration.js Migration creation helper (also runs as script)
40
40
  <output>/commons/security.js Helmet, rate limiting, custom headers
41
41
  <output>/middleware/logger.js Winston + Loki request logger
42
- <output>/route/index.js Central route mounting
43
- <output>/route/health.js GET /health endpoint
42
+ <output>/routes/index.js Central route mounting
43
+ <output>/routes/health.js GET /health endpoint
44
44
  <output>/migrations/ Initial migration files
45
45
 
46
46
  Examples:
@@ -79,8 +79,9 @@ Options:
79
79
  --from <path> Path to schema file (default: dbmr.schema.json)
80
80
  --models Generate only model files
81
81
  --routes Generate only route files (including child routes and index)
82
- --openapi Generate only OpenAPI spec
82
+ --openapi Generate only OpenAPI spec + Swagger UI docs route
83
83
  --tests Generate only test files
84
+ --migrations Generate only database migration files
84
85
  --llm-docs Generate only LLM documentation (llms.txt + docs/llm.md)
85
86
  --yes Accept all defaults without prompting
86
87
  --json Output machine-readable JSON
@@ -90,8 +91,11 @@ Options:
90
91
  Generated files:
91
92
  models/<table>.js Model with CRUD operations
92
93
  routes/<table>.js Express route handlers
93
- routes/<child>_child_of_<parent>.js Child route (scoped by FK)
94
+ routes/<parent>/<child>.js Child route (scoped by FK)
95
+ routes/docs.js Swagger UI at /docs
94
96
  routes/index.js Route mounting index
97
+ migrations/<timestamp>_create_tables.sql Database migration (SQL adapters)
98
+ migrations/<timestamp>_create_<t>.js Database migration (NoSQL adapters)
95
99
  test/<table>.test.js CRUD endpoint tests
96
100
  openapi.json OpenAPI 3.0 spec
97
101
  llms.txt LLM quick reference
@@ -101,6 +105,7 @@ Examples:
101
105
  db-model-router generate --from dbmr.schema.json
102
106
  db-model-router generate --models --dry-run
103
107
  db-model-router generate --routes --tests
108
+ db-model-router generate --migrations
104
109
  db-model-router generate --from dbmr.schema.json --json`,
105
110
 
106
111
  doctor: `Usage: db-model-router doctor [options]
@@ -165,8 +165,8 @@ function planFiles(answers, outputDir) {
165
165
  `${prefix}commons/add_migration.js`,
166
166
  `${prefix}commons/security.js`,
167
167
  `${prefix}commons/db.js`,
168
- `${prefix}route/health.js`,
169
- `${prefix}route/index.js`,
168
+ `${prefix}routes/health.js`,
169
+ `${prefix}routes/index.js`,
170
170
  `${prefix}migrations/<timestamp>_create_migrations_table` +
171
171
  (isSql(answers.database) ? ".sql" : ".js"),
172
172
  );
@@ -79,6 +79,7 @@ function modelMetaToSchema(adapter, framework, models) {
79
79
  unique,
80
80
  softDelete,
81
81
  timestamps,
82
+ parent: null,
82
83
  };
83
84
  }
84
85
 
@@ -11,6 +11,7 @@ const {
11
11
  generateChildTestFile,
12
12
  } = require("./generate-route.js");
13
13
  const { generateOpenAPISpec } = require("./generate-openapi.js");
14
+ const { generateDocsRoute } = require("./generate-docs-route.js");
14
15
 
15
16
  /**
16
17
  * Simple line-by-line diff between two strings.
@@ -52,54 +53,63 @@ function buildExpectedFiles(meta, relationships) {
52
53
  const modelsRelPath = "../models";
53
54
  const tableNames = meta.map((m) => m.table).sort();
54
55
 
56
+ // Collect child tables
57
+ const nestedChildren = new Set();
58
+ for (const rel of relationships) {
59
+ nestedChildren.add(rel.child);
60
+ }
61
+
55
62
  // Model files
56
63
  for (const m of meta) {
57
64
  expected.set(`models/${m.table}.js`, generateModelFile(m));
58
65
  }
59
66
 
60
- // Route files (one per table)
67
+ // Route files (top-level only, skip children)
61
68
  for (const m of meta) {
69
+ if (nestedChildren.has(m.table)) continue;
62
70
  expected.set(
63
71
  `routes/${m.table}.js`,
64
72
  generateRouteFile(m.table, modelsRelPath),
65
73
  );
66
74
  }
67
75
 
68
- // Child route files (one per relationship)
76
+ // Child route files in subfolders: routes/<parent>/<child>.js
69
77
  for (const rel of relationships) {
70
- const childMeta = meta.find((m) => m.table === rel.child);
71
- const pk = childMeta ? childMeta.primary_key : "id";
72
78
  expected.set(
73
- `routes/${rel.child}_child_of_${rel.parent}.js`,
79
+ `routes/${rel.parent}/${rel.child}.js`,
74
80
  generateChildRouteFile(
75
81
  rel.child,
76
82
  rel.parent,
77
83
  rel.foreignKey,
78
- modelsRelPath,
84
+ `../../models`,
79
85
  ),
80
86
  );
81
87
  }
82
88
 
83
- // Routes index file
89
+ // Routes index file (with docs route)
84
90
  expected.set(
85
91
  "routes/index.js",
86
- generateRoutesIndexFile(tableNames, relationships),
92
+ generateRoutesIndexFile(tableNames, relationships, { includeDocs: true }),
87
93
  );
88
94
 
89
- // Test files (one per table)
95
+ // Docs route (Swagger UI)
96
+ expected.set("routes/docs.js", generateDocsRoute());
97
+
98
+ // Test files (top-level only, skip children)
90
99
  for (const m of meta) {
100
+ if (nestedChildren.has(m.table)) continue;
91
101
  expected.set(
92
102
  `test/${m.table}.test.js`,
93
103
  generateTestFile(m.table, m.primary_key),
94
104
  );
95
105
  }
96
106
 
97
- // Child test files (one per relationship)
107
+ // Child test files in subfolders: test/<parent>/<child>.test.js
98
108
  for (const rel of relationships) {
99
109
  const childMeta = meta.find((m) => m.table === rel.child);
100
110
  const pk = childMeta ? childMeta.primary_key : "id";
101
111
  expected.set(
102
- `test/${rel.child}_child_of_${rel.parent}.test.js`,
112
+ `test/${rel.parent}/${rel.child}.test.js`,
103
113
  generateChildTestFile(rel.child, rel.parent, rel.foreignKey, pk),
104
114
  );
105
115
  }
@@ -115,7 +125,7 @@ function buildExpectedFiles(meta, relationships) {
115
125
 
116
126
  /**
117
127
  * Scan known artifact directories on disk and return a set of relative paths
118
- * that exist.
128
+ * that exist. Recursively scans subdirectories.
119
129
  *
120
130
  * @param {string} baseDir
121
131
  * @returns {Set<string>}
@@ -123,22 +133,42 @@ function buildExpectedFiles(meta, relationships) {
123
133
  function scanDiskFiles(baseDir) {
124
134
  const files = new Set();
125
135
 
126
- const dirs = [
127
- { dir: "models", ext: ".js" },
128
- { dir: "routes", ext: ".js" },
129
- { dir: "test", ext: ".test.js" },
130
- ];
136
+ function scanDir(dir, prefix) {
137
+ const fullDir = path.join(baseDir, dir);
138
+ if (!fs.existsSync(fullDir)) return;
139
+ for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
140
+ const relPath = prefix
141
+ ? `${prefix}/${entry.name}`
142
+ : `${dir}/${entry.name}`;
143
+ if (entry.isDirectory()) {
144
+ scanDir(path.join(dir, entry.name), relPath);
145
+ } else if (entry.name.endsWith(".js")) {
146
+ files.add(relPath);
147
+ }
148
+ }
149
+ }
150
+
151
+ scanDir("models");
152
+ scanDir("routes");
131
153
 
132
- for (const { dir, ext } of dirs) {
154
+ // For test dir, only include .test.js files
155
+ function scanTestDir(dir, prefix) {
133
156
  const fullDir = path.join(baseDir, dir);
134
- if (!fs.existsSync(fullDir)) continue;
135
- for (const file of fs.readdirSync(fullDir)) {
136
- if (file.endsWith(ext)) {
137
- files.add(`${dir}/${file}`);
157
+ if (!fs.existsSync(fullDir)) return;
158
+ for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
159
+ const relPath = prefix
160
+ ? `${prefix}/${entry.name}`
161
+ : `${dir}/${entry.name}`;
162
+ if (entry.isDirectory()) {
163
+ scanTestDir(path.join(dir, entry.name), relPath);
164
+ } else if (entry.name.endsWith(".test.js")) {
165
+ files.add(relPath);
138
166
  }
139
167
  }
140
168
  }
141
169
 
170
+ scanTestDir("test");
171
+
142
172
  // Check for openapi.json at root
143
173
  const openapiPath = path.join(baseDir, "openapi.json");
144
174
  if (fs.existsSync(openapiPath)) {
@@ -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 };