@happyvertical/smrt-cli 0.40.69 → 0.41.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/AGENTS.md +132 -1
- package/README.md +2 -1
- package/dist/{commands-U7gYv14z.js → commands-DjgCD-0o.js} +1331 -421
- package/dist/index.js +9 -9
- package/package.json +7 -7
|
@@ -5,7 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFile
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
import { ObjectRegistry, SchemaComparer, createQualifiedName, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
|
|
8
|
+
import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
|
|
9
9
|
import { loadExternalManifestSync } from "@happyvertical/smrt-core/manifest";
|
|
10
10
|
import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
11
|
import { createLogger } from "@happyvertical/logger";
|
|
@@ -409,266 +409,12 @@ async function autoDiscoverAndLoad(projectRoot = process.cwd()) {
|
|
|
409
409
|
};
|
|
410
410
|
}
|
|
411
411
|
//#endregion
|
|
412
|
-
//#region src/commands/postgres-timestamp-migration.ts
|
|
413
|
-
/**
|
|
414
|
-
* Fail-closed confirmation shared by PostgreSQL schema preview and migration.
|
|
415
|
-
*
|
|
416
|
-
* A `timestamp without time zone` value cannot establish its own original
|
|
417
|
-
* instant. Callers must therefore make the same explicit UTC provenance
|
|
418
|
-
* confirmation whether they are previewing or applying the conversion.
|
|
419
|
-
*/
|
|
420
|
-
function resolvePostgresTimestampMigration(legacyTimezone) {
|
|
421
|
-
if (legacyTimezone === void 0) return;
|
|
422
|
-
if (legacyTimezone !== "UTC") throw new Error("--postgres-timestamp-legacy-timezone must be exactly UTC; refusing to infer the offset of legacy PostgreSQL timestamps");
|
|
423
|
-
return { legacyTimezone: "UTC" };
|
|
424
|
-
}
|
|
425
|
-
//#endregion
|
|
426
|
-
//#region src/commands/db-diff.ts
|
|
427
|
-
/**
|
|
428
|
-
* db:diff Command
|
|
429
|
-
*
|
|
430
|
-
* Compares manifest schemas to database.
|
|
431
|
-
*/
|
|
432
|
-
var UNSUPPORTED_GENERATE_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
433
|
-
var dbDiffCommand = {
|
|
434
|
-
name: "db:diff",
|
|
435
|
-
description: "Compare manifest schema to database",
|
|
436
|
-
aliases: ["diff", "schema-diff"],
|
|
437
|
-
args: [],
|
|
438
|
-
options: {
|
|
439
|
-
generate: {
|
|
440
|
-
type: "boolean",
|
|
441
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
442
|
-
default: false,
|
|
443
|
-
short: "g"
|
|
444
|
-
},
|
|
445
|
-
name: {
|
|
446
|
-
type: "string",
|
|
447
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
448
|
-
short: "n"
|
|
449
|
-
},
|
|
450
|
-
format: {
|
|
451
|
-
type: "string",
|
|
452
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
453
|
-
short: "f"
|
|
454
|
-
},
|
|
455
|
-
"with-down": {
|
|
456
|
-
type: "boolean",
|
|
457
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
458
|
-
default: false
|
|
459
|
-
},
|
|
460
|
-
output: {
|
|
461
|
-
type: "string",
|
|
462
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
463
|
-
short: "o"
|
|
464
|
-
},
|
|
465
|
-
json: {
|
|
466
|
-
type: "boolean",
|
|
467
|
-
description: "Output as JSON",
|
|
468
|
-
default: false,
|
|
469
|
-
short: "j"
|
|
470
|
-
},
|
|
471
|
-
verbose: {
|
|
472
|
-
type: "boolean",
|
|
473
|
-
description: "Show detailed output",
|
|
474
|
-
default: false,
|
|
475
|
-
short: "v"
|
|
476
|
-
},
|
|
477
|
-
"drop-indexes": {
|
|
478
|
-
type: "boolean",
|
|
479
|
-
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
480
|
-
default: false
|
|
481
|
-
},
|
|
482
|
-
"postgres-timestamp-legacy-timezone": {
|
|
483
|
-
type: "string",
|
|
484
|
-
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
485
|
-
}
|
|
486
|
-
},
|
|
487
|
-
handler: async (_args, options) => {
|
|
488
|
-
let db;
|
|
489
|
-
try {
|
|
490
|
-
const postgresTimestampMigration = resolvePostgresTimestampMigration(options["postgres-timestamp-legacy-timezone"]);
|
|
491
|
-
const unsupportedFileOptions = [
|
|
492
|
-
"generate",
|
|
493
|
-
"name",
|
|
494
|
-
"format",
|
|
495
|
-
"with-down",
|
|
496
|
-
"output"
|
|
497
|
-
].filter((option) => options[option] !== void 0 && options[option] !== false);
|
|
498
|
-
if (unsupportedFileOptions.length > 0) {
|
|
499
|
-
const message = `${UNSUPPORTED_GENERATE_MESSAGE} Unsupported option(s): ${unsupportedFileOptions.map((option) => `--${option}`).join(", ")}.`;
|
|
500
|
-
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
501
|
-
else console.error(`\n❌ ${message}\n`);
|
|
502
|
-
process.exitCode = 1;
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
506
|
-
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
507
|
-
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
508
|
-
if (!config.database?.url || config.database.url === ":memory:") {
|
|
509
|
-
if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
|
|
510
|
-
else {
|
|
511
|
-
console.error("\n❌ Database configuration required");
|
|
512
|
-
console.error("\nPlease configure database in smrt.config.js\n");
|
|
513
|
-
}
|
|
514
|
-
process.exit(1);
|
|
515
|
-
}
|
|
516
|
-
const dbUrl = config.database.url;
|
|
517
|
-
const dbType = config.database.type || "sqlite";
|
|
518
|
-
if (!options.json) console.log("\n📊 Schema Diff\n");
|
|
519
|
-
const { discovered, totalObjects } = await autoDiscoverAndLoad();
|
|
520
|
-
if (discovered.length === 0) {
|
|
521
|
-
if (options.json) console.log(JSON.stringify({ error: "No manifests found" }));
|
|
522
|
-
else {
|
|
523
|
-
console.error("❌ No SMRT manifests found");
|
|
524
|
-
console.error("\nRun: smrt test --manifest-only (generates manifest)");
|
|
525
|
-
}
|
|
526
|
-
process.exit(1);
|
|
527
|
-
}
|
|
528
|
-
if (!options.json) console.log(`✓ Found ${totalObjects} object(s) in ${discovered.length} manifest(s)\n`);
|
|
529
|
-
const { getDatabase } = await import("@happyvertical/sql");
|
|
530
|
-
db = await getDatabase({
|
|
531
|
-
type: dbType,
|
|
532
|
-
url: dbUrl
|
|
533
|
-
});
|
|
534
|
-
const schemaDefinitions = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
535
|
-
const { SchemaComparer } = await import("@happyvertical/smrt-core/migrations");
|
|
536
|
-
const diff = await new SchemaComparer(db, {
|
|
537
|
-
includeDroppedTables: false,
|
|
538
|
-
includeDroppedColumns: false,
|
|
539
|
-
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
540
|
-
postgresTimestampMigration
|
|
541
|
-
}).compare(schemaDefinitions);
|
|
542
|
-
if (options.json) {
|
|
543
|
-
console.log(JSON.stringify({
|
|
544
|
-
hasChanges: diff.has_changes,
|
|
545
|
-
addedTables: diff.added_tables.map((t) => t.tableName),
|
|
546
|
-
droppedTables: diff.dropped_tables,
|
|
547
|
-
changes: diff.changes
|
|
548
|
-
}, null, 2));
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
if (!diff.has_changes) {
|
|
552
|
-
console.log("✅ Database schema is up to date - no changes detected\n");
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
console.log("📋 Changes Detected:\n");
|
|
556
|
-
if (diff.added_tables.length > 0) {
|
|
557
|
-
console.log(` 📦 New tables (${diff.added_tables.length}):`);
|
|
558
|
-
for (const table of diff.added_tables) console.log(` + ${table.tableName}`);
|
|
559
|
-
console.log();
|
|
560
|
-
}
|
|
561
|
-
const columnChanges = diff.changes.filter((c) => c.type === "add_column");
|
|
562
|
-
const indexChanges = diff.changes.filter((c) => c.type === "add_index");
|
|
563
|
-
const indexDrops = diff.changes.filter((c) => c.type === "drop_index");
|
|
564
|
-
const typeUpgrades = diff.changes.filter((c) => c.type === "type_upgrade");
|
|
565
|
-
const typeMismatches = diff.changes.filter((c) => c.type === "type_mismatch");
|
|
566
|
-
const recreateNames = /* @__PURE__ */ new Set();
|
|
567
|
-
const dropNames = new Set(indexDrops.map((c) => c.name));
|
|
568
|
-
for (const add of indexChanges) if (add.name && dropNames.has(add.name)) recreateNames.add(add.name);
|
|
569
|
-
if (columnChanges.length > 0) {
|
|
570
|
-
console.log(` 📊 New columns (${columnChanges.length}):`);
|
|
571
|
-
for (const change of columnChanges) console.log(` + ${change.table}.${change.name}`);
|
|
572
|
-
console.log();
|
|
573
|
-
}
|
|
574
|
-
if (recreateNames.size > 0) {
|
|
575
|
-
console.log(` ♻️ Indexes to recreate (${recreateNames.size}):`);
|
|
576
|
-
for (const name of recreateNames) {
|
|
577
|
-
const add = indexChanges.find((c) => c.name === name);
|
|
578
|
-
const cols = add?.index?.columns?.join(", ") ?? "?";
|
|
579
|
-
const unique = add?.index?.unique ? "UNIQUE " : "";
|
|
580
|
-
console.log(` ↻ ${name} → ${unique}(${cols})`);
|
|
581
|
-
}
|
|
582
|
-
console.log(" (each recreate is a drop_index followed by add_index)\n");
|
|
583
|
-
}
|
|
584
|
-
const orphanDrops = indexDrops.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
585
|
-
if (orphanDrops.length > 0) {
|
|
586
|
-
console.log(` 🗑️ Indexes to drop (${orphanDrops.length}):`);
|
|
587
|
-
for (const change of orphanDrops) console.log(` - ${change.name} on ${change.table}`);
|
|
588
|
-
console.log();
|
|
589
|
-
}
|
|
590
|
-
const newIndexes = indexChanges.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
591
|
-
if (newIndexes.length > 0) {
|
|
592
|
-
console.log(` 🗂️ New indexes (${newIndexes.length}):`);
|
|
593
|
-
for (const change of newIndexes) console.log(` + ${change.name} on ${change.table}`);
|
|
594
|
-
console.log();
|
|
595
|
-
}
|
|
596
|
-
if (typeUpgrades.length > 0) {
|
|
597
|
-
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
598
|
-
for (const change of typeUpgrades) console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
599
|
-
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
600
|
-
}
|
|
601
|
-
if (typeMismatches.length > 0) {
|
|
602
|
-
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
603
|
-
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
604
|
-
console.log(" (Type changes require manual migration)\n");
|
|
605
|
-
}
|
|
606
|
-
console.log("━".repeat(50));
|
|
607
|
-
console.log("\n💡 Schema migrations are manifest-driven.");
|
|
608
|
-
console.log(" Update @smrt object definitions, then run smrt db:migrate.\n");
|
|
609
|
-
} catch (error) {
|
|
610
|
-
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
611
|
-
else {
|
|
612
|
-
console.error("\n❌ Failed to generate diff:");
|
|
613
|
-
if (error instanceof Error) console.error(` ${error.message}`);
|
|
614
|
-
}
|
|
615
|
-
process.exitCode = 1;
|
|
616
|
-
return;
|
|
617
|
-
} finally {
|
|
618
|
-
await closeDatabaseConnection(db);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
};
|
|
622
|
-
//#endregion
|
|
623
|
-
//#region src/commands/db-generate.ts
|
|
624
|
-
var UNSUPPORTED_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
625
|
-
var dbGenerateCommand = {
|
|
626
|
-
name: "db:generate",
|
|
627
|
-
description: "[Unsupported] File-backed migrations are not supported",
|
|
628
|
-
aliases: [
|
|
629
|
-
"generate",
|
|
630
|
-
"migration-create",
|
|
631
|
-
"db:create"
|
|
632
|
-
],
|
|
633
|
-
args: ["<name>"],
|
|
634
|
-
options: {
|
|
635
|
-
sql: {
|
|
636
|
-
type: "boolean",
|
|
637
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
638
|
-
default: true
|
|
639
|
-
},
|
|
640
|
-
ts: {
|
|
641
|
-
type: "boolean",
|
|
642
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
643
|
-
default: false
|
|
644
|
-
},
|
|
645
|
-
timestamp: {
|
|
646
|
-
type: "boolean",
|
|
647
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
648
|
-
default: false,
|
|
649
|
-
short: "t"
|
|
650
|
-
},
|
|
651
|
-
output: {
|
|
652
|
-
type: "string",
|
|
653
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
654
|
-
default: "./migrations",
|
|
655
|
-
short: "o"
|
|
656
|
-
},
|
|
657
|
-
json: {
|
|
658
|
-
type: "boolean",
|
|
659
|
-
description: "Output as JSON",
|
|
660
|
-
default: false,
|
|
661
|
-
short: "j"
|
|
662
|
-
}
|
|
663
|
-
},
|
|
664
|
-
handler: async (_args, options) => {
|
|
665
|
-
if (options.json) console.log(JSON.stringify({ error: UNSUPPORTED_MESSAGE }));
|
|
666
|
-
else console.error(`\n❌ ${UNSUPPORTED_MESSAGE}\n`);
|
|
667
|
-
process.exitCode = 1;
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
//#endregion
|
|
671
412
|
//#region src/commands/db-migrate-actions.ts
|
|
413
|
+
/** True when a change carries an advisory and no executable statement. */
|
|
414
|
+
function isAdvisoryOnlyChangeLike(change) {
|
|
415
|
+
if (!change.advisory) return false;
|
|
416
|
+
return (change.sqlStatements ?? (change.sql ? [change.sql] : [])).length === 0;
|
|
417
|
+
}
|
|
672
418
|
/**
|
|
673
419
|
* Short stable fingerprint of the SQL we'd execute for an action, used
|
|
674
420
|
* to disambiguate index synthetic ids when an index's shape changes
|
|
@@ -716,9 +462,27 @@ function classifyTypeUpgradeSql(sql) {
|
|
|
716
462
|
if (/no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed)) return "noop";
|
|
717
463
|
return "manual";
|
|
718
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* `alter_column` identities embed the alteration kind and the SQL shape so a
|
|
467
|
+
* column that is tightened, relaxed, and tightened again over its lifetime
|
|
468
|
+
* never reuses an applied tracker id with a different checksum (#2369).
|
|
469
|
+
*/
|
|
470
|
+
function alterColumnMigrationName(tableName, columnName, alteration, action) {
|
|
471
|
+
const kind = alteration ?? "alter";
|
|
472
|
+
const fingerprint = sqlShapeFingerprint(action);
|
|
473
|
+
return fingerprint ? `alter_column_${tableName}_${columnName}_${kind}_${fingerprint}` : `alter_column_${tableName}_${columnName}_${kind}`;
|
|
474
|
+
}
|
|
719
475
|
function getSyntheticMigrationNameForAction(action) {
|
|
720
476
|
switch (action.type) {
|
|
721
477
|
case "add_column": return action.column ? `add_column_${action.tableName}_${action.column.name}` : null;
|
|
478
|
+
case "drop_column": {
|
|
479
|
+
const columnName = action.columnName ?? action.column?.name;
|
|
480
|
+
return columnName ? `drop_column_${action.tableName}_${columnName}` : null;
|
|
481
|
+
}
|
|
482
|
+
case "alter_column": {
|
|
483
|
+
const columnName = action.columnName ?? action.column?.name;
|
|
484
|
+
return columnName ? alterColumnMigrationName(action.tableName, columnName, action.alteration, action) : null;
|
|
485
|
+
}
|
|
722
486
|
case "add_index": {
|
|
723
487
|
if (!action.index) return null;
|
|
724
488
|
const fingerprint = sqlShapeFingerprint(action);
|
|
@@ -740,6 +504,8 @@ function getSyntheticMigrationNameForAction(action) {
|
|
|
740
504
|
function getSyntheticMigrationNameForChange(change) {
|
|
741
505
|
switch (change.type) {
|
|
742
506
|
case "add_column": return change.name ? `add_column_${change.table}_${change.name}` : null;
|
|
507
|
+
case "drop_column": return change.name ? `drop_column_${change.table}_${change.name}` : null;
|
|
508
|
+
case "alter_column": return change.name && !isAdvisoryOnlyChangeLike(change) ? alterColumnMigrationName(change.table, change.name, change.alteration, change) : null;
|
|
743
509
|
case "add_index": {
|
|
744
510
|
const indexName = change.index?.name ?? change.name;
|
|
745
511
|
if (!indexName) return null;
|
|
@@ -790,14 +556,15 @@ function shouldApplySchemaMigrations(state) {
|
|
|
790
556
|
return !state.dryRun;
|
|
791
557
|
}
|
|
792
558
|
function classifyFailedMigration(migrationName, unresolvedSyntheticMigrationNames) {
|
|
793
|
-
if (!migrationName.startsWith("add_column_") && !migrationName.startsWith("add_index_") && !migrationName.startsWith("drop_index_") && !migrationName.startsWith("type_upgrade_")) return "other";
|
|
559
|
+
if (!migrationName.startsWith("add_column_") && !migrationName.startsWith("drop_column_") && !migrationName.startsWith("alter_column_") && !migrationName.startsWith("add_index_") && !migrationName.startsWith("drop_index_") && !migrationName.startsWith("type_upgrade_")) return "other";
|
|
794
560
|
return unresolvedSyntheticMigrationNames.has(migrationName) ? "unresolved" : "superseded";
|
|
795
561
|
}
|
|
796
562
|
function getUnresolvedGeneratedMigrationNames(changes) {
|
|
797
563
|
const names = /* @__PURE__ */ new Set();
|
|
798
564
|
for (const change of changes) {
|
|
799
|
-
if (change.type !== "add_column" && change.type !== "add_index" && change.type !== "drop_index" && change.type !== "type_upgrade") continue;
|
|
565
|
+
if (change.type !== "add_column" && change.type !== "drop_column" && change.type !== "alter_column" && change.type !== "add_index" && change.type !== "drop_index" && change.type !== "type_upgrade") continue;
|
|
800
566
|
if (change.type === "type_upgrade" && classifyTypeUpgradeSql(change.sql) === "noop") continue;
|
|
567
|
+
if (change.type === "alter_column" && isAdvisoryOnlyChangeLike(change)) continue;
|
|
801
568
|
for (const migrationName of getSyntheticMigrationNamesForChange(change)) names.add(migrationName);
|
|
802
569
|
}
|
|
803
570
|
return names;
|
|
@@ -831,9 +598,77 @@ function summarizeFailedMigrations(failedMigrations, unresolvedSyntheticMigratio
|
|
|
831
598
|
function partitionSchemaChanges(changes, getClassForTable) {
|
|
832
599
|
const migrations = [];
|
|
833
600
|
const manualInterventions = [];
|
|
601
|
+
const advisories = [];
|
|
834
602
|
for (const change of changes) {
|
|
835
603
|
const className = getClassForTable(change.table);
|
|
836
604
|
switch (change.type) {
|
|
605
|
+
case "orphan_column":
|
|
606
|
+
case "orphan_index":
|
|
607
|
+
if (!change.name || !change.advisory) continue;
|
|
608
|
+
advisories.push({
|
|
609
|
+
type: change.type,
|
|
610
|
+
tableName: change.table,
|
|
611
|
+
className,
|
|
612
|
+
name: change.name,
|
|
613
|
+
actual: change.mismatch?.actual,
|
|
614
|
+
advisory: change.advisory
|
|
615
|
+
});
|
|
616
|
+
break;
|
|
617
|
+
case "drop_column":
|
|
618
|
+
if (!change.name) continue;
|
|
619
|
+
migrations.push({
|
|
620
|
+
type: "drop_column",
|
|
621
|
+
tableName: change.table,
|
|
622
|
+
className,
|
|
623
|
+
columnName: change.name,
|
|
624
|
+
mismatch: change.mismatch ? {
|
|
625
|
+
column: change.name,
|
|
626
|
+
expected: change.mismatch.expected,
|
|
627
|
+
actual: change.mismatch.actual
|
|
628
|
+
} : void 0,
|
|
629
|
+
sql: change.sql,
|
|
630
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
631
|
+
});
|
|
632
|
+
break;
|
|
633
|
+
case "alter_column": {
|
|
634
|
+
if (!change.name) continue;
|
|
635
|
+
if (isAdvisoryOnlyChangeLike(change) && change.advisory) {
|
|
636
|
+
advisories.push({
|
|
637
|
+
type: "alter_column",
|
|
638
|
+
tableName: change.table,
|
|
639
|
+
className,
|
|
640
|
+
name: change.name,
|
|
641
|
+
alteration: change.alteration,
|
|
642
|
+
actual: change.mismatch?.actual,
|
|
643
|
+
advisory: change.advisory
|
|
644
|
+
});
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
const action = {
|
|
648
|
+
type: "alter_column",
|
|
649
|
+
tableName: change.table,
|
|
650
|
+
className,
|
|
651
|
+
columnName: change.name,
|
|
652
|
+
alteration: change.alteration,
|
|
653
|
+
...change.column ? { column: {
|
|
654
|
+
name: change.name,
|
|
655
|
+
type: change.column.type,
|
|
656
|
+
notNull: change.column.notNull,
|
|
657
|
+
defaultValue: change.column.defaultValue,
|
|
658
|
+
unique: change.column.unique
|
|
659
|
+
} } : {},
|
|
660
|
+
mismatch: change.mismatch ? {
|
|
661
|
+
column: change.name,
|
|
662
|
+
expected: change.mismatch.expected,
|
|
663
|
+
actual: change.mismatch.actual
|
|
664
|
+
} : void 0,
|
|
665
|
+
sql: change.sql,
|
|
666
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
667
|
+
};
|
|
668
|
+
if (classifyTypeUpgradeSql(change.sql) === "executable") migrations.push(action);
|
|
669
|
+
else manualInterventions.push(action);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
837
672
|
case "add_column": {
|
|
838
673
|
const col = change.column;
|
|
839
674
|
if (!change.name || !col) continue;
|
|
@@ -880,57 +715,413 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
880
715
|
sql: change.sql,
|
|
881
716
|
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
882
717
|
});
|
|
883
|
-
break;
|
|
884
|
-
case "type_mismatch": {
|
|
885
|
-
const mm = change.mismatch;
|
|
886
|
-
if (!change.name || !mm) continue;
|
|
887
|
-
manualInterventions.push({
|
|
888
|
-
type: "type_mismatch",
|
|
889
|
-
tableName: change.table,
|
|
890
|
-
className,
|
|
891
|
-
mismatch: {
|
|
892
|
-
column: change.name,
|
|
893
|
-
expected: mm.expected,
|
|
894
|
-
actual: mm.actual
|
|
895
|
-
}
|
|
718
|
+
break;
|
|
719
|
+
case "type_mismatch": {
|
|
720
|
+
const mm = change.mismatch;
|
|
721
|
+
if (!change.name || !mm) continue;
|
|
722
|
+
manualInterventions.push({
|
|
723
|
+
type: "type_mismatch",
|
|
724
|
+
tableName: change.table,
|
|
725
|
+
className,
|
|
726
|
+
mismatch: {
|
|
727
|
+
column: change.name,
|
|
728
|
+
expected: mm.expected,
|
|
729
|
+
actual: mm.actual
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
case "type_upgrade": {
|
|
735
|
+
const mm = change.mismatch;
|
|
736
|
+
const col = change.column;
|
|
737
|
+
if (!change.name || !mm || !col) continue;
|
|
738
|
+
const action = {
|
|
739
|
+
type: "type_upgrade",
|
|
740
|
+
tableName: change.table,
|
|
741
|
+
className,
|
|
742
|
+
column: {
|
|
743
|
+
name: change.name,
|
|
744
|
+
type: col.type,
|
|
745
|
+
notNull: col.notNull,
|
|
746
|
+
defaultValue: col.defaultValue,
|
|
747
|
+
unique: col.unique
|
|
748
|
+
},
|
|
749
|
+
mismatch: {
|
|
750
|
+
column: change.name,
|
|
751
|
+
expected: mm.expected,
|
|
752
|
+
actual: mm.actual
|
|
753
|
+
},
|
|
754
|
+
sql: change.sql,
|
|
755
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
756
|
+
};
|
|
757
|
+
const executionKind = classifyTypeUpgradeSql(change.sql);
|
|
758
|
+
if (executionKind === "executable") migrations.push(action);
|
|
759
|
+
else if (executionKind === "manual") manualInterventions.push(action);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
migrations,
|
|
766
|
+
manualInterventions,
|
|
767
|
+
advisories
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Print report-only schema findings (#2369) in a stable order: warnings
|
|
772
|
+
* first (an orphan NOT NULL column that breaks inserts, a stale unique
|
|
773
|
+
* constraint, a relaxation not opted into), then info, then orphan tables
|
|
774
|
+
* (verbose only — shared databases legitimately hold tables from other
|
|
775
|
+
* apps). Shared by db:migrate and db:diff so both commands report the same
|
|
776
|
+
* findings the same way.
|
|
777
|
+
*/
|
|
778
|
+
function printSchemaAdvisories(advisories, options = {}) {
|
|
779
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
780
|
+
const warnings = advisories.filter((a) => a.advisory.severity === "warning");
|
|
781
|
+
const infos = advisories.filter((a) => a.advisory.severity !== "warning");
|
|
782
|
+
if (warnings.length > 0) {
|
|
783
|
+
log("⚠️ Live schema findings that need an operator decision (not applied):\n");
|
|
784
|
+
for (const item of warnings) {
|
|
785
|
+
log(` ${describeAdvisory(item)}`);
|
|
786
|
+
log(` ${item.advisory.message}`);
|
|
787
|
+
for (const suggestion of item.advisory.suggestedSql ?? []) log(` ↳ ${suggestion}`);
|
|
788
|
+
}
|
|
789
|
+
log("");
|
|
790
|
+
}
|
|
791
|
+
if (infos.length > 0) {
|
|
792
|
+
log(`ℹ️ Live schema notes (${infos.length}, not applied):`);
|
|
793
|
+
for (const item of infos) {
|
|
794
|
+
log(` ${describeAdvisory(item)}`);
|
|
795
|
+
if (options.verbose) {
|
|
796
|
+
log(` ${item.advisory.message}`);
|
|
797
|
+
for (const suggestion of item.advisory.suggestedSql ?? []) log(` ↳ ${suggestion}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
log("");
|
|
801
|
+
}
|
|
802
|
+
const orphanTables = options.orphanTables ?? [];
|
|
803
|
+
if (orphanTables.length > 0 && options.verbose) {
|
|
804
|
+
log(`ℹ️ Tables in the database that no loaded manifest declares (${orphanTables.length}): ${orphanTables.join(", ")}`);
|
|
805
|
+
log(" Not dropped; remove them manually if they are stale.\n");
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function describeAdvisory(item) {
|
|
809
|
+
switch (item.type) {
|
|
810
|
+
case "orphan_column": return `${item.tableName}.${item.name}: orphan column${item.actual ? ` (${item.actual})` : ""}`;
|
|
811
|
+
case "orphan_index": return `${item.tableName}.${item.name}: unique constraint not in manifest${item.actual ? ` (${item.actual})` : ""}`;
|
|
812
|
+
default: return `${item.tableName}.${item.name}: ${item.alteration ?? "alter_column"}${item.actual ? ` (live: ${item.actual})` : ""}`;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/commands/postgres-timestamp-migration.ts
|
|
817
|
+
/**
|
|
818
|
+
* Fail-closed confirmation shared by PostgreSQL schema preview and migration.
|
|
819
|
+
*
|
|
820
|
+
* A `timestamp without time zone` value cannot establish its own original
|
|
821
|
+
* instant. Callers must therefore make the same explicit UTC provenance
|
|
822
|
+
* confirmation whether they are previewing or applying the conversion.
|
|
823
|
+
*/
|
|
824
|
+
function resolvePostgresTimestampMigration(legacyTimezone) {
|
|
825
|
+
if (legacyTimezone === void 0) return;
|
|
826
|
+
if (legacyTimezone !== "UTC") throw new Error("--postgres-timestamp-legacy-timezone must be exactly UTC; refusing to infer the offset of legacy PostgreSQL timestamps");
|
|
827
|
+
return { legacyTimezone: "UTC" };
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/commands/db-diff.ts
|
|
831
|
+
/**
|
|
832
|
+
* db:diff Command
|
|
833
|
+
*
|
|
834
|
+
* Compares manifest schemas to database.
|
|
835
|
+
*/
|
|
836
|
+
var UNSUPPORTED_GENERATE_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
837
|
+
var dbDiffCommand = {
|
|
838
|
+
name: "db:diff",
|
|
839
|
+
description: "Compare manifest schema to database",
|
|
840
|
+
aliases: ["diff", "schema-diff"],
|
|
841
|
+
args: [],
|
|
842
|
+
options: {
|
|
843
|
+
generate: {
|
|
844
|
+
type: "boolean",
|
|
845
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
846
|
+
default: false,
|
|
847
|
+
short: "g"
|
|
848
|
+
},
|
|
849
|
+
name: {
|
|
850
|
+
type: "string",
|
|
851
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
852
|
+
short: "n"
|
|
853
|
+
},
|
|
854
|
+
format: {
|
|
855
|
+
type: "string",
|
|
856
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
857
|
+
short: "f"
|
|
858
|
+
},
|
|
859
|
+
"with-down": {
|
|
860
|
+
type: "boolean",
|
|
861
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
862
|
+
default: false
|
|
863
|
+
},
|
|
864
|
+
output: {
|
|
865
|
+
type: "string",
|
|
866
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
867
|
+
short: "o"
|
|
868
|
+
},
|
|
869
|
+
json: {
|
|
870
|
+
type: "boolean",
|
|
871
|
+
description: "Output as JSON",
|
|
872
|
+
default: false,
|
|
873
|
+
short: "j"
|
|
874
|
+
},
|
|
875
|
+
verbose: {
|
|
876
|
+
type: "boolean",
|
|
877
|
+
description: "Show detailed output",
|
|
878
|
+
default: false,
|
|
879
|
+
short: "v"
|
|
880
|
+
},
|
|
881
|
+
"drop-indexes": {
|
|
882
|
+
type: "boolean",
|
|
883
|
+
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
884
|
+
default: false
|
|
885
|
+
},
|
|
886
|
+
"drop-columns": {
|
|
887
|
+
type: "boolean",
|
|
888
|
+
description: "Include orphan-column drops in the diff (columns in DB but not in the manifest). Off by default; orphans are always reported.",
|
|
889
|
+
default: false
|
|
890
|
+
},
|
|
891
|
+
"relax-columns": {
|
|
892
|
+
type: "boolean",
|
|
893
|
+
description: "Include constraint relaxations in the diff (DROP NOT NULL / DROP DEFAULT on live columns stricter than the manifest, DROP NOT NULL on orphan NOT NULL columns). Off by default; relaxations are always reported.",
|
|
894
|
+
default: false
|
|
895
|
+
},
|
|
896
|
+
"postgres-timestamp-legacy-timezone": {
|
|
897
|
+
type: "string",
|
|
898
|
+
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
handler: async (_args, options) => {
|
|
902
|
+
let db;
|
|
903
|
+
try {
|
|
904
|
+
const postgresTimestampMigration = resolvePostgresTimestampMigration(options["postgres-timestamp-legacy-timezone"]);
|
|
905
|
+
const unsupportedFileOptions = [
|
|
906
|
+
"generate",
|
|
907
|
+
"name",
|
|
908
|
+
"format",
|
|
909
|
+
"with-down",
|
|
910
|
+
"output"
|
|
911
|
+
].filter((option) => options[option] !== void 0 && options[option] !== false);
|
|
912
|
+
if (unsupportedFileOptions.length > 0) {
|
|
913
|
+
const message = `${UNSUPPORTED_GENERATE_MESSAGE} Unsupported option(s): ${unsupportedFileOptions.map((option) => `--${option}`).join(", ")}.`;
|
|
914
|
+
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
915
|
+
else console.error(`\n❌ ${message}\n`);
|
|
916
|
+
process.exitCode = 1;
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
920
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
921
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
922
|
+
if (!config.database?.url || config.database.url === ":memory:") {
|
|
923
|
+
if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
|
|
924
|
+
else {
|
|
925
|
+
console.error("\n❌ Database configuration required");
|
|
926
|
+
console.error("\nPlease configure database in smrt.config.js\n");
|
|
927
|
+
}
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
const dbUrl = config.database.url;
|
|
931
|
+
const dbType = config.database.type || "sqlite";
|
|
932
|
+
if (!options.json) console.log("\n📊 Schema Diff\n");
|
|
933
|
+
const { discovered, totalObjects } = await autoDiscoverAndLoad();
|
|
934
|
+
if (discovered.length === 0) {
|
|
935
|
+
if (options.json) console.log(JSON.stringify({ error: "No manifests found" }));
|
|
936
|
+
else {
|
|
937
|
+
console.error("❌ No SMRT manifests found");
|
|
938
|
+
console.error("\nRun: smrt test --manifest-only (generates manifest)");
|
|
939
|
+
}
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
if (!options.json) console.log(`✓ Found ${totalObjects} object(s) in ${discovered.length} manifest(s)\n`);
|
|
943
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
944
|
+
db = await getDatabase({
|
|
945
|
+
type: dbType,
|
|
946
|
+
url: dbUrl
|
|
947
|
+
});
|
|
948
|
+
const schemaDefinitions = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
949
|
+
const { SchemaComparer } = await import("@happyvertical/smrt-core/migrations");
|
|
950
|
+
const diff = await new SchemaComparer(db, {
|
|
951
|
+
includeDroppedTables: false,
|
|
952
|
+
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
953
|
+
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
954
|
+
relaxColumns: Boolean(options["relax-columns"]),
|
|
955
|
+
postgresTimestampMigration
|
|
956
|
+
}).compare(schemaDefinitions);
|
|
957
|
+
if (options.json) {
|
|
958
|
+
console.log(JSON.stringify({
|
|
959
|
+
hasChanges: diff.has_changes,
|
|
960
|
+
addedTables: diff.added_tables.map((t) => t.tableName),
|
|
961
|
+
droppedTables: diff.dropped_tables,
|
|
962
|
+
orphanTables: diff.orphan_tables ?? [],
|
|
963
|
+
changes: diff.changes
|
|
964
|
+
}, null, 2));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const { advisories } = partitionSchemaChanges(diff.changes, (tableName) => tableName);
|
|
968
|
+
if (!diff.has_changes) {
|
|
969
|
+
console.log("✅ Database schema is up to date - no changes detected\n");
|
|
970
|
+
printSchemaAdvisories(advisories, {
|
|
971
|
+
orphanTables: diff.orphan_tables,
|
|
972
|
+
verbose: options.verbose
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (diff.changes.filter((c) => !(c.advisory && !c.sql && !c.sqlStatements)).length === 0 && diff.added_tables.length === 0 && diff.dropped_tables.length === 0) {
|
|
977
|
+
console.log("✅ Database schema is up to date - no migrations needed (see notes below)\n");
|
|
978
|
+
printSchemaAdvisories(advisories, {
|
|
979
|
+
orphanTables: diff.orphan_tables,
|
|
980
|
+
verbose: options.verbose
|
|
896
981
|
});
|
|
897
|
-
|
|
982
|
+
return;
|
|
898
983
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
break;
|
|
984
|
+
console.log("📋 Changes Detected:\n");
|
|
985
|
+
if (diff.added_tables.length > 0) {
|
|
986
|
+
console.log(` 📦 New tables (${diff.added_tables.length}):`);
|
|
987
|
+
for (const table of diff.added_tables) console.log(` + ${table.tableName}`);
|
|
988
|
+
console.log();
|
|
989
|
+
}
|
|
990
|
+
const columnChanges = diff.changes.filter((c) => c.type === "add_column");
|
|
991
|
+
const indexChanges = diff.changes.filter((c) => c.type === "add_index");
|
|
992
|
+
const indexDrops = diff.changes.filter((c) => c.type === "drop_index");
|
|
993
|
+
const typeUpgrades = diff.changes.filter((c) => c.type === "type_upgrade");
|
|
994
|
+
const typeMismatches = diff.changes.filter((c) => c.type === "type_mismatch");
|
|
995
|
+
const recreateNames = /* @__PURE__ */ new Set();
|
|
996
|
+
const dropNames = new Set(indexDrops.map((c) => c.name));
|
|
997
|
+
for (const add of indexChanges) if (add.name && dropNames.has(add.name)) recreateNames.add(add.name);
|
|
998
|
+
if (columnChanges.length > 0) {
|
|
999
|
+
console.log(` 📊 New columns (${columnChanges.length}):`);
|
|
1000
|
+
for (const change of columnChanges) console.log(` + ${change.table}.${change.name}`);
|
|
1001
|
+
console.log();
|
|
1002
|
+
}
|
|
1003
|
+
const columnAlterations = diff.changes.filter((c) => c.type === "alter_column" && (c.sql || c.sqlStatements));
|
|
1004
|
+
const autoAlterations = columnAlterations.filter((c) => classifyTypeUpgradeSql(c.sql) === "executable");
|
|
1005
|
+
const manualAlterations = columnAlterations.filter((c) => classifyTypeUpgradeSql(c.sql) !== "executable");
|
|
1006
|
+
if (autoAlterations.length > 0) {
|
|
1007
|
+
console.log(` 🔧 Column constraint repairs (${autoAlterations.length}):`);
|
|
1008
|
+
for (const change of autoAlterations) console.log(` ~ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected} (${change.alteration})`);
|
|
1009
|
+
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
926
1010
|
}
|
|
1011
|
+
if (manualAlterations.length > 0) {
|
|
1012
|
+
console.log(` ⚠️ Column constraint drift needing a manual step (${manualAlterations.length}):`);
|
|
1013
|
+
for (const change of manualAlterations) {
|
|
1014
|
+
console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected} (${change.alteration})`);
|
|
1015
|
+
if (options.verbose && change.sql) console.log(` ${change.sql.replace(/^--\s*/, "")}`);
|
|
1016
|
+
}
|
|
1017
|
+
console.log();
|
|
1018
|
+
}
|
|
1019
|
+
const columnDrops = diff.changes.filter((c) => c.type === "drop_column");
|
|
1020
|
+
if (columnDrops.length > 0) {
|
|
1021
|
+
console.log(` 🗑️ Columns to drop (${columnDrops.length}, --drop-columns):`);
|
|
1022
|
+
for (const change of columnDrops) console.log(` - ${change.table}.${change.name}`);
|
|
1023
|
+
console.log();
|
|
1024
|
+
}
|
|
1025
|
+
if (recreateNames.size > 0) {
|
|
1026
|
+
console.log(` ♻️ Indexes to recreate (${recreateNames.size}):`);
|
|
1027
|
+
for (const name of recreateNames) {
|
|
1028
|
+
const add = indexChanges.find((c) => c.name === name);
|
|
1029
|
+
const cols = add?.index?.columns?.join(", ") ?? "?";
|
|
1030
|
+
const unique = add?.index?.unique ? "UNIQUE " : "";
|
|
1031
|
+
console.log(` ↻ ${name} → ${unique}(${cols})`);
|
|
1032
|
+
}
|
|
1033
|
+
console.log(" (each recreate is a drop_index followed by add_index)\n");
|
|
1034
|
+
}
|
|
1035
|
+
const orphanDrops = indexDrops.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
1036
|
+
if (orphanDrops.length > 0) {
|
|
1037
|
+
console.log(` 🗑️ Indexes to drop (${orphanDrops.length}):`);
|
|
1038
|
+
for (const change of orphanDrops) console.log(` - ${change.name} on ${change.table}`);
|
|
1039
|
+
console.log();
|
|
1040
|
+
}
|
|
1041
|
+
const newIndexes = indexChanges.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
1042
|
+
if (newIndexes.length > 0) {
|
|
1043
|
+
console.log(` 🗂️ New indexes (${newIndexes.length}):`);
|
|
1044
|
+
for (const change of newIndexes) console.log(` + ${change.name} on ${change.table}`);
|
|
1045
|
+
console.log();
|
|
1046
|
+
}
|
|
1047
|
+
if (typeUpgrades.length > 0) {
|
|
1048
|
+
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
1049
|
+
for (const change of typeUpgrades) console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
1050
|
+
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
1051
|
+
}
|
|
1052
|
+
if (typeMismatches.length > 0) {
|
|
1053
|
+
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
1054
|
+
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
1055
|
+
console.log(" (Type changes require manual migration)\n");
|
|
1056
|
+
}
|
|
1057
|
+
printSchemaAdvisories(advisories, {
|
|
1058
|
+
orphanTables: diff.orphan_tables,
|
|
1059
|
+
verbose: options.verbose
|
|
1060
|
+
});
|
|
1061
|
+
console.log("━".repeat(50));
|
|
1062
|
+
console.log("\n💡 Schema migrations are manifest-driven.");
|
|
1063
|
+
console.log(" Update @smrt object definitions, then run smrt db:migrate.\n");
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1066
|
+
else {
|
|
1067
|
+
console.error("\n❌ Failed to generate diff:");
|
|
1068
|
+
if (error instanceof Error) console.error(` ${error.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
process.exitCode = 1;
|
|
1071
|
+
return;
|
|
1072
|
+
} finally {
|
|
1073
|
+
await closeDatabaseConnection(db);
|
|
927
1074
|
}
|
|
928
1075
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/commands/db-generate.ts
|
|
1079
|
+
var UNSUPPORTED_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
1080
|
+
var dbGenerateCommand = {
|
|
1081
|
+
name: "db:generate",
|
|
1082
|
+
description: "[Unsupported] File-backed migrations are not supported",
|
|
1083
|
+
aliases: [
|
|
1084
|
+
"generate",
|
|
1085
|
+
"migration-create",
|
|
1086
|
+
"db:create"
|
|
1087
|
+
],
|
|
1088
|
+
args: ["<name>"],
|
|
1089
|
+
options: {
|
|
1090
|
+
sql: {
|
|
1091
|
+
type: "boolean",
|
|
1092
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1093
|
+
default: true
|
|
1094
|
+
},
|
|
1095
|
+
ts: {
|
|
1096
|
+
type: "boolean",
|
|
1097
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1098
|
+
default: false
|
|
1099
|
+
},
|
|
1100
|
+
timestamp: {
|
|
1101
|
+
type: "boolean",
|
|
1102
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1103
|
+
default: false,
|
|
1104
|
+
short: "t"
|
|
1105
|
+
},
|
|
1106
|
+
output: {
|
|
1107
|
+
type: "string",
|
|
1108
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1109
|
+
default: "./migrations",
|
|
1110
|
+
short: "o"
|
|
1111
|
+
},
|
|
1112
|
+
json: {
|
|
1113
|
+
type: "boolean",
|
|
1114
|
+
description: "Output as JSON",
|
|
1115
|
+
default: false,
|
|
1116
|
+
short: "j"
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
handler: async (_args, options) => {
|
|
1120
|
+
if (options.json) console.log(JSON.stringify({ error: UNSUPPORTED_MESSAGE }));
|
|
1121
|
+
else console.error(`\n❌ ${UNSUPPORTED_MESSAGE}\n`);
|
|
1122
|
+
process.exitCode = 1;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
934
1125
|
//#endregion
|
|
935
1126
|
//#region src/commands/migration-failure-analysis.ts
|
|
936
1127
|
function getActionableGeneratedMigrationNames(change) {
|
|
@@ -946,6 +1137,8 @@ function getActionableFailedMigrationNames(diff) {
|
|
|
946
1137
|
}
|
|
947
1138
|
function classifyKind(name) {
|
|
948
1139
|
if (name.startsWith("add_column_")) return "add_column";
|
|
1140
|
+
if (name.startsWith("drop_column_")) return "drop_column";
|
|
1141
|
+
if (name.startsWith("alter_column_")) return "alter_column";
|
|
949
1142
|
if (name.startsWith("add_index_")) return "add_index";
|
|
950
1143
|
if (name.startsWith("type_upgrade_")) return "type_upgrade";
|
|
951
1144
|
return "other";
|
|
@@ -1157,60 +1350,305 @@ var dbHistoryCommand = {
|
|
|
1157
1350
|
}
|
|
1158
1351
|
console.log();
|
|
1159
1352
|
}
|
|
1160
|
-
const completed = annotatedHistory.filter((m) => m.status === "completed").length;
|
|
1161
|
-
const failed = annotatedHistory.filter((m) => m.status === "failed").length;
|
|
1162
|
-
const failedUnresolved = annotatedHistory.filter((m) => m.classification === "unresolved").length;
|
|
1163
|
-
const failedSuperseded = annotatedHistory.filter((m) => m.classification === "superseded").length;
|
|
1164
|
-
const failedOther = annotatedHistory.filter((m) => m.classification === "other").length;
|
|
1165
|
-
const rolledBack = annotatedHistory.filter((m) => m.status === "rolled_back").length;
|
|
1166
|
-
const actionRequired = failedAssessments.filter((item) => item.resolution === "action_required").length;
|
|
1167
|
-
const superseded = failedAssessments.filter((item) => item.resolution === "superseded").length;
|
|
1168
|
-
const manualReview = failedAssessments.filter((item) => item.resolution === "manual_review").length;
|
|
1169
|
-
console.log("━".repeat(50));
|
|
1170
|
-
console.log(`Summary: ${completed} completed, ${failed} failed (${failedUnresolved} unresolved, ${failedSuperseded} superseded, ${failedOther} other), ${rolledBack} rolled back`);
|
|
1171
|
-
if (failed > 0) console.log(` ${actionRequired} failed still require action, ${superseded} are superseded history, ${manualReview} need manual review`);
|
|
1172
|
-
console.log();
|
|
1173
|
-
console.log("💡 Commands:");
|
|
1174
|
-
console.log(" smrt db:status - Current migration status");
|
|
1175
|
-
console.log(" smrt db:rollback - Rollback migrations");
|
|
1176
|
-
console.log();
|
|
1177
|
-
} catch (error) {
|
|
1178
|
-
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1353
|
+
const completed = annotatedHistory.filter((m) => m.status === "completed").length;
|
|
1354
|
+
const failed = annotatedHistory.filter((m) => m.status === "failed").length;
|
|
1355
|
+
const failedUnresolved = annotatedHistory.filter((m) => m.classification === "unresolved").length;
|
|
1356
|
+
const failedSuperseded = annotatedHistory.filter((m) => m.classification === "superseded").length;
|
|
1357
|
+
const failedOther = annotatedHistory.filter((m) => m.classification === "other").length;
|
|
1358
|
+
const rolledBack = annotatedHistory.filter((m) => m.status === "rolled_back").length;
|
|
1359
|
+
const actionRequired = failedAssessments.filter((item) => item.resolution === "action_required").length;
|
|
1360
|
+
const superseded = failedAssessments.filter((item) => item.resolution === "superseded").length;
|
|
1361
|
+
const manualReview = failedAssessments.filter((item) => item.resolution === "manual_review").length;
|
|
1362
|
+
console.log("━".repeat(50));
|
|
1363
|
+
console.log(`Summary: ${completed} completed, ${failed} failed (${failedUnresolved} unresolved, ${failedSuperseded} superseded, ${failedOther} other), ${rolledBack} rolled back`);
|
|
1364
|
+
if (failed > 0) console.log(` ${actionRequired} failed still require action, ${superseded} are superseded history, ${manualReview} need manual review`);
|
|
1365
|
+
console.log();
|
|
1366
|
+
console.log("💡 Commands:");
|
|
1367
|
+
console.log(" smrt db:status - Current migration status");
|
|
1368
|
+
console.log(" smrt db:rollback - Rollback migrations");
|
|
1369
|
+
console.log();
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1372
|
+
else {
|
|
1373
|
+
console.error("\n❌ Failed to get migration history:");
|
|
1374
|
+
if (error instanceof Error) console.error(` ${error.message}`);
|
|
1375
|
+
}
|
|
1376
|
+
process.exitCode = 1;
|
|
1377
|
+
return;
|
|
1378
|
+
} finally {
|
|
1379
|
+
await closeDatabaseConnection(db);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
/**
|
|
1384
|
+
* Get status icon for migration status
|
|
1385
|
+
*/
|
|
1386
|
+
function getStatusIcon(status) {
|
|
1387
|
+
switch (status) {
|
|
1388
|
+
case "completed": return "✓";
|
|
1389
|
+
case "failed": return "✗";
|
|
1390
|
+
case "rolled_back": return "↩";
|
|
1391
|
+
case "running": return "⟳";
|
|
1392
|
+
case "pending": return "○";
|
|
1393
|
+
default: return "?";
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Format date/time for display
|
|
1398
|
+
*/
|
|
1399
|
+
function formatDateTime(date) {
|
|
1400
|
+
return date.toISOString().replace("T", " ").substring(0, 19);
|
|
1401
|
+
}
|
|
1402
|
+
//#endregion
|
|
1403
|
+
//#region src/commands/db-prune.ts
|
|
1404
|
+
/**
|
|
1405
|
+
* Packages that contribute retention tasks by registering them on import.
|
|
1406
|
+
*
|
|
1407
|
+
* `db:prune` runs in the CLI's own process, so a task only reaches the sweep
|
|
1408
|
+
* if this process actually loaded the package that registers it. These are
|
|
1409
|
+
* imported optionally — a project that does not depend on jobs or users simply
|
|
1410
|
+
* has no jobs or users tasks, which is the correct outcome, not an error.
|
|
1411
|
+
*/
|
|
1412
|
+
var RETENTION_TASK_PACKAGES = ["@happyvertical/smrt-jobs", "@happyvertical/smrt-users"];
|
|
1413
|
+
/**
|
|
1414
|
+
* Load every installed package that contributes retention tasks.
|
|
1415
|
+
*
|
|
1416
|
+
* A rejected import is always treated as "not installed" — this function
|
|
1417
|
+
* cannot reliably tell a genuine module-resolution miss apart from a package
|
|
1418
|
+
* that resolved but threw during its own top-level evaluation (error shapes
|
|
1419
|
+
* differ across bundlers and runtimes, and `importPackage` is caller-supplied
|
|
1420
|
+
* for exactly that flexibility). It still logs the message on `stderr`
|
|
1421
|
+
* rather than swallowing it outright, so an operator can tell "this project
|
|
1422
|
+
* doesn't depend on jobs/users" apart from "smrt-jobs is installed but broke
|
|
1423
|
+
* on import" without the sweep itself needing to fail over an optional
|
|
1424
|
+
* dependency.
|
|
1425
|
+
*
|
|
1426
|
+
* @returns The specifiers that loaded, in declaration order.
|
|
1427
|
+
*/
|
|
1428
|
+
async function loadRetentionTaskPackages(importPackage) {
|
|
1429
|
+
const loaded = [];
|
|
1430
|
+
for (const specifier of RETENTION_TASK_PACKAGES) try {
|
|
1431
|
+
await importPackage(specifier);
|
|
1432
|
+
loaded.push(specifier);
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1435
|
+
console.warn(`⚠️ Could not load ${specifier} (${message}). Treating its retention tasks as not contributed — if the package is installed, this may be a real import failure rather than a missing dependency.`);
|
|
1436
|
+
}
|
|
1437
|
+
return loaded;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Merge command-line overrides onto the configured retention policy.
|
|
1441
|
+
*
|
|
1442
|
+
* `--skip` names tasks, not tables, so it covers both the built-in tables and
|
|
1443
|
+
* anything a package registered — the same names `db:prune --json` reports.
|
|
1444
|
+
*/
|
|
1445
|
+
function buildPrunePolicy(configured, options) {
|
|
1446
|
+
const policy = { ...configured ?? {} };
|
|
1447
|
+
policy.dryRun = options["dry-run"] || (configured?.dryRun ?? false);
|
|
1448
|
+
if (options["changes-days"] !== void 0) policy.changes = {
|
|
1449
|
+
...policy.changes === false ? {} : policy.changes ?? {},
|
|
1450
|
+
maxAgeDays: options["changes-days"]
|
|
1451
|
+
};
|
|
1452
|
+
if (options["usage-days"] !== void 0) policy.aiUsage = {
|
|
1453
|
+
...policy.aiUsage === false ? {} : policy.aiUsage ?? {},
|
|
1454
|
+
maxAgeDays: options["usage-days"]
|
|
1455
|
+
};
|
|
1456
|
+
if (options["dispatch-days"] !== void 0) policy.dispatch = {
|
|
1457
|
+
...policy.dispatch === false ? {} : policy.dispatch ?? {},
|
|
1458
|
+
completedOlderThanDays: options["dispatch-days"]
|
|
1459
|
+
};
|
|
1460
|
+
const skipped = (options.skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0);
|
|
1461
|
+
for (const name of skipped) switch (name) {
|
|
1462
|
+
case "changes":
|
|
1463
|
+
policy.changes = false;
|
|
1464
|
+
break;
|
|
1465
|
+
case "ai-usage":
|
|
1466
|
+
policy.aiUsage = false;
|
|
1467
|
+
break;
|
|
1468
|
+
case "contexts":
|
|
1469
|
+
policy.contexts = false;
|
|
1470
|
+
break;
|
|
1471
|
+
case "dispatch":
|
|
1472
|
+
policy.dispatch = false;
|
|
1473
|
+
break;
|
|
1474
|
+
default: policy.tasks = {
|
|
1475
|
+
...policy.tasks ?? {},
|
|
1476
|
+
[name]: false
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
return policy;
|
|
1480
|
+
}
|
|
1481
|
+
/** Render a completed sweep as an operator-readable table. */
|
|
1482
|
+
function formatSweepResult(result) {
|
|
1483
|
+
const lines = [];
|
|
1484
|
+
const verb = result.dryRun ? "would prune" : "pruned";
|
|
1485
|
+
for (const task of result.tasks) {
|
|
1486
|
+
const status = task.error ? `error: ${task.error}` : task.skipped ? task.skipped : `${verb} ${task.pruned}`;
|
|
1487
|
+
const detail = task.details ? ` (${Object.entries(task.details).map(([key, value]) => `${key}=${value}`).join(", ")})` : "";
|
|
1488
|
+
lines.push(` ${task.task.padEnd(24)}${status}${detail}`);
|
|
1489
|
+
}
|
|
1490
|
+
lines.push("");
|
|
1491
|
+
lines.push(` ${"total".padEnd(24)}${verb} ${result.pruned} row(s) in ${result.durationMs}ms`);
|
|
1492
|
+
return lines.join("\n");
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Names passed to `--skip` that no task in the completed sweep answered to.
|
|
1496
|
+
*
|
|
1497
|
+
* A typo and a package that was never installed look identical on the command
|
|
1498
|
+
* line, so the command says which names it did not recognize rather than
|
|
1499
|
+
* silently doing less than the operator asked for.
|
|
1500
|
+
*/
|
|
1501
|
+
function unmatchedSkipNames(skip, result) {
|
|
1502
|
+
const known = new Set(result.tasks.map((task) => task.task));
|
|
1503
|
+
return (skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0 && !known.has(name));
|
|
1504
|
+
}
|
|
1505
|
+
var dbPruneCommand = {
|
|
1506
|
+
name: "db:prune",
|
|
1507
|
+
description: "Prune framework-owned system tables to their retention windows",
|
|
1508
|
+
args: [],
|
|
1509
|
+
options: {
|
|
1510
|
+
"dry-run": {
|
|
1511
|
+
type: "boolean",
|
|
1512
|
+
description: "Report what would be deleted without deleting it",
|
|
1513
|
+
default: false
|
|
1514
|
+
},
|
|
1515
|
+
json: {
|
|
1516
|
+
type: "boolean",
|
|
1517
|
+
description: "Output as JSON (for CI/cron integration)",
|
|
1518
|
+
default: false,
|
|
1519
|
+
short: "j"
|
|
1520
|
+
},
|
|
1521
|
+
"changes-days": {
|
|
1522
|
+
type: "number",
|
|
1523
|
+
description: "Retention window for _smrt_changes, in days"
|
|
1524
|
+
},
|
|
1525
|
+
"usage-days": {
|
|
1526
|
+
type: "number",
|
|
1527
|
+
description: "Retention window for _smrt_ai_usage, in days"
|
|
1528
|
+
},
|
|
1529
|
+
"dispatch-days": {
|
|
1530
|
+
type: "number",
|
|
1531
|
+
description: "Retention window for completed _smrt_dispatch rows, in days"
|
|
1532
|
+
},
|
|
1533
|
+
skip: {
|
|
1534
|
+
type: "string",
|
|
1535
|
+
description: "Comma-separated task names to skip (changes, ai-usage, contexts, dispatch, …)"
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
handler: async (_args, options) => {
|
|
1539
|
+
let db;
|
|
1540
|
+
try {
|
|
1541
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
1542
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
1543
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
1544
|
+
if (!config.database?.url) {
|
|
1545
|
+
const message = "Database not configured. Set database.url in smrt.config.ts.";
|
|
1546
|
+
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
1547
|
+
else console.error(`\n❌ ${message}\n`);
|
|
1548
|
+
process.exitCode = 1;
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const dbUrl = config.database.url;
|
|
1552
|
+
const dbType = config.database.type || "sqlite";
|
|
1553
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
1554
|
+
db = await getDatabase({
|
|
1555
|
+
type: dbType,
|
|
1556
|
+
url: dbUrl
|
|
1557
|
+
});
|
|
1558
|
+
const { config: smrtConfig, importOptionalDependency, runRetentionSweep } = await import("@happyvertical/smrt-core");
|
|
1559
|
+
const loaded = await loadRetentionTaskPackages((specifier) => importOptionalDependency(specifier, "Install it in the project to include its retention tasks."));
|
|
1560
|
+
const policy = buildPrunePolicy(smrtConfig.toJSON().retention, options);
|
|
1561
|
+
const result = await runRetentionSweep(db, policy);
|
|
1562
|
+
const unmatched = unmatchedSkipNames(options.skip, result);
|
|
1563
|
+
if (options.json) console.log(JSON.stringify({
|
|
1564
|
+
...result,
|
|
1565
|
+
contributors: loaded,
|
|
1566
|
+
unmatched
|
|
1567
|
+
}, null, 2));
|
|
1179
1568
|
else {
|
|
1180
|
-
console.
|
|
1181
|
-
|
|
1569
|
+
console.log(`\n🧹 Retention sweep${result.dryRun ? " (dry run)" : ""}\n`);
|
|
1570
|
+
console.log(`Database: ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
|
|
1571
|
+
console.log(formatSweepResult(result));
|
|
1572
|
+
console.log();
|
|
1573
|
+
if (unmatched.length > 0) console.warn(`⚠️ --skip named no known task: ${unmatched.join(", ")}. Check the spelling, or the package that registers it may not be installed.
|
|
1574
|
+
`);
|
|
1182
1575
|
}
|
|
1576
|
+
if (result.failed) process.exitCode = 1;
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1579
|
+
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
1580
|
+
else console.error(`\n❌ Retention sweep failed: ${message}\n`);
|
|
1183
1581
|
process.exitCode = 1;
|
|
1184
|
-
return;
|
|
1185
1582
|
} finally {
|
|
1186
1583
|
await closeDatabaseConnection(db);
|
|
1187
1584
|
}
|
|
1188
1585
|
}
|
|
1189
1586
|
};
|
|
1587
|
+
//#endregion
|
|
1588
|
+
//#region src/commands/db-rollback.ts
|
|
1190
1589
|
/**
|
|
1191
|
-
*
|
|
1590
|
+
* Name prefix of the only migration class `db:migrate` records a DOWN script
|
|
1591
|
+
* for (`utilities.ts`, `diff.added_tables` loop:
|
|
1592
|
+
* `down: ['DROP TABLE IF EXISTS "<table>"']`). Keep the two in step — this
|
|
1593
|
+
* command reconstructs that exact statement from the recorded migration name.
|
|
1192
1594
|
*/
|
|
1193
|
-
|
|
1194
|
-
switch (status) {
|
|
1195
|
-
case "completed": return "✓";
|
|
1196
|
-
case "failed": return "✗";
|
|
1197
|
-
case "rolled_back": return "↩";
|
|
1198
|
-
case "running": return "⟳";
|
|
1199
|
-
case "pending": return "○";
|
|
1200
|
-
default: return "?";
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1595
|
+
var CREATE_TABLE_MIGRATION_PREFIX = "create_table_";
|
|
1203
1596
|
/**
|
|
1204
|
-
*
|
|
1597
|
+
* The suffix of a `create_table_*` row must be a bare SQL identifier before it
|
|
1598
|
+
* is interpolated into the reconstructed `DROP TABLE`. Quotes, whitespace,
|
|
1599
|
+
* semicolons and dots all fail closed (the migration is refused) rather than
|
|
1600
|
+
* producing a statement that drops something other than the recorded table.
|
|
1601
|
+
*
|
|
1602
|
+
* Deliberately not lowercase-only: `classnameToTablename` yields snake_case,
|
|
1603
|
+
* but `@smrt({ tableName })` passes a consumer-supplied name through verbatim,
|
|
1604
|
+
* so a legitimately reversible table can carry capitals. This is a shape and
|
|
1605
|
+
* injection guard, not a naming-convention check.
|
|
1205
1606
|
*/
|
|
1206
|
-
|
|
1207
|
-
|
|
1607
|
+
var SAFE_TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
1608
|
+
/** Human-readable refusal headline, printed above the per-migration reasons. */
|
|
1609
|
+
var REFUSAL_HEADLINE = "Refusing to roll back: no DOWN script is available for the selected migration(s).";
|
|
1610
|
+
/** Single-line refusal used for the JSON payload. */
|
|
1611
|
+
var REFUSAL_ERROR = `${REFUSAL_HEADLINE} Nothing was changed.`;
|
|
1612
|
+
/** Why the refusal happened and the two ways forward, printed with it. */
|
|
1613
|
+
var REFUSAL_GUIDANCE = [
|
|
1614
|
+
"SMRT schema migrations are diff-driven: db:migrate stores no SQL in _smrt_schema_migrations and records a DOWN script only for create_table_<table> migrations.",
|
|
1615
|
+
"Revert these by updating the @smrt object definitions and running smrt db:migrate, or by reverting the schema by hand.",
|
|
1616
|
+
"Re-run with --mark-only to mark them rolled_back in the tracking table WITHOUT changing the schema (record-only)."
|
|
1617
|
+
];
|
|
1618
|
+
/**
|
|
1619
|
+
* Reconstruct the DOWN script for an applied migration, or explain why it
|
|
1620
|
+
* cannot be reconstructed.
|
|
1621
|
+
*
|
|
1622
|
+
* Recoverable only for `create_table_<table>` rows recorded as reversible: the
|
|
1623
|
+
* DOWN `db:migrate` attached to them is the deterministic
|
|
1624
|
+
* `DROP TABLE IF EXISTS "<table>"`. A row recorded reversible under any other
|
|
1625
|
+
* name came from a caller-supplied `MigrationDefinition` whose SQL was never
|
|
1626
|
+
* persisted, so it is refused rather than guessed at.
|
|
1627
|
+
*
|
|
1628
|
+
* Exported for unit testing.
|
|
1629
|
+
*/
|
|
1630
|
+
function recoverDownStatements(migration) {
|
|
1631
|
+
if (!migration.name.startsWith(CREATE_TABLE_MIGRATION_PREFIX)) return {
|
|
1632
|
+
recoverable: false,
|
|
1633
|
+
reason: migration.is_reversible ? "recorded as reversible, but its DOWN SQL is not stored in _smrt_schema_migrations and cannot be reconstructed" : "was applied without a DOWN script"
|
|
1634
|
+
};
|
|
1635
|
+
if (!migration.is_reversible) return {
|
|
1636
|
+
recoverable: false,
|
|
1637
|
+
reason: "is recorded as not reversible"
|
|
1638
|
+
};
|
|
1639
|
+
const tableName = migration.name.slice(13);
|
|
1640
|
+
if (!SAFE_TABLE_NAME_RE.test(tableName)) return {
|
|
1641
|
+
recoverable: false,
|
|
1642
|
+
reason: `names table "${tableName}", which is not a plain identifier db:migrate would have generated`
|
|
1643
|
+
};
|
|
1644
|
+
return {
|
|
1645
|
+
recoverable: true,
|
|
1646
|
+
statements: [`DROP TABLE IF EXISTS "${tableName}"`]
|
|
1647
|
+
};
|
|
1208
1648
|
}
|
|
1209
|
-
//#endregion
|
|
1210
|
-
//#region src/commands/db-rollback.ts
|
|
1211
1649
|
var dbRollbackCommand = {
|
|
1212
1650
|
name: "db:rollback",
|
|
1213
|
-
description: "Rollback applied migrations",
|
|
1651
|
+
description: "Rollback applied migrations by executing their DOWN script",
|
|
1214
1652
|
aliases: ["rollback", "migration-rollback"],
|
|
1215
1653
|
args: [],
|
|
1216
1654
|
options: {
|
|
@@ -1230,6 +1668,12 @@ var dbRollbackCommand = {
|
|
|
1230
1668
|
description: "Preview rollback without executing",
|
|
1231
1669
|
default: false
|
|
1232
1670
|
},
|
|
1671
|
+
"mark-only": {
|
|
1672
|
+
type: "boolean",
|
|
1673
|
+
description: "Record-only: mark migrations rolled back WITHOUT running any DOWN script (schema untouched)",
|
|
1674
|
+
default: false,
|
|
1675
|
+
short: "m"
|
|
1676
|
+
},
|
|
1233
1677
|
force: {
|
|
1234
1678
|
type: "boolean",
|
|
1235
1679
|
description: "Skip confirmation prompt",
|
|
@@ -1252,6 +1696,7 @@ var dbRollbackCommand = {
|
|
|
1252
1696
|
handler: async (_args, options) => {
|
|
1253
1697
|
let db;
|
|
1254
1698
|
const dryRun = options["dry-run"] ?? options.dryRun;
|
|
1699
|
+
const markOnly = options["mark-only"] ?? options.markOnly;
|
|
1255
1700
|
try {
|
|
1256
1701
|
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
1257
1702
|
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
@@ -1297,26 +1742,45 @@ var dbRollbackCommand = {
|
|
|
1297
1742
|
else console.log("✅ Nothing to rollback\n");
|
|
1298
1743
|
return;
|
|
1299
1744
|
}
|
|
1745
|
+
const plan = migrationsToRollback.map((migration) => ({
|
|
1746
|
+
migration,
|
|
1747
|
+
recovery: recoverDownStatements(migration)
|
|
1748
|
+
}));
|
|
1749
|
+
const unrecoverable = plan.flatMap(({ migration, recovery }) => recovery.recoverable ? [] : [{
|
|
1750
|
+
name: migration.name,
|
|
1751
|
+
reason: recovery.reason
|
|
1752
|
+
}]);
|
|
1300
1753
|
if (!options.json) {
|
|
1301
1754
|
console.log(`Migrations to rollback (${migrationsToRollback.length}):\n`);
|
|
1302
|
-
for (const
|
|
1303
|
-
const appliedAt =
|
|
1304
|
-
|
|
1755
|
+
for (const { migration, recovery } of plan) {
|
|
1756
|
+
const appliedAt = migration.applied_at.toISOString().substring(0, 19);
|
|
1757
|
+
const marker = recovery.recoverable ? "↩" : "⊘";
|
|
1758
|
+
console.log(` ${marker} ${migration.name} (${shortChecksum(migration.checksum)} applied ${appliedAt})`);
|
|
1759
|
+
if (recovery.recoverable) for (const sql of recovery.statements) console.log(` ${sql};`);
|
|
1760
|
+
else console.log(` no DOWN script — ${recovery.reason}`);
|
|
1305
1761
|
}
|
|
1306
1762
|
console.log();
|
|
1307
1763
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1764
|
+
if (unrecoverable.length > 0 && !markOnly) {
|
|
1765
|
+
if (options.json) console.log(JSON.stringify({
|
|
1766
|
+
error: REFUSAL_ERROR,
|
|
1767
|
+
dryRun: Boolean(dryRun),
|
|
1768
|
+
unrecoverable,
|
|
1769
|
+
nonReversible: unrecoverable.map((entry) => entry.name),
|
|
1770
|
+
guidance: REFUSAL_GUIDANCE
|
|
1771
|
+
}, null, 2));
|
|
1772
|
+
else {
|
|
1773
|
+
console.error(`❌ ${REFUSAL_HEADLINE}\n`);
|
|
1774
|
+
for (const detail of unrecoverable) console.error(` - ${detail.name} — ${detail.reason}`);
|
|
1775
|
+
console.error();
|
|
1776
|
+
for (const line of REFUSAL_GUIDANCE) console.error(` ${line}`);
|
|
1777
|
+
console.error("\n Nothing was changed.\n");
|
|
1778
|
+
}
|
|
1779
|
+
process.exitCode = 1;
|
|
1780
|
+
return;
|
|
1317
1781
|
}
|
|
1318
1782
|
if (!options.force && !dryRun && !options.json) {
|
|
1319
|
-
console.log("⚠️ WARNING: This will revert database changes!");
|
|
1783
|
+
console.log(markOnly ? "⚠️ WARNING: This marks migrations rolled back WITHOUT changing the schema!" : "⚠️ WARNING: This will revert database changes!");
|
|
1320
1784
|
const rl = (await import("node:readline/promises")).createInterface({
|
|
1321
1785
|
input: process.stdin,
|
|
1322
1786
|
output: process.stdout
|
|
@@ -1332,68 +1796,86 @@ var dbRollbackCommand = {
|
|
|
1332
1796
|
if (dryRun) {
|
|
1333
1797
|
if (options.json) console.log(JSON.stringify({
|
|
1334
1798
|
dryRun: true,
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1799
|
+
markOnly: Boolean(markOnly),
|
|
1800
|
+
migrationsToRollback: plan.map(({ migration, recovery }) => ({
|
|
1801
|
+
name: migration.name,
|
|
1802
|
+
checksum: migration.checksum,
|
|
1803
|
+
isReversible: migration.is_reversible,
|
|
1804
|
+
down: recovery.recoverable ? recovery.statements : []
|
|
1339
1805
|
}))
|
|
1340
1806
|
}));
|
|
1341
1807
|
else {
|
|
1342
1808
|
console.log("📋 Dry-run - no changes made\n");
|
|
1343
|
-
console.log("Would
|
|
1344
|
-
for (const
|
|
1345
|
-
const status =
|
|
1346
|
-
console.log(`
|
|
1809
|
+
console.log(markOnly ? "Would mark the following migrations rolled back (schema untouched):" : "Would execute the DOWN script of the following migrations:");
|
|
1810
|
+
for (const { migration, recovery } of plan) {
|
|
1811
|
+
const status = recovery.recoverable ? `↩ ${migration.name} (${recovery.statements.length} DOWN statement(s))` : `⊘ ${migration.name} (no DOWN script)`;
|
|
1812
|
+
console.log(` ${status}`);
|
|
1347
1813
|
}
|
|
1348
1814
|
console.log();
|
|
1349
1815
|
}
|
|
1350
1816
|
return;
|
|
1351
1817
|
}
|
|
1352
|
-
if (!options.json) console.log("🔨 Rolling back migrations...\n");
|
|
1818
|
+
if (!options.json) console.log(markOnly ? "🔨 Marking migrations rolled back (record-only)...\n" : "🔨 Rolling back migrations...\n");
|
|
1353
1819
|
let successCount = 0;
|
|
1354
1820
|
let errorCount = 0;
|
|
1355
1821
|
const results = [];
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1822
|
+
let stoppedBy = null;
|
|
1823
|
+
for (const { migration, recovery } of plan) {
|
|
1824
|
+
if (stoppedBy) {
|
|
1825
|
+
const message = `Not attempted: rollback stopped after ${stoppedBy} failed`;
|
|
1826
|
+
if (!options.json) console.error(` ⊘ ${migration.name} skipped: ${message}`);
|
|
1827
|
+
results.push({
|
|
1828
|
+
name: migration.name,
|
|
1829
|
+
success: false,
|
|
1830
|
+
error: message
|
|
1831
|
+
});
|
|
1832
|
+
errorCount++;
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
if (markOnly) {
|
|
1837
|
+
await db.query(`UPDATE _smrt_schema_migrations SET status = 'rolled_back', rolled_back_at = CURRENT_TIMESTAMP WHERE name = ?`, [migration.name]);
|
|
1838
|
+
if (!options.json) console.log(` ⊙ ${migration.name} marked rolled back (schema untouched)`);
|
|
1368
1839
|
results.push({
|
|
1369
1840
|
name: migration.name,
|
|
1370
|
-
success: true
|
|
1841
|
+
success: true,
|
|
1842
|
+
markedOnly: true
|
|
1371
1843
|
});
|
|
1372
1844
|
successCount++;
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
if (!recovery.recoverable) throw new Error(`No DOWN script available — ${recovery.reason}`);
|
|
1848
|
+
const result = await tracker.rollback(migration.name, {
|
|
1849
|
+
id: migration.name,
|
|
1850
|
+
description: `Rollback: ${migration.name}`,
|
|
1851
|
+
version: migration.version,
|
|
1852
|
+
up: [],
|
|
1853
|
+
down: recovery.statements
|
|
1854
|
+
}, { dryRun: false });
|
|
1855
|
+
if (!result.success) throw result.error || /* @__PURE__ */ new Error("Rollback failed");
|
|
1856
|
+
if (!options.json) console.log(` ✓ ${migration.name} rolled back`);
|
|
1377
1857
|
results.push({
|
|
1378
1858
|
name: migration.name,
|
|
1379
1859
|
success: true,
|
|
1380
|
-
|
|
1860
|
+
statements: recovery.statements
|
|
1381
1861
|
});
|
|
1382
1862
|
successCount++;
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
errorCount++;
|
|
1865
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1866
|
+
if (!options.json) console.error(` ✗ ${migration.name} failed: ${errorMsg}`);
|
|
1867
|
+
results.push({
|
|
1868
|
+
name: migration.name,
|
|
1869
|
+
success: false,
|
|
1870
|
+
error: errorMsg
|
|
1871
|
+
});
|
|
1872
|
+
stoppedBy = migration.name;
|
|
1873
|
+
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
1383
1874
|
}
|
|
1384
|
-
} catch (error) {
|
|
1385
|
-
errorCount++;
|
|
1386
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1387
|
-
if (!options.json) console.error(` ✗ ${migration.name} failed: ${errorMsg}`);
|
|
1388
|
-
results.push({
|
|
1389
|
-
name: migration.name,
|
|
1390
|
-
success: false,
|
|
1391
|
-
error: errorMsg
|
|
1392
|
-
});
|
|
1393
|
-
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
1394
1875
|
}
|
|
1395
1876
|
if (options.json) console.log(JSON.stringify({
|
|
1396
1877
|
success: errorCount === 0,
|
|
1878
|
+
markOnly: Boolean(markOnly),
|
|
1397
1879
|
successCount,
|
|
1398
1880
|
errorCount,
|
|
1399
1881
|
results
|
|
@@ -1401,6 +1883,7 @@ var dbRollbackCommand = {
|
|
|
1401
1883
|
else {
|
|
1402
1884
|
console.log();
|
|
1403
1885
|
if (errorCount > 0) console.log(`⚠️ Rollback completed with errors: ${successCount} succeeded, ${errorCount} failed\n`);
|
|
1886
|
+
else if (markOnly) console.log(`✅ Marked ${successCount} migration(s) rolled back — the schema was NOT changed\n`);
|
|
1404
1887
|
else console.log(`✅ Successfully rolled back ${successCount} migration(s)\n`);
|
|
1405
1888
|
console.log("💡 Commands:");
|
|
1406
1889
|
console.log(" smrt db:status - View current migration status");
|
|
@@ -1408,6 +1891,7 @@ var dbRollbackCommand = {
|
|
|
1408
1891
|
console.log(" smrt db:migrate - Re-apply migrations");
|
|
1409
1892
|
console.log();
|
|
1410
1893
|
}
|
|
1894
|
+
if (errorCount > 0) process.exitCode = 1;
|
|
1411
1895
|
} catch (error) {
|
|
1412
1896
|
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1413
1897
|
else {
|
|
@@ -1422,6 +1906,131 @@ var dbRollbackCommand = {
|
|
|
1422
1906
|
}
|
|
1423
1907
|
};
|
|
1424
1908
|
//#endregion
|
|
1909
|
+
//#region src/commands/db-parity.ts
|
|
1910
|
+
/**
|
|
1911
|
+
* Live-schema parity reporting for the CLI (#2368)
|
|
1912
|
+
*
|
|
1913
|
+
* Shared by `smrt doctor --db` and `smrt db:status --parity`. Both surfaces
|
|
1914
|
+
* introspect the configured database and compare it to the shape the model
|
|
1915
|
+
* layer assumes — including the hand-DDL `_smrt_*` system tables and an index
|
|
1916
|
+
* policy that does not consult the manifest, since the manifest is the artifact
|
|
1917
|
+
* that dropped the index in the first place (#2356).
|
|
1918
|
+
*
|
|
1919
|
+
* Everything here fails closed: a database that cannot be reached, or an
|
|
1920
|
+
* adapter that cannot describe its tables, is reported as a failure rather
|
|
1921
|
+
* than as "in sync".
|
|
1922
|
+
*/
|
|
1923
|
+
/**
|
|
1924
|
+
* Collect every declared upsert conflict target, keyed by table name.
|
|
1925
|
+
*
|
|
1926
|
+
* The conflict target is a registry property, not a manifest one: a class may
|
|
1927
|
+
* declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
|
|
1928
|
+
* default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
|
|
1929
|
+
* resolves to, the live database needs a matching UNIQUE index or every upsert
|
|
1930
|
+
* against that table either errors (PostgreSQL) or duplicates rows.
|
|
1931
|
+
*/
|
|
1932
|
+
function collectRegistryConflictTargets() {
|
|
1933
|
+
const targets = {};
|
|
1934
|
+
for (const className of ObjectRegistry.getQualifiedClassNames()) {
|
|
1935
|
+
const tableName = ObjectRegistry.getTableName(className);
|
|
1936
|
+
if (!tableName) continue;
|
|
1937
|
+
const columns = ObjectRegistry.getConflictColumns(className);
|
|
1938
|
+
if (!columns || columns.length === 0) continue;
|
|
1939
|
+
const bucket = targets[tableName] ?? [];
|
|
1940
|
+
bucket.push({
|
|
1941
|
+
columns,
|
|
1942
|
+
source: className
|
|
1943
|
+
});
|
|
1944
|
+
targets[tableName] = bucket;
|
|
1945
|
+
}
|
|
1946
|
+
return targets;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Run the parity check against the configured database.
|
|
1950
|
+
*
|
|
1951
|
+
* Never throws for an operational problem: connection and introspection
|
|
1952
|
+
* failures come back as `{ report: null, error }` so both callers can render
|
|
1953
|
+
* them in their own idiom while still failing closed.
|
|
1954
|
+
*/
|
|
1955
|
+
async function runLiveSchemaParity(options = {}) {
|
|
1956
|
+
const { includeSystemTables = true, discover = true } = options;
|
|
1957
|
+
let db;
|
|
1958
|
+
try {
|
|
1959
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
1960
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
1961
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
1962
|
+
if (!config.database?.url || config.database.url === ":memory:") return {
|
|
1963
|
+
report: null,
|
|
1964
|
+
database: null,
|
|
1965
|
+
error: "No persistent database is configured, so live-schema parity cannot be verified. Set `database.url` in smrt.config.ts (or DATABASE_URL)."
|
|
1966
|
+
};
|
|
1967
|
+
const dbUrl = config.database.url;
|
|
1968
|
+
const dbType = config.database.type || "sqlite";
|
|
1969
|
+
const database = {
|
|
1970
|
+
type: dbType,
|
|
1971
|
+
url: formatDatabaseDisplayUrl(dbType, dbUrl)
|
|
1972
|
+
};
|
|
1973
|
+
if (discover) await autoDiscoverAndLoad();
|
|
1974
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
1975
|
+
db = await getDatabase({
|
|
1976
|
+
type: dbType,
|
|
1977
|
+
url: dbUrl
|
|
1978
|
+
});
|
|
1979
|
+
return {
|
|
1980
|
+
report: await checkLiveSchemaParity({
|
|
1981
|
+
db,
|
|
1982
|
+
schemas: ObjectRegistry.getAllSchemasAsDefinitions(),
|
|
1983
|
+
conflictTargets: collectRegistryConflictTargets(),
|
|
1984
|
+
includeSystemTables,
|
|
1985
|
+
engineHint: dbType
|
|
1986
|
+
}),
|
|
1987
|
+
database,
|
|
1988
|
+
error: null
|
|
1989
|
+
};
|
|
1990
|
+
} catch (error) {
|
|
1991
|
+
return {
|
|
1992
|
+
report: null,
|
|
1993
|
+
database: null,
|
|
1994
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1995
|
+
};
|
|
1996
|
+
} finally {
|
|
1997
|
+
await closeDatabaseConnection(db);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
var SEVERITY_ICON = {
|
|
2001
|
+
error: "❌",
|
|
2002
|
+
warning: "⚠️",
|
|
2003
|
+
info: "ℹ️"
|
|
2004
|
+
};
|
|
2005
|
+
/** Findings of one severity, ordered by table then target. */
|
|
2006
|
+
function selectFindings(report, severity) {
|
|
2007
|
+
return report.findings.filter((finding) => finding.severity === severity).sort((left, right) => left.table.localeCompare(right.table) || (left.target ?? "").localeCompare(right.target ?? ""));
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Render a parity report as console lines.
|
|
2011
|
+
*
|
|
2012
|
+
* Errors and warnings always print. Informational findings (undeclared tables,
|
|
2013
|
+
* columns and indexes) print only with `verbose`, because on a shared database
|
|
2014
|
+
* they are numerous and expected.
|
|
2015
|
+
*/
|
|
2016
|
+
function formatParityReport(report, options = {}) {
|
|
2017
|
+
const lines = [];
|
|
2018
|
+
lines.push(` Engine: ${report.engine} · tables checked: ${report.tablesChecked}` + (report.tablesMissing > 0 ? ` · missing: ${report.tablesMissing}` : "") + (report.systemTablesIncluded ? " · system tables included" : ""));
|
|
2019
|
+
if (report.indexIntrospection === "unavailable") lines.push(" ⚠️ Index metadata is not readable on this engine; index, uniqueness and conflict-target checks were skipped.");
|
|
2020
|
+
const severities = options.verbose ? [
|
|
2021
|
+
"error",
|
|
2022
|
+
"warning",
|
|
2023
|
+
"info"
|
|
2024
|
+
] : ["error", "warning"];
|
|
2025
|
+
for (const severity of severities) for (const finding of selectFindings(report, severity)) {
|
|
2026
|
+
lines.push(` ${SEVERITY_ICON[severity]} ${finding.message}`);
|
|
2027
|
+
if (options.verbose) lines.push(` → ${finding.recommendation}`);
|
|
2028
|
+
}
|
|
2029
|
+
if (report.counts.info > 0 && !options.verbose) lines.push(` ℹ️ ${report.counts.info} informational finding(s) hidden; re-run with --verbose.`);
|
|
2030
|
+
if (report.findings.length === 0) lines.push(" ✅ Live schema matches the expected shape.");
|
|
2031
|
+
return lines;
|
|
2032
|
+
}
|
|
2033
|
+
//#endregion
|
|
1425
2034
|
//#region src/commands/schema-contract.ts
|
|
1426
2035
|
var SchemaContractError = class extends Error {
|
|
1427
2036
|
report;
|
|
@@ -1777,9 +2386,76 @@ function summarizeSchemaDiff(diff) {
|
|
|
1777
2386
|
recommendation: change.mismatch ? `Manual intervention required: expected ${change.mismatch.expected}, found ${change.mismatch.actual}.` : "Manual intervention required to reconcile this incompatible live column type."
|
|
1778
2387
|
});
|
|
1779
2388
|
break;
|
|
2389
|
+
case "alter_column": {
|
|
2390
|
+
const name = `${change.table}.${change.name ?? "(unknown)"}`;
|
|
2391
|
+
const driftType = change.alteration === "set_not_null" || change.alteration === "drop_not_null" ? "nullability_drift" : "default_drift";
|
|
2392
|
+
if (!Boolean(change.sql || change.sqlStatements?.length) && change.advisory) {
|
|
2393
|
+
if (change.advisory.severity !== "warning") break;
|
|
2394
|
+
drift.push({
|
|
2395
|
+
name,
|
|
2396
|
+
type: driftType,
|
|
2397
|
+
recommendation: change.advisory.message
|
|
2398
|
+
});
|
|
2399
|
+
} else if (classifyTypeUpgradeSql(change.sql) === "executable") drift.push({
|
|
2400
|
+
name,
|
|
2401
|
+
type: driftType,
|
|
2402
|
+
recommendation: `Run \`smrt db:migrate\` to repair this live column (${change.alteration ?? "alter_column"}: expected ${change.mismatch?.expected ?? "?"}, found ${change.mismatch?.actual ?? "?"}).`
|
|
2403
|
+
});
|
|
2404
|
+
else drift.push({
|
|
2405
|
+
name,
|
|
2406
|
+
type: driftType,
|
|
2407
|
+
recommendation: change.advisory?.message ? `Manual intervention required: ${change.advisory.message}` : `Manual intervention required: expected ${change.mismatch?.expected ?? "?"}, found ${change.mismatch?.actual ?? "?"} (${change.alteration ?? "alter_column"}; cannot auto-apply on this database engine).`
|
|
2408
|
+
});
|
|
2409
|
+
break;
|
|
2410
|
+
}
|
|
2411
|
+
case "orphan_column":
|
|
2412
|
+
if (change.advisory?.severity !== "warning") break;
|
|
2413
|
+
drift.push({
|
|
2414
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2415
|
+
type: "orphan_column_blocking",
|
|
2416
|
+
recommendation: change.advisory?.message ?? "Column exists in the database but not in the manifest."
|
|
2417
|
+
});
|
|
2418
|
+
break;
|
|
2419
|
+
case "orphan_index":
|
|
2420
|
+
drift.push({
|
|
2421
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2422
|
+
type: "orphan_unique_constraint",
|
|
2423
|
+
recommendation: change.advisory?.message ?? "Unique constraint exists in the database but not in the manifest."
|
|
2424
|
+
});
|
|
2425
|
+
break;
|
|
2426
|
+
case "drop_column":
|
|
2427
|
+
drift.push({
|
|
2428
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2429
|
+
type: "orphan_column",
|
|
2430
|
+
recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
|
|
2431
|
+
});
|
|
2432
|
+
break;
|
|
1780
2433
|
}
|
|
1781
2434
|
return drift;
|
|
1782
2435
|
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Info-level live-schema notes (#2369): harmless orphan columns, live
|
|
2438
|
+
* defaults the manifest no longer declares, and tables no loaded manifest
|
|
2439
|
+
* declares. Reported separately from `drift` so a JSON consumer counting
|
|
2440
|
+
* drift entries is not tripped by findings that need no action.
|
|
2441
|
+
*/
|
|
2442
|
+
function summarizeSchemaNotes(diff) {
|
|
2443
|
+
const notes = [];
|
|
2444
|
+
for (const change of diff.changes) {
|
|
2445
|
+
if (Boolean(change.sql || change.sqlStatements?.length) || change.advisory?.severity !== "info") continue;
|
|
2446
|
+
notes.push({
|
|
2447
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2448
|
+
type: change.type === "orphan_column" ? "orphan_column" : change.type === "alter_column" ? "default_drift" : change.type,
|
|
2449
|
+
recommendation: change.advisory.message
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
for (const table of diff.orphan_tables ?? []) notes.push({
|
|
2453
|
+
name: table,
|
|
2454
|
+
type: "orphan_table",
|
|
2455
|
+
recommendation: "Table exists in the database but no loaded manifest declares it. Not dropped; remove it manually if it is stale."
|
|
2456
|
+
});
|
|
2457
|
+
return notes;
|
|
2458
|
+
}
|
|
1783
2459
|
var dbStatusCommand = {
|
|
1784
2460
|
name: "db:status",
|
|
1785
2461
|
description: "Show migration status (applied, pending, drift)",
|
|
@@ -1797,6 +2473,12 @@ var dbStatusCommand = {
|
|
|
1797
2473
|
description: "Show detailed migration information",
|
|
1798
2474
|
default: false,
|
|
1799
2475
|
short: "v"
|
|
2476
|
+
},
|
|
2477
|
+
parity: {
|
|
2478
|
+
type: "boolean",
|
|
2479
|
+
description: "Also compare the live schema to the expected shape (incl. _smrt_* system tables and index policy)",
|
|
2480
|
+
default: false,
|
|
2481
|
+
short: "p"
|
|
1800
2482
|
}
|
|
1801
2483
|
},
|
|
1802
2484
|
handler: async (_args, options) => {
|
|
@@ -1873,9 +2555,12 @@ var dbStatusCommand = {
|
|
|
1873
2555
|
}))
|
|
1874
2556
|
},
|
|
1875
2557
|
drift: [],
|
|
2558
|
+
notes: [],
|
|
1876
2559
|
preconditions: [],
|
|
1877
2560
|
failedMigrations: summarizeFailedMigrations(failed, null),
|
|
1878
|
-
schemaContract
|
|
2561
|
+
schemaContract,
|
|
2562
|
+
parity: null,
|
|
2563
|
+
parityError: null
|
|
1879
2564
|
};
|
|
1880
2565
|
let diff = {
|
|
1881
2566
|
added_tables: [],
|
|
@@ -1887,6 +2572,7 @@ var dbStatusCommand = {
|
|
|
1887
2572
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
1888
2573
|
diff = await new SchemaComparer(db).compare(manifestSchemas);
|
|
1889
2574
|
status.drift = summarizeSchemaDiff(diff);
|
|
2575
|
+
status.notes = summarizeSchemaNotes(diff);
|
|
1890
2576
|
status.preconditions = await checkTenantIdUuidPreconditions({
|
|
1891
2577
|
db,
|
|
1892
2578
|
dbType,
|
|
@@ -1894,7 +2580,18 @@ var dbStatusCommand = {
|
|
|
1894
2580
|
manifestSchemas
|
|
1895
2581
|
});
|
|
1896
2582
|
status.failedMigrations = summarizeFailedMigrations(failed, getUnresolvedGeneratedMigrationNames(diff.changes));
|
|
1897
|
-
|
|
2583
|
+
if (options.parity) try {
|
|
2584
|
+
status.parity = await checkLiveSchemaParity({
|
|
2585
|
+
db,
|
|
2586
|
+
schemas: manifestSchemas,
|
|
2587
|
+
conflictTargets: collectRegistryConflictTargets(),
|
|
2588
|
+
includeSystemTables: true,
|
|
2589
|
+
engineHint: dbType
|
|
2590
|
+
});
|
|
2591
|
+
} catch (error) {
|
|
2592
|
+
status.parityError = error instanceof Error ? error.message : String(error);
|
|
2593
|
+
}
|
|
2594
|
+
} else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
|
|
1898
2595
|
const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
|
|
1899
2596
|
status.migrations.failed = {
|
|
1900
2597
|
total: failedAssessments.length,
|
|
@@ -1903,9 +2600,10 @@ var dbStatusCommand = {
|
|
|
1903
2600
|
manualReview: failedAssessments.filter((item) => item.resolution === "manual_review").length,
|
|
1904
2601
|
details: failedAssessments
|
|
1905
2602
|
};
|
|
2603
|
+
const parityFailed = status.parityError !== null || status.parity?.ok === false;
|
|
1906
2604
|
if (options.json) {
|
|
1907
2605
|
console.log(JSON.stringify(status, null, 2));
|
|
1908
|
-
if (!schemaContract.ok || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
|
|
2606
|
+
if (!schemaContract.ok || parityFailed || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
|
|
1909
2607
|
return;
|
|
1910
2608
|
}
|
|
1911
2609
|
console.log(`📦 Manifests: ${status.manifests.count} discovered`);
|
|
@@ -1949,6 +2647,25 @@ var dbStatusCommand = {
|
|
|
1949
2647
|
console.log("✅ Live schema matches current manifests");
|
|
1950
2648
|
console.log();
|
|
1951
2649
|
}
|
|
2650
|
+
if (status.notes.length > 0) {
|
|
2651
|
+
console.log(`ℹ️ Live schema notes (${status.notes.length}, no action required):`);
|
|
2652
|
+
for (const note of status.notes) {
|
|
2653
|
+
console.log(` • ${note.name}: ${note.type}`);
|
|
2654
|
+
if (options.verbose) console.log(` ${note.recommendation}`);
|
|
2655
|
+
}
|
|
2656
|
+
console.log();
|
|
2657
|
+
}
|
|
2658
|
+
if (options.parity) {
|
|
2659
|
+
console.log("🗄️ Live Schema Parity:");
|
|
2660
|
+
if (status.parityError) {
|
|
2661
|
+
console.log(` ❌ ${status.parityError}`);
|
|
2662
|
+
process.exitCode = 1;
|
|
2663
|
+
} else if (status.parity) {
|
|
2664
|
+
for (const line of formatParityReport(status.parity, { verbose: options.verbose })) console.log(line);
|
|
2665
|
+
if (!status.parity.ok) process.exitCode = 1;
|
|
2666
|
+
}
|
|
2667
|
+
console.log();
|
|
2668
|
+
}
|
|
1952
2669
|
if (!schemaContract.ok) {
|
|
1953
2670
|
console.log("❌ Schema Contract Failed:");
|
|
1954
2671
|
console.log(formatSchemaContractFailures(schemaContract));
|
|
@@ -6512,6 +7229,15 @@ async function repairStiDiscriminatorRows(options) {
|
|
|
6512
7229
|
*
|
|
6513
7230
|
* Commands for introspection, testing, and project management
|
|
6514
7231
|
*/
|
|
7232
|
+
/**
|
|
7233
|
+
* Fallbacks for `migrations.postgres.lockTimeout` / `.statementTimeout`.
|
|
7234
|
+
*
|
|
7235
|
+
* These mirror `DEFAULT_CLI_CONFIG.migrations.postgres` ('30s' / '60s') so a
|
|
7236
|
+
* project that never wrote a `migrations` block still gets a bounded
|
|
7237
|
+
* PostgreSQL migration rather than an unbounded lock wait (issue #2362).
|
|
7238
|
+
*/
|
|
7239
|
+
var DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 3e4;
|
|
7240
|
+
var DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 6e4;
|
|
6515
7241
|
function formatSchemaCommandFailureHeader(error, fallback) {
|
|
6516
7242
|
if (error instanceof SchemaContractError) return "\n❌ Schema contract failed:";
|
|
6517
7243
|
if (error instanceof UnsupportedFileMigrationsError) return "\n❌ File-backed migrations are not supported:";
|
|
@@ -6543,6 +7269,28 @@ function resolveDDLPreviewEngine(dbType) {
|
|
|
6543
7269
|
default: return "sqlite";
|
|
6544
7270
|
}
|
|
6545
7271
|
}
|
|
7272
|
+
/**
|
|
7273
|
+
* Run the framework's deferred system-table compatibility pass after
|
|
7274
|
+
* `db:migrate` has created the tables it reshapes (issue #2376).
|
|
7275
|
+
*
|
|
7276
|
+
* `_smrt_jobs` and `_smrt_job_events` are dual-owned — created here from the
|
|
7277
|
+
* jobs manifest, then given their compatibility columns and indexes by
|
|
7278
|
+
* `@happyvertical/smrt-core`. On a fresh install the framework bootstrap runs
|
|
7279
|
+
* before those tables exist, so without this call the pass would first become
|
|
7280
|
+
* reachable at the next process start.
|
|
7281
|
+
*
|
|
7282
|
+
* Best-effort: the pass is idempotent and re-runs on the next boot, so a
|
|
7283
|
+
* failure here must not fail an otherwise-successful migration.
|
|
7284
|
+
*/
|
|
7285
|
+
async function settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose }) {
|
|
7286
|
+
try {
|
|
7287
|
+
const { settled } = await ensureDeferredSystemTableCompatibility(db, dbType);
|
|
7288
|
+
if (verbose) console.log(settled ? "Deferred system-table compatibility settled (_smrt_jobs, _smrt_job_events)\n" : "Deferred system-table compatibility pending (jobs tables not present)\n");
|
|
7289
|
+
} catch (error) {
|
|
7290
|
+
console.warn(`⚠️ Deferred system-table compatibility did not complete: ${error instanceof Error ? error.message : String(error)}`);
|
|
7291
|
+
console.warn(" It will be retried the next time the framework starts.\n");
|
|
7292
|
+
}
|
|
7293
|
+
}
|
|
6546
7294
|
function formatStiConflictIdentity(conflict) {
|
|
6547
7295
|
const entries = Object.entries(conflict.conflictIdentity);
|
|
6548
7296
|
return `${entries.length > 0 ? entries.map(([column, value]) => `${column}=${JSON.stringify(value)}`).join(", ") : "no non-_meta_type conflict columns"}${conflict.legacyId || conflict.qualifiedId ? ` (legacy id: ${conflict.legacyId ?? "unknown"}, qualified id: ${conflict.qualifiedId ?? "unknown"})` : ""}`;
|
|
@@ -6584,6 +7332,79 @@ function assertForceMigrationTargetsExist(forceMigrations, generatedMigrationIds
|
|
|
6584
7332
|
const unknown = forceMigrations.filter((migrationId) => !generated.has(migrationId));
|
|
6585
7333
|
if (unknown.length > 0) throw new Error(`--force-migration target${unknown.length === 1 ? "" : "s"} not found in the current generated migration batch: ${unknown.join(", ")}`);
|
|
6586
7334
|
}
|
|
7335
|
+
/**
|
|
7336
|
+
* Match an actual `oxc: { … decorator: … }` configuration block.
|
|
7337
|
+
*
|
|
7338
|
+
* Deliberately stricter than "the words `oxc` and `decorator` both appear":
|
|
7339
|
+
* a stray mention in a comment or an unrelated import would otherwise mark the
|
|
7340
|
+
* transform configured on a Vite 8 project that throws
|
|
7341
|
+
* `SyntaxError: Invalid or unexpected token` on its first SSR request — the
|
|
7342
|
+
* exact failure this check exists to catch. The lazy body stops at the first
|
|
7343
|
+
* `decorator:` key after the `oxc` object opens.
|
|
7344
|
+
*
|
|
7345
|
+
* A config that assembles `oxc` indirectly (a spread, or an imported base
|
|
7346
|
+
* config) is reported as unconfigured. That direction is the safe one: the
|
|
7347
|
+
* recommendation names the exact key to add, whereas the opposite error is
|
|
7348
|
+
* silent until runtime.
|
|
7349
|
+
*/
|
|
7350
|
+
var OXC_DECORATOR_BLOCK_RE = /\boxc\s*:\s*\{[\s\S]*?\bdecorator\s*:/;
|
|
7351
|
+
/**
|
|
7352
|
+
* Read the declared Vite major version from a project's package.json.
|
|
7353
|
+
*
|
|
7354
|
+
* Returns `null` when Vite is absent or the range is not a simple one whose
|
|
7355
|
+
* major can be read off the front (`workspace:*`, `*`, a git URL, …); callers
|
|
7356
|
+
* treat that as "unknown major" and accept either decorator recipe.
|
|
7357
|
+
*/
|
|
7358
|
+
function resolveDeclaredViteMajor(packageJson) {
|
|
7359
|
+
const range = packageJson.devDependencies?.vite ?? packageJson.dependencies?.vite;
|
|
7360
|
+
if (typeof range !== "string") return null;
|
|
7361
|
+
const match = range.match(/(\d+)\s*\./);
|
|
7362
|
+
if (!match) return null;
|
|
7363
|
+
const major = Number.parseInt(match[1], 10);
|
|
7364
|
+
return Number.isFinite(major) ? major : null;
|
|
7365
|
+
}
|
|
7366
|
+
/**
|
|
7367
|
+
* Assess whether a project's decorator transform is actually configured.
|
|
7368
|
+
*
|
|
7369
|
+
* The doctor used to require `experimentalDecorators` in tsconfig.json
|
|
7370
|
+
* unconditionally, which contradicts the framework's own Vite 8 guidance: the
|
|
7371
|
+
* oxc transform does not honor tsconfig `experimentalDecorators` (nor the
|
|
7372
|
+
* pre-Vite-8 `esbuild.tsconfigRaw` recipe), so a correctly configured Vite 8
|
|
7373
|
+
* project — decorators declared under `oxc.decorator` — was reported broken,
|
|
7374
|
+
* and a Vite 8 project relying on tsconfig alone was reported healthy right up
|
|
7375
|
+
* until the first SSR request threw `SyntaxError: Invalid or unexpected token`.
|
|
7376
|
+
*
|
|
7377
|
+
* The check now follows the documented recipe per Vite major:
|
|
7378
|
+
* - Vite 8+: `oxc.decorator` in vite.config is the only working configuration.
|
|
7379
|
+
* - Vite <8 (or no declared Vite): tsconfig `experimentalDecorators` is the
|
|
7380
|
+
* legacy recipe and remains valid.
|
|
7381
|
+
*/
|
|
7382
|
+
function assessDecoratorSupport(input) {
|
|
7383
|
+
const { viteMajor, viteConfigContent, tsconfigContent } = input;
|
|
7384
|
+
const hasOxcDecorator = viteConfigContent !== null && OXC_DECORATOR_BLOCK_RE.test(viteConfigContent);
|
|
7385
|
+
const hasTsconfigDecorators = tsconfigContent?.includes("experimentalDecorators");
|
|
7386
|
+
const isVite8Plus = viteMajor !== null && viteMajor >= 8;
|
|
7387
|
+
if (hasOxcDecorator) return {
|
|
7388
|
+
status: "ok",
|
|
7389
|
+
message: "oxc.decorator configured in vite.config"
|
|
7390
|
+
};
|
|
7391
|
+
if (isVite8Plus) return {
|
|
7392
|
+
status: "error",
|
|
7393
|
+
message: hasTsconfigDecorators ? "Vite 8 ignores tsconfig experimentalDecorators - add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config" : "Add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config (required for @smrt() under Vite 8+)"
|
|
7394
|
+
};
|
|
7395
|
+
if (hasTsconfigDecorators) return {
|
|
7396
|
+
status: "ok",
|
|
7397
|
+
message: "experimentalDecorators enabled in tsconfig.json"
|
|
7398
|
+
};
|
|
7399
|
+
if (viteConfigContent === null && tsconfigContent === null) return {
|
|
7400
|
+
status: "warning",
|
|
7401
|
+
message: "No vite.config or tsconfig.json found - configure the decorator transform wherever this project compiles @smrt() classes"
|
|
7402
|
+
};
|
|
7403
|
+
return {
|
|
7404
|
+
status: "error",
|
|
7405
|
+
message: "No decorator transform configured - add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config (Vite 8+), or \"experimentalDecorators\": true to tsconfig.json (vite<8)"
|
|
7406
|
+
};
|
|
7407
|
+
}
|
|
6587
7408
|
function getErrorContext(error) {
|
|
6588
7409
|
if (error && typeof error === "object" && "context" in error) {
|
|
6589
7410
|
const context = error.context;
|
|
@@ -7141,7 +7962,7 @@ export default testManifest;
|
|
|
7141
7962
|
},
|
|
7142
7963
|
"postgres-safe": {
|
|
7143
7964
|
type: "boolean",
|
|
7144
|
-
description: "
|
|
7965
|
+
description: "PostgreSQL concurrent-index mode: commit non-index DDL atomically, then build each index with CREATE INDEX CONCURRENTLY outside that transaction. Trades batch atomicity for availability; lock_timeout/statement_timeout apply either way.",
|
|
7145
7966
|
default: false
|
|
7146
7967
|
},
|
|
7147
7968
|
"postgres-timestamp-legacy-timezone": {
|
|
@@ -7173,6 +7994,16 @@ export default testManifest;
|
|
|
7173
7994
|
description: "Drop orphan indexes (in DB but not in manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default.",
|
|
7174
7995
|
default: false
|
|
7175
7996
|
},
|
|
7997
|
+
"drop-columns": {
|
|
7998
|
+
type: "boolean",
|
|
7999
|
+
description: "DESTRUCTIVE: drop orphan columns (in DB but not in manifest). Off by default; orphans are always reported.",
|
|
8000
|
+
default: false
|
|
8001
|
+
},
|
|
8002
|
+
"relax-columns": {
|
|
8003
|
+
type: "boolean",
|
|
8004
|
+
description: "Apply constraint relaxations the manifest implies: DROP NOT NULL / DROP DEFAULT on live columns stricter than the manifest, and DROP NOT NULL on orphan NOT NULL columns (PostgreSQL/DuckDB). Off by default; relaxations are always reported.",
|
|
8005
|
+
default: false
|
|
8006
|
+
},
|
|
7176
8007
|
verbose: {
|
|
7177
8008
|
type: "boolean",
|
|
7178
8009
|
description: "Show detailed output",
|
|
@@ -7266,16 +8097,26 @@ export default testManifest;
|
|
|
7266
8097
|
console.log(`✓ Connected to ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
|
|
7267
8098
|
const systemTimestampPreview = postgresTimestampMigration && isDryRun ? await planPostgresSystemTimestampMigrations(db, postgresTimestampMigration, dbType) : [];
|
|
7268
8099
|
if (postgresTimestampMigration && !isDryRun) await migratePostgresSystemTimestamps(db, postgresTimestampMigration, dbType);
|
|
7269
|
-
const { MigrationTracker, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
|
|
8100
|
+
const { buildConcurrentIndexPlan, MigrationTracker, parsePostgresTimeoutMs, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
|
|
8101
|
+
const postgresMigrationConfig = config.migrations?.postgres;
|
|
8102
|
+
const lockTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.lockTimeout, DEFAULT_MIGRATION_LOCK_TIMEOUT_MS);
|
|
8103
|
+
const statementTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.statementTimeout, DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS);
|
|
8104
|
+
const concurrentIndexMode = Boolean(options["postgres-safe"]) && (postgresMigrationConfig?.useConcurrently ?? true);
|
|
7270
8105
|
const tracker = new MigrationTracker({
|
|
7271
8106
|
db,
|
|
7272
|
-
|
|
8107
|
+
lockTimeout,
|
|
8108
|
+
statementTimeout,
|
|
8109
|
+
useConcurrentIndexes: concurrentIndexMode
|
|
7273
8110
|
});
|
|
7274
8111
|
if (!isDryRun) await tracker.initialize();
|
|
7275
8112
|
if (options.verbose) {
|
|
7276
8113
|
const engine = tracker.getEngine();
|
|
7277
8114
|
console.log(isDryRun ? `Migration tracker preview configured (engine: ${engine})` : `Migration tracker initialized (engine: ${engine})`);
|
|
7278
|
-
if (
|
|
8115
|
+
if (engine === "postgres") {
|
|
8116
|
+
console.log(`PostgreSQL lock_timeout: ${lockTimeout}ms, statement_timeout: ${statementTimeout}ms`);
|
|
8117
|
+
if (concurrentIndexMode) console.log("PostgreSQL concurrent-index mode enabled (CREATE INDEX CONCURRENTLY outside the atomic batch)");
|
|
8118
|
+
else if (options["postgres-safe"]) console.log("PostgreSQL concurrent-index mode disabled by migrations.postgres.useConcurrently = false");
|
|
8119
|
+
}
|
|
7279
8120
|
console.log();
|
|
7280
8121
|
}
|
|
7281
8122
|
const migrations = [];
|
|
@@ -7290,6 +8131,8 @@ export default testManifest;
|
|
|
7290
8131
|
console.log("🔍 Comparing schemas...\n");
|
|
7291
8132
|
const diff = await new SchemaComparer(db, {
|
|
7292
8133
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
8134
|
+
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
8135
|
+
relaxColumns: Boolean(options["relax-columns"]),
|
|
7293
8136
|
postgresTimestampMigration
|
|
7294
8137
|
}).compare(manifestSchemas);
|
|
7295
8138
|
const getClassForTable = (tableName) => {
|
|
@@ -7308,6 +8151,7 @@ export default testManifest;
|
|
|
7308
8151
|
const partitionedChanges = partitionSchemaChanges(diff.changes, getClassForTable);
|
|
7309
8152
|
migrations.push(...partitionedChanges.migrations);
|
|
7310
8153
|
manualInterventions.push(...partitionedChanges.manualInterventions);
|
|
8154
|
+
const advisories = partitionedChanges.advisories;
|
|
7311
8155
|
assertForceMigrationTargetsExist(forceSelection.forceMigrations, [...diff.added_tables.map((schema) => `create_table_${schema.tableName}`), ...migrations.flatMap((migration) => {
|
|
7312
8156
|
const migrationName = getSyntheticMigrationNameForAction(migration);
|
|
7313
8157
|
return migrationName ? [migrationName] : [];
|
|
@@ -7317,17 +8161,22 @@ export default testManifest;
|
|
|
7317
8161
|
console.log("⚠️ Schema drift detected that requires manual intervention:\n");
|
|
7318
8162
|
for (const change of manualInterventions) {
|
|
7319
8163
|
if (!change.mismatch) continue;
|
|
7320
|
-
const detail = change.type === "type_upgrade" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (cannot auto-apply on this database engine)` : `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual}`;
|
|
8164
|
+
const detail = change.type === "type_upgrade" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (cannot auto-apply on this database engine)` : change.type === "alter_column" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (${change.alteration ?? "alter_column"}; ${change.sql?.replace(/^--\s*/, "") ?? "cannot auto-apply on this database engine"})` : `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual}`;
|
|
7321
8165
|
console.log(` ${detail}`);
|
|
7322
8166
|
}
|
|
7323
8167
|
console.log();
|
|
7324
8168
|
console.log(" Manual migration required (backup, recreate, restore as needed).\n");
|
|
7325
8169
|
}
|
|
8170
|
+
printSchemaAdvisories(advisories, {
|
|
8171
|
+
orphanTables: diff.orphan_tables ?? [],
|
|
8172
|
+
verbose: Boolean(options.verbose)
|
|
8173
|
+
});
|
|
7326
8174
|
const tablesCreated = diff.added_tables.length > 0;
|
|
7327
8175
|
const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
|
|
8176
|
+
const upToDateMessage = advisories.some((a) => a.advisory.severity === "warning") ? "✅ No migrations needed - see the live schema findings above\n" : "✅ Database schema is up to date - no migrations needed\n";
|
|
7328
8177
|
if (isDryRun) {
|
|
7329
8178
|
if (schemaUpToDate && !repairData) {
|
|
7330
|
-
console.log(
|
|
8179
|
+
console.log(upToDateMessage);
|
|
7331
8180
|
return;
|
|
7332
8181
|
}
|
|
7333
8182
|
if (schemaUpToDate) console.log("✅ Database schema is up to date - no schema migrations needed\n");
|
|
@@ -7341,11 +8190,23 @@ export default testManifest;
|
|
|
7341
8190
|
const columnMigrations = migrations.filter((m) => m.type === "add_column");
|
|
7342
8191
|
const indexMigrations = migrations.filter((m) => m.type === "add_index");
|
|
7343
8192
|
const indexDrops = migrations.filter((m) => m.type === "drop_index");
|
|
8193
|
+
const columnAlterations = migrations.filter((m) => m.type === "alter_column");
|
|
8194
|
+
const columnDrops = migrations.filter((m) => m.type === "drop_column");
|
|
7344
8195
|
if (columnMigrations.length > 0) {
|
|
7345
8196
|
console.log(` 📊 Columns to add: ${columnMigrations.length}`);
|
|
7346
8197
|
for (const m of columnMigrations) console.log(` ${m.tableName}.${m.column?.name} (${m.column?.type})`);
|
|
7347
8198
|
console.log();
|
|
7348
8199
|
}
|
|
8200
|
+
if (columnAlterations.length > 0) {
|
|
8201
|
+
console.log(` 🔧 Columns to alter: ${columnAlterations.length}`);
|
|
8202
|
+
for (const m of columnAlterations) console.log(` ${m.tableName}.${m.columnName ?? m.column?.name} (${m.alteration ?? "alter"}: ${m.mismatch?.actual ?? "?"} → ${m.mismatch?.expected ?? "?"})`);
|
|
8203
|
+
console.log();
|
|
8204
|
+
}
|
|
8205
|
+
if (columnDrops.length > 0) {
|
|
8206
|
+
console.log(` 🗑️ Columns to drop (DESTRUCTIVE, --drop-columns): ${columnDrops.length}`);
|
|
8207
|
+
for (const m of columnDrops) console.log(` ${m.tableName}.${m.columnName}`);
|
|
8208
|
+
console.log();
|
|
8209
|
+
}
|
|
7349
8210
|
if (indexDrops.length > 0) {
|
|
7350
8211
|
console.log(` 🗑️ Indexes to drop: ${indexDrops.length}`);
|
|
7351
8212
|
for (const m of indexDrops) console.log(` ${m.indexName} on ${m.tableName}`);
|
|
@@ -7378,7 +8239,6 @@ export default testManifest;
|
|
|
7378
8239
|
let errorCount = 0;
|
|
7379
8240
|
let stiErrorCount = 0;
|
|
7380
8241
|
const schemaChangeCount = diff.added_tables.length + migrations.length;
|
|
7381
|
-
if (applySchemaMigrations && schemaChangeCount > 0) console.log(`🔨 Applying ${schemaChangeCount} schema change(s) atomically...\n`);
|
|
7382
8242
|
if (applySchemaMigrations && schemaChangeCount > 0) {
|
|
7383
8243
|
const migrationDefs = [];
|
|
7384
8244
|
const migrationLogs = /* @__PURE__ */ new Map();
|
|
@@ -7410,6 +8270,12 @@ export default testManifest;
|
|
|
7410
8270
|
if (migration.type === "add_column" && migration.column) {
|
|
7411
8271
|
migrationSql = migration.sql || "";
|
|
7412
8272
|
actionDesc = `Added column ${migration.tableName}.${migration.column.name}`;
|
|
8273
|
+
} else if (migration.type === "alter_column" && (migration.columnName || migration.column)) {
|
|
8274
|
+
migrationSql = migration.sql || "";
|
|
8275
|
+
actionDesc = `Altered column ${migration.tableName}.${migration.columnName ?? migration.column?.name} (${migration.alteration ?? "alter"})`;
|
|
8276
|
+
} else if (migration.type === "drop_column" && migration.columnName) {
|
|
8277
|
+
migrationSql = migration.sql || "";
|
|
8278
|
+
actionDesc = `Dropped column ${migration.tableName}.${migration.columnName}`;
|
|
7413
8279
|
} else if (migration.type === "type_upgrade" && migration.column) {
|
|
7414
8280
|
migrationSql = migration.sql || "";
|
|
7415
8281
|
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}`;
|
|
@@ -7423,7 +8289,7 @@ export default testManifest;
|
|
|
7423
8289
|
const migrationSqlStatements = migration.sqlStatements ?? (migrationSql ? [migrationSql] : []);
|
|
7424
8290
|
migrationDefs.push({
|
|
7425
8291
|
id: migrationName,
|
|
7426
|
-
description: migration.type === "add_column" ? `Add column ${migration.column?.name} to ${migration.tableName}` : migration.type === "type_upgrade" ? `Upgrade column ${migration.column?.name} on ${migration.tableName} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}` : migration.type === "drop_index" ? `Drop index ${migration.indexName} on ${migration.tableName}` : `Add index ${migration.index?.name} on ${migration.tableName}`,
|
|
8292
|
+
description: migration.type === "add_column" ? `Add column ${migration.column?.name} to ${migration.tableName}` : migration.type === "alter_column" ? `Alter column ${migration.columnName ?? migration.column?.name} on ${migration.tableName} (${migration.alteration ?? "alter"}: ${migration.mismatch?.actual ?? "?"} → ${migration.mismatch?.expected ?? "?"})` : migration.type === "drop_column" ? `Drop column ${migration.columnName} from ${migration.tableName}` : migration.type === "type_upgrade" ? `Upgrade column ${migration.column?.name} on ${migration.tableName} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}` : migration.type === "drop_index" ? `Drop index ${migration.indexName} on ${migration.tableName}` : `Add index ${migration.index?.name} on ${migration.tableName}`,
|
|
7427
8293
|
version: "1.0.0",
|
|
7428
8294
|
up: migrationSqlStatements,
|
|
7429
8295
|
down: []
|
|
@@ -7433,14 +8299,20 @@ export default testManifest;
|
|
|
7433
8299
|
skippedMessage: `${migrationName} already applied`
|
|
7434
8300
|
});
|
|
7435
8301
|
}
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
8302
|
+
const deferredIndexMigrations = concurrentIndexMode && engine === "postgres" ? buildConcurrentIndexPlan(migrationDefs, true).size : 0;
|
|
8303
|
+
const batchHasIndexDDL = migrations.some((migration) => migration.type === "add_index" || migration.type === "drop_index");
|
|
8304
|
+
console.log(deferredIndexMigrations > 0 ? `🔨 Applying ${schemaChangeCount} schema change(s) (non-index DDL atomically, ${deferredIndexMigrations} index migration(s) CONCURRENTLY)...\n` : `🔨 Applying ${schemaChangeCount} schema change(s) atomically...\n`);
|
|
8305
|
+
if (deferredIndexMigrations > 0) {
|
|
8306
|
+
console.warn("⚠️ PostgreSQL concurrent-index mode: index DDL runs CONCURRENTLY after the non-index batch commits.");
|
|
8307
|
+
console.warn(" This batch is therefore NOT atomic — committed column/table changes survive a later index failure, and unfinished index migrations are recorded failed so the next db:migrate retries them.\n");
|
|
8308
|
+
} else if (engine === "postgres" && batchHasIndexDDL && options["postgres-safe"] && !concurrentIndexMode) {
|
|
8309
|
+
console.warn("⚠️ --postgres-safe requested, but migrations.postgres.useConcurrently is false.");
|
|
8310
|
+
console.warn(" Index DDL in this batch will run inside the atomic transaction (bounded by lock_timeout/statement_timeout).\n");
|
|
7439
8311
|
}
|
|
7440
8312
|
try {
|
|
7441
8313
|
const results = await tracker.applyAll(migrationDefs, {
|
|
7442
8314
|
atomic: true,
|
|
7443
|
-
postgresSafe:
|
|
8315
|
+
postgresSafe: concurrentIndexMode,
|
|
7444
8316
|
force: forceSelection.force,
|
|
7445
8317
|
forceMigrations: forceSelection.forceMigrations,
|
|
7446
8318
|
reconcile: true,
|
|
@@ -7465,10 +8337,10 @@ export default testManifest;
|
|
|
7465
8337
|
console.error(` ✗ atomic schema migration failed: ${errorMsg}`);
|
|
7466
8338
|
if (error instanceof Error && getErrorContext(error)?.originalError) console.error(` Cause: ${getErrorContext(error)?.originalError}`);
|
|
7467
8339
|
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
7468
|
-
console.error(" Rolled back all schema changes from this migration batch, including any successful steps shown above.");
|
|
8340
|
+
console.error(deferredIndexMigrations > 0 ? " Non-index changes in this batch were committed; concurrent index builds that did not finish are recorded failed and will be retried on the next db:migrate." : " Rolled back all schema changes from this migration batch, including any successful steps shown above.");
|
|
7469
8341
|
}
|
|
7470
8342
|
}
|
|
7471
|
-
if (schemaUpToDate && !repairData) console.log(
|
|
8343
|
+
if (schemaUpToDate && !repairData) console.log(upToDateMessage);
|
|
7472
8344
|
if (repairData) {
|
|
7473
8345
|
console.log("\n🔄 Repairing STI discriminators to qualified names...\n");
|
|
7474
8346
|
const allTableNames = /* @__PURE__ */ new Set();
|
|
@@ -7564,11 +8436,14 @@ export default testManifest;
|
|
|
7564
8436
|
stiErrorCount,
|
|
7565
8437
|
dryRun: isDryRun
|
|
7566
8438
|
})) process.exitCode = 1;
|
|
7567
|
-
if (!isDryRun)
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
8439
|
+
if (!isDryRun) {
|
|
8440
|
+
await settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose: Boolean(options.verbose) });
|
|
8441
|
+
assertSchemaContract(await evaluateSchemaContract({
|
|
8442
|
+
discovered,
|
|
8443
|
+
schemaContract: config.schemaContract,
|
|
8444
|
+
db
|
|
8445
|
+
}));
|
|
8446
|
+
}
|
|
7572
8447
|
console.log("💡 Next steps:");
|
|
7573
8448
|
console.log(" - Run: smrt db:status (view migration status)");
|
|
7574
8449
|
console.log(" - Run: smrt db:history (view migration history)");
|
|
@@ -7607,6 +8482,17 @@ export default testManifest;
|
|
|
7607
8482
|
default: false,
|
|
7608
8483
|
short: "f"
|
|
7609
8484
|
},
|
|
8485
|
+
db: {
|
|
8486
|
+
type: "boolean",
|
|
8487
|
+
description: "Compare the live database to the expected schema shape (incl. _smrt_* system tables)",
|
|
8488
|
+
default: false
|
|
8489
|
+
},
|
|
8490
|
+
verbose: {
|
|
8491
|
+
type: "boolean",
|
|
8492
|
+
description: "Show recommendations and informational findings",
|
|
8493
|
+
default: false,
|
|
8494
|
+
short: "v"
|
|
8495
|
+
},
|
|
7610
8496
|
"generation-snapshot": {
|
|
7611
8497
|
type: "string",
|
|
7612
8498
|
description: "Verify a transported SMRT generation snapshot"
|
|
@@ -7713,14 +8599,22 @@ export default testManifest;
|
|
|
7713
8599
|
const viteConfigJs = resolve(cwd, "vite.config.js");
|
|
7714
8600
|
const hasViteConfig = existsSync(viteConfigTs) || existsSync(viteConfigJs);
|
|
7715
8601
|
check("vite.config.ts/js exists", hasViteConfig, void 0, "Missing vite.config (optional for non-Vite projects)");
|
|
7716
|
-
|
|
8602
|
+
const viteConfigContent = hasViteConfig ? readFileSync(existsSync(viteConfigTs) ? viteConfigTs : viteConfigJs, "utf-8") : null;
|
|
8603
|
+
if (viteConfigContent !== null) check("smrtPlugin in vite.config", viteConfigContent.includes("smrtPlugin"), "Missing smrtPlugin - add: import { smrtPlugin } from \"@happyvertical/smrt-core/vite-plugin\"");
|
|
7717
8604
|
const tsconfigPath = resolve(cwd, "tsconfig.json");
|
|
8605
|
+
let tsconfigContent = null;
|
|
7718
8606
|
if (existsSync(tsconfigPath)) try {
|
|
7719
|
-
|
|
8607
|
+
tsconfigContent = readFileSync(tsconfigPath, "utf-8");
|
|
7720
8608
|
} catch {
|
|
7721
8609
|
check("tsconfig.json readable", false, "Could not read tsconfig.json");
|
|
7722
8610
|
}
|
|
7723
8611
|
else check("tsconfig.json exists", false, void 0, "No tsconfig.json found (optional for JavaScript projects)");
|
|
8612
|
+
const decoratorSupport = assessDecoratorSupport({
|
|
8613
|
+
viteMajor: resolveDeclaredViteMajor(packageJson),
|
|
8614
|
+
viteConfigContent,
|
|
8615
|
+
tsconfigContent
|
|
8616
|
+
});
|
|
8617
|
+
check("Decorator transform configured", decoratorSupport.status === "ok", decoratorSupport.status === "error" ? decoratorSupport.message : void 0, decoratorSupport.status === "warning" ? decoratorSupport.message : void 0);
|
|
7724
8618
|
console.log();
|
|
7725
8619
|
console.log("📦 SMRT Objects\n");
|
|
7726
8620
|
const objectsDir = resolve(cwd, "src/lib/objects");
|
|
@@ -7774,6 +8668,21 @@ export default testManifest;
|
|
|
7774
8668
|
else if (existsSync(envExamplePath)) check(".env file exists", false, void 0, ".env.example exists - copy it to .env");
|
|
7775
8669
|
else check(".env file exists", false, void 0, "No .env file - environment variables may be needed");
|
|
7776
8670
|
console.log();
|
|
8671
|
+
if (options.db) {
|
|
8672
|
+
console.log("🗄️ Live Database Parity\n");
|
|
8673
|
+
const { report, error, database } = await runLiveSchemaParity({ discover: false });
|
|
8674
|
+
if (!report) check("Live schema parity", false, error ?? "Could not verify the live schema");
|
|
8675
|
+
else {
|
|
8676
|
+
if (database) console.log(` Database: ${database.url}`);
|
|
8677
|
+
for (const line of formatParityReport(report, { verbose: options.verbose })) console.log(line);
|
|
8678
|
+
const parityErrors = selectFindings(report, "error");
|
|
8679
|
+
const parityWarnings = selectFindings(report, "warning");
|
|
8680
|
+
for (const finding of parityErrors) issues.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
|
|
8681
|
+
for (const finding of parityWarnings) warnings.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
|
|
8682
|
+
if (parityErrors.length === 0 && parityWarnings.length === 0) passed.push("Live schema parity");
|
|
8683
|
+
}
|
|
8684
|
+
console.log();
|
|
8685
|
+
}
|
|
7777
8686
|
console.log("━".repeat(50));
|
|
7778
8687
|
console.log(`\n📊 Summary\n`);
|
|
7779
8688
|
console.log(` ✅ Passed: ${passed.length}`);
|
|
@@ -7805,6 +8714,7 @@ export default testManifest;
|
|
|
7805
8714
|
"db:rollback": dbRollbackCommand,
|
|
7806
8715
|
"db:generate": dbGenerateCommand,
|
|
7807
8716
|
"db:migrate-uuid": dbMigrateUuidCommand,
|
|
8717
|
+
"db:prune": dbPruneCommand,
|
|
7808
8718
|
"config:export": configExportCommand,
|
|
7809
8719
|
export: exportCommand
|
|
7810
8720
|
};
|