turbine-orm 0.26.0 → 0.27.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/README.md CHANGED
@@ -661,6 +661,12 @@ npx turbine migrate down
661
661
  npx turbine migrate status
662
662
  ```
663
663
 
664
+ **Destructive migrations require explicit confirmation.** If a pending migration (or a DOWN
665
+ section being rolled back) contains data-destroying SQL — `DROP TABLE`, `DROP COLUMN`,
666
+ `TRUNCATE`, `DELETE FROM`, `UPDATE` without `WHERE`, `ALTER COLUMN … TYPE` — Turbine refuses
667
+ to run it and prints an itemized report. Interactively you must type `destroy my data` and
668
+ then `yes`; in CI you must pass `--allow-destructive`. A refused batch applies nothing.
669
+
664
670
  ## Studio
665
671
 
666
672
  The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * Destructive-migration detection.
4
+ *
5
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
6
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
7
+ * migration runs with no ceremony. This module scans migration SQL for
8
+ * statements that can destroy data so the CLI can force an explicit,
9
+ * interactive confirmation (and the programmatic API can refuse by default).
10
+ *
11
+ * Deliberately conservative in BOTH directions:
12
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
13
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
14
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
15
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
16
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
17
+ * structures; no row data lost).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.DESTRUCTIVE_KIND_LABEL = void 0;
21
+ exports.scanDestructiveSql = scanDestructiveSql;
22
+ /** Human explanation per kind, used in CLI output. */
23
+ exports.DESTRUCTIVE_KIND_LABEL = {
24
+ 'drop-table': 'drops a table and ALL its rows',
25
+ 'drop-schema': 'drops an entire schema',
26
+ 'drop-column': 'drops a column and its data in every row',
27
+ truncate: 'deletes every row',
28
+ delete: 'deletes rows',
29
+ 'update-without-where': 'rewrites every row (no WHERE clause)',
30
+ 'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
31
+ };
32
+ /** Strip -- line comments, C-style block comments, and quoted literals. */
33
+ function stripCommentsAndStrings(sql) {
34
+ let out = '';
35
+ let i = 0;
36
+ while (i < sql.length) {
37
+ const two = sql.slice(i, i + 2);
38
+ if (two === '--') {
39
+ const nl = sql.indexOf('\n', i);
40
+ i = nl === -1 ? sql.length : nl; // keep the newline
41
+ }
42
+ else if (two === '/*') {
43
+ const end = sql.indexOf('*/', i + 2);
44
+ i = end === -1 ? sql.length : end + 2;
45
+ out += ' ';
46
+ }
47
+ else if (sql[i] === "'") {
48
+ // single-quoted literal ('' escapes a quote)
49
+ let j = i + 1;
50
+ while (j < sql.length) {
51
+ if (sql[j] === "'" && sql[j + 1] === "'")
52
+ j += 2;
53
+ else if (sql[j] === "'")
54
+ break;
55
+ else
56
+ j++;
57
+ }
58
+ i = j + 1;
59
+ out += "''";
60
+ }
61
+ else if (sql[i] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(i))) {
62
+ // dollar-quoted literal ($$...$$ / $tag$...$tag$)
63
+ const tag = sql.slice(i).match(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
64
+ const end = sql.indexOf(tag, i + tag.length);
65
+ i = end === -1 ? sql.length : end + tag.length;
66
+ out += "''";
67
+ }
68
+ else {
69
+ out += sql[i];
70
+ i++;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Unquote a "quoted" identifier for display. */
76
+ const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
77
+ const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
78
+ /** Ordered rules — first match per statement wins. */
79
+ const RULES = [
80
+ {
81
+ kind: 'drop-table',
82
+ regex: new RegExp(String.raw `^DROP\s+TABLE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
83
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
84
+ },
85
+ {
86
+ kind: 'drop-schema',
87
+ regex: new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
88
+ target: (m) => ident(m[2]),
89
+ },
90
+ {
91
+ kind: 'truncate',
92
+ regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
93
+ target: (m) => (m[5] ? `${ident(m[3])}.${ident(m[5])}` : ident(m[3])),
94
+ },
95
+ {
96
+ kind: 'drop-column',
97
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+COLUMN\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
98
+ target: (m) => `${ident(m[3])}.${ident(m[7])}`,
99
+ },
100
+ {
101
+ kind: 'alter-column-type',
102
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i'),
103
+ target: (m) => `${ident(m[3])}.${ident(m[7])}`,
104
+ },
105
+ {
106
+ kind: 'delete',
107
+ regex: new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i'),
108
+ target: (m) => ident(m[2]),
109
+ },
110
+ {
111
+ kind: 'update-without-where',
112
+ regex: new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'),
113
+ target: (m) => ident(m[2]),
114
+ also: (stmt) => !/\bWHERE\b/i.test(stmt),
115
+ },
116
+ ];
117
+ /**
118
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
119
+ * return every statement that can destroy data.
120
+ */
121
+ function scanDestructiveSql(sql) {
122
+ const found = [];
123
+ const cleaned = stripCommentsAndStrings(sql);
124
+ for (const rawStmt of cleaned.split(';')) {
125
+ const stmt = rawStmt.trim();
126
+ if (!stmt)
127
+ continue;
128
+ for (const rule of RULES) {
129
+ const m = stmt.match(rule.regex);
130
+ if (!m)
131
+ continue;
132
+ if (rule.also && !rule.also(stmt))
133
+ continue;
134
+ found.push({ statement: stmt.replace(/\s+/g, ' '), kind: rule.kind, target: rule.target(m) });
135
+ break;
136
+ }
137
+ }
138
+ return found;
139
+ }
@@ -128,6 +128,9 @@ function parseArgs() {
128
128
  case '--fix':
129
129
  result.fix = true;
130
130
  break;
131
+ case '--allow-destructive':
132
+ result.allowDestructive = true;
133
+ break;
131
134
  case '--force':
132
135
  case '-f':
133
136
  result.force = true;
@@ -784,11 +787,35 @@ async function cmdMigrateUp(args, config) {
784
787
  console.log(` ${(0, ui_js_1.dim)('Proceed only if you are intentionally rewriting migration history.')}`);
785
788
  (0, ui_js_1.newline)();
786
789
  }
790
+ if (args.allowDestructive) {
791
+ (0, ui_js_1.warn)('--allow-destructive is set — data-destroying statements in migrations WILL run.');
792
+ (0, ui_js_1.newline)();
793
+ }
787
794
  const spinner = new ui_js_1.Spinner('Applying migrations').start();
788
- const result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
789
- step: args.step,
790
- allowDrift: args.allowDrift,
791
- });
795
+ let result;
796
+ try {
797
+ result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
798
+ step: args.step,
799
+ allowDrift: args.allowDrift,
800
+ allowDestructive: args.allowDestructive,
801
+ });
802
+ }
803
+ catch (err) {
804
+ if (!isDestructiveRefusal(err))
805
+ throw err;
806
+ spinner.stop();
807
+ if (!(await confirmDestructive(err.message))) {
808
+ (0, ui_js_1.error)('Aborted — no migrations were applied and no data was touched.');
809
+ (0, ui_js_1.newline)();
810
+ process.exit(1);
811
+ }
812
+ spinner.start();
813
+ result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
814
+ step: args.step,
815
+ allowDrift: args.allowDrift,
816
+ allowDestructive: true,
817
+ });
818
+ }
792
819
  if (result.applied.length === 0 && result.errors.length === 0) {
793
820
  spinner.succeed('All migrations are up to date');
794
821
  (0, ui_js_1.newline)();
@@ -811,6 +838,44 @@ async function cmdMigrateUp(args, config) {
811
838
  }
812
839
  (0, ui_js_1.newline)();
813
840
  }
