db-model-router 1.0.12 → 1.0.14

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.12",
3
+ "version": "1.0.14",
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
 
@@ -270,7 +270,7 @@ db-model-router inspect --type postgres --env .env [--out schema.json] [--tables
270
270
  ### `generate` — Generate code from schema
271
271
 
272
272
  ```bash
273
- db-model-router generate --from dbmr.schema.json [--models=false] [--routes=false] [--openapi=false] [--tests=false] [--migrations=false] [--saas-structure=false]
273
+ db-model-router generate --from dbmr.schema.json [--output <dir>] [--models=false] [--routes=false] [--openapi=false] [--tests=false] [--migrations=false] [--saas-structure=false]
274
274
  ```
275
275
 
276
276
  All artifact types are **enabled by default**. Use `--flag=false` to disable specific ones.
@@ -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) {
@@ -6,6 +6,7 @@ const { parseSchema } = require("../../schema/schema-parser");
6
6
  const { SchemaValidationError } = require("../../schema/schema-validator");
7
7
  const { schemaToModelMeta } = require("../../schema/schema-to-meta");
8
8
  const { computeDiff } = require("../diff-engine");
9
+ const { generateSaasStructure } = require("../generate-saas-structure");
9
10
 
10
11
  /**
11
12
  * Adapter-to-driver mapping.
@@ -111,13 +112,53 @@ async function doctor(args, flags, ctx) {
111
112
 
112
113
  if (schema) {
113
114
  const meta = schemaToModelMeta(schema);
114
- const relationships = schema.relationships || [];
115
- const diff = computeDiff(baseDir, meta, relationships);
115
+
116
+ // Derive route relationships from parent fields (same logic as generate)
117
+ const routeRelationships = [];
118
+ for (const [tableName, tableDef] of Object.entries(schema.tables || {})) {
119
+ if (tableDef.parent) {
120
+ const parentTable = schema.tables[tableDef.parent];
121
+ if (parentTable) {
122
+ routeRelationships.push({
123
+ parent: tableDef.parent,
124
+ child: tableName,
125
+ foreignKey: parentTable.pk,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ const tableNames = meta.map((m) => m.table).sort();
132
+
133
+ // Detect whether OpenAPI docs were generated
134
+ const includeDocs = fs.existsSync(path.join(baseDir, "openapi.json"));
135
+
136
+ // Detect whether SaaS structure was generated
137
+ const saasFiles = [];
138
+ if (fs.existsSync(path.join(baseDir, "routes", "auth", "index.js"))) {
139
+ const adapter = schema.adapter;
140
+ saasFiles.push(
141
+ ...generateSaasStructure(adapter, {
142
+ tableNames,
143
+ relationships: routeRelationships,
144
+ routeOptions: { includeDocs },
145
+ }),
146
+ );
147
+ }
148
+
149
+ const diffOptions = { includeDocs, saasFiles };
150
+ const diff = computeDiff(baseDir, meta, routeRelationships, diffOptions);
151
+
152
+ // Filter out known init-scaffold files that doctor should ignore
153
+ const initWhitelist = new Set(["routes/health.js"]);
154
+ const filteredDeleted = diff.deleted.filter(
155
+ (f) => !initWhitelist.has(f),
156
+ );
116
157
 
117
158
  if (
118
159
  diff.added.length > 0 ||
119
160
  diff.modified.length > 0 ||
120
- diff.deleted.length > 0
161
+ filteredDeleted.length > 0
121
162
  ) {
122
163
  sync.ok = false;
123
164
  for (const f of diff.added) {
@@ -126,7 +167,7 @@ async function doctor(args, flags, ctx) {
126
167
  for (const m of diff.modified) {
127
168
  sync.outOfSync.push({ file: m.file, status: "modified" });
128
169
  }
129
- for (const f of diff.deleted) {
170
+ for (const f of filteredDeleted) {
130
171
  sync.outOfSync.push({ file: f, status: "extra" });
131
172
  }
132
173
  }
@@ -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).
@@ -127,7 +156,7 @@ async function generate(args, flags, ctx) {
127
156
  genSaas = args["saas-structure"] !== false;
128
157
  }
129
158
 
130
- const baseDir = process.cwd();
159
+ const baseDir = path.resolve(args.output || process.cwd());
131
160
 
132
161
  // Collect all planned files: { relPath, content }
133
162
  const planned = [];
@@ -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,8 +336,9 @@ 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 },
341
+ baseDir,
297
342
  });
298
343
 
299
344
  // The SaaS generator produces a combined routes/index.js that includes
@@ -11,6 +11,7 @@ const {
11
11
  ensurePackageJson,
12
12
  } = require("../init");
13
13
  const { promptUser } = require("../init/prompt");
14
+ const generateCmd = require("./generate");
14
15
 
15
16
  /**
16
17
  * Default answers used when --yes is provided and no schema is available.
@@ -86,6 +87,12 @@ async function init(args, flags, ctx) {
86
87
  for (const f of planned) {
87
88
  ctx.log(` ${f}`);
88
89
  }
90
+ }
91
+ // Also preview schema-generated artifacts when --from is used
92
+ if (args.from) {
93
+ await generateCmd(args, flags, ctx);
94
+ }
95
+ if (!flags.json) {
89
96
  ctx.log("\nNo files were written.");
90
97
  }
91
98
  return;
@@ -106,6 +113,12 @@ async function init(args, flags, ctx) {
106
113
  runInstall();
107
114
  }
108
115
 
116
+ // When --from points to a schema, also generate models, routes, tests, etc.
117
+ if (args.from) {
118
+ await generateCmd(args, flags, ctx);
119
+ if (process.exitCode) return; // bail if generate reported an error
120
+ }
121
+
109
122
  // Output
110
123
  const allFiles = [
111
124
  ...generated.files,