turbine-orm 0.40.0 → 0.40.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/dist/cjs/seed.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStackFramePath = parseStackFramePath;
3
4
  exports.defineSeed = defineSeed;
4
5
  const node_fs_1 = require("node:fs");
5
6
  const node_path_1 = require("node:path");
@@ -7,29 +8,101 @@ const node_url_1 = require("node:url");
7
8
  const client_js_1 = require("./client.js");
8
9
  const errors_js_1 = require("./errors.js");
9
10
  const emptySchema = { tables: {}, enums: {} };
10
- function entryUrl() {
11
- const entry = process.argv[1];
12
- if (!entry)
11
+ /**
12
+ * Extract the filesystem path from a single V8 stack-trace line, regardless of
13
+ * whether the frame is a `file://` URL (ESM), a bare absolute path (CJS / tsx),
14
+ * or a wrapped `(… )` location. The trailing `:line:col` (and any surrounding
15
+ * parens) are peeled from the END so a Windows drive colon or a URL scheme colon
16
+ * inside the path never confuses the match. Non-file frames (`node:internal/…`,
17
+ * `<anonymous>`) return null.
18
+ *
19
+ * Exported for unit testing the frame parser in isolation.
20
+ */
21
+ function parseStackFramePath(line) {
22
+ // Peel the trailing `:line:col` (with any closing paren) from the END so a
23
+ // Windows drive colon or a `file://` scheme colon earlier in the path is never
24
+ // mistaken for the location separator.
25
+ const loc = line.match(/:(\d+):(\d+)\)?\s*$/);
26
+ if (!loc || loc.index === undefined)
13
27
  return null;
28
+ let head = line.slice(0, loc.index);
29
+ const paren = head.lastIndexOf('(');
30
+ if (paren !== -1) {
31
+ // `at fn (PATH:line:col)`: the path is whatever the last "(" wraps.
32
+ head = head.slice(paren + 1);
33
+ }
34
+ else {
35
+ // `at PATH:line:col`: drop the leading " at " prefix.
36
+ head = head.replace(/^\s*at\s+/, '');
37
+ }
38
+ head = head.trim();
39
+ if (head.startsWith('file://')) {
40
+ try {
41
+ return (0, node_url_1.fileURLToPath)(head);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ // Accept only absolute filesystem paths (POSIX `/…` or Windows `C:\…` / `C:/…`).
48
+ if (/^(\/|[A-Za-z]:[\\/])/.test(head))
49
+ return head;
50
+ return null;
51
+ }
52
+ /** Best-effort canonicalization so two spellings of the same file compare equal. */
53
+ function canonicalPath(p) {
14
54
  try {
15
- return (0, node_url_1.pathToFileURL)((0, node_fs_1.realpathSync)(entry)).href;
55
+ return (0, node_fs_1.realpathSync)(p);
16
56
  }
17
57
  catch {
18
- return (0, node_url_1.pathToFileURL)((0, node_path_1.resolve)(entry)).href;
58
+ return (0, node_path_1.resolve)(p);
59
+ }
60
+ }
61
+ /**
62
+ * The canonical path of THIS module's own file, captured once at load time from
63
+ * a fresh stack. It is used to skip the library's own frames when locating the
64
+ * caller. This is robust to the src (`seed.ts`) vs published dist (`seed.js`)
65
+ * basename difference AND to the fact that the user's own file is ALSO named
66
+ * `seed.ts`, which a basename skip-list would wrongly exclude. This fixes the
67
+ * silent no-op: previously the plain-path skip-list only knew `src/seed.ts`, so
68
+ * the library's own `dist/seed.js` frame (or a tsx plain-path frame) was
69
+ * mistaken for the caller and the entry===caller self-run check never passed.
70
+ */
71
+ const SELF_PATH = (() => {
72
+ const stack = new Error().stack;
73
+ if (!stack)
74
+ return null;
75
+ // Frame [1] (after the "Error" header) is this IIFE, i.e. the current module.
76
+ for (const line of stack.split('\n').slice(1)) {
77
+ const p = parseStackFramePath(line);
78
+ if (p)
79
+ return canonicalPath(p);
19
80
  }
81
+ return null;
82
+ })();
83
+ function entryUrl() {
84
+ const entry = process.argv[1];
85
+ if (!entry)
86
+ return null;
87
+ return (0, node_url_1.pathToFileURL)(canonicalPath(entry)).href;
20
88
  }
89
+ /**
90
+ * The first stack frame that is NOT part of this module, expressed as a
91
+ * canonical `file://` URL. That frame is whoever invoked `defineSeed`: the
92
+ * user's seed module when the file is run directly.
93
+ */
21
94
  function callerUrl() {
22
95
  const stack = new Error().stack;
23
96
  if (!stack)
24
97
  return null;
25
98
  for (const line of stack.split('\n').slice(2)) {
26
- const fileUrl = line.match(/(file:\/\/\/[^):]+):\d+:\d+/)?.[1];
27
- if (fileUrl && !fileUrl.endsWith('/seed.ts') && !fileUrl.endsWith('/seed.js'))
28
- return fileUrl;
29
- const filePath = line.match(/\(?((?:\/|[A-Za-z]:\\)[^):]+):\d+:\d+\)?/)?.[1];
30
- if (filePath && !filePath.endsWith('/src/seed.ts') && !filePath.endsWith('\\src\\seed.ts')) {
31
- return (0, node_url_1.pathToFileURL)((0, node_path_1.resolve)(filePath)).href;
32
- }
99
+ const p = parseStackFramePath(line);
100
+ if (!p)
101
+ continue;
102
+ const canonical = canonicalPath(p);
103
+ if (SELF_PATH && canonical === SELF_PATH)
104
+ continue; // skip the library's own frames
105
+ return (0, node_url_1.pathToFileURL)(canonical).href;
33
106
  }
34
107
  return null;
35
108
  }
