tss-stack 1.3.0 → 1.3.1

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.
package/bin/cli.js CHANGED
@@ -243,8 +243,15 @@ async function promptTableCount() {
243
243
  ]);
244
244
  }
245
245
 
246
+ // ============================================================
247
+ // ONLY THE CHANGED FUNCTION — drop this into your cli.js
248
+ // replacing the existing promptTable() function.
249
+ // Everything else in cli.js stays exactly the same.
250
+ // ============================================================
251
+
246
252
  async function promptTable(index) {
247
- return inquirer.prompt([
253
+ // ── Step A: basic table info ─────────────────────────────
254
+ const base = await inquirer.prompt([
248
255
  {
249
256
  name: "name",
250
257
  message: `Table ${index + 1} name (snake_case, e.g. spare_parts):`,
@@ -268,7 +275,6 @@ async function promptTable(index) {
268
275
  ],
269
276
  validate: (value) => (value.length > 0 ? true : "Select at least one operation."),
270
277
  },
271
- // STEP 1 — per-table report opt-in
272
278
  {
273
279
  name: "reports",
274
280
  type: "confirm",
@@ -276,6 +282,74 @@ async function promptTable(index) {
276
282
  default: true,
277
283
  },
278
284
  ]);
285
+
286
+ // ── Step B: FK prompts — only for fields ending in _id ───
287
+ // Detect candidate FK fields automatically so the user
288
+ // doesn't have to think about it — just confirm or skip.
289
+ const fkFields = base.fields.filter((f) => f.endsWith("_id"));
290
+ const foreignKeys = [];
291
+
292
+ for (const field of fkFields) {
293
+ // Guess the referenced table name from the field name:
294
+ // spare_part_id → spare_parts (strip _id, pluralise naively)
295
+ const guessedTable = field.replace(/_id$/, "") + "s";
296
+
297
+ logger.info(
298
+ `\n ${COLORS.yellow}[FK]${COLORS.reset} "${field}" looks like a foreign key.`
299
+ );
300
+
301
+ const fkAnswer = await inquirer.prompt([
302
+ {
303
+ name: "confirm",
304
+ type: "confirm",
305
+ message: ` Add a FOREIGN KEY constraint for "${field}"?`,
306
+ default: true,
307
+ },
308
+ ]);
309
+
310
+ if (!fkAnswer.confirm) continue;
311
+
312
+ const fkDetails = await inquirer.prompt([
313
+ {
314
+ name: "refTable",
315
+ message: ` References which table?`,
316
+ default: guessedTable,
317
+ validate: validateTableName,
318
+ },
319
+ {
320
+ name: "refColumn",
321
+ message: ` References which column in that table?`,
322
+ default: "id",
323
+ validate: (v) =>
324
+ /^[a-z][a-z0-9_]*$/.test(v)
325
+ ? true
326
+ : "Use lowercase snake_case.",
327
+ },
328
+ {
329
+ name: "onDelete",
330
+ type: "list",
331
+ message: ` ON DELETE behaviour:`,
332
+ // CASCADE — delete child rows when parent is deleted
333
+ // SET NULL — set the FK column to NULL (column must allow NULL)
334
+ // RESTRICT — block parent deletion if children exist (safest default)
335
+ choices: [
336
+ { name: "RESTRICT (block deletion of referenced row)", value: "RESTRICT" },
337
+ { name: "CASCADE (delete this row too)", value: "CASCADE" },
338
+ { name: "SET NULL (set column to NULL)", value: "SET NULL" },
339
+ ],
340
+ default: "RESTRICT",
341
+ },
342
+ ]);
343
+
344
+ foreignKeys.push({
345
+ field, // e.g. spare_part_id
346
+ refTable: fkDetails.refTable, // e.g. spare_parts
347
+ refColumn: fkDetails.refColumn, // e.g. id
348
+ onDelete: fkDetails.onDelete, // e.g. RESTRICT
349
+ });
350
+ }
351
+
352
+ return { ...base, foreignKeys };
279
353
  }
280
354
 
281
355
  async function promptFeatures() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tss-stack",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Interactive full-stack Node.js + React + MySQL project generator",
5
5
  "bin": {
6
6
  "tss-stack": "bin/cli.js"
@@ -3,9 +3,78 @@ const path = require("path");
3
3
 
4
4
  const { toPascal, escapeSqlIdentifier, inferSqlType } = require("./utils");
5
5
 
6
+ /* ==========================================================================
7
+ TOPOLOGICAL SORT
8
+ Ensures tables are created in the right order so that a FOREIGN KEY
9
+ never references a table that hasn't been created yet.
10
+
11
+ Example: stock_in references spare_parts
12
+ → spare_parts must appear before stock_in in the SQL file
13
+
14
+ Algorithm: Kahn's algorithm (BFS-based topological sort)
15
+ If a cycle is detected (table A references B which references A),
16
+ we fall back to the original order and add a warning comment.
17
+ ========================================================================== */
18
+
19
+ function sortTablesByDependency(tables) {
20
+ // Build a map of tableName → table object for quick lookup
21
+ const tableMap = new Map(tables.map((t) => [t.name, t]));
22
+
23
+ // in-degree = number of tables this table depends on
24
+ const inDegree = new Map(tables.map((t) => [t.name, 0]));
25
+
26
+ // adjacency list: if A depends on B, then B → [A, ...]
27
+ const dependents = new Map(tables.map((t) => [t.name, []]));
28
+
29
+ for (const table of tables) {
30
+ const fks = table.foreignKeys || [];
31
+ for (const fk of fks) {
32
+ // Only count dependencies on tables we know about
33
+ if (!tableMap.has(fk.refTable)) continue;
34
+ // Ignore self-references (table referencing itself)
35
+ if (fk.refTable === table.name) continue;
36
+
37
+ inDegree.set(table.name, (inDegree.get(table.name) || 0) + 1);
38
+ dependents.get(fk.refTable).push(table.name);
39
+ }
40
+ }
41
+
42
+ // Start with tables that have no dependencies
43
+ const queue = tables
44
+ .filter((t) => inDegree.get(t.name) === 0)
45
+ .map((t) => t.name);
46
+
47
+ const sorted = [];
48
+
49
+ while (queue.length > 0) {
50
+ const current = queue.shift();
51
+ sorted.push(tableMap.get(current));
52
+
53
+ for (const dependent of dependents.get(current) || []) {
54
+ const newDegree = inDegree.get(dependent) - 1;
55
+ inDegree.set(dependent, newDegree);
56
+ if (newDegree === 0) queue.push(dependent);
57
+ }
58
+ }
59
+
60
+ // If not all tables were sorted, a cycle exists — fall back to original order
61
+ if (sorted.length !== tables.length) {
62
+ return { tables, hasCycle: true };
63
+ }
64
+
65
+ return { tables: sorted, hasCycle: false };
66
+ }
67
+
68
+ /* ==========================================================================
69
+ GENERATE DATABASE SQL
70
+ ========================================================================== */
71
+
6
72
  async function generateDatabase(config) {
7
73
  const { dbName, tables, needsAuth, targetDir } = config;
8
74
 
75
+ // Sort tables so referenced tables are created first
76
+ const { tables: sortedTables, hasCycle } = sortTablesByDependency(tables);
77
+
9
78
  let sql = `-- ======================================================
10
79
  -- Database: ${dbName}
11
80
  -- Generated automatically
@@ -16,6 +85,16 @@ USE ${escapeSqlIdentifier(dbName)};
16
85
 
17
86
  `;
18
87
 
88
+ if (hasCycle) {
89
+ sql += `-- WARNING: A circular foreign key reference was detected.
90
+ -- Tables are in their original order. You may need to adjust
91
+ -- the CREATE TABLE statements manually.
92
+
93
+ `;
94
+ }
95
+
96
+ // users table always first when auth is enabled,
97
+ // since other tables may FK to it
19
98
  if (needsAuth) {
20
99
  sql += `CREATE TABLE IF NOT EXISTS users (
21
100
  id INT AUTO_INCREMENT PRIMARY KEY,
@@ -27,26 +106,64 @@ USE ${escapeSqlIdentifier(dbName)};
27
106
  `;
28
107
  }
29
108
 
30
- for (const table of tables) {
31
- sql += `-- ${toPascal(table.name)} table
32
- CREATE TABLE IF NOT EXISTS ${escapeSqlIdentifier(table.name)} (
33
- id INT AUTO_INCREMENT PRIMARY KEY,
34
- `;
109
+ for (const table of sortedTables) {
110
+ const fks = table.foreignKeys || [];
35
111
 
112
+ sql += `-- ${toPascal(table.name)} table\n`;
113
+ sql += `CREATE TABLE IF NOT EXISTS ${escapeSqlIdentifier(table.name)} (\n`;
114
+ sql += ` id INT AUTO_INCREMENT PRIMARY KEY,\n`;
115
+
116
+ // ── Column definitions ─────────────────────────────────────────────
36
117
  for (const field of table.fields) {
37
- sql += ` ${escapeSqlIdentifier(field)} ${inferSqlType(field)} NOT NULL,\n`;
118
+ const sqlType = inferSqlType(field);
119
+
120
+ // If this field is a FK, it must allow NULL only when ON DELETE SET NULL
121
+ // is chosen. Otherwise keep NOT NULL.
122
+ const fk = fks.find((f) => f.field === field);
123
+ const nullable = fk && fk.onDelete === "SET NULL" ? "NULL" : "NOT NULL";
124
+
125
+ sql += ` ${escapeSqlIdentifier(field)} ${sqlType} ${nullable},\n`;
38
126
  }
39
127
 
40
- sql += ` created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
41
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
42
- );
128
+ sql += ` created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n`;
129
+ sql += ` updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`;
43
130
 
44
- `;
131
+ // ── FOREIGN KEY constraints ────────────────────────────────────────
132
+ // Written as inline table constraints after all columns.
133
+ // Format:
134
+ // CONSTRAINT fk_<table>_<field>
135
+ // FOREIGN KEY (<field>)
136
+ // REFERENCES <refTable> (<refColumn>)
137
+ // ON DELETE <behaviour>
138
+ if (fks.length > 0) {
139
+ sql += `,\n`;
140
+
141
+ fks.forEach((fk, i) => {
142
+ const constraintName = `fk_${table.name}_${fk.field}`;
143
+ const isLast = i === fks.length - 1;
144
+
145
+ sql += ` CONSTRAINT ${escapeSqlIdentifier(constraintName)}\n`;
146
+ sql += ` FOREIGN KEY (${escapeSqlIdentifier(fk.field)})\n`;
147
+ sql += ` REFERENCES ${escapeSqlIdentifier(fk.refTable)} (${escapeSqlIdentifier(fk.refColumn)})\n`;
148
+ sql += ` ON DELETE ${fk.onDelete}`;
149
+ sql += isLast ? "\n" : ",\n";
150
+ });
151
+ } else {
152
+ sql += `\n`;
153
+ }
154
+
155
+ sql += `);\n\n`;
45
156
  }
46
157
 
47
- const outputPath = path.join(targetDir, "backend-project", "config", "database.sql");
158
+ const outputPath = path.join(
159
+ targetDir,
160
+ "backend-project",
161
+ "config",
162
+ "database.sql"
163
+ );
164
+
48
165
  await fs.outputFile(outputPath, sql);
49
166
  console.log(" [✓] database.sql");
50
167
  }
51
168
 
52
- module.exports = { generateDatabase };
169
+ module.exports = { generateDatabase };