@xubylele/schema-forge 1.5.1 → 1.6.0
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/README.md +92 -3
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +593 -123
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -171,12 +171,25 @@ Creates the necessary directory structure and configuration files.
|
|
|
171
171
|
Generate SQL migration from schema changes.
|
|
172
172
|
|
|
173
173
|
```bash
|
|
174
|
-
schema-forge generate [--name "migration description"]
|
|
174
|
+
schema-forge generate [--name "migration description"] [--safe] [--force]
|
|
175
175
|
```
|
|
176
176
|
|
|
177
177
|
**Options:**
|
|
178
178
|
|
|
179
179
|
- `--name` - Optional name for the migration (default: "migration")
|
|
180
|
+
- `--safe` - Block execution if destructive operations are detected (exits with error)
|
|
181
|
+
- `--force` - Bypass safety checks and proceed with destructive operations (shows warning)
|
|
182
|
+
|
|
183
|
+
**Safety Behavior:**
|
|
184
|
+
|
|
185
|
+
When destructive or risky operations are detected (like dropping columns or tables), SchemaForge will:
|
|
186
|
+
|
|
187
|
+
1. **Without flags** - Display an interactive prompt showing the risky operations and ask for confirmation (yes/no)
|
|
188
|
+
2. **With `--safe`** - Block execution immediately and exit with error code 1, listing all destructive operations
|
|
189
|
+
3. **With `--force`** - Bypass safety checks, show a warning message, and proceed with generating the migration
|
|
190
|
+
4. **In CI environment** (`CI=true`) - Skip the interactive prompt, fail with exit code 3 for destructive operations unless `--force` is used
|
|
191
|
+
|
|
192
|
+
See [CI Behavior](#ci-behavior) for more details.
|
|
180
193
|
|
|
181
194
|
Compares your current schema with the tracked state, generates SQL for any changes, and updates the state file.
|
|
182
195
|
|
|
@@ -185,10 +198,15 @@ Compares your current schema with the tracked state, generates SQL for any chang
|
|
|
185
198
|
Compare your schema with the current state without generating files.
|
|
186
199
|
|
|
187
200
|
```bash
|
|
188
|
-
schema-forge diff
|
|
201
|
+
schema-forge diff [--safe] [--force]
|
|
189
202
|
```
|
|
190
203
|
|
|
191
|
-
|
|
204
|
+
**Options:**
|
|
205
|
+
|
|
206
|
+
- `--safe` - Block execution if destructive operations are detected (exits with error)
|
|
207
|
+
- `--force` - Bypass safety checks and proceed with displaying destructive SQL (shows warning)
|
|
208
|
+
|
|
209
|
+
Shows what SQL would be generated if you ran `generate`. Useful for previewing changes. Safety behavior is the same as `generate` command. In CI environments, exits with code 3 if destructive operations are detected unless `--force` is used. See [CI Behavior](#ci-behavior) for more details.
|
|
192
210
|
|
|
193
211
|
### `schema-forge import`
|
|
194
212
|
|
|
@@ -229,11 +247,82 @@ Use JSON mode for CI and automation:
|
|
|
229
247
|
schema-forge validate --json
|
|
230
248
|
```
|
|
231
249
|
|
|
250
|
+
Exit codes (also see [CI Behavior](#ci-behavior)):
|
|
251
|
+
|
|
252
|
+
- `3` in CI environment if destructive findings detected
|
|
253
|
+
- `1` if one or more `error` findings are detected
|
|
254
|
+
- `0` if no `error` findings are detected (warnings alone do not fail)
|
|
255
|
+
|
|
232
256
|
Exit codes:
|
|
233
257
|
|
|
234
258
|
- `1` when one or more `error` findings are detected
|
|
235
259
|
- `0` when no `error` findings are detected (warnings alone do not fail)
|
|
236
260
|
|
|
261
|
+
## CI Behavior
|
|
262
|
+
|
|
263
|
+
SchemaForge ensures deterministic behavior in Continuous Integration (CI) environments to prevent accidental destructive operations.
|
|
264
|
+
|
|
265
|
+
### Detecting CI Environment
|
|
266
|
+
|
|
267
|
+
CI mode is automatically activated when either environment variable is set:
|
|
268
|
+
|
|
269
|
+
- `CI=true`
|
|
270
|
+
- `CONTINUOUS_INTEGRATION=true`
|
|
271
|
+
|
|
272
|
+
### Exit Codes
|
|
273
|
+
|
|
274
|
+
SchemaForge uses specific exit codes for different scenarios:
|
|
275
|
+
|
|
276
|
+
| Exit Code | Meaning |
|
|
277
|
+
| --------- | ------- |
|
|
278
|
+
| `0` | Success - no changes or no destructive operations detected |
|
|
279
|
+
| `1` | General error - validation failed, operation declined, missing files, etc. |
|
|
280
|
+
| `2` | Schema validation error - invalid DSL syntax or structure |
|
|
281
|
+
| `3` | **CI Destructive** - destructive operations detected in CI environment without `--force` |
|
|
282
|
+
|
|
283
|
+
### Destructive Operations in CI
|
|
284
|
+
|
|
285
|
+
When running in a CI environment, destructive operations (those flagged as `error` or `warning` level findings) trigger exit code 3:
|
|
286
|
+
|
|
287
|
+
**Operations classified as destructive:**
|
|
288
|
+
|
|
289
|
+
- Dropping tables (`DROP_TABLE`)
|
|
290
|
+
- Dropping columns (`DROP_COLUMN`)
|
|
291
|
+
- Changing column types in incompatible ways
|
|
292
|
+
- Making columns NOT NULL when they allow NULL
|
|
293
|
+
|
|
294
|
+
### Overriding in CI
|
|
295
|
+
|
|
296
|
+
To proceed with destructive operations in CI, use the `--force` flag:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
# This will fail with exit code 3 if destructive changes detected
|
|
300
|
+
schema-forge generate
|
|
301
|
+
|
|
302
|
+
# This will proceed despite destructive changes (requires explicit acknowledgment)
|
|
303
|
+
schema-forge generate --force
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### No Interactive Prompts in CI
|
|
307
|
+
|
|
308
|
+
When `CI=true`, SchemaForge will:
|
|
309
|
+
|
|
310
|
+
- ✅ Never show interactive prompts, preventing script hangs
|
|
311
|
+
- ✅ Fail deterministically (exit code 3) for destructive operations
|
|
312
|
+
- ✅ Allow explicit override with `--force` flag
|
|
313
|
+
- ❌ Not accept user input for confirmation
|
|
314
|
+
|
|
315
|
+
### Using `--safe` in CI
|
|
316
|
+
|
|
317
|
+
The `--safe` flag is compatible with CI and blocks execution of destructive operations:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
# Blocks execution if destructive operations detected, exits with code 1
|
|
321
|
+
schema-forge generate --safe
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
This is useful for strict CI pipelines where all destructive changes must be reviewed and merged separately.
|
|
325
|
+
|
|
237
326
|
## Constraint Change Detection
|
|
238
327
|
|
|
239
328
|
SchemaForge detects and generates migrations for:
|
package/dist/cli.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
export { }
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
"use strict";
|
|
3
2
|
var __create = Object.create;
|
|
4
3
|
var __defProp = Object.defineProperty;
|
|
@@ -45,20 +44,112 @@ function parseSchema(source) {
|
|
|
45
44
|
"timestamptz",
|
|
46
45
|
"date"
|
|
47
46
|
]);
|
|
48
|
-
|
|
47
|
+
const validIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
function normalizeColumnType4(type) {
|
|
49
49
|
return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
|
|
50
50
|
}
|
|
51
51
|
function isValidColumnType2(type) {
|
|
52
|
-
const normalizedType =
|
|
52
|
+
const normalizedType = normalizeColumnType4(type);
|
|
53
53
|
if (validBaseColumnTypes.has(normalizedType)) {
|
|
54
54
|
return true;
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
const varcharMatch = normalizedType.match(/^varchar\((\d+)\)$/);
|
|
57
|
+
if (varcharMatch) {
|
|
58
|
+
const length = Number(varcharMatch[1]);
|
|
59
|
+
return Number.isInteger(length) && length > 0;
|
|
60
|
+
}
|
|
61
|
+
const numericMatch = normalizedType.match(/^numeric\((\d+),(\d+)\)$/);
|
|
62
|
+
if (numericMatch) {
|
|
63
|
+
const precision = Number(numericMatch[1]);
|
|
64
|
+
const scale = Number(numericMatch[2]);
|
|
65
|
+
return Number.isInteger(precision) && Number.isInteger(scale) && precision > 0 && scale >= 0 && scale <= precision;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
function validateIdentifier(identifier, lineNum, context) {
|
|
70
|
+
if (!validIdentifierPattern.test(identifier)) {
|
|
71
|
+
throw new Error(`Line ${lineNum}: Invalid ${context} name '${identifier}'. Use letters, numbers, and underscores, and do not start with a number.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function validateDefaultValue(value, lineNum) {
|
|
75
|
+
let parenBalance = 0;
|
|
76
|
+
let inSingleQuote = false;
|
|
77
|
+
let inDoubleQuote = false;
|
|
78
|
+
for (let index = 0; index < value.length; index++) {
|
|
79
|
+
const char = value[index];
|
|
80
|
+
if (char === "'" && !inDoubleQuote) {
|
|
81
|
+
if (inSingleQuote && value[index + 1] === "'") {
|
|
82
|
+
index++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
inSingleQuote = !inSingleQuote;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (char === '"' && !inSingleQuote) {
|
|
89
|
+
if (inDoubleQuote && value[index + 1] === '"') {
|
|
90
|
+
index++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
inDoubleQuote = !inDoubleQuote;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === "(") {
|
|
100
|
+
parenBalance++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (char === ")") {
|
|
104
|
+
parenBalance--;
|
|
105
|
+
if (parenBalance < 0) {
|
|
106
|
+
throw new Error(`Line ${lineNum}: Invalid default value '${value}'. Unmatched parentheses in function call.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
111
|
+
throw new Error(`Line ${lineNum}: Invalid default value '${value}'. Unterminated quoted string.`);
|
|
112
|
+
}
|
|
113
|
+
if (parenBalance > 0) {
|
|
114
|
+
if (parenBalance === 1) {
|
|
115
|
+
throw new Error(`Line ${lineNum}: Invalid default value '${value}'. Function call is missing closing parenthesis.`);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Line ${lineNum}: Invalid default value '${value}'. Unmatched parentheses in function call.`);
|
|
118
|
+
}
|
|
57
119
|
}
|
|
58
120
|
function cleanLine(line) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
121
|
+
let inSingleQuote = false;
|
|
122
|
+
let inDoubleQuote = false;
|
|
123
|
+
for (let index = 0; index < line.length; index++) {
|
|
124
|
+
const char = line[index];
|
|
125
|
+
const nextChar = line[index + 1];
|
|
126
|
+
if (char === "'" && !inDoubleQuote) {
|
|
127
|
+
if (inSingleQuote && nextChar === "'") {
|
|
128
|
+
index++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
inSingleQuote = !inSingleQuote;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (char === '"' && !inSingleQuote) {
|
|
135
|
+
if (inDoubleQuote && nextChar === '"') {
|
|
136
|
+
index++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
inDoubleQuote = !inDoubleQuote;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (char === "#") {
|
|
146
|
+
line = line.substring(0, index);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (char === "/" && nextChar === "/") {
|
|
150
|
+
line = line.substring(0, index);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
62
153
|
}
|
|
63
154
|
return line.trim();
|
|
64
155
|
}
|
|
@@ -67,6 +158,8 @@ function parseSchema(source) {
|
|
|
67
158
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
68
159
|
throw new Error(`Line ${lineNum}: Invalid foreign key format '${fkRef}'. Expected format: table.column`);
|
|
69
160
|
}
|
|
161
|
+
validateIdentifier(parts[0], lineNum, "foreign key table");
|
|
162
|
+
validateIdentifier(parts[1], lineNum, "foreign key column");
|
|
70
163
|
return {
|
|
71
164
|
table: parts[0],
|
|
72
165
|
column: parts[1]
|
|
@@ -75,11 +168,13 @@ function parseSchema(source) {
|
|
|
75
168
|
function parseColumn(line, lineNum) {
|
|
76
169
|
const tokens = line.split(/\s+/).filter((t) => t.length > 0);
|
|
77
170
|
const modifiers = /* @__PURE__ */ new Set(["pk", "unique", "nullable", "default", "fk"]);
|
|
171
|
+
const appliedModifiers = /* @__PURE__ */ new Set();
|
|
78
172
|
if (tokens.length < 2) {
|
|
79
173
|
throw new Error(`Line ${lineNum}: Invalid column definition. Expected: <name> <type> [modifiers...]`);
|
|
80
174
|
}
|
|
81
175
|
const colName = tokens[0];
|
|
82
|
-
const colType =
|
|
176
|
+
const colType = normalizeColumnType4(tokens[1]);
|
|
177
|
+
validateIdentifier(colName, lineNum, "column");
|
|
83
178
|
if (!isValidColumnType2(colType)) {
|
|
84
179
|
throw new Error(`Line ${lineNum}: Invalid column type '${tokens[1]}'. Valid types: ${Array.from(validBaseColumnTypes).join(", ")}, varchar(n), numeric(p,s)`);
|
|
85
180
|
}
|
|
@@ -91,16 +186,28 @@ function parseSchema(source) {
|
|
|
91
186
|
let i = 2;
|
|
92
187
|
while (i < tokens.length) {
|
|
93
188
|
const modifier = tokens[i];
|
|
189
|
+
const markModifierApplied = (name) => {
|
|
190
|
+
if (appliedModifiers.has(name)) {
|
|
191
|
+
throw new Error(`Line ${lineNum}: Duplicate modifier '${name}'`);
|
|
192
|
+
}
|
|
193
|
+
appliedModifiers.add(name);
|
|
194
|
+
};
|
|
94
195
|
switch (modifier) {
|
|
95
196
|
case "pk":
|
|
197
|
+
markModifierApplied("pk");
|
|
96
198
|
column.primaryKey = true;
|
|
97
199
|
i++;
|
|
98
200
|
break;
|
|
99
201
|
case "unique":
|
|
202
|
+
markModifierApplied("unique");
|
|
100
203
|
column.unique = true;
|
|
101
204
|
i++;
|
|
102
205
|
break;
|
|
103
206
|
case "nullable":
|
|
207
|
+
if (appliedModifiers.has("not null")) {
|
|
208
|
+
throw new Error(`Line ${lineNum}: Cannot combine 'nullable' with 'not null'`);
|
|
209
|
+
}
|
|
210
|
+
markModifierApplied("nullable");
|
|
104
211
|
column.nullable = true;
|
|
105
212
|
i++;
|
|
106
213
|
break;
|
|
@@ -108,27 +215,35 @@ function parseSchema(source) {
|
|
|
108
215
|
if (tokens[i + 1] !== "null") {
|
|
109
216
|
throw new Error(`Line ${lineNum}: Unknown modifier 'not'`);
|
|
110
217
|
}
|
|
218
|
+
if (appliedModifiers.has("nullable")) {
|
|
219
|
+
throw new Error(`Line ${lineNum}: Cannot combine 'not null' with 'nullable'`);
|
|
220
|
+
}
|
|
221
|
+
markModifierApplied("not null");
|
|
111
222
|
column.nullable = false;
|
|
112
223
|
i += 2;
|
|
113
224
|
break;
|
|
114
225
|
case "default":
|
|
226
|
+
markModifierApplied("default");
|
|
115
227
|
i++;
|
|
116
228
|
if (i >= tokens.length) {
|
|
117
229
|
throw new Error(`Line ${lineNum}: 'default' modifier requires a value`);
|
|
118
230
|
}
|
|
119
231
|
{
|
|
120
232
|
const defaultTokens = [];
|
|
121
|
-
while (i < tokens.length && !modifiers.has(tokens[i])) {
|
|
233
|
+
while (i < tokens.length && !modifiers.has(tokens[i]) && !(tokens[i] === "not" && tokens[i + 1] === "null")) {
|
|
122
234
|
defaultTokens.push(tokens[i]);
|
|
123
235
|
i++;
|
|
124
236
|
}
|
|
125
237
|
if (defaultTokens.length === 0) {
|
|
126
238
|
throw new Error(`Line ${lineNum}: 'default' modifier requires a value`);
|
|
127
239
|
}
|
|
128
|
-
|
|
240
|
+
const defaultValue = defaultTokens.join(" ");
|
|
241
|
+
validateDefaultValue(defaultValue, lineNum);
|
|
242
|
+
column.default = defaultValue;
|
|
129
243
|
}
|
|
130
244
|
break;
|
|
131
245
|
case "fk":
|
|
246
|
+
markModifierApplied("fk");
|
|
132
247
|
i++;
|
|
133
248
|
if (i >= tokens.length) {
|
|
134
249
|
throw new Error(`Line ${lineNum}: 'fk' modifier requires a table.column reference`);
|
|
@@ -144,11 +259,12 @@ function parseSchema(source) {
|
|
|
144
259
|
}
|
|
145
260
|
function parseTableBlock(startLine) {
|
|
146
261
|
const firstLine = cleanLine(lines[startLine]);
|
|
147
|
-
const match = firstLine.match(/^table\s+(\w+)\s*\{
|
|
262
|
+
const match = firstLine.match(/^table\s+(\w+)\s*\{\s*$/);
|
|
148
263
|
if (!match) {
|
|
149
264
|
throw new Error(`Line ${startLine + 1}: Invalid table definition. Expected: table <name> {`);
|
|
150
265
|
}
|
|
151
266
|
const tableName = match[1];
|
|
267
|
+
validateIdentifier(tableName, startLine + 1, "table");
|
|
152
268
|
if (tables[tableName]) {
|
|
153
269
|
throw new Error(`Line ${startLine + 1}: Duplicate table definition '${tableName}'`);
|
|
154
270
|
}
|
|
@@ -660,7 +776,7 @@ var init_validator = __esm({
|
|
|
660
776
|
}
|
|
661
777
|
});
|
|
662
778
|
|
|
663
|
-
// node_modules/@xubylele/schema-forge-core/dist/core/
|
|
779
|
+
// node_modules/@xubylele/schema-forge-core/dist/core/safety/operation-classifier.js
|
|
664
780
|
function normalizeColumnType2(type) {
|
|
665
781
|
return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
|
|
666
782
|
}
|
|
@@ -683,119 +799,246 @@ function classifyTypeChange(from, to) {
|
|
|
683
799
|
const toType = normalizeColumnType2(to);
|
|
684
800
|
const uuidInvolved = fromType === "uuid" || toType === "uuid";
|
|
685
801
|
if (uuidInvolved && fromType !== toType) {
|
|
686
|
-
return
|
|
687
|
-
severity: "error",
|
|
688
|
-
message: `Type changed from ${fromType} to ${toType} (likely incompatible cast)`
|
|
689
|
-
};
|
|
802
|
+
return "DESTRUCTIVE";
|
|
690
803
|
}
|
|
691
804
|
if (fromType === "int" && toType === "bigint") {
|
|
692
|
-
return
|
|
693
|
-
severity: "warning",
|
|
694
|
-
message: "Type widened from int to bigint"
|
|
695
|
-
};
|
|
805
|
+
return "WARNING";
|
|
696
806
|
}
|
|
697
807
|
if (fromType === "bigint" && toType === "int") {
|
|
698
|
-
return
|
|
699
|
-
severity: "error",
|
|
700
|
-
message: "Type narrowed from bigint to int (likely incompatible cast)"
|
|
701
|
-
};
|
|
808
|
+
return "DESTRUCTIVE";
|
|
702
809
|
}
|
|
703
810
|
if (fromType === "text" && parseVarcharLength(toType) !== null) {
|
|
704
|
-
return
|
|
705
|
-
severity: "error",
|
|
706
|
-
message: `Type changed from text to ${toType} (may truncate existing values)`
|
|
707
|
-
};
|
|
811
|
+
return "DESTRUCTIVE";
|
|
708
812
|
}
|
|
709
813
|
if (parseVarcharLength(fromType) !== null && toType === "text") {
|
|
710
|
-
return
|
|
711
|
-
severity: "warning",
|
|
712
|
-
message: "Type widened from varchar(n) to text"
|
|
713
|
-
};
|
|
814
|
+
return "WARNING";
|
|
714
815
|
}
|
|
715
816
|
const fromVarcharLength = parseVarcharLength(fromType);
|
|
716
817
|
const toVarcharLength = parseVarcharLength(toType);
|
|
717
818
|
if (fromVarcharLength !== null && toVarcharLength !== null) {
|
|
718
819
|
if (toVarcharLength >= fromVarcharLength) {
|
|
719
|
-
return
|
|
720
|
-
severity: "warning",
|
|
721
|
-
message: `Type widened from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`
|
|
722
|
-
};
|
|
820
|
+
return "WARNING";
|
|
723
821
|
}
|
|
724
|
-
return
|
|
725
|
-
severity: "error",
|
|
726
|
-
message: `Type narrowed from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`
|
|
727
|
-
};
|
|
822
|
+
return "DESTRUCTIVE";
|
|
728
823
|
}
|
|
729
824
|
const fromNumeric = parseNumericType(fromType);
|
|
730
825
|
const toNumeric = parseNumericType(toType);
|
|
731
826
|
if (fromNumeric && toNumeric && fromNumeric.scale === toNumeric.scale) {
|
|
732
827
|
if (toNumeric.precision >= fromNumeric.precision) {
|
|
733
|
-
return
|
|
734
|
-
severity: "warning",
|
|
735
|
-
message: `Type widened from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`
|
|
736
|
-
};
|
|
828
|
+
return "WARNING";
|
|
737
829
|
}
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
830
|
+
return "DESTRUCTIVE";
|
|
831
|
+
}
|
|
832
|
+
return "WARNING";
|
|
833
|
+
}
|
|
834
|
+
function classifyOperation(operation) {
|
|
835
|
+
switch (operation.kind) {
|
|
836
|
+
case "create_table":
|
|
837
|
+
return "SAFE";
|
|
838
|
+
case "add_column":
|
|
839
|
+
return "SAFE";
|
|
840
|
+
case "drop_table":
|
|
841
|
+
return "DESTRUCTIVE";
|
|
842
|
+
case "drop_column":
|
|
843
|
+
return "DESTRUCTIVE";
|
|
844
|
+
case "column_type_changed":
|
|
845
|
+
return classifyTypeChange(operation.fromType, operation.toType);
|
|
846
|
+
case "column_nullability_changed":
|
|
847
|
+
if (operation.from && !operation.to) {
|
|
848
|
+
return "WARNING";
|
|
849
|
+
}
|
|
850
|
+
return "SAFE";
|
|
851
|
+
case "column_default_changed":
|
|
852
|
+
return "SAFE";
|
|
853
|
+
case "column_unique_changed":
|
|
854
|
+
return "SAFE";
|
|
855
|
+
case "drop_primary_key_constraint":
|
|
856
|
+
return "DESTRUCTIVE";
|
|
857
|
+
case "add_primary_key_constraint":
|
|
858
|
+
return "SAFE";
|
|
859
|
+
default:
|
|
860
|
+
const _exhaustive = operation;
|
|
861
|
+
return _exhaustive;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
var init_operation_classifier = __esm({
|
|
865
|
+
"node_modules/@xubylele/schema-forge-core/dist/core/safety/operation-classifier.js"() {
|
|
866
|
+
"use strict";
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// node_modules/@xubylele/schema-forge-core/dist/core/safety/safety-checker.js
|
|
871
|
+
function normalizeColumnType3(type) {
|
|
872
|
+
return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
|
|
873
|
+
}
|
|
874
|
+
function parseVarcharLength2(type) {
|
|
875
|
+
const match = normalizeColumnType3(type).match(/^varchar\((\d+)\)$/);
|
|
876
|
+
return match ? Number(match[1]) : null;
|
|
877
|
+
}
|
|
878
|
+
function parseNumericType2(type) {
|
|
879
|
+
const match = normalizeColumnType3(type).match(/^numeric\((\d+),(\d+)\)$/);
|
|
880
|
+
if (!match) {
|
|
881
|
+
return null;
|
|
742
882
|
}
|
|
743
883
|
return {
|
|
744
|
-
|
|
745
|
-
|
|
884
|
+
precision: Number(match[1]),
|
|
885
|
+
scale: Number(match[2])
|
|
746
886
|
};
|
|
747
887
|
}
|
|
748
|
-
function
|
|
749
|
-
const
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
888
|
+
function generateTypeChangeMessage(from, to) {
|
|
889
|
+
const fromType = normalizeColumnType3(from);
|
|
890
|
+
const toType = normalizeColumnType3(to);
|
|
891
|
+
const uuidInvolved = fromType === "uuid" || toType === "uuid";
|
|
892
|
+
if (uuidInvolved && fromType !== toType) {
|
|
893
|
+
return `Type changed from ${fromType} to ${toType} (likely incompatible cast)`;
|
|
894
|
+
}
|
|
895
|
+
if (fromType === "int" && toType === "bigint") {
|
|
896
|
+
return "Type widened from int to bigint";
|
|
897
|
+
}
|
|
898
|
+
if (fromType === "bigint" && toType === "int") {
|
|
899
|
+
return "Type narrowed from bigint to int (likely incompatible cast)";
|
|
900
|
+
}
|
|
901
|
+
if (fromType === "text" && parseVarcharLength2(toType) !== null) {
|
|
902
|
+
return `Type changed from text to ${toType} (may truncate existing values)`;
|
|
903
|
+
}
|
|
904
|
+
if (parseVarcharLength2(fromType) !== null && toType === "text") {
|
|
905
|
+
const length = parseVarcharLength2(fromType);
|
|
906
|
+
return `Type widened from varchar(${length}) to text`;
|
|
907
|
+
}
|
|
908
|
+
const fromVarcharLength = parseVarcharLength2(fromType);
|
|
909
|
+
const toVarcharLength = parseVarcharLength2(toType);
|
|
910
|
+
if (fromVarcharLength !== null && toVarcharLength !== null) {
|
|
911
|
+
if (toVarcharLength >= fromVarcharLength) {
|
|
912
|
+
return `Type widened from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`;
|
|
913
|
+
}
|
|
914
|
+
return `Type narrowed from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`;
|
|
915
|
+
}
|
|
916
|
+
const fromNumeric = parseNumericType2(fromType);
|
|
917
|
+
const toNumeric = parseNumericType2(toType);
|
|
918
|
+
if (fromNumeric && toNumeric && fromNumeric.scale === toNumeric.scale) {
|
|
919
|
+
if (toNumeric.precision >= fromNumeric.precision) {
|
|
920
|
+
return `Type widened from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`;
|
|
921
|
+
}
|
|
922
|
+
return `Type narrowed from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`;
|
|
923
|
+
}
|
|
924
|
+
return `Type changed from ${fromType} to ${toType} (compatibility unknown)`;
|
|
925
|
+
}
|
|
926
|
+
function checkOperationSafety(operation) {
|
|
927
|
+
const safetyLevel = classifyOperation(operation);
|
|
928
|
+
if (safetyLevel === "SAFE") {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
switch (operation.kind) {
|
|
932
|
+
case "drop_table":
|
|
933
|
+
return {
|
|
934
|
+
safetyLevel,
|
|
935
|
+
code: "DROP_TABLE",
|
|
936
|
+
table: operation.tableName,
|
|
937
|
+
message: "Table removed",
|
|
938
|
+
operationKind: operation.kind
|
|
939
|
+
};
|
|
940
|
+
case "drop_column":
|
|
941
|
+
return {
|
|
942
|
+
safetyLevel,
|
|
943
|
+
code: "DROP_COLUMN",
|
|
944
|
+
table: operation.tableName,
|
|
945
|
+
column: operation.columnName,
|
|
946
|
+
message: "Column removed",
|
|
947
|
+
operationKind: operation.kind
|
|
948
|
+
};
|
|
949
|
+
case "column_type_changed":
|
|
950
|
+
return {
|
|
951
|
+
safetyLevel,
|
|
952
|
+
code: "ALTER_COLUMN_TYPE",
|
|
953
|
+
table: operation.tableName,
|
|
954
|
+
column: operation.columnName,
|
|
955
|
+
from: normalizeColumnType3(operation.fromType),
|
|
956
|
+
to: normalizeColumnType3(operation.toType),
|
|
957
|
+
message: generateTypeChangeMessage(operation.fromType, operation.toType),
|
|
958
|
+
operationKind: operation.kind
|
|
959
|
+
};
|
|
960
|
+
case "column_nullability_changed":
|
|
961
|
+
if (operation.from && !operation.to) {
|
|
962
|
+
return {
|
|
963
|
+
safetyLevel,
|
|
964
|
+
code: "SET_NOT_NULL",
|
|
775
965
|
table: operation.tableName,
|
|
776
966
|
column: operation.columnName,
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
});
|
|
781
|
-
break;
|
|
967
|
+
message: "Column changed to NOT NULL (may fail if data contains NULLs)",
|
|
968
|
+
operationKind: operation.kind
|
|
969
|
+
};
|
|
782
970
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
971
|
+
return null;
|
|
972
|
+
case "drop_primary_key_constraint":
|
|
973
|
+
return {
|
|
974
|
+
safetyLevel,
|
|
975
|
+
code: "DROP_TABLE",
|
|
976
|
+
// Reuse code for primary key drops
|
|
977
|
+
table: operation.tableName,
|
|
978
|
+
message: "Primary key constraint removed",
|
|
979
|
+
operationKind: operation.kind
|
|
980
|
+
};
|
|
981
|
+
default:
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function checkSchemaSafety(previousState, currentSchema) {
|
|
986
|
+
const findings = [];
|
|
987
|
+
const diff = diffSchemas(previousState, currentSchema);
|
|
988
|
+
for (const operation of diff.operations) {
|
|
989
|
+
const finding = checkOperationSafety(operation);
|
|
990
|
+
if (finding) {
|
|
991
|
+
findings.push(finding);
|
|
796
992
|
}
|
|
797
993
|
}
|
|
798
|
-
|
|
994
|
+
const hasWarnings = findings.some((f) => f.safetyLevel === "WARNING");
|
|
995
|
+
const hasDestructiveOps = findings.some((f) => f.safetyLevel === "DESTRUCTIVE");
|
|
996
|
+
return {
|
|
997
|
+
findings,
|
|
998
|
+
hasSafeIssues: false,
|
|
999
|
+
// All findings are non-safe by definition
|
|
1000
|
+
hasWarnings,
|
|
1001
|
+
hasDestructiveOps
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
var init_safety_checker = __esm({
|
|
1005
|
+
"node_modules/@xubylele/schema-forge-core/dist/core/safety/safety-checker.js"() {
|
|
1006
|
+
"use strict";
|
|
1007
|
+
init_diff();
|
|
1008
|
+
init_operation_classifier();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// node_modules/@xubylele/schema-forge-core/dist/core/safety/index.js
|
|
1013
|
+
var init_safety = __esm({
|
|
1014
|
+
"node_modules/@xubylele/schema-forge-core/dist/core/safety/index.js"() {
|
|
1015
|
+
"use strict";
|
|
1016
|
+
init_operation_classifier();
|
|
1017
|
+
init_safety_checker();
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// node_modules/@xubylele/schema-forge-core/dist/core/validate.js
|
|
1022
|
+
function safetyLevelToSeverity(level) {
|
|
1023
|
+
if (level === "DESTRUCTIVE") {
|
|
1024
|
+
return "error";
|
|
1025
|
+
}
|
|
1026
|
+
return "warning";
|
|
1027
|
+
}
|
|
1028
|
+
function adaptSafetyFinding(finding) {
|
|
1029
|
+
return {
|
|
1030
|
+
severity: safetyLevelToSeverity(finding.safetyLevel),
|
|
1031
|
+
code: finding.code,
|
|
1032
|
+
table: finding.table,
|
|
1033
|
+
column: finding.column,
|
|
1034
|
+
from: finding.from,
|
|
1035
|
+
to: finding.to,
|
|
1036
|
+
message: finding.message
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function validateSchemaChanges(previousState, currentSchema) {
|
|
1040
|
+
const safetyReport = checkSchemaSafety(previousState, currentSchema);
|
|
1041
|
+
return safetyReport.findings.map(adaptSafetyFinding);
|
|
799
1042
|
}
|
|
800
1043
|
function toValidationReport(findings) {
|
|
801
1044
|
const errors = findings.filter((finding) => finding.severity === "error");
|
|
@@ -810,7 +1053,7 @@ function toValidationReport(findings) {
|
|
|
810
1053
|
var init_validate = __esm({
|
|
811
1054
|
"node_modules/@xubylele/schema-forge-core/dist/core/validate.js"() {
|
|
812
1055
|
"use strict";
|
|
813
|
-
|
|
1056
|
+
init_safety();
|
|
814
1057
|
}
|
|
815
1058
|
});
|
|
816
1059
|
|
|
@@ -1976,6 +2219,9 @@ var dist_exports = {};
|
|
|
1976
2219
|
__export(dist_exports, {
|
|
1977
2220
|
SchemaValidationError: () => SchemaValidationError,
|
|
1978
2221
|
applySqlOps: () => applySqlOps,
|
|
2222
|
+
checkOperationSafety: () => checkOperationSafety,
|
|
2223
|
+
checkSchemaSafety: () => checkSchemaSafety,
|
|
2224
|
+
classifyOperation: () => classifyOperation,
|
|
1979
2225
|
diffSchemas: () => diffSchemas,
|
|
1980
2226
|
ensureDir: () => ensureDir2,
|
|
1981
2227
|
fileExists: () => fileExists2,
|
|
@@ -2029,6 +2275,7 @@ var init_dist = __esm({
|
|
|
2029
2275
|
init_diff();
|
|
2030
2276
|
init_validator();
|
|
2031
2277
|
init_validate();
|
|
2278
|
+
init_safety();
|
|
2032
2279
|
init_state_manager();
|
|
2033
2280
|
init_sql_generator();
|
|
2034
2281
|
init_parse_migration();
|
|
@@ -2050,7 +2297,7 @@ var import_commander6 = require("commander");
|
|
|
2050
2297
|
// package.json
|
|
2051
2298
|
var package_default = {
|
|
2052
2299
|
name: "@xubylele/schema-forge",
|
|
2053
|
-
version: "1.
|
|
2300
|
+
version: "1.6.0",
|
|
2054
2301
|
description: "Universal migration generator from schema DSL",
|
|
2055
2302
|
main: "dist/cli.js",
|
|
2056
2303
|
type: "commonjs",
|
|
@@ -2097,7 +2344,7 @@ var package_default = {
|
|
|
2097
2344
|
devDependencies: {
|
|
2098
2345
|
"@changesets/cli": "^2.29.8",
|
|
2099
2346
|
"@types/node": "^25.2.3",
|
|
2100
|
-
"@xubylele/schema-forge-core": "^1.0
|
|
2347
|
+
"@xubylele/schema-forge-core": "^1.2.0",
|
|
2101
2348
|
"ts-node": "^10.9.2",
|
|
2102
2349
|
tsup: "^8.5.1",
|
|
2103
2350
|
typescript: "^5.9.3",
|
|
@@ -2265,6 +2512,18 @@ async function isSchemaValidationError(error2) {
|
|
|
2265
2512
|
return error2 instanceof core.SchemaValidationError;
|
|
2266
2513
|
}
|
|
2267
2514
|
|
|
2515
|
+
// src/utils/exitCodes.ts
|
|
2516
|
+
var EXIT_CODES = {
|
|
2517
|
+
/** Successful operation */
|
|
2518
|
+
SUCCESS: 0,
|
|
2519
|
+
/** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
|
|
2520
|
+
VALIDATION_ERROR: 1,
|
|
2521
|
+
/** Drift detected - Reserved for future use when comparing actual DB state vs schema */
|
|
2522
|
+
DRIFT_DETECTED: 2,
|
|
2523
|
+
/** Destructive operation detected in CI environment without --force */
|
|
2524
|
+
CI_DESTRUCTIVE: 3
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2268
2527
|
// src/utils/output.ts
|
|
2269
2528
|
var import_boxen = __toESM(require("boxen"));
|
|
2270
2529
|
var import_chalk = require("chalk");
|
|
@@ -2305,13 +2564,96 @@ function warning(message) {
|
|
|
2305
2564
|
function error(message) {
|
|
2306
2565
|
console.error(theme.error(`[ERROR] ${message}`));
|
|
2307
2566
|
}
|
|
2567
|
+
function forceWarning(message) {
|
|
2568
|
+
console.error(theme.warning(`[FORCE] ${message}`));
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// src/utils/prompt.ts
|
|
2572
|
+
var import_node_readline = __toESM(require("readline"));
|
|
2573
|
+
function isCI() {
|
|
2574
|
+
return process.env.CI === "true" || process.env.CONTINUOUS_INTEGRATION === "true";
|
|
2575
|
+
}
|
|
2576
|
+
function formatFindingsSummary(findings) {
|
|
2577
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
2578
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
2579
|
+
const lines = [];
|
|
2580
|
+
if (errors.length > 0) {
|
|
2581
|
+
lines.push(theme.error("DESTRUCTIVE OPERATIONS:"));
|
|
2582
|
+
for (const finding of errors) {
|
|
2583
|
+
const columnPart = finding.column ? `.${finding.column}` : "";
|
|
2584
|
+
const fromTo = finding.from && finding.to ? ` (${finding.from} \u2192 ${finding.to})` : "";
|
|
2585
|
+
lines.push(theme.error(` \u2022 ${finding.code}: ${finding.table}${columnPart}${fromTo}`));
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
if (warnings.length > 0) {
|
|
2589
|
+
if (lines.length > 0) lines.push("");
|
|
2590
|
+
lines.push(theme.warning("WARNING OPERATIONS:"));
|
|
2591
|
+
for (const finding of warnings) {
|
|
2592
|
+
const columnPart = finding.column ? `.${finding.column}` : "";
|
|
2593
|
+
const fromTo = finding.from && finding.to ? ` (${finding.from} \u2192 ${finding.to})` : "";
|
|
2594
|
+
lines.push(theme.warning(` \u2022 ${finding.code}: ${finding.table}${columnPart}${fromTo}`));
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
return lines.join("\n");
|
|
2598
|
+
}
|
|
2599
|
+
async function readConfirmation(input = process.stdin, output = process.stdout) {
|
|
2600
|
+
const rl = import_node_readline.default.createInterface({
|
|
2601
|
+
input,
|
|
2602
|
+
output
|
|
2603
|
+
});
|
|
2604
|
+
return new Promise((resolve) => {
|
|
2605
|
+
const askQuestion = () => {
|
|
2606
|
+
rl.question(theme.primary("Proceed with these changes? (yes/no): "), (answer) => {
|
|
2607
|
+
const normalized = answer.trim().toLowerCase();
|
|
2608
|
+
if (normalized === "yes" || normalized === "y") {
|
|
2609
|
+
rl.close();
|
|
2610
|
+
resolve(true);
|
|
2611
|
+
} else if (normalized === "no" || normalized === "n") {
|
|
2612
|
+
rl.close();
|
|
2613
|
+
resolve(false);
|
|
2614
|
+
} else {
|
|
2615
|
+
console.log(theme.warning('Please answer "yes" or "no".'));
|
|
2616
|
+
askQuestion();
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
};
|
|
2620
|
+
askQuestion();
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
async function confirmDestructiveOps(findings, input, output) {
|
|
2624
|
+
const riskyFindings = findings.filter(
|
|
2625
|
+
(f) => f.severity === "error" || f.severity === "warning"
|
|
2626
|
+
);
|
|
2627
|
+
if (riskyFindings.length === 0) {
|
|
2628
|
+
return true;
|
|
2629
|
+
}
|
|
2630
|
+
if (isCI()) {
|
|
2631
|
+
error("Cannot run interactive prompts in CI environment. Use --force flag to bypass safety checks.");
|
|
2632
|
+
process.exitCode = EXIT_CODES.CI_DESTRUCTIVE;
|
|
2633
|
+
return false;
|
|
2634
|
+
}
|
|
2635
|
+
console.log("");
|
|
2636
|
+
console.log(formatFindingsSummary(riskyFindings));
|
|
2637
|
+
console.log("");
|
|
2638
|
+
const confirmed = await readConfirmation(input, output);
|
|
2639
|
+
if (!confirmed) {
|
|
2640
|
+
warning("Operation cancelled by user.");
|
|
2641
|
+
}
|
|
2642
|
+
return confirmed;
|
|
2643
|
+
}
|
|
2644
|
+
function hasDestructiveFindings(findings) {
|
|
2645
|
+
return findings.some((f) => f.severity === "error" || f.severity === "warning");
|
|
2646
|
+
}
|
|
2308
2647
|
|
|
2309
2648
|
// src/commands/diff.ts
|
|
2310
2649
|
var REQUIRED_CONFIG_FIELDS = ["schemaFile", "stateFile"];
|
|
2311
2650
|
function resolveConfigPath(root, targetPath) {
|
|
2312
2651
|
return import_path7.default.isAbsolute(targetPath) ? targetPath : import_path7.default.join(root, targetPath);
|
|
2313
2652
|
}
|
|
2314
|
-
async function runDiff() {
|
|
2653
|
+
async function runDiff(options = {}) {
|
|
2654
|
+
if (options.safe && options.force) {
|
|
2655
|
+
throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
|
|
2656
|
+
}
|
|
2315
2657
|
const root = getProjectRoot();
|
|
2316
2658
|
const configPath = getConfigPath(root);
|
|
2317
2659
|
if (!await fileExists(configPath)) {
|
|
@@ -2339,12 +2681,47 @@ async function runDiff() {
|
|
|
2339
2681
|
}
|
|
2340
2682
|
const previousState = await loadState2(statePath);
|
|
2341
2683
|
const diff = await diffSchemas2(previousState, schema);
|
|
2684
|
+
if (options.force) {
|
|
2685
|
+
forceWarning("Are you sure to use --force? This option will bypass safety checks for destructive operations.");
|
|
2686
|
+
}
|
|
2687
|
+
if (options.safe && !options.force && diff.operations.length > 0) {
|
|
2688
|
+
const findings = await validateSchemaChanges2(previousState, schema);
|
|
2689
|
+
const destructiveFindings = findings.filter((f) => f.severity === "error");
|
|
2690
|
+
if (destructiveFindings.length > 0) {
|
|
2691
|
+
const errorMessages = destructiveFindings.map((f) => {
|
|
2692
|
+
const target = f.column ? `${f.table}.${f.column}` : f.table;
|
|
2693
|
+
const typeRange = f.from && f.to ? ` (${f.from} -> ${f.to})` : "";
|
|
2694
|
+
return ` - ${f.code}: ${target}${typeRange}`;
|
|
2695
|
+
}).join("\n");
|
|
2696
|
+
throw await createSchemaValidationError(
|
|
2697
|
+
`Cannot proceed with --safe flag: Found ${destructiveFindings.length} destructive operation(s):
|
|
2698
|
+
${errorMessages}
|
|
2699
|
+
|
|
2700
|
+
Remove --safe flag or modify schema to avoid destructive changes.`
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (!options.safe && !options.force && diff.operations.length > 0) {
|
|
2705
|
+
const findings = await validateSchemaChanges2(previousState, schema);
|
|
2706
|
+
const riskyFindings = findings.filter((f) => f.severity === "error" || f.severity === "warning");
|
|
2707
|
+
if (riskyFindings.length > 0) {
|
|
2708
|
+
const confirmed = await confirmDestructiveOps(findings);
|
|
2709
|
+
if (!confirmed) {
|
|
2710
|
+
if (process.exitCode !== EXIT_CODES.CI_DESTRUCTIVE) {
|
|
2711
|
+
process.exitCode = EXIT_CODES.VALIDATION_ERROR;
|
|
2712
|
+
}
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2342
2717
|
if (diff.operations.length === 0) {
|
|
2343
2718
|
success("No changes detected");
|
|
2719
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2344
2720
|
return;
|
|
2345
2721
|
}
|
|
2346
2722
|
const sql = await generateSql2(diff, provider, config.sql);
|
|
2347
2723
|
console.log(sql);
|
|
2724
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2348
2725
|
}
|
|
2349
2726
|
|
|
2350
2727
|
// src/commands/generate.ts
|
|
@@ -2371,6 +2748,9 @@ function resolveConfigPath2(root, targetPath) {
|
|
|
2371
2748
|
return import_path8.default.isAbsolute(targetPath) ? targetPath : import_path8.default.join(root, targetPath);
|
|
2372
2749
|
}
|
|
2373
2750
|
async function runGenerate(options) {
|
|
2751
|
+
if (options.safe && options.force) {
|
|
2752
|
+
throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
|
|
2753
|
+
}
|
|
2374
2754
|
const root = getProjectRoot();
|
|
2375
2755
|
const configPath = getConfigPath(root);
|
|
2376
2756
|
if (!await fileExists(configPath)) {
|
|
@@ -2403,8 +2783,42 @@ async function runGenerate(options) {
|
|
|
2403
2783
|
}
|
|
2404
2784
|
const previousState = await loadState2(statePath);
|
|
2405
2785
|
const diff = await diffSchemas2(previousState, schema);
|
|
2786
|
+
if (options.force) {
|
|
2787
|
+
forceWarning("Are you sure to use --force? This option will bypass safety checks for destructive operations.");
|
|
2788
|
+
}
|
|
2789
|
+
if (options.safe && !options.force && diff.operations.length > 0) {
|
|
2790
|
+
const findings = await validateSchemaChanges2(previousState, schema);
|
|
2791
|
+
const destructiveFindings = findings.filter((f) => f.severity === "error");
|
|
2792
|
+
if (destructiveFindings.length > 0) {
|
|
2793
|
+
const errorMessages = destructiveFindings.map((f) => {
|
|
2794
|
+
const target = f.column ? `${f.table}.${f.column}` : f.table;
|
|
2795
|
+
const typeRange = f.from && f.to ? ` (${f.from} -> ${f.to})` : "";
|
|
2796
|
+
return ` - ${f.code}: ${target}${typeRange}`;
|
|
2797
|
+
}).join("\n");
|
|
2798
|
+
throw await createSchemaValidationError(
|
|
2799
|
+
`Cannot proceed with --safe flag: Found ${destructiveFindings.length} destructive operation(s):
|
|
2800
|
+
${errorMessages}
|
|
2801
|
+
|
|
2802
|
+
Remove --safe flag or modify schema to avoid destructive changes.`
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
if (!options.safe && !options.force && diff.operations.length > 0) {
|
|
2807
|
+
const findings = await validateSchemaChanges2(previousState, schema);
|
|
2808
|
+
const riskyFindings = findings.filter((f) => f.severity === "error" || f.severity === "warning");
|
|
2809
|
+
if (riskyFindings.length > 0) {
|
|
2810
|
+
const confirmed = await confirmDestructiveOps(findings);
|
|
2811
|
+
if (!confirmed) {
|
|
2812
|
+
if (process.exitCode !== EXIT_CODES.CI_DESTRUCTIVE) {
|
|
2813
|
+
process.exitCode = EXIT_CODES.VALIDATION_ERROR;
|
|
2814
|
+
}
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2406
2819
|
if (diff.operations.length === 0) {
|
|
2407
2820
|
info("No changes detected");
|
|
2821
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2408
2822
|
return;
|
|
2409
2823
|
}
|
|
2410
2824
|
const sql = await generateSql2(diff, provider, config.sql);
|
|
@@ -2417,6 +2831,7 @@ async function runGenerate(options) {
|
|
|
2417
2831
|
const nextState = await schemaToState2(schema);
|
|
2418
2832
|
await saveState2(statePath, nextState);
|
|
2419
2833
|
success(`SQL generated successfully: ${migrationPath}`);
|
|
2834
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2420
2835
|
}
|
|
2421
2836
|
|
|
2422
2837
|
// src/commands/import.ts
|
|
@@ -2468,6 +2883,7 @@ async function runImport(inputPath, options = {}) {
|
|
|
2468
2883
|
warning(`...and ${warnings.length - 10} more`);
|
|
2469
2884
|
}
|
|
2470
2885
|
}
|
|
2886
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2471
2887
|
}
|
|
2472
2888
|
|
|
2473
2889
|
// src/commands/init.ts
|
|
@@ -2476,24 +2892,19 @@ async function runInit() {
|
|
|
2476
2892
|
const root = getProjectRoot();
|
|
2477
2893
|
const schemaForgeDir = getSchemaForgeDir(root);
|
|
2478
2894
|
if (await fileExists(schemaForgeDir)) {
|
|
2479
|
-
|
|
2480
|
-
error("Please remove it or run init in a different directory");
|
|
2481
|
-
process.exit(1);
|
|
2895
|
+
throw new Error("schemaforge/ directory already exists. Please remove it or run init in a different directory.");
|
|
2482
2896
|
}
|
|
2483
2897
|
const schemaFilePath = getSchemaFilePath(root);
|
|
2484
2898
|
const configPath = getConfigPath(root);
|
|
2485
2899
|
const statePath = getStatePath(root);
|
|
2486
2900
|
if (await fileExists(schemaFilePath)) {
|
|
2487
|
-
|
|
2488
|
-
process.exit(1);
|
|
2901
|
+
throw new Error(`${schemaFilePath} already exists`);
|
|
2489
2902
|
}
|
|
2490
2903
|
if (await fileExists(configPath)) {
|
|
2491
|
-
|
|
2492
|
-
process.exit(1);
|
|
2904
|
+
throw new Error(`${configPath} already exists`);
|
|
2493
2905
|
}
|
|
2494
2906
|
if (await fileExists(statePath)) {
|
|
2495
|
-
|
|
2496
|
-
process.exit(1);
|
|
2907
|
+
throw new Error(`${statePath} already exists`);
|
|
2497
2908
|
}
|
|
2498
2909
|
info("Initializing schema project...");
|
|
2499
2910
|
await ensureDir(schemaForgeDir);
|
|
@@ -2532,6 +2943,7 @@ table users {
|
|
|
2532
2943
|
info("Next steps:");
|
|
2533
2944
|
info(" 1. Edit schemaforge/schema.sf to define your schema");
|
|
2534
2945
|
info(" 2. Run: schema-forge generate");
|
|
2946
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2535
2947
|
}
|
|
2536
2948
|
|
|
2537
2949
|
// src/commands/validate.ts
|
|
@@ -2569,14 +2981,17 @@ async function runValidate(options = {}) {
|
|
|
2569
2981
|
const previousState = await loadState2(statePath);
|
|
2570
2982
|
const findings = await validateSchemaChanges2(previousState, schema);
|
|
2571
2983
|
const report = await toValidationReport2(findings);
|
|
2984
|
+
if (isCI() && hasDestructiveFindings(findings)) {
|
|
2985
|
+
process.exitCode = EXIT_CODES.CI_DESTRUCTIVE;
|
|
2986
|
+
} else {
|
|
2987
|
+
process.exitCode = report.hasErrors ? EXIT_CODES.VALIDATION_ERROR : EXIT_CODES.SUCCESS;
|
|
2988
|
+
}
|
|
2572
2989
|
if (options.json) {
|
|
2573
2990
|
console.log(JSON.stringify(report, null, 2));
|
|
2574
|
-
process.exitCode = report.hasErrors ? 1 : 0;
|
|
2575
2991
|
return;
|
|
2576
2992
|
}
|
|
2577
2993
|
if (findings.length === 0) {
|
|
2578
2994
|
success("No destructive changes detected");
|
|
2579
|
-
process.exitCode = 0;
|
|
2580
2995
|
return;
|
|
2581
2996
|
}
|
|
2582
2997
|
console.log(
|
|
@@ -2593,16 +3008,61 @@ async function runValidate(options = {}) {
|
|
|
2593
3008
|
);
|
|
2594
3009
|
}
|
|
2595
3010
|
}
|
|
2596
|
-
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// src/utils/whatsNew.ts
|
|
3014
|
+
var import_node_os = __toESM(require("os"));
|
|
3015
|
+
var import_node_path = __toESM(require("path"));
|
|
3016
|
+
function getCliMetaPath() {
|
|
3017
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".schema-forge", "cli-meta.json");
|
|
3018
|
+
}
|
|
3019
|
+
function getReleaseUrl(version) {
|
|
3020
|
+
return `https://github.com/xubylele/schema-forge/releases/tag/v${version}`;
|
|
3021
|
+
}
|
|
3022
|
+
function shouldShowWhatsNew(argv) {
|
|
3023
|
+
if (argv.length === 0) {
|
|
3024
|
+
return false;
|
|
3025
|
+
}
|
|
3026
|
+
if (argv.includes("--help") || argv.includes("-h") || argv.includes("--version") || argv.includes("-V")) {
|
|
3027
|
+
return false;
|
|
3028
|
+
}
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
3031
|
+
async function showWhatsNewIfUpdated(currentVersion, argv) {
|
|
3032
|
+
if (!shouldShowWhatsNew(argv)) {
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
try {
|
|
3036
|
+
const metaPath = getCliMetaPath();
|
|
3037
|
+
const meta = await readJsonFile(metaPath, {});
|
|
3038
|
+
if (meta.lastSeenVersion === currentVersion) {
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
info(`What's new in schema-forge v${currentVersion}: ${getReleaseUrl(currentVersion)}`);
|
|
3042
|
+
await writeJsonFile(metaPath, { lastSeenVersion: currentVersion });
|
|
3043
|
+
} catch {
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
async function seedLastSeenVersion(version) {
|
|
3047
|
+
const metaPath = getCliMetaPath();
|
|
3048
|
+
const exists = await fileExists(metaPath);
|
|
3049
|
+
if (!exists) {
|
|
3050
|
+
await writeJsonFile(metaPath, { lastSeenVersion: version });
|
|
3051
|
+
}
|
|
2597
3052
|
}
|
|
2598
3053
|
|
|
2599
3054
|
// src/cli.ts
|
|
2600
3055
|
var program = new import_commander6.Command();
|
|
2601
|
-
program.name("schema-forge").description("CLI tool for schema management and SQL generation").version(package_default.version);
|
|
3056
|
+
program.name("schema-forge").description("CLI tool for schema management and SQL generation").version(package_default.version).option("--safe", "Prevent execution of destructive operations").option("--force", "Force execution by bypassing safety checks and CI detection");
|
|
3057
|
+
function validateFlagExclusivity(options) {
|
|
3058
|
+
if (options.safe && options.force) {
|
|
3059
|
+
throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
2602
3062
|
async function handleError(error2) {
|
|
2603
3063
|
if (await isSchemaValidationError(error2) && error2 instanceof Error) {
|
|
2604
3064
|
error(error2.message);
|
|
2605
|
-
process.exitCode =
|
|
3065
|
+
process.exitCode = EXIT_CODES.VALIDATION_ERROR;
|
|
2606
3066
|
return;
|
|
2607
3067
|
}
|
|
2608
3068
|
if (error2 instanceof Error) {
|
|
@@ -2610,7 +3070,7 @@ async function handleError(error2) {
|
|
|
2610
3070
|
} else {
|
|
2611
3071
|
error("Unexpected error");
|
|
2612
3072
|
}
|
|
2613
|
-
process.exitCode =
|
|
3073
|
+
process.exitCode = EXIT_CODES.VALIDATION_ERROR;
|
|
2614
3074
|
}
|
|
2615
3075
|
program.command("init").description("Initialize a new schema project").action(async () => {
|
|
2616
3076
|
try {
|
|
@@ -2619,16 +3079,20 @@ program.command("init").description("Initialize a new schema project").action(as
|
|
|
2619
3079
|
await handleError(error2);
|
|
2620
3080
|
}
|
|
2621
3081
|
});
|
|
2622
|
-
program.command("generate").description("Generate SQL from schema files").option("--name <string>", "Schema name to generate").action(async (options) => {
|
|
3082
|
+
program.command("generate").description("Generate SQL from schema files. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--name <string>", "Schema name to generate").action(async (options) => {
|
|
2623
3083
|
try {
|
|
2624
|
-
|
|
3084
|
+
const globalOptions = program.opts();
|
|
3085
|
+
validateFlagExclusivity(globalOptions);
|
|
3086
|
+
await runGenerate({ ...options, ...globalOptions });
|
|
2625
3087
|
} catch (error2) {
|
|
2626
3088
|
await handleError(error2);
|
|
2627
3089
|
}
|
|
2628
3090
|
});
|
|
2629
|
-
program.command("diff").description("Compare two schema versions and generate migration SQL").action(async () => {
|
|
3091
|
+
program.command("diff").description("Compare two schema versions and generate migration SQL. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").action(async () => {
|
|
2630
3092
|
try {
|
|
2631
|
-
|
|
3093
|
+
const globalOptions = program.opts();
|
|
3094
|
+
validateFlagExclusivity(globalOptions);
|
|
3095
|
+
await runDiff(globalOptions);
|
|
2632
3096
|
} catch (error2) {
|
|
2633
3097
|
await handleError(error2);
|
|
2634
3098
|
}
|
|
@@ -2640,14 +3104,20 @@ program.command("import").description("Import schema from SQL migrations").argum
|
|
|
2640
3104
|
await handleError(error2);
|
|
2641
3105
|
}
|
|
2642
3106
|
});
|
|
2643
|
-
program.command("validate").description("Detect destructive or risky schema changes").option("--json", "Output structured JSON").action(async (options) => {
|
|
3107
|
+
program.command("validate").description("Detect destructive or risky schema changes. In CI environments (CI=true), exits with code 3 if destructive operations are detected.").option("--json", "Output structured JSON").action(async (options) => {
|
|
2644
3108
|
try {
|
|
2645
3109
|
await runValidate(options);
|
|
2646
3110
|
} catch (error2) {
|
|
2647
3111
|
await handleError(error2);
|
|
2648
3112
|
}
|
|
2649
3113
|
});
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
3114
|
+
async function main() {
|
|
3115
|
+
const argv = process.argv.slice(2);
|
|
3116
|
+
await seedLastSeenVersion(package_default.version);
|
|
3117
|
+
await showWhatsNewIfUpdated(package_default.version, argv);
|
|
3118
|
+
program.parse(process.argv);
|
|
3119
|
+
if (!argv.length) {
|
|
3120
|
+
program.outputHelp();
|
|
3121
|
+
}
|
|
2653
3122
|
}
|
|
3123
|
+
void main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xubylele/schema-forge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Universal migration generator from schema DSL",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@changesets/cli": "^2.29.8",
|
|
49
49
|
"@types/node": "^25.2.3",
|
|
50
|
-
"@xubylele/schema-forge-core": "^1.0
|
|
50
|
+
"@xubylele/schema-forge-core": "^1.2.0",
|
|
51
51
|
"ts-node": "^10.9.2",
|
|
52
52
|
"tsup": "^8.5.1",
|
|
53
53
|
"typescript": "^5.9.3",
|
|
54
54
|
"vitest": "^4.0.18"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|