@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.
- package/AGENTS.md +2 -1
- package/agents/db-orphans.md +46 -0
- package/dist/{commands-C9UEWEvb.js → commands-uHfUPryV.js} +929 -125
- package/dist/index.js +9 -9
- package/package.json +14 -13
|
@@ -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
|
|
2089
|
-
* declare `conflictColumns` explicitly, inherit
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
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 ?? []).
|
|
7909
|
-
|
|
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,
|