db-model-router 1.0.11 → 1.0.13

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.
@@ -17,18 +17,16 @@ This document is the single source of truth for writing a `dbmr.schema.json` fil
17
17
  "logger": true,
18
18
  "loki": false
19
19
  },
20
- "tables": { ... },
21
- "relationships": [ ... ]
20
+ "tables": { ... }
22
21
  }
23
22
  ```
24
23
 
25
- | Field | Required | Description |
26
- | --------------- | -------- | ------------------------------------------------------------------------------ |
27
- | `adapter` | Yes | Database adapter. One of: `mysql`, `mariadb`, `postgres`, `sqlite3`, `mongodb`, `mssql`, `cockroachdb`, `oracle`, `redis`, `dynamodb` |
28
- | `framework` | Yes | Express variant. One of: `express`, `ultimate-express` |
29
- | `options` | No | Project scaffolding options (session store, rate limiting, helmet, logger, loki) |
30
- | `tables` | Yes | Object where each key is a table name and the value is a **Table Definition** |
31
- | `relationships` | No | Array of parent-child route nesting descriptors |
24
+ | Field | Required | Description |
25
+ | ----------- | -------- | ------------------------------------------------------------------------------ |
26
+ | `adapter` | Yes | Database adapter. One of: `mysql`, `mariadb`, `postgres`, `sqlite3`, `mongodb`, `mssql`, `cockroachdb`, `oracle`, `redis`, `dynamodb` |
27
+ | `framework` | Yes | Express variant. One of: `express`, `ultimate-express` |
28
+ | `options` | No | Project scaffolding options (session store, rate limiting, helmet, logger, loki) |
29
+ | `tables` | Yes | Object where each key is a table name and the value is a **Table Definition** |
32
30
 
33
31
  ---
34
32
 
@@ -54,7 +52,7 @@ Each entry in `tables` is a Table Definition object:
54
52
  | ------------ | -------- | --------------------------------------------------------------------------------------------- |
55
53
  | `columns` | Yes | **All** columns in the table, including PK, timestamps, and soft-delete. Each key is a column name; the value is a **Column Rule** string. |
56
54
  | `pk` | Yes | Primary key column name. Convention: `<table>_id` (e.g. `user_id`, `order_id`). |
57
- | `unique` | No | Array of column names with unique constraints. Defaults to `[pk]` if omitted. |
55
+ | `unique` | No | Unique constraint columns. A flat array creates one composite unique group; an array-of-arrays creates multiple independent constraints. Defaults to `[[pk]]`. |
58
56
  | `softDelete` | No | Column name used for soft-delete. When set, `remove()` updates this column to `1`/`true` instead of hard-deleting. |
59
57
  | `timestamps` | No | Object mapping `{ created_at: "col_name", modified_at: "col_name" }`. These columns are auto-excluded from insert/update payloads. |
60
58
  | `parent` | No | Parent table name for route nesting, or `null` for a top-level route. |
@@ -367,31 +365,28 @@ Tables that must **never** appear in your schema (because SaaS generates them):
367
365
 
368
366
  ---
369
367
 
370
- ## Relationships and Route Nesting
368
+ ## Route Nesting via the `parent` Field
371
369
 
372
- The `relationships` array defines parent-child route nesting independent of foreign key columns.
373
-
374
- ```json
375
- "relationships": [
376
- {
377
- "parent": "posts",
378
- "child": "comments",
379
- "foreignKey": "post_id"
380
- }
381
- ]
382
- ```
383
-
384
- | Field | Description |
385
- | ------------ | -------------------------------------------------------------- |
386
- | `parent` | Parent table name. Must exist in `tables`. |
387
- | `child` | Child table name. Must exist in `tables`. |
388
- | `foreignKey` | Column in the child table that references the parent's PK. |
370
+ Route nesting is driven **exclusively** by the `parent` field on each table. Set `parent` to the name of the table this module belongs under. Use `null` (or omit) for top-level routes.
389
371
 
390
372
  ### Routing Behavior
391
373
 
392
374
  - `parent: null` → Top-level routes: `GET /products/`, `GET /products/:product_id`
393
375
  - `parent: "posts"` → Nested routes: `GET /posts/:post_id/comments/`, `GET /posts/:post_id/comments/:comment_id`
394
- - Child routes are **also** mounted at top-level for direct access: `GET /comments/:comment_id`
376
+ - Child routes are **only** available under their parent path. There is no duplicate top-level route for child tables.
377
+
378
+ ### Multi-Level Nesting (Deep Hierarchies)
379
+
380
+ You can nest more than two levels. Intermediate tables that are both a child and a parent (e.g. `tasks` under `projects`, with `subtasks` under `tasks`) get a **hybrid route file** that:
381
+
382
+ 1. Scopes their own CRUD by the ancestor parameter (e.g. `project_id`).
383
+ 2. Mounts their own children under their path.
384
+
385
+ Example: `projects → tasks → subtasks` produces:
386
+
387
+ - `GET /projects/` — top-level project CRUD
388
+ - `GET /projects/:project_id/tasks/` — task CRUD scoped by project
389
+ - `GET /projects/:project_id/tasks/:task_id/subtasks/` — subtask CRUD scoped by task
395
390
 
396
391
  ### Best Practice: Don't Nest System Tables
397
392
 
@@ -399,7 +394,7 @@ Tables like `users`, `tenants`, `roles`, `permissions`, `sessions`, `accounts`,
399
394
 
400
395
  - `posts → comments`
401
396
  - `orders → order_items`
402
- - `projects → tasks`
397
+ - `projects → tasks → subtasks`
403
398
  - `invoices → invoice_items`
404
399
 
405
400
  ---
@@ -559,14 +554,7 @@ Validators map to OpenAPI schema properties:
559
554
  },
560
555
  "parent": "orders"
561
556
  }
562
- },
563
- "relationships": [
564
- {
565
- "parent": "orders",
566
- "child": "order_items",
567
- "foreignKey": "order_id"
568
- }
569
- ]
557
+ }
570
558
  }
571
559
  ```
