@happyvertical/smrt-cli 0.47.0 → 0.47.2

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.
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
6
6
  import * as path from "node:path";
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planForeignKeyCreation, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
9
+ import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, collectForeignKeyOrphanCounts, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, isSmrtCollectionExtendsName, migratePostgresSystemTimestamps, parseQualifiedName, planForeignKeyCreation, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
10
10
  import { loadExternalManifestSync } from "@happyvertical/smrt-core/manifest";
11
11
  import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
12
  import { createLogger } from "@happyvertical/logger";
@@ -623,6 +623,7 @@ function partitionSchemaChanges(changes, getClassForTable) {
623
623
  switch (change.type) {
624
624
  case "orphan_column":
625
625
  case "orphan_index":
626
+ case "rename_data_pending":
626
627
  if (!change.name || !change.advisory) continue;
627
628
  advisories.push({
628
629
  type: change.type,
@@ -856,6 +857,7 @@ function describeAdvisory(item) {
856
857
  case "orphan_column": return `${item.tableName}.${item.name}: orphan column${item.actual ? ` (${item.actual})` : ""}`;
857
858
  case "orphan_index": return `${item.tableName}.${item.name}: unique constraint not in manifest${item.actual ? ` (${item.actual})` : ""}`;
858
859
  case "type_upgrade": return `${item.tableName}.${item.name}: type upgrade blocked${item.actual ? ` (live: ${item.actual})` : ""}`;
860
+ case "rename_data_pending": return `${item.tableName}.${item.name}: rename data pending${item.actual ? ` (${item.actual})` : ""}`;
859
861
  default: return `${item.tableName}.${item.name}: ${item.alteration ?? "alter_column"}${item.actual ? ` (live: ${item.actual})` : ""}`;
860
862
  }
861
863
  }
@@ -1458,6 +1460,128 @@ function formatDateTime(date) {
1458
1460
  return date.toISOString().replace("T", " ").substring(0, 19);
1459
1461
  }
1460
1462
  //#endregion
1463
+ //#region src/commands/db-orphans.ts
1464
+ /**
1465
+ * db:orphans Command (#2753)
1466
+ *
1467
+ * Read-only per-foreign-key orphan count report. `db:migrate`'s orphan probe
1468
+ * already gates `ADD CONSTRAINT` with a `LIMIT 1` existence check and prints a
1469
+ * suggested repair; this command runs the identical probe as `COUNT(*)` for
1470
+ * every manifest-declared foreign key, so planning a repair no longer starts
1471
+ * with hand-writing the same SQL against the live database.
1472
+ *
1473
+ * Strictly diagnostic: it never repairs anything, and the orphan counts
1474
+ * themselves never affect the exit code — a database full of orphans still
1475
+ * exits 0. It exits non-zero only on a configuration or runtime error (no
1476
+ * database configured, or the report itself could not run), the same
1477
+ * contract every other `db:*` command uses. The existing "no foreign-key
1478
+ * orphan repair, by design" boundary stays exactly where it is. Use `--json`
1479
+ * for scripting.
1480
+ */
1481
+ /** Render one count row as a compact, script-friendly line. */
1482
+ function formatOrphanCountLine(count) {
1483
+ const marker = count.nullable ? "" : " [NOT NULL]";
1484
+ return `${count.childTable}.${count.childColumn} -> ${count.parentTable}.${count.parentColumn}: ${count.orphanCount} orphan(s)${marker}`;
1485
+ }
1486
+ /** Render the full report as console lines. */
1487
+ function formatOrphanReport(report, options = {}) {
1488
+ const lines = [];
1489
+ const affected = report.counts.filter((count) => count.orphanCount > 0);
1490
+ const failedProbeCount = report.skipped.filter((skip) => skip.kind === "probe_failed").length;
1491
+ lines.push(` Engine: ${report.engine} · foreign keys checked: ${report.counts.length}` + (report.skipped.length > 0 ? ` · skipped: ${report.skipped.length}` : ""));
1492
+ if (affected.length === 0 && failedProbeCount === 0) lines.push(" ✅ No orphan rows found across any manifest foreign key.");
1493
+ else if (affected.length === 0) lines.push(` ⚠️ No orphans found among the successfully probed foreign keys, but ${failedProbeCount} probe(s) failed — see Skipped below. The scan is incomplete, not clean.`);
1494
+ else for (const count of affected) {
1495
+ const icon = count.nullable ? "⚠️" : "❌";
1496
+ lines.push(` ${icon} ${formatOrphanCountLine(count)}`);
1497
+ }
1498
+ if (options.verbose) {
1499
+ const clean = report.counts.filter((count) => count.orphanCount === 0);
1500
+ if (clean.length > 0) {
1501
+ lines.push(` ✅ Zero orphans (${clean.length}):`);
1502
+ for (const count of clean) lines.push(` • ${formatOrphanCountLine(count)}`);
1503
+ }
1504
+ }
1505
+ if (report.skipped.length > 0) {
1506
+ lines.push(" Skipped:");
1507
+ for (const skip of report.skipped) {
1508
+ const marker = skip.kind === "probe_failed" ? " [PROBE FAILED]" : "";
1509
+ lines.push(` • ${skip.childTable}.${skip.childColumn} -> ${skip.parentTable}.${skip.parentColumn}: ${skip.reason}${marker}`);
1510
+ }
1511
+ }
1512
+ return lines;
1513
+ }
1514
+ /** Foreign keys with at least one orphan row, for `db:status`'s summary. */
1515
+ function affectedOrphanCounts(report) {
1516
+ return report.counts.filter((count) => count.orphanCount > 0);
1517
+ }
1518
+ var dbOrphansCommand = {
1519
+ name: "db:orphans",
1520
+ description: "Read-only per-foreign-key orphan count report: for every manifest foreign key, prints child/parent table and column, the live orphan row count, and whether the child column is NOT NULL. Never repairs. Orphans found never affect the exit code; exits non-zero only on a configuration or runtime error.",
1521
+ aliases: ["orphans", "db-orphans"],
1522
+ args: [],
1523
+ options: {
1524
+ json: {
1525
+ type: "boolean",
1526
+ description: "Output as JSON (for scripting)",
1527
+ default: false,
1528
+ short: "j"
1529
+ },
1530
+ verbose: {
1531
+ type: "boolean",
1532
+ description: "Also list foreign keys with zero orphans",
1533
+ default: false,
1534
+ short: "v"
1535
+ }
1536
+ },
1537
+ handler: async (_args, options) => {
1538
+ let db;
1539
+ try {
1540
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
1541
+ const { DEFAULT_CLI_CONFIG } = await import("./config-DfBo4hHU.js").then((n) => n.n);
1542
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
1543
+ if (!config.database?.url || config.database.url === ":memory:") {
1544
+ if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
1545
+ else {
1546
+ console.error("\n❌ Database configuration required");
1547
+ console.error("\nPlease configure database in smrt.config.js\n");
1548
+ }
1549
+ process.exitCode = 1;
1550
+ return;
1551
+ }
1552
+ const dbType = config.database.type || "sqlite";
1553
+ const dbUrl = config.database.url;
1554
+ if (!options.json) {
1555
+ console.log("\n🔎 Foreign-key orphan report\n");
1556
+ console.log(`Database: ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
1557
+ }
1558
+ const { getDatabase } = await import("@happyvertical/sql");
1559
+ db = await getDatabase({
1560
+ type: dbType,
1561
+ url: dbUrl
1562
+ });
1563
+ await autoDiscoverAndLoad();
1564
+ const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
1565
+ const report = await collectForeignKeyOrphanCounts(db, manifestSchemas, { engineHint: dbType });
1566
+ if (options.json) {
1567
+ console.log(JSON.stringify(report, null, 2));
1568
+ return;
1569
+ }
1570
+ for (const line of formatOrphanReport(report, { verbose: options.verbose })) console.log(line);
1571
+ console.log();
1572
+ } catch (error) {
1573
+ if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1574
+ else {
1575
+ console.error("\n❌ Failed to collect the orphan report:");
1576
+ if (error instanceof Error) console.error(` ${error.message}`);
1577
+ }
1578
+ process.exitCode = 1;
1579
+ } finally {
1580
+ await closeDatabaseConnection(db);
1581
+ }
1582
+ }
1583
+ };
1584
+ //#endregion
1461
1585
  //#region src/commands/db-permissions.ts
1462
1586
  /** The only mutating caller is the explicit permissions command. */
1463
1587
  async function runPostgresPermissions(options = {}, optional = false) {
@@ -2085,15 +2209,19 @@ var dbRollbackCommand = {
2085
2209
  /**
2086
2210
  * Collect every declared upsert conflict target, keyed by table name.
2087
2211
  *
2088
- * The conflict target is a registry property, not a manifest one: a class may
2089
- * declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
2090
- * default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
2091
- * resolves to, the live database needs a matching UNIQUE index or every upsert
2092
- * against that table either errors (PostgreSQL) or duplicates rows.
2212
+ * The conflict target is a registry property of a persisted object, not a
2213
+ * manifest one: an object may declare `conflictColumns` explicitly, inherit
2214
+ * the CTI `(slug, context)` default, or get the STI
2215
+ * `(slug, context, _meta_type)` triple. Collections share their item table,
2216
+ * but never persist through their own conflict target, so they must not add a
2217
+ * synthetic default target to the parity check. Every collected target needs a
2218
+ * matching UNIQUE index or its object upsert either errors (PostgreSQL) or
2219
+ * duplicates rows.
2093
2220
  */
2094
2221
  function collectRegistryConflictTargets() {
2095
2222
  const targets = {};
2096
2223
  for (const className of ObjectRegistry.getQualifiedClassNames()) {
2224
+ if (isCollectionRegistration(className)) continue;
2097
2225
  const tableName = ObjectRegistry.getTableName(className);
2098
2226
  if (!tableName) continue;
2099
2227
  const columns = ObjectRegistry.getConflictColumns(className);
@@ -2108,6 +2236,19 @@ function collectRegistryConflictTargets() {
2108
2236
  return targets;
2109
2237
  }
2110
2238
  /**
2239
+ * Matches core's collection classification, including a registered collection
2240
+ * subclass whose immediate parent is another collection class. Unknown
2241
+ * registrations stay included so a malformed registry cannot hide a real
2242
+ * persistence target from a fail-closed parity check.
2243
+ */
2244
+ function isCollectionRegistration(className) {
2245
+ for (const name of [className, ...ObjectRegistry.getInheritanceChain(className)]) {
2246
+ const registered = ObjectRegistry.getClass(name);
2247
+ if (registered && isSmrtCollectionExtendsName(registered.extends)) return true;
2248
+ }
2249
+ return false;
2250
+ }
2251
+ /**
2111
2252
  * Run the parity check against the configured database.
2112
2253
  *
2113
2254
  * Never throws for an operational problem: connection and introspection
@@ -2441,7 +2582,7 @@ function assertSchemaContract(report) {
2441
2582
  * - Pending migrations
2442
2583
  * - Drift detection warnings
2443
2584
  */
2444
- var CANONICAL_UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2585
+ var CANONICAL_UUID_RE$1 = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2445
2586
  function getSchemaColumn(schema, columnName) {
2446
2587
  const columns = schema?.columns;
2447
2588
  if (!columns) return null;
@@ -2465,7 +2606,7 @@ async function countNonUuidTenantIds(db) {
2465
2606
  const row = (await db.query(`SELECT count(*)::text AS count
2466
2607
  FROM tenants
2467
2608
  WHERE nullif(btrim(id), '') IS NOT NULL
2468
- AND btrim(id) !~* '${CANONICAL_UUID_RE}'`))?.rows?.[0] ?? {};
2609
+ AND btrim(id) !~* '${CANONICAL_UUID_RE$1}'`))?.rows?.[0] ?? {};
2469
2610
  return Number(row.count ?? row.n ?? 0);
2470
2611
  }
2471
2612
  async function checkTenantIdUuidPreconditions(input) {
@@ -2603,6 +2744,14 @@ function summarizeSchemaDiff(diff) {
2603
2744
  recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
2604
2745
  });
2605
2746
  break;
2747
+ case "rename_data_pending":
2748
+ if (change.advisory?.severity !== "warning") break;
2749
+ drift.push({
2750
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2751
+ type: "rename_data_pending",
2752
+ recommendation: change.advisory?.message ?? "A declared column is empty while an undeclared column of a compatible type holds data — data appears to still live in the old column after a field rename. Run `smrt db:diff` for the suggested backfill SQL."
2753
+ });
2754
+ break;
2606
2755
  }
2607
2756
  return drift;
2608
2757
  }
@@ -2733,7 +2882,10 @@ var dbStatusCommand = {
2733
2882
  failedMigrations: summarizeFailedMigrations(failed, null),
2734
2883
  schemaContract,
2735
2884
  parity: null,
2736
- parityError: null
2885
+ parityError: null,
2886
+ orphanedForeignKeys: [],
2887
+ orphanProbeFailures: [],
2888
+ orphansError: null
2737
2889
  };
2738
2890
  let diff = {
2739
2891
  added_tables: [],
@@ -2741,8 +2893,8 @@ var dbStatusCommand = {
2741
2893
  changes: [],
2742
2894
  has_changes: false
2743
2895
  };
2896
+ const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2744
2897
  if (typeof db.getTableSchema === "function") {
2745
- const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2746
2898
  diff = await new SchemaComparer(db).compare(manifestSchemas);
2747
2899
  status.drift = summarizeSchemaDiff(diff);
2748
2900
  status.notes = summarizeSchemaNotes(diff);
@@ -2765,6 +2917,13 @@ var dbStatusCommand = {
2765
2917
  status.parityError = error instanceof Error ? error.message : String(error);
2766
2918
  }
2767
2919
  } else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
2920
+ try {
2921
+ const orphanReport = await collectForeignKeyOrphanCounts(db, manifestSchemas, { engineHint: dbType });
2922
+ status.orphanedForeignKeys = affectedOrphanCounts(orphanReport);
2923
+ status.orphanProbeFailures = orphanReport.skipped.filter((skip) => skip.kind === "probe_failed");
2924
+ } catch (error) {
2925
+ status.orphansError = error instanceof Error ? error.message : String(error);
2926
+ }
2768
2927
  const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
2769
2928
  status.migrations.failed = {
2770
2929
  total: failedAssessments.length,
@@ -2828,6 +2987,23 @@ var dbStatusCommand = {
2828
2987
  }
2829
2988
  console.log();
2830
2989
  }
2990
+ if (status.orphansError) {
2991
+ console.log(`⚠️ Foreign-key orphan check unavailable: ${status.orphansError}`);
2992
+ console.log();
2993
+ } else {
2994
+ if (status.orphanedForeignKeys.length > 0) {
2995
+ console.log(`⚠️ Foreign-key orphans found (${status.orphanedForeignKeys.length}):`);
2996
+ for (const count of status.orphanedForeignKeys) console.log(` • ${formatOrphanCountLine(count)}`);
2997
+ console.log(" Run `smrt db:orphans` for the full report.");
2998
+ console.log();
2999
+ }
3000
+ if (status.orphanProbeFailures.length > 0) {
3001
+ console.log(`⚠️ ${status.orphanProbeFailures.length} foreign-key orphan probe(s) failed (not counted as clean):`);
3002
+ for (const failure of status.orphanProbeFailures) console.log(` • ${failure.childTable}.${failure.childColumn} -> ${failure.parentTable}.${failure.parentColumn}: ${failure.reason}`);
3003
+ console.log(" Run `smrt db:orphans` for details.");
3004
+ console.log();
3005
+ }
3006
+ }
2831
3007
  if (options.parity) {
2832
3008
  console.log("🗄️ Live Schema Parity:");
2833
3009
  if (status.parityError) {
@@ -6362,6 +6538,56 @@ var dbDropFrameworkBaseTablesCommand = {
6362
6538
  }
6363
6539
  };
6364
6540
  //#endregion
6541
+ //#region src/commands/db-migrate-agent-schedule-slugs.ts
6542
+ var dbMigrateAgentScheduleSlugsCommand = {
6543
+ name: "db:migrate-agent-schedule-slugs",
6544
+ description: "Backfill canonical slugs for legacy framework AgentSchedule rows after db:migrate. Requires a PostgreSQL maintenance window; it never runs schedules.",
6545
+ aliases: ["migrate-agent-schedule-slugs", "db-migrate-schedule-slugs"],
6546
+ args: [],
6547
+ options: { "dry-run": {
6548
+ type: "boolean",
6549
+ description: "Validate legacy schedule identities without writing.",
6550
+ default: false
6551
+ } },
6552
+ handler: async (_args, options) => {
6553
+ let db;
6554
+ try {
6555
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
6556
+ const { DEFAULT_CLI_CONFIG } = await import("./config-DfBo4hHU.js").then((n) => n.n);
6557
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
6558
+ if (!config.database?.url || config.database.url === ":memory:") throw new Error("Database configuration required for db:migrate-agent-schedule-slugs. Configure database.url in smrt.config.ts (or DATABASE_URL).");
6559
+ const dbType = config.database.type || "sqlite";
6560
+ if (dbType !== "postgres") throw new Error("db:migrate-agent-schedule-slugs currently supports PostgreSQL only; no changes were made.");
6561
+ const { getDatabase } = await import("@happyvertical/sql");
6562
+ const { AGENT_SCHEDULE_SLUG_BACKFILL, migrateAgentScheduleSlugs, planAgentScheduleSlugMigration } = await import("@happyvertical/smrt-agents");
6563
+ db = await getDatabase({
6564
+ type: dbType,
6565
+ url: config.database.url
6566
+ });
6567
+ console.log("\n🗓️ AgentSchedule slug migration\n");
6568
+ console.log(`✓ Connected to ${formatDatabaseDisplayUrl(dbType, config.database.url)}\n`);
6569
+ if (options["dry-run"]) {
6570
+ const plan = await planAgentScheduleSlugMigration(db);
6571
+ console.log(`DRY RUN: ${plan.pending} AgentSchedule slug(s) would be backfilled; no schedules run and no changes made.\n`);
6572
+ return;
6573
+ }
6574
+ const postgres = config.migrations?.postgres;
6575
+ const result = await migrateAgentScheduleSlugs(db, {
6576
+ backfillName: AGENT_SCHEDULE_SLUG_BACKFILL,
6577
+ packageName: "@happyvertical/smrt-agents",
6578
+ lockTimeout: parsePostgresTimeoutMs(postgres?.lockTimeout, 3e4),
6579
+ statementTimeout: parsePostgresTimeoutMs(postgres?.statementTimeout, 6e4)
6580
+ });
6581
+ console.log(result.ran ? `✓ Backfilled ${result.updated} AgentSchedule slug(s); no schedules were run.\n` : "No schedule slug migration was needed.\n");
6582
+ } catch (error) {
6583
+ console.error(`\n❌ AgentSchedule slug migration failed: ${error instanceof Error ? error.message : String(error)}\n`);
6584
+ process.exitCode = 1;
6585
+ } finally {
6586
+ await closeDatabaseConnection(db);
6587
+ }
6588
+ }
6589
+ };
6590
+ //#endregion
6365
6591
  //#region src/commands/db-migrate-int8.ts
6366
6592
  /**
6367
6593
  * db:migrate-int8 Command
@@ -6475,7 +6701,7 @@ var dbMigrateInt8Command = {
6475
6701
  * drop the old column.
6476
6702
  *
6477
6703
  * 2. **TEXT → native uuid conversion**. Most existing `id`/FK columns already
6478
- * hold canonical UUID strings in TEXT columns and can be promoted to native
6704
+ * hold UUID-shaped strings in TEXT columns and can be promoted to native
6479
6705
  * `uuid`. Conversion is gated on TWO independent checks, BOTH of which must
6480
6706
  * pass for a column to be converted:
6481
6707
  *
@@ -6490,7 +6716,7 @@ var dbMigrateInt8Command = {
6490
6716
  * Tables not present in the manifest (non-SMRT tables in the `public`
6491
6717
  * schema) drop out automatically.
6492
6718
  *
6493
- * (b) **Data-shape** — every non-empty value is already a canonical UUID.
6719
+ * (b) **Data-shape** — every non-empty value is already UUID-shaped.
6494
6720
  * A declared-UUID column that still holds genuine non-uuid values
6495
6721
  * (legacy unhyphenated ids, partially-migrated data, …) is SKIPPED and
6496
6722
  * reported so the operator can clean it before re-running.
@@ -6523,7 +6749,8 @@ var dbMigrateInt8Command = {
6523
6749
  * smrt db:migrate-uuid --rename "parent_id:source_asset_id" --table assets
6524
6750
  * smrt db:migrate-uuid --rename "parent_slug:parent_id" --table tags --skip-convert
6525
6751
  */
6526
- var UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
6752
+ var UUID_RE = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$";
6753
+ var CANONICAL_UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
6527
6754
  /**
6528
6755
  * Build the set of schema-declared UUID columns from a manifest's
6529
6756
  * `getAllSchemasAsDefinitions()` output.
@@ -6543,12 +6770,88 @@ function buildDeclaredUuidColumnSet(schemaDefinitions) {
6543
6770
  function declaredUuidKey(table, column) {
6544
6771
  return `${table}|${column}`;
6545
6772
  }
6773
+ function renamedSourceColumns(renameSpecs) {
6774
+ return new Set(renameSpecs.map((spec) => declaredUuidKey(spec.table, spec.from)));
6775
+ }
6776
+ /**
6777
+ * Propagate skips across foreign-key edges to a fixpoint.
6778
+ *
6779
+ * `db:migrate-uuid` converts a column only when BOTH its declared-UUID/clean
6780
+ * data gates pass (see `planUuidConversions`) AND every foreign-key partner
6781
+ * of it will also convert — otherwise `ALTER TABLE … ADD CONSTRAINT` at
6782
+ * recreation time would fail with a text/uuid type mismatch. Rather than
6783
+ * aborting the whole run, block the convertible partner too: a column
6784
+ * skipped for dirty data or because the schema intentionally keeps it TEXT
6785
+ * blocks every foreign-key partner of it, and that block propagates
6786
+ * transitively (a two-hop chain blocks both hops).
6787
+ *
6788
+ * Pure and DB-independent: `edges` is the full set of single-column foreign
6789
+ * keys touching the candidate columns, discovered separately. Multi-column
6790
+ * foreign keys are out of scope here and stay a hard refusal downstream.
6791
+ */
6792
+ /** Human-readable reason for one `skipDirtyData` entry — shared by dry-run/apply logging and FK-block-propagation reasons. */
6793
+ function dirtyDataReason(item) {
6794
+ const parts = [];
6795
+ if (item.nonUuid > 0) parts.push(`${item.nonUuid} non-uuid value(s)`);
6796
+ if (item.duplicateNormalized) parts.push(`${item.duplicateNormalized} duplicate value(s) after normalization`);
6797
+ return parts.join(", ");
6798
+ }
6799
+ function propagateBlockedForeignKeyPartners(plan, edges) {
6800
+ const convertByKey = new Map(plan.convert.map((candidate) => [declaredUuidKey(candidate.table, candidate.column), candidate]));
6801
+ const blockedReason = /* @__PURE__ */ new Map();
6802
+ for (const item of plan.skipDirtyData) blockedReason.set(declaredUuidKey(item.table, item.column), dirtyDataReason(item));
6803
+ for (const item of plan.skipNotDeclared) blockedReason.set(declaredUuidKey(item.table, item.column), "not schema-declared UUID");
6804
+ const skipBlockedPartner = [];
6805
+ let changed = true;
6806
+ while (changed) {
6807
+ changed = false;
6808
+ for (const edge of edges) {
6809
+ const childKey = declaredUuidKey(edge.childTable, edge.childColumn);
6810
+ const parentKey = declaredUuidKey(edge.parentTable, edge.parentColumn);
6811
+ const childBlockedReason = blockedReason.get(childKey);
6812
+ const parentBlockedReason = blockedReason.get(parentKey);
6813
+ if (childBlockedReason !== void 0 && parentBlockedReason === void 0 && convertByKey.has(parentKey)) {
6814
+ const candidate = convertByKey.get(parentKey);
6815
+ if (!candidate) continue;
6816
+ const reason = `blocked by foreign key ${edge.name} to ${edge.childTable}.${edge.childColumn} (${childBlockedReason})`;
6817
+ blockedReason.set(parentKey, reason);
6818
+ convertByKey.delete(parentKey);
6819
+ skipBlockedPartner.push({
6820
+ table: candidate.table,
6821
+ column: candidate.column,
6822
+ reason
6823
+ });
6824
+ changed = true;
6825
+ continue;
6826
+ }
6827
+ if (parentBlockedReason !== void 0 && childBlockedReason === void 0 && convertByKey.has(childKey)) {
6828
+ const candidate = convertByKey.get(childKey);
6829
+ if (!candidate) continue;
6830
+ const reason = `blocked by foreign key ${edge.name} to ${edge.parentTable}.${edge.parentColumn} (${parentBlockedReason})`;
6831
+ blockedReason.set(childKey, reason);
6832
+ convertByKey.delete(childKey);
6833
+ skipBlockedPartner.push({
6834
+ table: candidate.table,
6835
+ column: candidate.column,
6836
+ reason
6837
+ });
6838
+ changed = true;
6839
+ }
6840
+ }
6841
+ }
6842
+ return {
6843
+ convert: [...convertByKey.values()],
6844
+ skipDirtyData: plan.skipDirtyData,
6845
+ skipNotDeclared: plan.skipNotDeclared,
6846
+ skipBlockedPartner
6847
+ };
6848
+ }
6546
6849
  /**
6547
6850
  * Decide which live TEXT columns to convert to native `uuid`.
6548
6851
  *
6549
6852
  * A column is converted ONLY if BOTH gates pass:
6550
6853
  * 1. it is in the schema-declared-UUID set, AND
6551
- * 2. all of its non-empty values are already canonical UUIDs.
6854
+ * 2. all of its non-empty values are already UUID-shaped.
6552
6855
  *
6553
6856
  * Declared-UUID columns with dirty data are reported in `skipDirtyData`;
6554
6857
  * undeclared columns (schema-intentional TEXT, or non-SMRT tables) are reported
@@ -6566,11 +6869,13 @@ function planUuidConversions(liveColumns, declaredUuid) {
6566
6869
  });
6567
6870
  continue;
6568
6871
  }
6569
- if (col.nonUuid > 0) {
6872
+ const duplicateNormalized = col.duplicateNormalized ?? 0;
6873
+ if (col.nonUuid > 0 || duplicateNormalized > 0) {
6570
6874
  skipDirtyData.push({
6571
6875
  table: col.table,
6572
6876
  column: col.column,
6573
- nonUuid: col.nonUuid
6877
+ nonUuid: col.nonUuid,
6878
+ ...duplicateNormalized > 0 ? { duplicateNormalized } : {}
6574
6879
  });
6575
6880
  continue;
6576
6881
  }
@@ -6671,111 +6976,35 @@ var dbMigrateUuidCommand = {
6671
6976
  const isPostgres = dbType === "postgres" || /^postgres/i.test(dbUrl);
6672
6977
  const runRenames = renameSpecs.length > 0;
6673
6978
  const skipConvert = Boolean(options["skip-convert"]);
6674
- const sharedTxn = runRenames && !skipConvert && isPostgres && !dryRun;
6675
- let openTxn = false;
6676
- try {
6677
- if (runRenames) {
6678
- if (sharedTxn) {
6679
- await db.query("BEGIN");
6680
- openTxn = true;
6681
- }
6682
- await applyRenameBackfills(db, isPostgres, renameSpecs, dryRun, { ownTransaction: !sharedTxn });
6683
- }
6684
- if (skipConvert) {
6685
- console.log("Skipping TEXT→uuid conversion (--skip-convert).\n");
6686
- return;
6687
- }
6688
- if (!isPostgres) {
6689
- console.log(`Database type "${dbType}" has no native uuid column type that needs converting; uuid conversion is a no-op.\n`);
6690
- return;
6691
- }
6692
- const declaredUuid = await loadDeclaredUuidColumns();
6693
- if (declaredUuid.size === 0) {
6694
- console.log("No schema-declared UUID columns found in the loaded manifest.");
6695
- console.log("Skipping TEXT→uuid conversion (fail-closed). Run this command from\nthe project root so the SMRT manifest can be discovered, then re-run.\n");
6696
- if (openTxn && !dryRun) {
6697
- await db.query("COMMIT");
6698
- openTxn = false;
6699
- }
6700
- return;
6701
- }
6702
- const { rows: candidates } = await db.query(`SELECT table_name, column_name, (column_default IS NOT NULL) AS has_default
6703
- FROM information_schema.columns
6704
- WHERE table_schema = 'public'
6705
- AND data_type = 'text'
6706
- AND (column_name = 'id' OR column_name ~* '(_id|Id)$')
6707
- ORDER BY table_name, column_name`);
6708
- const liveColumns = [];
6709
- for (const row of candidates) {
6710
- const declared = declaredUuid.has(`${row.table_name}|${row.column_name}`);
6711
- let nonUuid = 0;
6712
- if (declared) {
6713
- const t = quoteIdentifier(row.table_name);
6714
- const c = quoteIdentifier(row.column_name);
6715
- const { rows } = await db.query(`SELECT count(*)::text AS n
6716
- FROM ${t}
6717
- WHERE nullif(btrim(${c}), '') IS NOT NULL
6718
- AND btrim(${c}) !~* '${UUID_RE}'`);
6719
- nonUuid = Number(rows[0]?.n ?? "0");
6720
- }
6721
- liveColumns.push({
6722
- table: row.table_name,
6723
- column: row.column_name,
6724
- hasDefault: Boolean(row.has_default),
6725
- nonUuid
6726
- });
6727
- }
6728
- const { convert, skipDirtyData, skipNotDeclared } = planUuidConversions(liveColumns, declaredUuid);
6729
- console.log(`Found ${candidates.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length} skipped.`);
6730
- if (skipDirtyData.length > 0) {
6731
- console.log("\nSKIP (declared UUID but data still has non-UUID values — clean these first):");
6732
- for (const s of skipDirtyData) console.log(` - ${s.table}.${s.column} (${s.nonUuid} non-uuid)`);
6733
- }
6734
- if (skipNotDeclared.length > 0) {
6735
- console.log("\nSKIP (not declared UUID by the SMRT schema, left as TEXT):");
6736
- for (const s of skipNotDeclared) console.log(` - ${s.table}.${s.column}`);
6737
- }
6738
- if (convert.length === 0) {
6739
- console.log("\nNothing to convert. Done.\n");
6740
- if (openTxn && !dryRun) {
6741
- await db.query("COMMIT");
6742
- openTxn = false;
6743
- }
6744
- return;
6745
- }
6746
- console.log(`\n${dryRun ? "DRY RUN — would run" : "Applying"} ${convert.length} conversion(s):`);
6747
- if (!sharedTxn && !dryRun) {
6748
- await db.query("BEGIN");
6749
- openTxn = true;
6750
- }
6751
- for (const { table, column, hasDefault } of convert) {
6752
- const t = quoteIdentifier(table);
6753
- const c = quoteIdentifier(column);
6754
- if (hasDefault) {
6755
- const dropDefault = `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`;
6756
- console.log(` ${dropDefault};`);
6757
- if (!dryRun) await db.query(dropDefault);
6758
- }
6759
- const alter = `ALTER TABLE ${t} ALTER COLUMN ${c} TYPE uuid USING NULLIF(btrim(${c}), '')::uuid`;
6760
- console.log(` ${alter};`);
6761
- if (!dryRun) await db.query(alter);
6762
- }
6763
- if (!dryRun) {
6764
- if (openTxn) {
6765
- await db.query("COMMIT");
6766
- openTxn = false;
6767
- }
6768
- console.log(`\n✓ Converted ${convert.length} column(s) to uuid.\n`);
6769
- } else console.log("\nDry run complete — no changes applied.\n");
6770
- } catch (error) {
6771
- if (openTxn && !dryRun) {
6772
- try {
6773
- await db.query("ROLLBACK");
6774
- } catch {}
6775
- openTxn = false;
6776
- }
6777
- throw error;
6979
+ const runConvert = !skipConvert && isPostgres;
6980
+ if (skipConvert) {
6981
+ await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
6982
+ console.log("Skipping TEXT→uuid conversion (--skip-convert).\n");
6983
+ return;
6984
+ }
6985
+ if (!isPostgres) {
6986
+ await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
6987
+ console.log(`Database type "${dbType}" has no native uuid column type that needs converting; uuid conversion is a no-op.\n`);
6988
+ return;
6778
6989
  }
6990
+ const declaredUuid = await loadDeclaredUuidColumns();
6991
+ if (declaredUuid.size === 0) {
6992
+ console.log("No schema-declared UUID columns found in the loaded manifest.");
6993
+ console.log("Skipping TEXT→uuid conversion (fail-closed). Run this command from\nthe project root so the SMRT manifest can be discovered, then re-run.\n");
6994
+ await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
6995
+ return;
6996
+ }
6997
+ if (dryRun) {
6998
+ await applyRenameBackfills(db, isPostgres, renameSpecs, true, { ownTransaction: false });
6999
+ await convertPostgresUuidColumns(db, declaredUuid, true, true, renamedSourceColumns(renameSpecs), renameSpecs);
7000
+ return;
7001
+ }
7002
+ if (!db.transaction) throw new Error("PostgreSQL UUID migration requires DatabaseInterface.transaction(); refusing to run unpinned DDL.");
7003
+ if (runRenames && runConvert) await convertPostgresUuidColumns(db, declaredUuid, true, false, renamedSourceColumns(renameSpecs), renameSpecs);
7004
+ await db.transaction(async (tx) => {
7005
+ await applyRenameBackfills(tx, true, renameSpecs, false, { ownTransaction: false });
7006
+ await convertPostgresUuidColumns(tx, declaredUuid, false);
7007
+ });
6779
7008
  } catch (error) {
6780
7009
  console.error(`\n❌ uuid migration failed: ${error instanceof Error ? error.message : String(error)}\n`);
6781
7010
  process.exitCode = 1;
@@ -6787,6 +7016,569 @@ var dbMigrateUuidCommand = {
6787
7016
  function nullifEmpty(isPostgres, quotedCol) {
6788
7017
  return isPostgres ? `nullif(btrim(${quotedCol}), '')` : `nullif(trim(${quotedCol}), '')`;
6789
7018
  }
7019
+ /** Execute a rename-only PostgreSQL mutation on the transaction callback. */
7020
+ async function applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun) {
7021
+ if (!isPostgres || dryRun) {
7022
+ await applyRenameBackfills(db, isPostgres, renameSpecs, dryRun, { ownTransaction: !dryRun });
7023
+ return;
7024
+ }
7025
+ if (renameSpecs.length === 0) {
7026
+ await applyRenameBackfills(db, true, renameSpecs, false, { ownTransaction: false });
7027
+ return;
7028
+ }
7029
+ if (!db.transaction) throw new Error("PostgreSQL UUID migration requires DatabaseInterface.transaction(); refusing to run unpinned DDL.");
7030
+ await db.transaction(async (tx) => {
7031
+ await applyRenameBackfills(tx, true, renameSpecs, false, { ownTransaction: false });
7032
+ });
7033
+ }
7034
+ function quoteLiteral(value) {
7035
+ return `'${value.replaceAll("'", "''")}'`;
7036
+ }
7037
+ /** PostgreSQL relation reference in the migration's fixed public schema. */
7038
+ function pgTable(table) {
7039
+ return `${quoteIdentifier("public")}.${quoteIdentifier(table)}`;
7040
+ }
7041
+ /**
7042
+ * Convert a deliberately narrow PostgreSQL dependency component. This is
7043
+ * intentionally catalog-driven: generated text bridges are consumer schema,
7044
+ * not SMRT manifest columns, and are never guessed from a name.
7045
+ */
7046
+ async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun = true, excludedColumns = /* @__PURE__ */ new Set(), projectedRenames = []) {
7047
+ const { rows: candidateRows } = await db.query(`SELECT cols.table_name, cols.column_name, cols.column_default,
7048
+ relation.oid::text AS relation_oid, attribute.attnum AS attribute_number,
7049
+ format_type(attribute.atttypid, attribute.atttypmod) AS type_name
7050
+ FROM information_schema.columns cols
7051
+ JOIN pg_namespace namespace ON namespace.nspname = cols.table_schema
7052
+ JOIN pg_class relation ON relation.relnamespace = namespace.oid AND relation.relname = cols.table_name
7053
+ JOIN pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attname = cols.column_name
7054
+ WHERE cols.table_schema = 'public' AND cols.data_type = 'text'
7055
+ AND (column_name = 'id' OR column_name ~* '(_id|Id)$')
7056
+ ORDER BY table_name, column_name`);
7057
+ const liveColumns = [];
7058
+ const defaults = /* @__PURE__ */ new Map();
7059
+ for (const row of candidateRows) {
7060
+ const table = String(row.table_name);
7061
+ const column = String(row.column_name);
7062
+ if (excludedColumns.has(declaredUuidKey(table, column))) continue;
7063
+ let nonUuid = 0;
7064
+ let duplicateNormalized = 0;
7065
+ if (declaredUuid.has(declaredUuidKey(table, column))) {
7066
+ const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
7067
+ WHERE nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL
7068
+ AND btrim(${quoteIdentifier(column)}) !~* '${UUID_RE}'`);
7069
+ nonUuid = Number(rows[0]?.n ?? 0);
7070
+ const uniqueIndexes = await findUniqueIndexKeyColumns(db, String(row.relation_oid), Number(row.attribute_number));
7071
+ for (const { keyColumns, nullsNotDistinct } of uniqueIndexes) {
7072
+ const otherColumns = keyColumns.filter((c) => c.name !== column);
7073
+ const otherColumnExpr = (other) => {
7074
+ const quoted = quoteIdentifier(other.name);
7075
+ if (other.typeName === "uuid") return quoted;
7076
+ if (declaredUuid.has(declaredUuidKey(table, other.name))) return `CASE WHEN btrim(${quoted}) ~* '${UUID_RE}' THEN NULLIF(btrim(${quoted}), '')::uuid::text ELSE ${quoted} END`;
7077
+ return quoted;
7078
+ };
7079
+ const nullGuards = nullsNotDistinct ? [] : otherColumns.map((other) => `${quoteIdentifier(other.name)} IS NOT NULL`);
7080
+ const groupBy = [...otherColumns.map(otherColumnExpr), `NULLIF(btrim(${quoteIdentifier(column)}), '')::uuid`].join(", ");
7081
+ const whereClause = [
7082
+ `nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL`,
7083
+ `btrim(${quoteIdentifier(column)}) ~* '${UUID_RE}'`,
7084
+ ...nullGuards
7085
+ ].join(" AND ");
7086
+ const { rows: dupRows } = await db.query(`SELECT count(*)::text AS n FROM (
7087
+ SELECT 1
7088
+ FROM ${pgTable(table)}
7089
+ WHERE ${whereClause}
7090
+ GROUP BY ${groupBy}
7091
+ HAVING count(DISTINCT ${quoteIdentifier(column)}) > 1
7092
+ ) collisions`);
7093
+ const found = Number(dupRows[0]?.n ?? 0);
7094
+ if (found > 0) {
7095
+ duplicateNormalized += found;
7096
+ break;
7097
+ }
7098
+ }
7099
+ }
7100
+ liveColumns.push({
7101
+ table,
7102
+ column,
7103
+ hasDefault: row.column_default != null,
7104
+ nonUuid,
7105
+ duplicateNormalized
7106
+ });
7107
+ defaults.set(declaredUuidKey(table, column), row.column_default == null ? null : String(row.column_default));
7108
+ }
7109
+ for (const spec of projectedRenames) {
7110
+ const destination = liveColumns.find((column) => column.table === spec.table && column.column === spec.to);
7111
+ if (!destination || !declaredUuid.has(declaredUuidKey(spec.table, spec.to))) continue;
7112
+ const { rows: source } = await db.query(`SELECT 1 FROM information_schema.columns
7113
+ WHERE table_schema = 'public'
7114
+ AND table_name = ${quoteLiteral(spec.table)}
7115
+ AND column_name = ${quoteLiteral(spec.from)}`);
7116
+ if (source.length === 0) continue;
7117
+ const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(spec.table)}
7118
+ WHERE ${nullifEmpty(true, quoteIdentifier(spec.from))} IS NOT NULL
7119
+ AND ${quoteIdentifier(spec.to)} IS NULL
7120
+ AND btrim(${quoteIdentifier(spec.from)}) !~* '${UUID_RE}'`);
7121
+ destination.nonUuid += Number(rows[0]?.n ?? 0);
7122
+ }
7123
+ const initialPlan = planUuidConversions(liveColumns, declaredUuid);
7124
+ const { skipDirtyData, skipNotDeclared } = initialPlan;
7125
+ await assertNoCrossSchemaForeignKeyPartners(db, new Set([...initialPlan.convert, ...skipDirtyData].map((candidate) => declaredUuidKey(candidate.table, candidate.column))));
7126
+ const { convert, skipBlockedPartner = [] } = propagateBlockedForeignKeyPartners(initialPlan, await fetchSingleColumnForeignKeyEdges(db));
7127
+ console.log(`Found ${candidateRows.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length + skipBlockedPartner.length} skipped.`);
7128
+ for (const item of skipDirtyData) console.log(` SKIP ${item.table}.${item.column}: ${dirtyDataReason(item)}`);
7129
+ for (const item of skipNotDeclared) console.log(` SKIP ${item.table}.${item.column}: not schema-declared UUID`);
7130
+ for (const item of skipBlockedPartner) console.log(` SKIP ${item.table}.${item.column}: ${item.reason}`);
7131
+ if (convert.length === 0) {
7132
+ console.log("\nNothing to convert. Done.\n");
7133
+ return;
7134
+ }
7135
+ const columns = convert.map((column) => ({
7136
+ ...column,
7137
+ defaultExpression: defaults.get(declaredUuidKey(column.table, column.column)) ?? null,
7138
+ relationOid: String(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.relation_oid),
7139
+ attributeNumber: Number(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.attribute_number),
7140
+ typeName: String(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.type_name)
7141
+ }));
7142
+ await assertSupportedSourceColumns(db, columns);
7143
+ const bridges = await snapshotGeneratedBridges(db, columns);
7144
+ const foreignKeys = await snapshotForeignKeys(db, columns, bridges);
7145
+ await assertSupportedBridgeDependencies(db, bridges, foreignKeys);
7146
+ const tables = [.../* @__PURE__ */ new Set([
7147
+ ...columns.map((column) => column.table),
7148
+ ...bridges.map((bridge) => bridge.table),
7149
+ ...foreignKeys.map((foreignKey) => foreignKey.table)
7150
+ ])].sort();
7151
+ if (dryRun) {
7152
+ if (!renderDryRun) return;
7153
+ console.log(`\nDRY RUN — dependency plan for ${columns.length} conversion(s):`);
7154
+ for (const table of tables) console.log(` LOCK TABLE ${pgTable(table)} IN ACCESS EXCLUSIVE MODE;`);
7155
+ renderUuidConversionSql(columns, bridges, foreignKeys);
7156
+ console.log("\nDry run complete — no changes applied.\n");
7157
+ return;
7158
+ }
7159
+ for (const table of tables) await db.query(`LOCK TABLE ${pgTable(table)} IN ACCESS EXCLUSIVE MODE`);
7160
+ await assertSupportedSourceColumns(db, columns);
7161
+ for (const column of columns) {
7162
+ const { rows } = await db.query(`SELECT cols.column_default, relation.oid::text AS relation_oid,
7163
+ attribute.attnum AS attribute_number,
7164
+ format_type(attribute.atttypid, attribute.atttypmod) AS type_name
7165
+ FROM information_schema.columns cols
7166
+ JOIN pg_namespace namespace ON namespace.nspname = cols.table_schema
7167
+ JOIN pg_class relation ON relation.relnamespace = namespace.oid AND relation.relname = cols.table_name
7168
+ JOIN pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attname = cols.column_name
7169
+ WHERE cols.table_schema = 'public'
7170
+ AND cols.table_name = ${quoteLiteral(column.table)}
7171
+ AND cols.column_name = ${quoteLiteral(column.column)}`);
7172
+ const lockedDefault = rows[0]?.column_default == null ? null : String(rows[0].column_default);
7173
+ const locked = rows[0];
7174
+ if (lockedDefault !== column.defaultExpression || String(locked?.relation_oid) !== column.relationOid || Number(locked?.attribute_number) !== column.attributeNumber || String(locked?.type_name) !== column.typeName) throw new Error(`UUID source identity, type, or default changed while locks were acquired for ${column.table}.${column.column}; refusing stale migration plan. Re-run the command.`);
7175
+ }
7176
+ const rescannedBridges = await snapshotGeneratedBridges(db, columns);
7177
+ const rescannedForeignKeys = await snapshotForeignKeys(db, columns, rescannedBridges);
7178
+ await assertSupportedBridgeDependencies(db, rescannedBridges, rescannedForeignKeys);
7179
+ if (JSON.stringify({
7180
+ bridges,
7181
+ foreignKeys
7182
+ }) !== JSON.stringify({
7183
+ bridges: rescannedBridges,
7184
+ foreignKeys: rescannedForeignKeys
7185
+ })) throw new Error("UUID dependency catalog changed while locks were acquired; refusing stale migration plan. Re-run the command.");
7186
+ renderUuidConversionSql(columns, bridges, foreignKeys);
7187
+ for (const foreignKey of foreignKeys) await db.query(`ALTER TABLE ${pgTable(foreignKey.table)} DROP CONSTRAINT ${quoteIdentifier(foreignKey.name)}`);
7188
+ for (const bridge of bridges) await db.query(`ALTER TABLE ${pgTable(bridge.table)} DROP COLUMN ${quoteIdentifier(bridge.column)}`);
7189
+ for (const column of columns) {
7190
+ const table = pgTable(column.table);
7191
+ const name = quoteIdentifier(column.column);
7192
+ if (column.defaultExpression) await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} DROP DEFAULT`);
7193
+ await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} TYPE uuid USING NULLIF(btrim(${name}), '')::uuid`);
7194
+ if (column.defaultExpression) await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT (${column.defaultExpression})::uuid`);
7195
+ }
7196
+ for (const bridge of bridges) {
7197
+ await db.query(`ALTER TABLE ${pgTable(bridge.table)} ADD COLUMN ${quoteIdentifier(bridge.column)} text GENERATED ALWAYS AS (${quoteIdentifier(bridge.sourceColumn)}::text) STORED`);
7198
+ if (bridge.statisticsTarget != null && bridge.statisticsTarget !== -1) await db.query(`ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET STATISTICS ${bridge.statisticsTarget}`);
7199
+ if (bridge.compression) await db.query(`ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET COMPRESSION ${bridge.compression === "l" ? "lz4" : "pglz"}`);
7200
+ }
7201
+ for (const { index, table } of uniqueBridgeIndexes(bridges)) {
7202
+ await db.query(index.definition);
7203
+ if (index.comment) await db.query(`COMMENT ON INDEX ${quoteIdentifier(index.schema)}.${quoteIdentifier(index.name)} IS ${quoteLiteral(index.comment)}`);
7204
+ if (index.clustered) await db.query(`ALTER TABLE ${pgTable(table)} CLUSTER ON ${quoteIdentifier(index.name)}`);
7205
+ if (index.replicaIdentity) await db.query(`ALTER TABLE ${pgTable(table)} REPLICA IDENTITY USING INDEX ${quoteIdentifier(index.name)}`);
7206
+ }
7207
+ for (const foreignKey of foreignKeys) {
7208
+ await db.query(`ALTER TABLE ${pgTable(foreignKey.table)} ADD CONSTRAINT ${quoteIdentifier(foreignKey.name)} ${foreignKey.definition}${foreignKey.validated ? "" : " NOT VALID"}`);
7209
+ if (foreignKey.comment) await db.query(`COMMENT ON CONSTRAINT ${quoteIdentifier(foreignKey.name)} ON ${pgTable(foreignKey.table)} IS ${quoteLiteral(foreignKey.comment)}`);
7210
+ }
7211
+ console.log(`\n✓ Converted ${columns.length} column(s) to uuid.\n`);
7212
+ }
7213
+ function uniqueBridgeIndexes(bridges) {
7214
+ return [...new Map(bridges.flatMap((bridge) => bridge.indexDefinitions.map((index) => [index.oid, {
7215
+ index,
7216
+ table: bridge.table
7217
+ }]))).values()];
7218
+ }
7219
+ async function assertSupportedSourceColumns(db, columns) {
7220
+ for (const column of columns) {
7221
+ const { rows: shape } = await db.query(`SELECT relispartition AS partitioned,
7222
+ EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = c.oid OR i.inhparent = c.oid) AS inherited
7223
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
7224
+ WHERE n.nspname = 'public' AND c.relname = ${quoteLiteral(column.table)} AND c.relkind = 'r'`);
7225
+ if (shape.length !== 1 || shape[0].partitioned || shape[0].inherited) throw new Error(`Unsupported table shape for ${column.table}.${column.column}; UUID conversion supports only non-partitioned, non-inherited public tables.`);
7226
+ const { rows: views } = await db.query(`SELECT dependent.relname AS name
7227
+ FROM pg_depend dep
7228
+ JOIN pg_rewrite rule ON rule.oid = dep.objid
7229
+ JOIN pg_class dependent ON dependent.oid = rule.ev_class
7230
+ JOIN pg_class source ON source.oid = dep.refobjid
7231
+ JOIN pg_namespace source_ns ON source_ns.oid = source.relnamespace
7232
+ JOIN pg_attribute source_attr ON source_attr.attrelid = source.oid AND source_attr.attnum = dep.refobjsubid
7233
+ WHERE dep.classid = 'pg_rewrite'::regclass AND source_ns.nspname = 'public'
7234
+ AND source.relname = ${quoteLiteral(column.table)} AND source_attr.attname = ${quoteLiteral(column.column)}`);
7235
+ if (views.length > 0) throw new Error(`Unsupported view or rule dependency on ${column.table}.${column.column}: ${views.map((view) => String(view.name)).join(", ")}. Remove or migrate it separately before db:migrate-uuid.`);
7236
+ }
7237
+ }
7238
+ function renderUuidConversionSql(columns, bridges, foreignKeys) {
7239
+ for (const foreignKey of foreignKeys) console.log(` ALTER TABLE ${pgTable(foreignKey.table)} DROP CONSTRAINT ${quoteIdentifier(foreignKey.name)};`);
7240
+ for (const bridge of bridges) console.log(` ALTER TABLE ${pgTable(bridge.table)} DROP COLUMN ${quoteIdentifier(bridge.column)};`);
7241
+ for (const column of columns) {
7242
+ const table = pgTable(column.table);
7243
+ const name = quoteIdentifier(column.column);
7244
+ if (column.defaultExpression) console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} DROP DEFAULT;`);
7245
+ console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} TYPE uuid USING NULLIF(btrim(${name}), '')::uuid;`);
7246
+ if (column.defaultExpression) console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT (${column.defaultExpression})::uuid;`);
7247
+ }
7248
+ for (const bridge of bridges) {
7249
+ console.log(` ALTER TABLE ${pgTable(bridge.table)} ADD COLUMN ${quoteIdentifier(bridge.column)} text GENERATED ALWAYS AS (${quoteIdentifier(bridge.sourceColumn)}::text) STORED;`);
7250
+ if (bridge.statisticsTarget != null && bridge.statisticsTarget !== -1) console.log(` ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET STATISTICS ${bridge.statisticsTarget};`);
7251
+ if (bridge.compression) console.log(` ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET COMPRESSION ${bridge.compression === "l" ? "lz4" : "pglz"};`);
7252
+ }
7253
+ for (const { index, table } of uniqueBridgeIndexes(bridges)) {
7254
+ console.log(` ${index.definition};`);
7255
+ if (index.comment) console.log(` COMMENT ON INDEX ${quoteIdentifier(index.schema)}.${quoteIdentifier(index.name)} IS ${quoteLiteral(index.comment)};`);
7256
+ if (index.clustered) console.log(` ALTER TABLE ${pgTable(table)} CLUSTER ON ${quoteIdentifier(index.name)};`);
7257
+ if (index.replicaIdentity) console.log(` ALTER TABLE ${pgTable(table)} REPLICA IDENTITY USING INDEX ${quoteIdentifier(index.name)};`);
7258
+ }
7259
+ for (const foreignKey of foreignKeys) {
7260
+ console.log(` ALTER TABLE ${pgTable(foreignKey.table)} ADD CONSTRAINT ${quoteIdentifier(foreignKey.name)} ${foreignKey.definition}${foreignKey.validated ? "" : " NOT VALID"};`);
7261
+ if (foreignKey.comment) console.log(` COMMENT ON CONSTRAINT ${quoteIdentifier(foreignKey.name)} ON ${pgTable(foreignKey.table)} IS ${quoteLiteral(foreignKey.comment)};`);
7262
+ }
7263
+ }
7264
+ async function snapshotGeneratedBridges(db, columns) {
7265
+ const requested = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
7266
+ const { rows } = await db.query(`SELECT generated.oid::text AS table_oid, generated_attr.attnum AS attribute_number,
7267
+ def.oid::text AS attribute_default_oid,
7268
+ generated.relname AS table_name, generated_attr.attname AS column_name,
7269
+ pg_get_expr(def.adbin, def.adrelid) AS expression,
7270
+ format_type(generated_attr.atttypid, generated_attr.atttypmod) AS type_name,
7271
+ generated_attr.attnotnull AS not_null, generated_attr.attstorage AS storage,
7272
+ generated_attr.attstattarget AS statistics_target,
7273
+ generated_attr.attcompression AS compression,
7274
+ generated_attr.attoptions AS options,
7275
+ generated_attr.attacl IS NOT NULL AS has_acl,
7276
+ generated_attr.attcollation <> (SELECT typcollation FROM pg_type WHERE oid = generated_attr.atttypid) AS nondefault_collation,
7277
+ col_description(generated_attr.attrelid, generated_attr.attnum) AS column_comment,
7278
+ generated.relispartition AS partitioned,
7279
+ EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = generated.oid OR i.inhparent = generated.oid) AS inherited
7280
+ FROM pg_attrdef def
7281
+ JOIN pg_class generated ON generated.oid = def.adrelid
7282
+ JOIN pg_namespace generated_ns ON generated_ns.oid = generated.relnamespace
7283
+ JOIN pg_attribute generated_attr ON generated_attr.attrelid = generated.oid AND generated_attr.attnum = def.adnum
7284
+ WHERE generated_ns.nspname = 'public'
7285
+ AND generated.relkind = 'r' AND generated_attr.attgenerated = 's'`);
7286
+ const bridges = [];
7287
+ for (const row of rows) {
7288
+ const bridgeTable = String(row.table_name);
7289
+ const bridgeColumn = String(row.column_name);
7290
+ const expression = String(row.expression).replaceAll(" ", "");
7291
+ const sourceColumn = expression.match(/^\(?([a-zA-Z_][a-zA-Z0-9_$]*)\)?(?:::text)?$/)?.[1];
7292
+ if (!sourceColumn) {
7293
+ const sourceColumns = columns.filter((column) => column.table === bridgeTable);
7294
+ if (sourceColumns.length > 0 && await generatedColumnDependsOn(db, String(row.attribute_default_oid), String(row.table_oid), sourceColumns.map((column) => column.attributeNumber))) throw new Error(`Unsupported generated dependency ${bridgeTable}.${bridgeColumn}; only a plain stored TEXT id::text bridge can coexist with a converted UUID column.`);
7295
+ continue;
7296
+ }
7297
+ if (!requested.has(declaredUuidKey(bridgeTable, sourceColumn))) continue;
7298
+ const table = bridgeTable;
7299
+ if (bridgeTable !== table || String(row.type_name) !== "text" || row.not_null || String(row.storage) !== "x" || row.options != null || row.has_acl || row.nondefault_collation || row.column_comment != null || row.partitioned || row.inherited || ![
7300
+ "id",
7301
+ "(id)::text",
7302
+ `${sourceColumn}::text`,
7303
+ `(${sourceColumn})::text`
7304
+ ].includes(expression)) throw new Error(`Unsupported generated dependency ${bridgeTable}.${bridgeColumn}; only a plain stored TEXT ${sourceColumn}::text bridge without ACLs, comments, inheritance, partitioning, or extra attributes is supported.`);
7305
+ const { rows: nonCanonical } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
7306
+ WHERE ${quoteIdentifier(sourceColumn)} IS NOT NULL
7307
+ AND ${quoteIdentifier(sourceColumn)} !~ '${CANONICAL_UUID_RE}'`);
7308
+ if (Number(nonCanonical[0]?.n ?? 0) > 0) throw new Error(`Refusing ${table}.${sourceColumn}: its generated TEXT bridge requires canonical lower-case UUID text; normalize or remove non-canonical values before migration.`);
7309
+ bridges.push({
7310
+ tableOid: String(row.table_oid),
7311
+ attributeNumber: Number(row.attribute_number),
7312
+ attributeDefaultOid: String(row.attribute_default_oid),
7313
+ statisticsTarget: row.statistics_target == null ? null : Number(row.statistics_target),
7314
+ compression: String(row.compression),
7315
+ table,
7316
+ column: bridgeColumn,
7317
+ sourceColumn,
7318
+ indexDefinitions: await snapshotBridgeIndexes(db, table, bridgeColumn)
7319
+ });
7320
+ }
7321
+ return bridges.sort((a, b) => `${a.table}.${a.column}`.localeCompare(`${b.table}.${b.column}`));
7322
+ }
7323
+ /** Whether a generated expression actually references a converted source attr. */
7324
+ async function generatedColumnDependsOn(db, attributeDefaultOid, tableOid, sourceAttributeNumbers) {
7325
+ const { rows } = await db.query(`SELECT EXISTS (
7326
+ SELECT 1 FROM pg_depend dep
7327
+ WHERE dep.classid = 'pg_attrdef'::regclass
7328
+ AND dep.objid = ${quoteLiteral(attributeDefaultOid)}::oid
7329
+ AND dep.refclassid = 'pg_class'::regclass
7330
+ AND dep.refobjid = ${quoteLiteral(tableOid)}::oid
7331
+ AND dep.refobjsubid IN (${sourceAttributeNumbers.join(", ")})
7332
+ ) AS depends_on_converted_column`);
7333
+ return Boolean(rows[0]?.depends_on_converted_column);
7334
+ }
7335
+ async function snapshotBridgeIndexes(db, table, column) {
7336
+ const { rows } = await db.query(`SELECT index_rel.oid::text AS oid, index_ns.nspname AS index_schema,
7337
+ index_rel.relname AS index_name, pg_get_indexdef(index_rel.oid) AS definition,
7338
+ obj_description(index_rel.oid, 'pg_class') AS comment,
7339
+ idx.indnkeyatts AS key_count, idx.indpred IS NOT NULL AS partial,
7340
+ idx.indexprs IS NOT NULL AS expression_index, idx.indisvalid AS valid,
7341
+ idx.indisready AS ready, idx.indisclustered AS clustered,
7342
+ idx.indisreplident AS replica_identity,
7343
+ index_rel.reltablespace <> 0 AS nondefault_tablespace,
7344
+ am.amname AS method
7345
+ FROM pg_index idx
7346
+ JOIN pg_class table_rel ON table_rel.oid = idx.indrelid
7347
+ JOIN pg_namespace ns ON ns.oid = table_rel.relnamespace
7348
+ JOIN pg_class index_rel ON index_rel.oid = idx.indexrelid
7349
+ JOIN pg_namespace index_ns ON index_ns.oid = index_rel.relnamespace
7350
+ JOIN pg_am am ON am.oid = index_rel.relam
7351
+ JOIN pg_attribute attr ON attr.attrelid = table_rel.oid AND attr.attnum = ANY(idx.indkey)
7352
+ WHERE ns.nspname = 'public' AND table_rel.relname = ${quoteLiteral(table)}
7353
+ AND attr.attname = ${quoteLiteral(column)}
7354
+ ORDER BY index_ns.nspname, index_rel.relname, index_rel.oid`);
7355
+ return rows.map((row) => {
7356
+ if (Number(row.key_count) !== 1 || row.partial || row.expression_index || !row.valid || !row.ready || row.nondefault_tablespace || row.method !== "btree") throw new Error(`Unsupported index depending on generated bridge ${table}.${column}; only one-key valid btree indexes without a custom tablespace can be reconstructed safely.`);
7357
+ return {
7358
+ oid: String(row.oid),
7359
+ schema: String(row.index_schema),
7360
+ name: String(row.index_name),
7361
+ definition: String(row.definition),
7362
+ comment: row.comment == null ? null : String(row.comment),
7363
+ clustered: Boolean(row.clustered),
7364
+ replicaIdentity: Boolean(row.replica_identity)
7365
+ };
7366
+ });
7367
+ }
7368
+ /**
7369
+ * Every unique or primary-key index that covers `attnum` as a key column
7370
+ * (single-key or composite; INCLUDE-only columns are excluded via
7371
+ * `indnkeyatts`), returned as one entry per covering index — the probed
7372
+ * column included in `keyColumns`, so callers can filter it out to get the
7373
+ * index's "other" key columns for a composite collision check.
7374
+ *
7375
+ * A key column that is itself an expression (not a plain column reference)
7376
+ * has no `pg_attribute` row and is silently dropped from `keyColumns` by the
7377
+ * inner join below — the resulting collision check under-specifies that
7378
+ * index's true key, which only widens (never narrows) what it flags, so it
7379
+ * cannot hide a real collision; documented as a known imprecision rather
7380
+ * than fully modeled here.
7381
+ */
7382
+ async function findUniqueIndexKeyColumns(db, relationOid, attnum) {
7383
+ const { rows } = await db.query(`SELECT to_json(array_agg(json_build_object(
7384
+ 'name', key_attr.attname,
7385
+ 'type', format_type(key_attr.atttypid, key_attr.atttypmod)
7386
+ ) ORDER BY key_order.ord)) AS key_columns,
7387
+ to_json(coalesce(
7388
+ bool_or((to_jsonb(idx) ->> 'indnullsnotdistinct')::boolean),
7389
+ false
7390
+ )) AS nulls_not_distinct
7391
+ FROM pg_index idx
7392
+ CROSS JOIN LATERAL unnest((idx.indkey::int2[])[0:idx.indnkeyatts - 1]) WITH ORDINALITY AS key_order(attnum, ord)
7393
+ JOIN pg_attribute key_attr
7394
+ ON key_attr.attrelid = idx.indrelid AND key_attr.attnum = key_order.attnum
7395
+ WHERE idx.indrelid = ${quoteLiteral(relationOid)}::oid AND idx.indisunique
7396
+ AND ${attnum} = ANY((idx.indkey::int2[])[0:idx.indnkeyatts - 1])
7397
+ GROUP BY idx.indexrelid`);
7398
+ return rows.map((row) => ({
7399
+ keyColumns: row.key_columns.map((col) => ({
7400
+ name: String(col.name),
7401
+ typeName: String(col.type)
7402
+ })),
7403
+ nullsNotDistinct: Boolean(row.nulls_not_distinct)
7404
+ }));
7405
+ }
7406
+ /**
7407
+ * Discover every single-column foreign key with BOTH endpoints in the public
7408
+ * schema — the only schema `db:migrate-uuid` ever converts or recreates DDL
7409
+ * against (see `pgTable`). Both `child_ns` and `parent_ns` are filtered to
7410
+ * `public` so a same-named table or column living in another schema can
7411
+ * never be silently treated as a candidate's FK partner by `declaredUuidKey`,
7412
+ * which keys purely on `table|column` with no schema qualifier. A
7413
+ * cross-schema partner that actually matters to a converting column is
7414
+ * caught separately, and fails closed, by
7415
+ * `assertNoCrossSchemaForeignKeyPartners`.
7416
+ */
7417
+ async function fetchSingleColumnForeignKeyEdges(db) {
7418
+ const { rows } = await db.query(`SELECT con.conname AS name, child.relname AS child_table,
7419
+ child_attr.attname AS child_column,
7420
+ parent.relname AS parent_table, parent_attr.attname AS parent_column
7421
+ FROM pg_constraint con
7422
+ JOIN pg_class child ON child.oid = con.conrelid
7423
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7424
+ JOIN pg_class parent ON parent.oid = con.confrelid
7425
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7426
+ JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = con.conkey[1]
7427
+ JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = con.confkey[1]
7428
+ WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND parent_ns.nspname = 'public'
7429
+ AND array_length(con.conkey, 1) = 1`);
7430
+ return rows.map((row) => ({
7431
+ name: String(row.name),
7432
+ childTable: String(row.child_table),
7433
+ childColumn: String(row.child_column),
7434
+ parentTable: String(row.parent_table),
7435
+ parentColumn: String(row.parent_column)
7436
+ }));
7437
+ }
7438
+ /**
7439
+ * Fail closed when a (single- OR multi-column) foreign key has exactly one
7440
+ * endpoint in the public schema and at least one of the public-side key
7441
+ * columns is in `relevant` (a schema-declared-UUID column this run treats
7442
+ * as a real conversion candidate/participant).
7443
+ *
7444
+ * `db:migrate-uuid` never converts or recreates DDL outside `public` (see
7445
+ * `pgTable`), and `declaredUuidKey` keys purely on `table|column` with no
7446
+ * schema — so a same-named table/column pair living in another schema could
7447
+ * otherwise be silently mistaken for that candidate's real FK partner
7448
+ * (making `propagateBlockedForeignKeyPartners` or `snapshotForeignKeys`
7449
+ * reason about the wrong table), or a genuine cross-schema partner could go
7450
+ * entirely undiscovered because the edge queries only ever look at
7451
+ * public-to-public constraints. Both are correctness bugs, not merely
7452
+ * documentation gaps, so this check runs independently of `declaredUuidKey`
7453
+ * lookups and fails the whole run with a specific, actionable message
7454
+ * instead of silently mis-propagating or mis-recreating a constraint.
7455
+ *
7456
+ * Deliberately not restricted to single-column FKs: `snapshotForeignKeys`
7457
+ * only refuses an unsupported multi-column FK once it has already matched
7458
+ * an in-`public` participant via `converted`/`bridgeColumns`, which this
7459
+ * cross-schema check runs ahead of and independently from. A composite FK
7460
+ * whose public-side key touches a converting column, but whose other
7461
+ * endpoint lives outside `public`, must fail closed here at preflight —
7462
+ * before `--dry-run` reports a plan `apply` cannot actually execute — the
7463
+ * same as the single-column case.
7464
+ */
7465
+ async function assertNoCrossSchemaForeignKeyPartners(db, relevant) {
7466
+ const { rows } = await db.query(`SELECT con.conname AS name,
7467
+ child_ns.nspname AS child_schema, child.relname AS child_table,
7468
+ to_json(array_agg(child_attr.attname ORDER BY child_key.ord)) AS child_columns,
7469
+ parent_ns.nspname AS parent_schema, parent.relname AS parent_table,
7470
+ to_json(array_agg(parent_attr.attname ORDER BY child_key.ord)) AS parent_columns
7471
+ FROM pg_constraint con
7472
+ JOIN pg_class child ON child.oid = con.conrelid
7473
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7474
+ JOIN pg_class parent ON parent.oid = con.confrelid
7475
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7476
+ JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
7477
+ JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
7478
+ JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
7479
+ JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = parent_key.attnum
7480
+ WHERE con.contype = 'f'
7481
+ AND (child_ns.nspname = 'public') <> (parent_ns.nspname = 'public')
7482
+ GROUP BY con.oid, con.conname, child_ns.nspname, child.relname,
7483
+ parent_ns.nspname, parent.relname`);
7484
+ for (const row of rows) {
7485
+ const childSchema = String(row.child_schema);
7486
+ const parentSchema = String(row.parent_schema);
7487
+ const childTable = String(row.child_table);
7488
+ const parentTable = String(row.parent_table);
7489
+ const childColumns = row.child_columns;
7490
+ const parentColumns = row.parent_columns;
7491
+ const childOnPublic = childSchema === "public";
7492
+ const publicTable = childOnPublic ? childTable : parentTable;
7493
+ if (!(childOnPublic ? childColumns : parentColumns).some((column) => relevant.has(declaredUuidKey(publicTable, column)))) continue;
7494
+ throw new Error(`Foreign key ${String(row.name)} crosses the public schema boundary (${childSchema}.${childTable}(${childColumns.join(", ")}) → ${parentSchema}.${parentTable}(${parentColumns.join(", ")})); db:migrate-uuid only ever converts or recreates constraints in the public schema.`);
7495
+ }
7496
+ }
7497
+ async function snapshotForeignKeys(db, columns, bridges) {
7498
+ const converted = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
7499
+ const bridgeColumns = new Set(bridges.map((bridge) => declaredUuidKey(bridge.table, bridge.column)));
7500
+ await assertNoCrossSchemaForeignKeyPartners(db, /* @__PURE__ */ new Set([...converted, ...bridgeColumns]));
7501
+ const { rows } = await db.query(`SELECT con.oid, child.relname AS child_table, con.conname AS name,
7502
+ parent.relname AS parent_table, con.convalidated AS validated,
7503
+ pg_get_constraintdef(con.oid) AS definition,
7504
+ obj_description(con.oid, 'pg_constraint') AS comment,
7505
+ EXISTS (
7506
+ SELECT 1 FROM pg_trigger trigger
7507
+ WHERE trigger.tgconstraint = con.oid
7508
+ AND trigger.tgenabled <> 'O'
7509
+ ) AS nondefault_trigger_mode,
7510
+ array_length(con.conkey, 1) AS child_keys,
7511
+ array_length(con.confkey, 1) AS parent_keys,
7512
+ child_attr.attname AS child_column, parent_attr.attname AS parent_column
7513
+ FROM pg_constraint con
7514
+ JOIN pg_class child ON child.oid = con.conrelid
7515
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7516
+ JOIN pg_class parent ON parent.oid = con.confrelid
7517
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7518
+ JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
7519
+ JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
7520
+ JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
7521
+ JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = parent_key.attnum
7522
+ WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND parent_ns.nspname = 'public'`);
7523
+ const participating = [];
7524
+ for (const row of rows) {
7525
+ const child = declaredUuidKey(String(row.child_table), String(row.child_column));
7526
+ const parent = declaredUuidKey(String(row.parent_table), String(row.parent_column));
7527
+ if (!converted.has(child) && !converted.has(parent) && !bridgeColumns.has(child) && !bridgeColumns.has(parent)) continue;
7528
+ if (Number(row.child_keys) !== 1 || Number(row.parent_keys) !== 1) throw new Error(`Unsupported multi-column foreign key ${String(row.name)} touches UUID migration component.`);
7529
+ const bridgeEdge = bridgeColumns.has(child) || bridgeColumns.has(parent);
7530
+ if (bridgeEdge && (converted.has(child) || converted.has(parent))) throw new Error(`Foreign key ${String(row.name)} mixes a UUID-converted endpoint with a retained TEXT generated bridge; refusing incompatible recreation.`);
7531
+ if (!bridgeEdge && (!converted.has(child) || !converted.has(parent))) throw new Error(`Foreign key ${String(row.name)} crosses a UUID conversion component boundary (${child} → ${parent}); declare both endpoints UUID or migrate it separately.`);
7532
+ if (row.nondefault_trigger_mode) throw new Error(`Unsupported foreign key ${String(row.name)} has nondefault PostgreSQL trigger enforcement; refusing to recreate it with weaker semantics.`);
7533
+ participating.push({
7534
+ oid: String(row.oid),
7535
+ table: String(row.child_table),
7536
+ name: String(row.name),
7537
+ definition: String(row.definition),
7538
+ validated: Boolean(row.validated),
7539
+ comment: row.comment == null ? null : String(row.comment)
7540
+ });
7541
+ }
7542
+ return participating.sort((a, b) => `${a.table}.${a.name}`.localeCompare(`${b.table}.${b.name}`));
7543
+ }
7544
+ /**
7545
+ * A bridge column is dropped and rebuilt. pg_depend is the authoritative
7546
+ * catalog for every object which would be removed with it, including objects
7547
+ * whose definition does not expose the column name (expression indexes,
7548
+ * predicates, and extended statistics). Keep only dependencies we snapshot
7549
+ * and recreate exactly; fail closed for every other catalog object.
7550
+ */
7551
+ async function assertSupportedBridgeDependencies(db, bridges, foreignKeys) {
7552
+ const foreignKeyOids = new Set(foreignKeys.map((foreignKey) => foreignKey.oid));
7553
+ for (const bridge of bridges) {
7554
+ const indexOids = new Set(bridge.indexDefinitions.map((index) => index.oid));
7555
+ const { rows } = await db.query(`SELECT dep.classid::regclass::text AS class_name,
7556
+ dep.objid::text AS object_oid,
7557
+ dep.objsubid AS object_subid,
7558
+ dep.deptype,
7559
+ coalesce(obj_class.relname, con.conname, stat.stxname, '') AS object_name
7560
+ FROM pg_depend dep
7561
+ LEFT JOIN pg_class obj_class
7562
+ ON dep.classid = 'pg_class'::regclass AND obj_class.oid = dep.objid
7563
+ LEFT JOIN pg_constraint con
7564
+ ON dep.classid = 'pg_constraint'::regclass AND con.oid = dep.objid
7565
+ LEFT JOIN pg_statistic_ext stat
7566
+ ON dep.classid = 'pg_statistic_ext'::regclass AND stat.oid = dep.objid
7567
+ WHERE dep.refclassid = 'pg_class'::regclass
7568
+ AND dep.refobjid = ${quoteLiteral(bridge.tableOid)}::oid
7569
+ AND dep.refobjsubid = ${bridge.attributeNumber}`);
7570
+ for (const row of rows) {
7571
+ const className = String(row.class_name);
7572
+ const objectOid = String(row.object_oid);
7573
+ const knownAttributeDefinition = className === "pg_attrdef" && objectOid === bridge.attributeDefaultOid;
7574
+ const knownIndex = className === "pg_class" && indexOids.has(objectOid);
7575
+ const knownForeignKey = className === "pg_constraint" && foreignKeyOids.has(objectOid);
7576
+ if (knownAttributeDefinition || knownIndex || knownForeignKey) continue;
7577
+ const name = row.object_name ? ` ${String(row.object_name)}` : "";
7578
+ throw new Error(`Unsupported catalog dependency on generated bridge ${bridge.table}.${bridge.column}:${name} (${className} OID ${objectOid}, dependency ${String(row.deptype)}). Remove or migrate it separately before db:migrate-uuid.`);
7579
+ }
7580
+ }
7581
+ }
6790
7582
  /**
6791
7583
  * Apply the R3 rename backfills: copy `old → new` where `new` is still empty,
6792
7584
  * then drop `old`. Idempotent (a missing source column is skipped).
@@ -6803,7 +7595,7 @@ async function applyRenameBackfills(db, isPostgres, renameSpecs, dryRun, { ownTr
6803
7595
  if (useOwnTxn) await db.query("BEGIN");
6804
7596
  try {
6805
7597
  for (const spec of renameSpecs) {
6806
- const t = quoteIdentifier(spec.table);
7598
+ const t = isPostgres ? pgTable(spec.table) : quoteIdentifier(spec.table);
6807
7599
  const from = quoteIdentifier(spec.from);
6808
7600
  const to = quoteIdentifier(spec.to);
6809
7601
  if (!await columnExists(db, isPostgres, spec.table, spec.from)) {
@@ -7905,14 +8697,19 @@ async function readAgentSurfaceReport(cwd) {
7905
8697
  name: String(playbook.key),
7906
8698
  detail: `${playbook.steps.length} step(s) · ${playbook.planes.join("+")} · ${playbook.sourceFile}`
7907
8699
  })),
7908
- notStatic: (surface?.diagnostics ?? []).map((diagnostic) => ({
7909
- sourceFile: diagnostic.line ? `${diagnostic.sourceFile}:${diagnostic.line}` : String(diagnostic.sourceFile),
7910
- message: String(diagnostic.message)
7911
- }))
8700
+ notStatic: (surface?.diagnostics ?? []).filter((diagnostic) => diagnostic.code !== "tool-name-collision").map(describeAgentSurfaceDiagnostic),
8701
+ toolNameCollisions: (surface?.diagnostics ?? []).filter((diagnostic) => diagnostic.code === "tool-name-collision").map(describeAgentSurfaceDiagnostic)
7912
8702
  };
7913
8703
  } catch {}
7914
8704
  }
7915
8705
  }
8706
+ /** Where a diagnostic points, and what it says, in one printable pair. */
8707
+ function describeAgentSurfaceDiagnostic(diagnostic) {
8708
+ return {
8709
+ sourceFile: diagnostic.line ? `${diagnostic.sourceFile}:${diagnostic.line}` : String(diagnostic.sourceFile),
8710
+ message: String(diagnostic.message)
8711
+ };
8712
+ }
7916
8713
  /** Render the surface report as the lines `smrt doctor` prints. */
7917
8714
  function renderAgentSurfaceReport(report) {
7918
8715
  if (!report) return [" ⚠️ No knowledge artifact found — run a build to emit the agent surface"];
@@ -7928,6 +8725,10 @@ function renderAgentSurfaceReport(report) {
7928
8725
  lines.push(` ⚠️ Not statically emittable: ${report.notStatic.length}`);
7929
8726
  for (const entry of report.notStatic) lines.push(` - ${entry.sourceFile}: ${entry.message}`);
7930
8727
  }
8728
+ if (report.toolNameCollisions.length > 0) {
8729
+ lines.push(` ⚠️ Tool name also claimed: ${report.toolNameCollisions.length}`);
8730
+ for (const entry of report.toolNameCollisions) lines.push(` - ${entry.sourceFile}: ${entry.message}`);
8731
+ }
7931
8732
  return lines;
7932
8733
  }
7933
8734
  /**
@@ -9266,6 +10067,7 @@ export default testManifest;
9266
10067
  if (agentSurfaceReport) {
9267
10068
  check(`${agentSurfaceReport.modelTools.length + agentSurfaceReport.intents.length + agentSurfaceReport.playbooks.length} agent-addressable operation(s) reported`, true);
9268
10069
  if (agentSurfaceReport.notStatic.length > 0) check("Agent-surface declarations are all static", false, void 0, `${agentSurfaceReport.notStatic.length} declaration(s) could not be read statically and were excluded — use useWebMcpTool for a computed tool set`);
10070
+ if (agentSurfaceReport.toolNameCollisions.length > 0) check("Declared intents own their WebMCP tool names", false, void 0, `${agentSurfaceReport.toolNameCollisions.length} intent(s) derive a tool name a generated or fixed UI tool also claims — namespace the generated tools, reprefix the UI tools, or rename the intent; each message says the runtime condition it assumes`);
9269
10071
  } else check("Agent surface reported", false, void 0, "No knowledge artifact — run a build first");
9270
10072
  console.log();
9271
10073
  console.log("🏃 Runtime\n");
@@ -9344,7 +10146,9 @@ export default testManifest;
9344
10146
  "db:generate": dbGenerateCommand,
9345
10147
  "db:migrate-uuid": dbMigrateUuidCommand,
9346
10148
  "db:migrate-int8": dbMigrateInt8Command,
10149
+ "db:migrate-agent-schedule-slugs": dbMigrateAgentScheduleSlugsCommand,
9347
10150
  "db:drop-framework-base-tables": dbDropFrameworkBaseTablesCommand,
10151
+ "db:orphans": dbOrphansCommand,
9348
10152
  "db:prune": dbPruneCommand,
9349
10153
  "db:permissions": dbPermissionsCommand,
9350
10154
  "config:export": configExportCommand,