@@ -38,6 +111,24 @@ function isDirectSeedModule() {
38
111
  const caller = callerUrl();
39
112
  return process.env.NODE_TEST_CONTEXT === undefined && !!entry && !!caller && entry === caller;
40
113
  }
114
+ /**
115
+ * Signal, to a parent `turbine seed` process, that a defineSeed callback
116
+ * actually executed to completion. The CLI sets `TURBINE_SEED_SENTINEL` to a
117
+ * temp path before spawning the seed; if the file never appears the CLI knows
118
+ * the seed module loaded but no callback ran, and reports that as a failure
119
+ * instead of a false "Seed completed".
120
+ */
121
+ function markSeedRan() {
122
+ const sentinel = process.env.TURBINE_SEED_SENTINEL;
123
+ if (!sentinel)
124
+ return;
125
+ try {
126
+ (0, node_fs_1.writeFileSync)(sentinel, 'ran');
127
+ }
128
+ catch {
129
+ // Best-effort only: an unwritable sentinel must never fail a good seed run.
130
+ }
131
+ }
41
132
  async function runSeed(fn) {
42
133
  const connectionString = process.env.DATABASE_URL;
43
134
  if (!connectionString) {
@@ -46,6 +137,7 @@ async function runSeed(fn) {
46
137
  const db = new client_js_1.TurbineClient({ connectionString }, emptySchema);
47
138
  try {
48
139
  await fn(db);
140
+ markSeedRan();
49
141
  }
50
142
  finally {
51
143
  await db.disconnect();
@@ -189,8 +189,8 @@ export interface InitPlanFlags {
189
189
  * skipped when there is no URL or the database is unreachable.
190
190
  */
191
191
  export declare function planInitSteps(state: InitPlanState, flags: InitPlanFlags): InitPlanStep[];
192
- export declare function buildMigrateDeployOptions(_args: CliArgs): {
193
- allowDrift: false;
192
+ export declare function buildMigrateDeployOptions(args: CliArgs): {
193
+ allowDrift: boolean;
194
194
  allowDestructive: true;
195
195
  step: undefined;
196
196
  };
package/dist/cli/index.js CHANGED
@@ -23,7 +23,8 @@
23
23
  * npx turbine init --url postgres://...
24
24
  * npx turbine migrate create add_users_table
25
25
  */
26
- import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
26
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
27
28
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
28
29
  import { pathToFileURL } from 'node:url';
29
30
  import { generate } from '../generate.js';
@@ -31,9 +32,10 @@ import { findMissingRelationIndexes } from '../index-advisor.js';
31
32
  import { introspect } from '../introspect.js';
32
33
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
33
34
  import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
35
+ import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
34
36
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
35
37
  import { runMcpServer } from './mcp.js';
36
- import { buildDiffMigrationBody, createMigration, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
38
+ import { buildDiffMigrationBody, collectUpDestructive, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
37
39
  import { startObserve } from './observe.js';
38
40
  import { startStudio } from './studio.js';
39
41
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
@@ -1086,12 +1088,13 @@ async function cmdMigrate(args, config) {
1086
1088
  console.log(` ${cyan('status')} Show migration status`);
1087
1089
  newline();
1088
1090
  console.log(` ${bold('Options:')}`);
1089
- console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
1090
- console.log(` ${cyan('--from-diff')} Like --auto, but flags destructive statements in the file`);
1091
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff (destructive flagged)`);
1092
+ console.log(` ${cyan('--from-diff')} Generate from schema diff, destructive statements flagged inline`);
1091
1093
  console.log(` ${cyan('--recipe <name>')} Scaffold a sanctioned migration pattern`);
1092
1094
  console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
1093
1095
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1094
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
1096
+ console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting`);
1097
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('up')} / ${cyan('deploy')} ${dim('(advanced)')}`);
1095
1098
  newline();
1096
1099
  console.log(` ${bold('Recipes')} ${dim('(--recipe):')}`);
1097
1100
  for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
@@ -1174,9 +1177,13 @@ async function cmdMigrateCreate(args, config) {
1174
1177
  }
1175
1178
  diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
1176
1179
  newline();
1177
- const upSQL = diff.statements.join('\n');
1178
- const downSQL = diff.reverseStatements.join('\n');
1179
- const file = createMigration(config.migrationsDir, name, { up: upSQL, down: downSQL });
1180
+ // Route through buildDiffMigrationBody so any destructive statement (a lossy
1181
+ // ALTER COLUMN ... TYPE, or a DROP COLUMN for a column removed from the
1182
+ // schema) is flagged inline in the file, matching --from-diff. A
1183
+ // destructive-only diff therefore produces a real, flagged migration instead
1184
+ // of the old "already in sync" false negative.
1185
+ const body = buildDiffMigrationBody(diff);
1186
+ const file = createMigration(config.migrationsDir, name, { up: body.up, down: body.down });
1180
1187
  const relPath = relative(process.cwd(), file.path);
1181
1188
  success(`Created auto-migration: ${bold(file.filename)}`);
1182
1189
  newline();
@@ -1204,6 +1211,27 @@ async function cmdMigrateCreate(args, config) {
1204
1211
  }
1205
1212
  }
1206
1213
  newline();
1214
+ // Loudly flag any destructive statements written into the file (same as
1215
+ // --from-diff). They stay intact so `migrate up` still refuses them by
1216
+ // default, but the operator must know they are there before running.
1217
+ const destructiveCount = body.destructiveUp.length + body.destructiveDown.length;
1218
+ if (destructiveCount > 0) {
1219
+ warn(`This migration contains ${bold(String(destructiveCount))} DESTRUCTIVE statement(s), flagged in the file.`);
1220
+ for (const h of body.destructiveUp) {
1221
+ console.log(` ${red(symbols.warning)} ${dim('UP')} [${h.kind}] ${h.target}`);
1222
+ }
1223
+ for (const h of body.destructiveDown) {
1224
+ console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
1225
+ }
1226
+ newline();
1227
+ console.log(` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`);
1228
+ newline();
1229
+ }
1230
+ if (diff.warnings && diff.warnings.length > 0) {
1231
+ for (const w of diff.warnings)
1232
+ warn(w);
1233
+ newline();
1234
+ }
1207
1235
  console.log(` ${dim('Review the migration, then run:')}`);
1208
1236
  console.log(` ${cyan('npx turbine migrate up')}`);
1209
1237
  newline();
@@ -1374,6 +1402,7 @@ async function cmdMigrateUp(args, config) {
1374
1402
  console.log(` ${green(symbols.check)} ${file.filename}`);
1375
1403
  }
1376
1404
  }
1405
+ warnOutOfOrder(result.outOfOrder);
1377
1406
  if (result.errors.length > 0) {
1378
1407
  spinner.fail('Migration failed');
1379
1408
  for (const { file, error: msg } of result.errors) {
@@ -1385,58 +1414,101 @@ async function cmdMigrateUp(args, config) {
1385
1414
  }
1386
1415
  newline();
1387
1416
  }
1388
- export function buildMigrateDeployOptions(_args) {
1417
+ /** Print a one-line warning for each migration applied out of timestamp order. */
1418
+ function warnOutOfOrder(outOfOrder) {
1419
+ if (outOfOrder.length === 0)
1420
+ return;
1421
+ newline();
1422
+ for (const o of outOfOrder) {
1423
+ warn(`Applied ${bold(o.applied)} out of order (older than already-applied ${bold(o.newestPrior)}).`);
1424
+ }
1425
+ }
1426
+ export function buildMigrateDeployOptions(args) {
1389
1427
  return {
1390
- allowDrift: false,
1428
+ allowDrift: args.allowDrift === true,
1391
1429
  allowDestructive: true,
1392
1430
  step: undefined,
1393
1431
  };
1394
1432
  }
1433
+ /**
1434
+ * Print the itemized, classified destructive-statement report as a NOTICE.
1435
+ * `deploy` proceeds by design (the gate ran at author time), but it must not run
1436
+ * data-destroying SQL in total silence; the notice ends that zero-ceremony hole.
1437
+ */
1438
+ function printDestructiveNotice(offenders) {
1439
+ warn('NOTICE: this deploy runs DESTRUCTIVE statement(s):');
1440
+ for (const o of offenders) {
1441
+ console.log(` ${red(symbols.warning)} ${o.file}`);
1442
+ for (const h of o.hits) {
1443
+ console.log(` ${dim('-')} [${h.kind}] ${h.target} ${dim(DESTRUCTIVE_KIND_LABEL[h.kind])}`);
1444
+ }
1445
+ }
1446
+ newline();
1447
+ }
1395
1448
  async function cmdMigrateDeploy(args, config) {
1396
1449
  banner();
1397
1450
  const url = requireUrl(config);
1398
1451
  label('Database', redactUrl(url));
1399
1452
  label('Migrations', config.migrationsDir);
1400
1453
  newline();
1401
- if (args.dryRun) {
1402
- const spinner = new Spinner('Checking pending migrations').start();
1403
- const plan = await inspectMigrationDeploy(url, config.migrationsDir);
1404
- if (plan.mismatches.length > 0) {
1405
- spinner.fail('Deploy blocked by migration drift');
1406
- for (const mismatch of plan.mismatches) {
1407
- const reason = mismatch.type === 'missing' ? 'deleted from disk' : 'modified on disk';
1408
- console.log(` ${red(symbols.cross)} ${mismatch.name}.sql ${dim(`(${reason})`)}`);
1454
+ const spinner = new Spinner('Checking pending migrations').start();
1455
+ const plan = await inspectMigrationDeploy(url, config.migrationsDir);
1456
+ spinner.stop();
1457
+ // Drift handling: honor --allow-drift exactly like `up`. Without it, block;
1458
+ // with it, warn loudly and proceed.
1459
+ if (plan.mismatches.length > 0) {
1460
+ if (!args.allowDrift) {
1461
+ error('Deploy blocked by migration drift');
1462
+ newline();
1463
+ for (const line of formatChecksumMismatchError(plan.mismatches).split('\n')) {
1464
+ console.log(` ${line.replace('[turbine] ', '')}`);
1409
1465
  }
1410
1466
  newline();
1411
1467
  process.exit(1);
1412
1468
  }
1469
+ warn('--allow-drift is set: checksum validation is DISABLED for this deploy.');
1470
+ console.log(` ${dim('Applied migrations may have been modified or deleted on disk.')}`);
1471
+ newline();
1472
+ }
1473
+ if (args.dryRun) {
1413
1474
  if (plan.pending.length === 0) {
1414
- spinner.succeed('No pending migrations');
1475
+ info('No pending migrations');
1415
1476
  newline();
1416
1477
  return;
1417
1478
  }
1418
- spinner.succeed(`${bold(String(plan.pending.length))} pending migration(s)`);
1479
+ info(`${bold(String(plan.pending.length))} pending migration(s)`);
1419
1480
  for (const file of plan.pending) {
1420
1481
  console.log(` ${yellow(symbols.dot)} ${file.filename}`);
1421
1482
  }
1483
+ // Surface destructive statements even in a dry run so CI can see them.
1484
+ const destructive = collectUpDestructive(plan.pending);
1485
+ if (destructive.length > 0) {
1486
+ newline();
1487
+ printDestructiveNotice(destructive);
1488
+ }
1422
1489
  newline();
1423
1490
  return;
1424
1491
  }
1425
- const spinner = new Spinner('Deploying migrations').start();
1426
- const result = await migrateDeploy(url, config.migrationsDir);
1492
+ // Destructive notice before applying (deploy still proceeds by design).
1493
+ const destructive = collectUpDestructive(plan.pending);
1494
+ if (destructive.length > 0)
1495
+ printDestructiveNotice(destructive);
1496
+ const runSpinner = new Spinner('Deploying migrations').start();
1497
+ const result = await migrateDeploy(url, config.migrationsDir, { allowDrift: args.allowDrift });
1427
1498
  if (result.applied.length === 0 && result.errors.length === 0) {
1428
- spinner.succeed('0 applied all migrations are up to date');
1499
+ runSpinner.succeed('0 applied, all migrations are up to date');
1429
1500
  newline();
1430
1501
  return;
1431
1502
  }
1432
1503
  if (result.applied.length > 0) {
1433
- spinner.succeed(`${bold(String(result.applied.length))} applied`);
1504
+ runSpinner.succeed(`${bold(String(result.applied.length))} applied`);
1434
1505
  for (const file of result.applied) {
1435
1506
  console.log(` ${green(symbols.check)} ${file.filename}`);
1436
1507
  }
1437
1508
  }
1509
+ warnOutOfOrder(result.outOfOrder);
1438
1510
  if (result.errors.length > 0) {
1439
- spinner.fail('Deploy failed');
1511
+ runSpinner.fail('Deploy failed');
1440
1512
  for (const { file, error: msg } of result.errors) {
1441
1513
  console.log(` ${red(symbols.cross)} ${file.filename}`);
1442
1514
  console.log(` ${dim(msg)}`);
@@ -1553,8 +1625,17 @@ async function cmdMigrateStatus(_args, config) {
1553
1625
  const pendingCount = statuses.filter((s) => !s.applied).length;
1554
1626
  info(`${bold(String(appliedCount))} applied, ${pendingCount > 0 ? yellow(bold(String(pendingCount))) : bold(String(pendingCount))} pending`);
1555
1627
  newline();
1556
- // Check for checksum mismatches
1557
- const driftCount = statuses.filter((s) => s.checksumValid === false).length;
1628
+ // Applied migrations whose file was deleted from disk (distinct from an
1629
+ // on-disk edit). Counted in "applied" above; surfaced with their own banner.
1630
+ const missingCount = statuses.filter((s) => s.missingFile).length;
1631
+ if (missingCount > 0) {
1632
+ warn(`${bold(String(missingCount))} applied migration(s) are missing from disk!`);
1633
+ console.log(` ${dim('The history table records them, but their .sql file is gone.')}`);
1634
+ console.log(` ${dim('Restore the file(s) before running')} ${cyan('migrate up')} ${dim('or')} ${cyan('migrate deploy')}${dim('.')}`);
1635
+ newline();
1636
+ }
1637
+ // Check for checksum mismatches (on-disk edits only; missing files above).
1638
+ const driftCount = statuses.filter((s) => s.checksumValid === false && !s.missingFile).length;
1558
1639
  if (driftCount > 0) {
1559
1640
  warn(`${bold(String(driftCount))} migration(s) have been modified after application!`);
1560
1641
  console.log(` ${dim('Applied migrations should be immutable. Modifying them can cause drift.')}`);
@@ -1564,7 +1645,10 @@ async function cmdMigrateStatus(_args, config) {
1564
1645
  const headers = ['Status', 'Migration', 'Applied at'];
1565
1646
  const rows = statuses.map((s) => {
1566
1647
  let status;
1567
- if (s.applied && s.checksumValid === false) {
1648
+ if (s.missingFile) {
1649
+ status = red(`${symbols.warning} Missing file`);
1650
+ }
1651
+ else if (s.applied && s.checksumValid === false) {
1568
1652
  status = red(`${symbols.warning} Drifted`);
1569
1653
  }
1570
1654
  else if (s.applied) {
@@ -1613,21 +1697,40 @@ async function runSeedPlan(plan, config) {
1613
1697
  if (!canResolveTsx()) {
1614
1698
  throw new Error('TypeScript seed files require tsx — install tsx or use seed.js/seed.sql.');
1615
1699
  }
1700
+ // The seed runs in a child process, so we cannot observe its callback
1701
+ // directly. Hand it a sentinel path: `defineSeed`'s runner writes the file
1702
+ // only after a callback executes to completion. If the child exits cleanly
1703
+ // but the sentinel never appears, the seed module loaded without running
1704
+ // anything: a silent no-op we must report as a failure, not success.
1705
+ const sentinelDir = mkdtempSync(join(tmpdir(), 'turbine-seed-'));
1706
+ const sentinel = join(sentinelDir, 'ran');
1616
1707
  const { execFileSync } = await import('node:child_process');
1617
- execFileSync(plan.command, plan.args, {
1618
- stdio: 'inherit',
1619
- env: {
1620
- ...process.env,
1621
- DATABASE_URL: config.url || process.env.DATABASE_URL,
1622
- },
1623
- });
1708
+ try {
1709
+ execFileSync(plan.command, plan.args, {
1710
+ stdio: 'inherit',
1711
+ env: {
1712
+ ...process.env,
1713
+ DATABASE_URL: config.url || process.env.DATABASE_URL,
1714
+ TURBINE_SEED_SENTINEL: sentinel,
1715
+ },
1716
+ });
1717
+ if (!existsSync(sentinel)) {
1718
+ throw new Error('The seed file completed without running a seed callback. ' +
1719
+ 'Make sure it calls defineSeed(async (db) => { ... }) at the top level.');
1720
+ }
1721
+ }
1722
+ finally {
1723
+ rmSync(sentinelDir, { recursive: true, force: true });
1724
+ }
1624
1725
  return;
1625
1726
  }
1626
1727
  if (plan.kind === 'js') {
1627
1728
  const mod = await import(pathToFileURL(plan.file).href);
1628
- if (typeof mod.default === 'function') {
1629
- await mod.default();
1729
+ if (typeof mod.default !== 'function') {
1730
+ throw new Error('The seed file has no callable default export. ' +
1731
+ 'Export your seed with `export default defineSeed(async (db) => { ... })`.');
1630
1732
  }
1733
+ await mod.default();
1631
1734
  return;
1632
1735
  }
1633
1736
  const url = requireUrl(config);
@@ -2220,8 +2323,18 @@ function showHelp() {
2220
2323
  newline();
2221
2324
  console.log(` ${bold('Migrate options:')}`);
2222
2325
  console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create)')}`);
2326
+ console.log(` ${cyan('--from-diff')} Generate from schema diff, destructive statements flagged ${dim('(create)')}`);
2327
+ console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a named migration recipe, e.g. backfill ${dim('(create)')}`);
2223
2328
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
2224
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
2329
+ console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting ${dim('(up/down/push)')}`);
2330
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} / ${cyan('deploy')} ${dim('(advanced)')}`);
2331
+ newline();
2332
+ console.log(` ${bold('Init options:')}`);
2333
+ console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
2334
+ console.log(` ${cyan('--skip-schema')} Don't scaffold the schema file`);
2335
+ console.log(` ${cyan('--skip-seed')} Don't scaffold or run the seed file`);
2336
+ console.log(` ${cyan('--skip-push')} Don't offer to push the schema to the database`);
2337
+ console.log(` ${cyan('--skip-generate')} Don't offer to generate the typed client`);
2225
2338
  newline();
2226
2339
  console.log(` ${bold('Studio / observe options:')}`);
2227
2340
  console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983 studio, 4984 observe)')}`);
@@ -36,7 +36,43 @@ export interface MigrationStatus {
36
36
  appliedAt?: Date;
37
37
  /** True if the file checksum matches the stored checksum (only set for applied migrations) */
38
38
  checksumValid?: boolean;
39
+ /** True when the migration was applied but its file is missing from disk. */
40
+ missingFile?: boolean;
41
+ }
42
+ /** A pending migration whose UP section contains data-destroying statements. */
43
+ export interface DestructiveOffender {
44
+ file: string;
45
+ hits: DestructiveStatement[];
39
46
  }
47
+ /**
48
+ * A migration that was applied even though an already-applied migration carries
49
+ * a newer timestamp prefix, i.e. history was written out of order.
50
+ */
51
+ export interface OutOfOrderApply {
52
+ /** The out-of-order migration that was just applied. */
53
+ applied: string;
54
+ /** The newest previously-applied migration it landed behind. */
55
+ newestPrior: string;
56
+ }
57
+ export interface MigrationRunResult {
58
+ applied: MigrationFile[];
59
+ errors: Array<{
60
+ file: MigrationFile;
61
+ error: string;
62
+ }>;
63
+ /**
64
+ * Destructive statements found in the pending batch. Populated whether or not
65
+ * the run was allowed to proceed, so a caller (deploy) can print a notice even
66
+ * when it applies them by design.
67
+ */
68
+ destructive: DestructiveOffender[];
69
+ /** Migrations applied with a timestamp older than an already-applied one. */
70
+ outOfOrder: OutOfOrderApply[];
71
+ }
72
+ /** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
73
+ export declare function migrationTimestamp(name: string): string | null;
74
+ /** Scan a set of migration files' UP sections for data-destroying statements. */
75
+ export declare function collectUpDestructive(files: MigrationFile[]): DestructiveOffender[];
40
76
  /**
41
77
  * Parse a migration filename into its components.
42
78
  * Expected format: YYYYMMDDHHMMSS_description.sql
@@ -164,6 +200,7 @@ export interface MigrationDeployPlan {
164
200
  pending: MigrationFile[];
165
201
  mismatches: ChecksumMismatch[];
166
202
  }
203
+ export declare function formatChecksumMismatchError(mismatches: ChecksumMismatch[]): string;
167
204
  /**
168
205
  * Build a deploy plan from local migration files and applied migration rows.
169
206
  * This is pure file-system planning; callers with a database connection should
@@ -198,13 +235,7 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
198
235
  allowDestructive?: boolean;
199
236
  adapter?: DatabaseAdapter;
200
237
  dialect?: Dialect;
201
- }): Promise<{
202
- applied: MigrationFile[];
203
- errors: Array<{
204
- file: MigrationFile;
205
- error: string;
206
- }>;
207
- }>;
238
+ }): Promise<MigrationRunResult>;
208
239
  /**
209
240
  * Production migration apply. This intentionally applies files as written and
210
241
  * never performs interactive destructive confirmation.
@@ -212,13 +243,8 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
212
243
  export declare function migrateDeploy(connectionString: string, migrationsDir: string, options?: {
213
244
  adapter?: DatabaseAdapter;
214
245
  dialect?: Dialect;
215
- }): Promise<{
216
- applied: MigrationFile[];
217
- errors: Array<{
218
- file: MigrationFile;
219
- error: string;
220
- }>;
221
- }>;
246
+ allowDrift?: boolean;
247
+ }): Promise<MigrationRunResult>;
222
248
  /**
223
249
  * Rollback the last N migrations (DOWN).
224
250
  *