572
560
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "db-model-router",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Generative API Creation using mysql2 and express libraries in node js",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  "test:all": "mocha test/adapters/*.test.js test/properties/*.property.test.js test/function.test.js test/commands/*.test.js --timeout 300000 --exit",
24
24
  "test:command": "mocha test/commands/*.test.js --timeout 30000 --exit",
25
25
  "demo:clear": "node -e \"var fs=require('fs'),p=require('path'),d=p.join(__dirname,'demo');fs.existsSync(d)&&fs.rmSync(d,{recursive:true,force:true});fs.mkdirSync(d,{recursive:true})\"",
26
- "demo:create": "node scripts/demo-create.js"
26
+ "demo:create": "node scripts/demo-create.js",
27
+ "demo:create-postgres": "node scripts/demo-create.js postgres"
27
28
  },
28
29
  "repository": {
29
30
  "type": "git",
@@ -8,6 +8,15 @@ const { execSync } = require("child_process");
8
8
  const ROOT = path.resolve(__dirname, "..");
9
9
  const DEMO = path.join(ROOT, "demo");
10
10
 
11
+ const ADAPTER = process.argv[2] || "sqlite3";
12
+ const SUPPORTED_ADAPTERS = ["sqlite3", "postgres", "mysql", "mariadb"];
13
+
14
+ if (!SUPPORTED_ADAPTERS.includes(ADAPTER)) {
15
+ console.error(`Error: Unsupported adapter "${ADAPTER}".`);
16
+ console.error(`Supported: ${SUPPORTED_ADAPTERS.join(", ")}`);
17
+ process.exit(1);
18
+ }
19
+
11
20
  // 1. Clear demo folder
12
21
  if (fs.existsSync(DEMO)) {
13
22
  fs.rmSync(DEMO, { recursive: true, force: true });
@@ -19,29 +28,60 @@ const run = (cmd, cwd) => {
19
28
  execSync(cmd, { cwd, stdio: "inherit" });
20
29
  };
21
30
 
22
- // 2. Scaffold project with sqlite3
23
- run("node ../src/cli/main.js init --database sqlite3 --yes --no-install", DEMO);
24
-
25
- // 3. Copy schema and patch adapter to sqlite3
26
- const schema = JSON.parse(
27
- fs.readFileSync(path.join(ROOT, "dbmr.schema.json"), "utf8"),
28
- );
29
- schema.adapter = "sqlite3";
30
- if (schema.options) {
31
- delete schema.options.session;
32
- delete schema.options.loki;
33
- }
34
- fs.writeFileSync(
35
- path.join(DEMO, "dbmr.schema.json"),
36
- JSON.stringify(schema, null, 2) + "\n",
37
- );
38
- console.log("\n> Copied and patched dbmr.schema.json (adapter → sqlite3)");
31
+ if (ADAPTER === "postgres") {
32
+ // --- Postgres demo flow ---
33
+ const schemaPath = path.join(ROOT, "dbmr.postgres.test.schema.json");
34
+
35
+ // 2. Copy Postgres schema into the demo folder
36
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
37
+ const schemaDest = path.join(DEMO, "dbmr.schema.json");
38
+ fs.writeFileSync(schemaDest, JSON.stringify(schema, null, 2) + "\n");
39
+ console.log(`\n> Copied ${schemaPath} → ${schemaDest}`);
40
+
41
+ // 3. Scaffold project with postgres
42
+ run(
43
+ "node ../src/cli/main.js init --database postgres --yes --no-install",
44
+ DEMO,
45
+ );
39
46
 
40
- // 4. Generate models, routes, tests, openapi from schema
41
- run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
47
+ // 4. Generate from schema (already copied)
48
+ run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
42
49
 
43
- // 5. Install dependencies
44
- run("npm install", DEMO);
50
+ // 5. Install dependencies
51
+ run("npm install", DEMO);
45
52
 
46
- console.log("\n✔ Demo project ready in ./demo");
47
- console.log("cd demo && npm run migrate && npm run dev\n");
53
+ console.log("\n✔ Postgres demo project ready in ./demo");
54
+ console.log("cd demo && npm run migrate && npm run dev\n");
55
+ } else {
56
+ // --- SQLite3 demo flow (default) ---
57
+
58
+ // 2. Scaffold project with sqlite3
59
+ run(
60
+ "node ../src/cli/main.js init --database sqlite3 --yes --no-install",
61
+ DEMO,
62
+ );
63
+
64
+ // 3. Copy schema and patch adapter to sqlite3
65
+ const schema = JSON.parse(
66
+ fs.readFileSync(path.join(ROOT, "dbmr.schema.json"), "utf8"),
67
+ );
68
+ schema.adapter = "sqlite3";
69
+ if (schema.options) {
70
+ delete schema.options.session;
71
+ delete schema.options.loki;
72
+ }
73
+ fs.writeFileSync(
74
+ path.join(DEMO, "dbmr.schema.json"),
75
+ JSON.stringify(schema, null, 2) + "\n",
76
+ );
77
+ console.log("\n> Copied and patched dbmr.schema.json (adapter → sqlite3)");
78
+
79
+ // 4. Generate models, routes, tests, openapi from schema
80
+ run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
81
+
82
+ // 5. Install dependencies
83
+ run("npm install", DEMO);
84
+
85
+ console.log("\n✔ Demo project ready in ./demo");
86
+ console.log("cd demo && npm run migrate && npm run dev\n");
87
+ }
package/skill/SKILL.md CHANGED
@@ -117,7 +117,7 @@ For adapter-specific connect options (ports, env vars, upsert behavior), read th
117
117
  | ----------- | --------------- | --------------------------------------------------------------------------------------------------------------- |
118
118
  | `structure` | `{col: "rule"}` | Types: `string\|integer\|numeric\|boolean\|object\|datetime\|auto_increment`. Prefix `required\|` for NOT NULL. |
119
119
  | `pk` | string | Primary key column. Convention: `<table>_id` |
120
- | `unique` | string[] | Columns for upsert conflict resolution |
120
+ | `unique` | string[] \| string[][] | Flat array = one composite unique group; array-of-arrays = multiple independent constraints |
121
121
  | `option` | object | `{ safeDelete, created_at, modified_at }` — column names or null |
122
122
 
123
123
  > PK, timestamp, soft-delete, and `auto_increment` cols are auto-excluded from insert/update payloads.
@@ -249,7 +249,7 @@ Generated structure (ESM, `"type":"module"`):
249
249
  ```
250
250
  app.js Express entry point
251
251
  .env / .env.example Env config (random passwords)
252
- docker-compose.yml DB + CloudBeaver + optional Loki/Grafana
252
+ docker-compose.yml DB + optional Loki/Grafana
253
253
  <output>/commons/db.js Database init + global.db
254
254
  <output>/commons/migrate.js Migration runner
255
255
  <output>/route/index.js Central route mounting
@@ -257,7 +257,7 @@ docker-compose.yml DB + CloudBeaver + optional Loki/Grafana
257
257
  <output>/migrations/ Initial migration files
258
258
  ```
259
259
 
260
- Docker services auto-generated: database, Redis (if session=redis), CloudBeaver (SQL/MongoDB, port 8978), Loki + Grafana (if --loki).
260
+ Docker services auto-generated: database, Redis (if session=redis), Loki + Grafana (if --loki).
261
261
 
262
262
  Scripts: `start`, `dev`, `test`, `migrate`, `add_migration`, `docker:build`, `docker:up`, `docker:down`.
263
263
 
@@ -383,7 +383,7 @@ Requires a `.env` file with `DB_TYPE` and connection variables.
383
383
  ### `parent` field rules
384
384
 
385
385
  - `"parent": null` → top-level route: `/comments/`
386
- - `"parent": "posts"` → nested route: `/posts/:post_id/comments/` (also mounted at top-level for direct access)
386
+ - `"parent": "posts"` → nested route: `/posts/:post_id/comments/` (only available under parent path)
387
387
  - **Do NOT use system tables as parents** (`users`, `tenants`, `roles`, `permissions`, `sessions`, `accounts`, `auth_tokens`). They are cross-cutting and referenced via FK columns — not route hierarchies. Only use `parent` for true domain hierarchies: `posts → comments`, `orders → order_items`, `projects → tasks`.
388
388
 
389
389
  ### Column Rules
@@ -61,8 +61,23 @@ async function diff(args, flags, ctx) {
61
61
 
62
62
  // --- 2. Compute diff ---
63
63
  const meta = schemaToModelMeta(schema);
64
- const relationships = schema.relationships || [];
65
- const result = computeDiff(baseDir, meta, relationships);
64
+
65
+ // For diff, match generate by using only parent-derived relationships
66
+ const routeRelationships = [];
67
+ for (const [tableName, tableDef] of Object.entries(schema.tables)) {
68
+ if (tableDef.parent) {
69
+ const parentTable = schema.tables[tableDef.parent];
70
+ if (parentTable) {
71
+ routeRelationships.push({
72
+ parent: tableDef.parent,
73
+ child: tableName,
74
+ foreignKey: parentTable.pk,
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ const result = computeDiff(baseDir, meta, routeRelationships);
66
81
 
67
82
  // --- 3. Output results ---
68
83
  if (flags.json) {
@@ -97,6 +97,35 @@ async function generate(args, flags, ctx) {
97
97
  const relationships = schema.relationships || [];
98
98
  const tableNames = meta.map((m) => m.table).sort();
99
99
 
100
+ // For route generation, only use parent-derived relationships.
101
+ // Explicit relationships may define multiple foreign keys per table,
102
+ // but nested routes should follow the canonical parent declared on each table.
103
+ const routeRelationships = [];
104
+ for (const [tableName, tableDef] of Object.entries(schema.tables)) {
105
+ if (tableDef.parent) {
106
+ const parentTable = schema.tables[tableDef.parent];
107
+ if (parentTable) {
108
+ routeRelationships.push({
109
+ parent: tableDef.parent,
110
+ child: tableName,
111
+ foreignKey: parentTable.pk,
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // Build ancestry chains for correct multi-level nested file placement.
118
+ const ancestors = {};
119
+ for (const m of meta) {
120
+ const chain = [];
121
+ let current = m.table;
122
+ while (schema.tables[current]?.parent) {
123
+ chain.unshift(schema.tables[current].parent);
124
+ current = schema.tables[current].parent;
125
+ }
126
+ ancestors[m.table] = chain;
127
+ }
128
+
100
129
  // Determine which artifact types to generate.
101
130
  // If any specific artifact flag is explicitly set to true, only generate those.
102
131
  // Otherwise, all artifact types are generated (unless explicitly set to false).
@@ -144,47 +173,62 @@ async function generate(args, flags, ctx) {
144
173
 
145
174
  // --- Route files ---
146
175
  if (genRoutes) {
147
- // Collect child tables and group by parent
148
- const nestedChildren = new Set();
149
176
  const childrenByParent = {};
150
- for (const rel of relationships) {
151
- nestedChildren.add(rel.child);
177
+ for (const rel of routeRelationships) {
152
178
  if (!childrenByParent[rel.parent]) childrenByParent[rel.parent] = [];
153
179
  childrenByParent[rel.parent].push(rel);
154
180
  }
155
181
 
156
- // Generate route files for each table
182
+ // Generate exactly ONE route file per table at its correct nested path.
183
+ // Intermediate tables (both child and parent) get a hybrid route file
184
+ // that scopes their own CRUD by an ancestor FK and mounts their children.
157
185
  for (const m of meta) {
158
- if (nestedChildren.has(m.table)) continue;
159
-
160
- const children = childrenByParent[m.table] || [];
161
- if (children.length > 0) {
162
- // Parent with children: generates index.js that mounts child routes
186
+ const tableName = m.table;
187
+ const chain = ancestors[tableName];
188
+ const hasChildren = (childrenByParent[tableName] || []).length > 0;
189
+ const hasParent = chain.length > 0;
190
+
191
+ const pathParts = [...chain, tableName];
192
+ const relPath = `routes/${pathParts.join("/")}/index.js`;
193
+
194
+ if (hasChildren) {
195
+ const children = childrenByParent[tableName];
196
+ if (hasParent) {
197
+ // Intermediate node: own CRUD scoped by parent PK + mounts children
198
+ const immediateParent = chain[chain.length - 1];
199
+ const parentFk = schema.tables[immediateParent].pk;
200
+ planned.push({
201
+ relPath,
202
+ content: generateParentRouteFile(tableName, children, parentFk),
203
+ });
204
+ } else {
205
+ // Root parent: own CRUD unscoped + mounts children
206
+ planned.push({
207
+ relPath,
208
+ content: generateParentRouteFile(tableName, children),
209
+ });
210
+ }
211
+ } else if (hasParent) {
212
+ // Leaf child
213
+ const immediateParent = chain[chain.length - 1];
214
+ const parentFk = schema.tables[immediateParent].pk;
163
215
  planned.push({
164
- relPath: `routes/${m.table}/index.js`,
165
- content: generateParentRouteFile(m.table, children),
216
+ relPath,
217
+ content: generateChildRouteFile(tableName, immediateParent, parentFk),
166
218
  });
167
219
  } else {
168
- // Simple table: just CRUD
220
+ // Root leaf
169
221
  planned.push({
170
- relPath: `routes/${m.table}/index.js`,
171
- content: generateRouteFile(m.table),
222
+ relPath,
223
+ content: generateRouteFile(tableName),
172
224
  });
173
225
  }
174
226
  }
175
227
 
176
- // Child route files inside parent folders: routes/<parent>/<child>/index.js
177
- for (const rel of relationships) {
178
- planned.push({
179
- relPath: `routes/${rel.parent}/${rel.child}/index.js`,
180
- content: generateChildRouteFile(rel.child, rel.parent, rel.foreignKey),
181
- });
182
- }
183
-
184
228
  // Routes index file (include docs route when openapi is being generated)
185
229
  planned.push({
186
230
  relPath: "routes/index.js",
187
- content: generateRoutesIndexFile(tableNames, relationships, {
231
+ content: generateRoutesIndexFile(tableNames, routeRelationships, {
188
232
  includeDocs: genOpenapi,
189
233
  }),
190
234
  });
@@ -192,7 +236,7 @@ async function generate(args, flags, ctx) {
192
236
 
193
237
  // --- OpenAPI spec + docs route ---
194
238
  if (genOpenapi) {
195
- const spec = generateOpenAPISpec(meta, { relationships });
239
+ const spec = generateOpenAPISpec(meta, { relationships: routeRelationships });
196
240
 
197
241
  // Merge SaaS routes into the OpenAPI spec when saas-structure is active
198
242
  // SaaS routes appear BEFORE product routes in the docs
@@ -239,33 +283,33 @@ async function generate(args, flags, ctx) {
239
283
 
240
284
  // --- Test files ---
241
285
  if (genTests) {
242
- // Collect child tables to skip generating top-level test files for them
243
- const nestedChildrenForTests = new Set();
244
- for (const rel of relationships) {
245
- nestedChildrenForTests.add(rel.child);
246
- }
247
-
248
286
  for (const m of meta) {
249
- if (nestedChildrenForTests.has(m.table)) continue;
250
- planned.push({
251
- relPath: `test/${m.table}.test.js`,
252
- content: generateTestFile(m.table, m.primary_key, m.structure),
253
- });
254
- }
255
-
256
- // Child test files in subfolders: test/<parent>/<child>.test.js
257
- for (const rel of relationships) {
258
- const childMeta = meta.find((m) => m.table === rel.child);
259
- const pk = childMeta ? childMeta.primary_key : "id";
260
- planned.push({
261
- relPath: `test/${rel.parent}/${rel.child}.test.js`,
262
- content: generateChildTestFile(
263
- rel.child,
264
- rel.parent,
265
- rel.foreignKey,
266
- pk,
267
- ),
268
- });
287
+ const tableName = m.table;
288
+ const chain = ancestors[tableName];
289
+ const hasParent = chain.length > 0;
290
+
291
+ if (hasParent) {
292
+ const immediateParent = chain[chain.length - 1];
293
+ const parentFk = schema.tables[immediateParent].pk;
294
+ const pathParts = [...chain, tableName];
295
+ const depth = pathParts.length;
296
+ const modelsRelPath = "../".repeat(depth) + "models/";
297
+ planned.push({
298
+ relPath: `test/${pathParts.join("/")}.test.js`,
299
+ content: generateChildTestFile(
300
+ tableName,
301
+ immediateParent,
302
+ parentFk,
303
+ m.primary_key,
304
+ modelsRelPath,
305
+ ),
306
+ });
307
+ } else {
308
+ planned.push({
309
+ relPath: `test/${tableName}.test.js`,
310
+ content: generateTestFile(tableName, m.primary_key, m.structure),
311
+ });
312
+ }
269
313
  }
270
314
  }
271
315
 
@@ -292,7 +336,7 @@ async function generate(args, flags, ctx) {
292
336
  json: flags.json,
293
337
  timestamp: new Date(),
294
338
  tableNames,
295
- relationships,
339
+ relationships: routeRelationships,
296
340
  routeOptions: { includeDocs: genOpenapi },
297
341
  });
298
342
 
@@ -51,53 +51,69 @@ function lineDiff(expected, actual) {
51
51
  */
52
52
  function buildExpectedFiles(meta, relationships) {
53
53
  const expected = new Map();
54
- const modelsRelPath = "../models";
55
54
  const tableNames = meta.map((m) => m.table).sort();
56
55
 
57
- // Collect child tables
58
- const nestedChildren = new Set();
56
+ // Build children map
59
57
  const childrenByParent = {};
60
58
  for (const rel of relationships) {
61
- nestedChildren.add(rel.child);
62
59
  if (!childrenByParent[rel.parent]) childrenByParent[rel.parent] = [];
63
60
  childrenByParent[rel.parent].push(rel);
64
61
  }
65
62
 
63
+ // Build ancestry chains from relationships
64
+ const parentMap = {};
65
+ for (const rel of relationships) {
66
+ parentMap[rel.child] = rel.parent;
67
+ }
68
+ const ancestors = {};
69
+ for (const m of meta) {
70
+ const chain = [];
71
+ let current = m.table;
72
+ while (parentMap[current]) {
73
+ chain.unshift(parentMap[current]);
74
+ current = parentMap[current];
75
+ }
76
+ ancestors[m.table] = chain;
77
+ }
78
+
79
+ const getPk = (table) => {
80
+ const m = meta.find((x) => x.table === table);
81
+ return m ? m.primary_key : "id";
82
+ };
83
+
66
84
  // Model files
67
85
  for (const m of meta) {
68
86
  expected.set(`models/${m.table}.js`, generateModelFile(m));
69
87
  }
70
88
 
71
- // Route files (top-level only, skip children)
89
+ // Route files: exactly one per table at its correct nested path
72
90
  for (const m of meta) {
73
- if (nestedChildren.has(m.table)) continue;
74
- const children = childrenByParent[m.table] || [];
75
- if (children.length > 0) {
76
- expected.set(
77
- `routes/${m.table}/index.js`,
78
- generateParentRouteFile(m.table, children),
79
- );
91
+ const tableName = m.table;
92
+ const chain = ancestors[tableName];
93
+ const hasChildren = (childrenByParent[tableName] || []).length > 0;
94
+ const hasParent = chain.length > 0;
95
+
96
+ const pathParts = [...chain, tableName];
97
+ const relPath = `routes/${pathParts.join("/")}/index.js`;
98
+
99
+ if (hasChildren) {
100
+ const children = childrenByParent[tableName];
101
+ if (hasParent) {
102
+ const immediateParent = chain[chain.length - 1];
103
+ const parentFk = getPk(immediateParent);
104
+ expected.set(relPath, generateParentRouteFile(tableName, children, parentFk));
105
+ } else {
106
+ expected.set(relPath, generateParentRouteFile(tableName, children));
107
+ }
108
+ } else if (hasParent) {
109
+ const immediateParent = chain[chain.length - 1];
110
+ const parentFk = getPk(immediateParent);
111
+ expected.set(relPath, generateChildRouteFile(tableName, immediateParent, parentFk));
80
112
  } else {
81
- expected.set(
82
- `routes/${m.table}/index.js`,
83
- generateRouteFile(m.table, modelsRelPath),
84
- );
113
+ expected.set(relPath, generateRouteFile(tableName));
85
114
  }
86
115
  }
87
116
 
88
- // Child route files inside parent folders: routes/<parent>/<child>/index.js
89
- for (const rel of relationships) {
90
- expected.set(
91
- `routes/${rel.parent}/${rel.child}/index.js`,
92
- generateChildRouteFile(
93
- rel.child,
94
- rel.parent,
95
- rel.foreignKey,
96
- `../../models`,
97
- ),
98
- );
99
- }
100
-
101
117
  // Routes index file (with docs route)
102
118
  expected.set(
103
119
  "routes/index.js",
@@ -107,23 +123,28 @@ function buildExpectedFiles(meta, relationships) {
107
123
  // Docs route (Swagger UI)
108
124
  expected.set("routes/docs.js", generateDocsRoute());
109
125
 
110
- // Test files (top-level only, skip children)
126
+ // Test files at correct nested paths
111
127
  for (const m of meta) {
112
- if (nestedChildren.has(m.table)) continue;
113
- expected.set(
114
- `test/${m.table}.test.js`,
115
- generateTestFile(m.table, m.primary_key, m.structure),
116
- );
117
- }
128
+ const tableName = m.table;
129
+ const chain = ancestors[tableName];
130
+ const hasParent = chain.length > 0;
118
131
 
119
- // Child test files in subfolders: test/<parent>/<child>.test.js
120
- for (const rel of relationships) {
121
- const childMeta = meta.find((m) => m.table === rel.child);
122
- const pk = childMeta ? childMeta.primary_key : "id";
123
- expected.set(
124
- `test/${rel.parent}/${rel.child}.test.js`,
125
- generateChildTestFile(rel.child, rel.parent, rel.foreignKey, pk),
126
- );
132
+ if (hasParent) {
133
+ const immediateParent = chain[chain.length - 1];
134
+ const parentFk = getPk(immediateParent);
135
+ const pathParts = [...chain, tableName];
136
+ const depth = pathParts.length;
137
+ const modelsRelPath = "../".repeat(depth) + "models/";
138
+ expected.set(
139
+ `test/${pathParts.join("/")}.test.js`,
140
+ generateChildTestFile(tableName, immediateParent, parentFk, m.primary_key, modelsRelPath),
141
+ );
142
+ } else {
143
+ expected.set(
144
+ `test/${tableName}.test.js`,
145
+ generateTestFile(tableName, m.primary_key, m.structure),
146
+ );
147
+ }
127
148
  }
128
149
 
129
150
  // OpenAPI spec