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 +76 -2
- package/package.json +1 -1
- package/src/generators/database.js +129 -12
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
|
-
|
|
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
|
@@ -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
|
|
31
|
-
|
|
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
|
-
|
|
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(
|
|
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 };
|