841
+ /** True when the error is migrate up/down's destructive-statement refusal. */
842
+ function isDestructiveRefusal(err) {
843
+ return err instanceof Error && err.message.includes('DESTRUCTIVE');
844
+ }
845
+ /**
846
+ * Triple confirmation for destructive migrations:
847
+ * 1. show the full itemized report (statement kinds + targets),
848
+ * 2. require typing the literal phrase `destroy my data`,
849
+ * 3. require a final explicit `yes`.
850
+ * Non-interactive shells (CI, pipes) can never pass this — they must use the
851
+ * explicit `--allow-destructive` flag instead. Anything but exact answers aborts.
852
+ */
853
+ async function confirmDestructive(report) {
854
+ (0, ui_js_1.newline)();
855
+ (0, ui_js_1.error)('DESTRUCTIVE MIGRATION DETECTED');
856
+ (0, ui_js_1.newline)();
857
+ for (const line of report.split('\n'))
858
+ console.log(` ${line.includes('[turbine]') ? line.replace('[turbine] ', '') : line}`);
859
+ (0, ui_js_1.newline)();
860
+ if (!process.stdin.isTTY) {
861
+ console.log(` ${(0, ui_js_1.dim)('Non-interactive shell: rerun with')} ${(0, ui_js_1.cyan)('--allow-destructive')} ${(0, ui_js_1.dim)('to proceed.')}`);
862
+ (0, ui_js_1.newline)();
863
+ return false;
864
+ }
865
+ const { createInterface } = await Promise.resolve().then(() => __importStar(require('node:readline/promises')));
866
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
867
+ try {
868
+ console.log(` ${(0, ui_js_1.yellow)('This will permanently destroy data. There is no undo.')}`);
869
+ const phrase = await rl.question(` Type ${(0, ui_js_1.bold)('destroy my data')} to continue, anything else to abort: `);
870
+ if (phrase.trim() !== 'destroy my data')
871
+ return false;
872
+ const finalAnswer = await rl.question(` Final confirmation — apply the destructive statements above? Type ${(0, ui_js_1.bold)('yes')}: `);
873
+ return finalAnswer.trim() === 'yes';
874
+ }
875
+ finally {
876
+ rl.close();
877
+ }
878
+ }
814
879
  async function cmdMigrateDown(args, config) {
815
880
  (0, ui_js_1.banner)();
816
881
  const url = requireUrl(config);
@@ -818,9 +883,28 @@ async function cmdMigrateDown(args, config) {
818
883
  (0, ui_js_1.label)('Migrations', config.migrationsDir);
819
884
  (0, ui_js_1.newline)();
820
885
  const spinner = new ui_js_1.Spinner('Rolling back migration(s)').start();
821
- const result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
822
- step: args.step ?? 1,
823
- });
886
+ let result;
887
+ try {
888
+ result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
889
+ step: args.step ?? 1,
890
+ allowDestructive: args.allowDestructive,
891
+ });
892
+ }
893
+ catch (err) {
894
+ if (!isDestructiveRefusal(err))
895
+ throw err;
896
+ spinner.stop();
897
+ if (!(await confirmDestructive(err.message))) {
898
+ (0, ui_js_1.error)('Aborted — nothing was rolled back and no data was touched.');
899
+ (0, ui_js_1.newline)();
900
+ process.exit(1);
901
+ }
902
+ spinner.start();
903
+ result = await (0, migrate_js_1.migrateDown)(url, config.migrationsDir, {
904
+ step: args.step ?? 1,
905
+ allowDestructive: true,
906
+ });
907
+ }
824
908
  if (result.rolledBack.length === 0 && result.errors.length === 0) {
825
909
  spinner.succeed('No migrations to roll back');
826
910
  (0, ui_js_1.newline)();
@@ -1308,6 +1392,7 @@ function showMigrateHelp() {
1308
1392
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1309
1393
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1310
1394
  console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1395
+ console.log(` ${(0, ui_js_1.cyan)('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
1311
1396
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1312
1397
  (0, ui_js_1.newline)();
1313
1398
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
@@ -35,6 +35,7 @@ const pg_1 = __importDefault(require("pg"));
35
35
  const index_js_1 = require("../adapters/index.js");
36
36
  const dialect_js_1 = require("../dialect.js");
37
37
  const errors_js_1 = require("../errors.js");
38
+ const destructive_js_1 = require("./destructive.js");
38
39
  // ---------------------------------------------------------------------------
39
40
  // Tracking table management
40
41
  // ---------------------------------------------------------------------------
@@ -368,6 +369,35 @@ async function migrateUp(connectionString, migrationsDir, options) {
368
369
  if (options?.step != null && options.step > 0) {
369
370
  pending = pending.slice(0, options.step);
370
371
  }
372
+ // Data-loss gate: refuse to run pending migrations containing destructive
373
+ // statements unless the caller has EXPLICITLY opted in. The CLI layers an
374
+ // interactive typed confirmation on top of this; programmatic callers must
375
+ // pass `allowDestructive: true`. Safe-by-default is the whole point — a
376
+ // DROP TABLE should never run just because a file exists.
377
+ if (!options?.allowDestructive) {
378
+ const offenders = [];
379
+ for (const file of pending) {
380
+ const { up } = parseMigrationSQL(file.path);
381
+ if (!up)
382
+ continue;
383
+ const hits = (0, destructive_js_1.scanDestructiveSql)(up);
384
+ if (hits.length > 0)
385
+ offenders.push({ file: file.filename, hits });
386
+ }
387
+ if (offenders.length > 0) {
388
+ const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
389
+ for (const o of offenders) {
390
+ lines.push(` ${o.file}`);
391
+ for (const h of o.hits) {
392
+ lines.push(` - [${h.kind}] ${h.target} — ${destructive_js_1.DESTRUCTIVE_KIND_LABEL[h.kind]}`);
393
+ }
394
+ }
395
+ lines.push('');
396
+ lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
397
+ lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
398
+ throw new errors_js_1.MigrationError(lines.join('\n'));
399
+ }
400
+ }
371
401
  const results = [];
372
402
  const errors = [];
373
403
  for (const file of pending) {
@@ -435,6 +465,36 @@ async function migrateDown(connectionString, migrationsDir, options) {
435
465
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
436
466
  // Reverse order — rollback most recent first
437
467
  const toRollback = applied.reverse().slice(0, options?.step ?? 1);
468
+ // Same data-loss gate as migrateUp — DOWN sections routinely contain
469
+ // DROP TABLE (the legitimate reverse of a CREATE), which still destroys
470
+ // every row written since the migration ran. Explicit opt-in required.
471
+ if (!options?.allowDestructive) {
472
+ const offenders = [];
473
+ for (const migration of toRollback) {
474
+ const file = fileMap.get(migration.name);
475
+ if (!file)
476
+ continue;
477
+ const { down } = parseMigrationSQL(file.path);
478
+ if (!down)
479
+ continue;
480
+ const hits = (0, destructive_js_1.scanDestructiveSql)(down);
481
+ if (hits.length > 0)
482
+ offenders.push({ file: file.filename, hits });
483
+ }
484
+ if (offenders.length > 0) {
485
+ const lines = ['[turbine] Refusing to roll back migrations whose DOWN sections are DESTRUCTIVE:', ''];
486
+ for (const o of offenders) {
487
+ lines.push(` ${o.file}`);
488
+ for (const h of o.hits) {
489
+ lines.push(` - [${h.kind}] ${h.target} — ${destructive_js_1.DESTRUCTIVE_KIND_LABEL[h.kind]}`);
490
+ }
491
+ }
492
+ lines.push('');
493
+ lines.push('To proceed: run `npx turbine migrate down` interactively and confirm, pass');
494
+ lines.push('--allow-destructive, or set allowDestructive: true programmatically.');
495
+ throw new errors_js_1.MigrationError(lines.join('\n'));
496
+ }
497
+ }
438
498
  const results = [];
439
499
  const errors = [];
440
500
  for (const migration of toRollback) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Destructive-migration detection.
3
+ *
4
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
5
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
6
+ * migration runs with no ceremony. This module scans migration SQL for
7
+ * statements that can destroy data so the CLI can force an explicit,
8
+ * interactive confirmation (and the programmatic API can refuse by default).
9
+ *
10
+ * Deliberately conservative in BOTH directions:
11
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
12
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
13
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
14
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
15
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
16
+ * structures; no row data lost).
17
+ */
18
+ export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-column' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type';
19
+ export interface DestructiveStatement {
20
+ /** The offending SQL statement (trimmed, possibly long — display truncated) */
21
+ statement: string;
22
+ kind: DestructiveKind;
23
+ /** Best-effort extracted object name (table, schema, or table.column) */
24
+ target: string;
25
+ }
26
+ /** Human explanation per kind, used in CLI output. */
27
+ export declare const DESTRUCTIVE_KIND_LABEL: Record<DestructiveKind, string>;
28
+ /**
29
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
30
+ * return every statement that can destroy data.
31
+ */
32
+ export declare function scanDestructiveSql(sql: string): DestructiveStatement[];
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Destructive-migration detection.
3
+ *
4
+ * `migrate up`/`down` execute user-authored SQL files verbatim, which is the
5
+ * one place data loss can hide: a `DROP TABLE` or a `DELETE FROM` in a
6
+ * migration runs with no ceremony. This module scans migration SQL for
7
+ * statements that can destroy data so the CLI can force an explicit,
8
+ * interactive confirmation (and the programmatic API can refuse by default).
9
+ *
10
+ * Deliberately conservative in BOTH directions:
11
+ * - comments and string literals are stripped first, so `-- DROP TABLE foo`
12
+ * or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
13
+ * - anything that removes rows, columns, tables, or schemas — or rewrites a
14
+ * column's type (a potentially lossy cast) — is flagged. `DROP INDEX`,
15
+ * `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
16
+ * structures; no row data lost).
17
+ */
18
+ /** Human explanation per kind, used in CLI output. */
19
+ export const DESTRUCTIVE_KIND_LABEL = {
20
+ 'drop-table': 'drops a table and ALL its rows',
21
+ 'drop-schema': 'drops an entire schema',
22
+ 'drop-column': 'drops a column and its data in every row',
23
+ truncate: 'deletes every row',
24
+ delete: 'deletes rows',
25
+ 'update-without-where': 'rewrites every row (no WHERE clause)',
26
+ 'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
27
+ };
28
+ /** Strip -- line comments, C-style block comments, and quoted literals. */
29
+ function stripCommentsAndStrings(sql) {
30
+ let out = '';
31
+ let i = 0;
32
+ while (i < sql.length) {
33
+ const two = sql.slice(i, i + 2);
34
+ if (two === '--') {
35
+ const nl = sql.indexOf('\n', i);
36
+ i = nl === -1 ? sql.length : nl; // keep the newline
37
+ }
38
+ else if (two === '/*') {
39
+ const end = sql.indexOf('*/', i + 2);
40
+ i = end === -1 ? sql.length : end + 2;
41
+ out += ' ';
42
+ }
43
+ else if (sql[i] === "'") {
44
+ // single-quoted literal ('' escapes a quote)
45
+ let j = i + 1;
46
+ while (j < sql.length) {
47
+ if (sql[j] === "'" && sql[j + 1] === "'")
48
+ j += 2;
49
+ else if (sql[j] === "'")
50
+ break;
51
+ else
52
+ j++;
53
+ }
54
+ i = j + 1;
55
+ out += "''";
56
+ }
57
+ else if (sql[i] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(i))) {
58
+ // dollar-quoted literal ($$...$$ / $tag$...$tag$)
59
+ const tag = sql.slice(i).match(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
60
+ const end = sql.indexOf(tag, i + tag.length);
61
+ i = end === -1 ? sql.length : end + tag.length;
62
+ out += "''";
63
+ }
64
+ else {
65
+ out += sql[i];
66
+ i++;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ /** Unquote a "quoted" identifier for display. */
72
+ const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
73
+ const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
74
+ /** Ordered rules — first match per statement wins. */
75
+ const RULES = [
76
+ {
77
+ kind: 'drop-table',
78
+ regex: new RegExp(String.raw `^DROP\s+TABLE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
79
+ target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
80
+ },
81
+ {
82
+ kind: 'drop-schema',
83
+ regex: new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
84
+ target: (m) => ident(m[2]),
85
+ },
86
+ {
87
+ kind: 'truncate',
88
+ regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
89
+ target: (m) => (m[5] ? `${ident(m[3])}.${ident(m[5])}` : ident(m[3])),
90
+ },
91
+ {
92
+ kind: 'drop-column',
93
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+COLUMN\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
94
+ target: (m) => `${ident(m[3])}.${ident(m[7])}`,
95
+ },
96
+ {
97
+ kind: 'alter-column-type',
98
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i'),
99
+ target: (m) => `${ident(m[3])}.${ident(m[7])}`,
100
+ },
101
+ {
102
+ kind: 'delete',
103
+ regex: new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i'),
104
+ target: (m) => ident(m[2]),
105
+ },
106
+ {
107
+ kind: 'update-without-where',
108
+ regex: new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'),
109
+ target: (m) => ident(m[2]),
110
+ also: (stmt) => !/\bWHERE\b/i.test(stmt),
111
+ },
112
+ ];
113
+ /**
114
+ * Scan SQL (one file's worth; may contain many `;`-separated statements) and
115
+ * return every statement that can destroy data.
116
+ */
117
+ export function scanDestructiveSql(sql) {
118
+ const found = [];
119
+ const cleaned = stripCommentsAndStrings(sql);
120
+ for (const rawStmt of cleaned.split(';')) {
121
+ const stmt = rawStmt.trim();
122
+ if (!stmt)
123
+ continue;
124
+ for (const rule of RULES) {
125
+ const m = stmt.match(rule.regex);
126
+ if (!m)
127
+ continue;
128
+ if (rule.also && !rule.also(stmt))
129
+ continue;
130
+ found.push({ statement: stmt.replace(/\s+/g, ' '), kind: rule.kind, target: rule.target(m) });
131
+ break;
132
+ }
133
+ }
134
+ return found;
135
+ }
package/dist/cli/index.js CHANGED
@@ -93,6 +93,9 @@ function parseArgs() {
93
93
  case '--fix':
94
94
  result.fix = true;
95
95
  break;
96
+ case '--allow-destructive':
97
+ result.allowDestructive = true;
98
+ break;
96
99
  case '--force':
97
100
  case '-f':
98
101
  result.force = true;
@@ -749,11 +752,35 @@ async function cmdMigrateUp(args, config) {
749
752
  console.log(` ${dim('Proceed only if you are intentionally rewriting migration history.')}`);
750
753
  newline();
751
754
  }
755
+ if (args.allowDestructive) {
756
+ warn('--allow-destructive is set — data-destroying statements in migrations WILL run.');
757
+ newline();
758
+ }
752
759
  const spinner = new Spinner('Applying migrations').start();
753
- const result = await migrateUp(url, config.migrationsDir, {
754
- step: args.step,
755
- allowDrift: args.allowDrift,
756
- });
760
+ let result;
761
+ try {
762
+ result = await migrateUp(url, config.migrationsDir, {
763
+ step: args.step,
764
+ allowDrift: args.allowDrift,
765
+ allowDestructive: args.allowDestructive,
766
+ });
767
+ }
768
+ catch (err) {
769
+ if (!isDestructiveRefusal(err))
770
+ throw err;
771
+ spinner.stop();
772
+ if (!(await confirmDestructive(err.message))) {
773
+ error('Aborted — no migrations were applied and no data was touched.');
774
+ newline();
775
+ process.exit(1);
776
+ }
777
+ spinner.start();
778
+ result = await migrateUp(url, config.migrationsDir, {
779
+ step: args.step,
780
+ allowDrift: args.allowDrift,
781
+ allowDestructive: true,
782
+ });
783
+ }
757
784
  if (result.applied.length === 0 && result.errors.length === 0) {
758
785
  spinner.succeed('All migrations are up to date');
759
786
  newline();
@@ -776,6 +803,44 @@ async function cmdMigrateUp(args, config) {
776
803
  }
777
804
  newline();
778
805
  }
806
+ /** True when the error is migrate up/down's destructive-statement refusal. */
807
+ function isDestructiveRefusal(err) {
808
+ return err instanceof Error && err.message.includes('DESTRUCTIVE');
809
+ }
810
+ /**
811
+ * Triple confirmation for destructive migrations:
812
+ * 1. show the full itemized report (statement kinds + targets),
813
+ * 2. require typing the literal phrase `destroy my data`,
814
+ * 3. require a final explicit `yes`.
815
+ * Non-interactive shells (CI, pipes) can never pass this — they must use the
816
+ * explicit `--allow-destructive` flag instead. Anything but exact answers aborts.
817
+ */
818
+ async function confirmDestructive(report) {
819
+ newline();
820
+ error('DESTRUCTIVE MIGRATION DETECTED');
821
+ newline();
822
+ for (const line of report.split('\n'))
823
+ console.log(` ${line.includes('[turbine]') ? line.replace('[turbine] ', '') : line}`);
824
+ newline();
825
+ if (!process.stdin.isTTY) {
826
+ console.log(` ${dim('Non-interactive shell: rerun with')} ${cyan('--allow-destructive')} ${dim('to proceed.')}`);
827
+ newline();
828
+ return false;
829
+ }
830
+ const { createInterface } = await import('node:readline/promises');
831
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
832
+ try {
833
+ console.log(` ${yellow('This will permanently destroy data. There is no undo.')}`);
834
+ const phrase = await rl.question(` Type ${bold('destroy my data')} to continue, anything else to abort: `);
835
+ if (phrase.trim() !== 'destroy my data')
836
+ return false;
837
+ const finalAnswer = await rl.question(` Final confirmation — apply the destructive statements above? Type ${bold('yes')}: `);
838
+ return finalAnswer.trim() === 'yes';
839
+ }
840
+ finally {
841
+ rl.close();
842
+ }
843
+ }
779
844
  async function cmdMigrateDown(args, config) {
780
845
  banner();
781
846
  const url = requireUrl(config);
@@ -783,9 +848,28 @@ async function cmdMigrateDown(args, config) {
783
848
  label('Migrations', config.migrationsDir);
784
849
  newline();
785
850
  const spinner = new Spinner('Rolling back migration(s)').start();
786
- const result = await migrateDown(url, config.migrationsDir, {
787
- step: args.step ?? 1,
788
- });
851
+ let result;
852
+ try {
853
+ result = await migrateDown(url, config.migrationsDir, {
854
+ step: args.step ?? 1,
855
+ allowDestructive: args.allowDestructive,
856
+ });
857
+ }
858
+ catch (err) {
859
+ if (!isDestructiveRefusal(err))
860
+ throw err;
861
+ spinner.stop();
862
+ if (!(await confirmDestructive(err.message))) {
863
+ error('Aborted — nothing was rolled back and no data was touched.');
864
+ newline();
865
+ process.exit(1);
866
+ }
867
+ spinner.start();
868
+ result = await migrateDown(url, config.migrationsDir, {
869
+ step: args.step ?? 1,
870
+ allowDestructive: true,
871
+ });
872
+ }
789
873
  if (result.rolledBack.length === 0 && result.errors.length === 0) {
790
874
  spinner.succeed('No migrations to roll back');
791
875
  newline();
@@ -1273,6 +1357,7 @@ function showMigrateHelp() {
1273
1357
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1274
1358
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1275
1359
  console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1360
+ console.log(` ${cyan('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
1276
1361
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1277
1362
  newline();
1278
1363
  console.log(` ${bold('Examples:')}`);
@@ -115,6 +115,8 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
115
115
  step?: number;
116
116
  allowDrift?: boolean;
117
117
  force?: boolean /** @deprecated use allowDrift */;
118
+ /** Run migrations even when they contain data-destroying statements. Default false. */
119
+ allowDestructive?: boolean;
118
120
  adapter?: DatabaseAdapter;
119
121
  dialect?: Dialect;
120
122
  }): Promise<{
@@ -134,6 +136,7 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
134
136
  */
135
137
  export declare function migrateDown(connectionString: string, migrationsDir: string, options?: {
136
138
  step?: number;
139
+ allowDestructive?: boolean;
137
140
  adapter?: DatabaseAdapter;
138
141
  dialect?: Dialect;
139
142
  }): Promise<{
@@ -18,6 +18,7 @@ import pg from 'pg';
18
18
  import { postgresql } from '../adapters/index.js';
19
19
  import { postgresDialect } from '../dialect.js';
20
20
  import { MigrationError } from '../errors.js';
21
+ import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './destructive.js';
21
22
  // ---------------------------------------------------------------------------
22
23
  // Tracking table management
23
24
  // ---------------------------------------------------------------------------
@@ -351,6 +352,35 @@ export async function migrateUp(connectionString, migrationsDir, options) {
351
352
  if (options?.step != null && options.step > 0) {
352
353
  pending = pending.slice(0, options.step);
353
354
  }
355
+ // Data-loss gate: refuse to run pending migrations containing destructive
356
+ // statements unless the caller has EXPLICITLY opted in. The CLI layers an
357
+ // interactive typed confirmation on top of this; programmatic callers must
358
+ // pass `allowDestructive: true`. Safe-by-default is the whole point — a
359
+ // DROP TABLE should never run just because a file exists.
360
+ if (!options?.allowDestructive) {
361
+ const offenders = [];
362
+ for (const file of pending) {
363
+ const { up } = parseMigrationSQL(file.path);
364
+ if (!up)
365
+ continue;
366
+ const hits = scanDestructiveSql(up);
367
+ if (hits.length > 0)
368
+ offenders.push({ file: file.filename, hits });
369
+ }
370
+ if (offenders.length > 0) {
371
+ const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
372
+ for (const o of offenders) {
373
+ lines.push(` ${o.file}`);
374
+ for (const h of o.hits) {
375
+ lines.push(` - [${h.kind}] ${h.target} — ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
376
+ }
377
+ }
378
+ lines.push('');
379
+ lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
380
+ lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
381
+ throw new MigrationError(lines.join('\n'));
382
+ }
383
+ }
354
384
  const results = [];
355
385
  const errors = [];
356
386
  for (const file of pending) {
@@ -418,6 +448,36 @@ export async function migrateDown(connectionString, migrationsDir, options) {
418
448
  const fileMap = new Map(allFiles.map((f) => [f.name, f]));
419
449
  // Reverse order — rollback most recent first
420
450
  const toRollback = applied.reverse().slice(0, options?.step ?? 1);
451
+ // Same data-loss gate as migrateUp — DOWN sections routinely contain
452
+ // DROP TABLE (the legitimate reverse of a CREATE), which still destroys
453
+ // every row written since the migration ran. Explicit opt-in required.
454
+ if (!options?.allowDestructive) {
455
+ const offenders = [];
456
+ for (const migration of toRollback) {
457
+ const file = fileMap.get(migration.name);
458
+ if (!file)
459
+ continue;
460
+ const { down } = parseMigrationSQL(file.path);
461
+ if (!down)
462
+ continue;
463
+ const hits = scanDestructiveSql(down);
464
+ if (hits.length > 0)
465
+ offenders.push({ file: file.filename, hits });
466
+ }
467
+ if (offenders.length > 0) {
468
+ const lines = ['[turbine] Refusing to roll back migrations whose DOWN sections are DESTRUCTIVE:', ''];
469
+ for (const o of offenders) {
470
+ lines.push(` ${o.file}`);
471
+ for (const h of o.hits) {
472
+ lines.push(` - [${h.kind}] ${h.target} — ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
473
+ }
474
+ }
475
+ lines.push('');
476
+ lines.push('To proceed: run `npx turbine migrate down` interactively and confirm, pass');
477
+ lines.push('--allow-destructive, or set allowDestructive: true programmatically.');
478
+ throw new MigrationError(lines.join('\n'));
479
+ }
480
+ }
421
481
  const results = [];
422
482
  const errors = [];
423
483
  for (const migration of toRollback) {
package/dist/cli/ui.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare const symbols: {
30
30
  readonly bullet: "*" | "•";
31
31
  readonly arrow: "→" | "->";
32
32
  readonly arrowRight: ">" | "▸";
33
- readonly info: "" | "i";
33
+ readonly info: "i" | "";
34
34
  readonly warning: "⚠" | "!";
35
35
  readonly dot: "." | "∙";
36
36
  readonly line: "─" | "-";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {