@happyvertical/smrt-cli 0.46.0 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{commands-C4xB-FxS.js → commands-BiXRxejk.js} +496 -110
- package/dist/index.js +9 -9
- package/package.json +13 -13
|
@@ -1494,7 +1494,8 @@ async function runPostgresPermissions(options = {}, optional = false) {
|
|
|
1494
1494
|
])].sort();
|
|
1495
1495
|
const contract = {
|
|
1496
1496
|
...configured,
|
|
1497
|
-
managedTables
|
|
1497
|
+
managedTables,
|
|
1498
|
+
managedTriggerFunctions: configured.managedTriggerFunctions ?? []
|
|
1498
1499
|
};
|
|
1499
1500
|
return {
|
|
1500
1501
|
skipped: false,
|
|
@@ -6361,6 +6362,56 @@ var dbDropFrameworkBaseTablesCommand = {
|
|
|
6361
6362
|
}
|
|
6362
6363
|
};
|
|
6363
6364
|
//#endregion
|
|
6365
|
+
//#region src/commands/db-migrate-agent-schedule-slugs.ts
|
|
6366
|
+
var dbMigrateAgentScheduleSlugsCommand = {
|
|
6367
|
+
name: "db:migrate-agent-schedule-slugs",
|
|
6368
|
+
description: "Backfill canonical slugs for legacy framework AgentSchedule rows after db:migrate. Requires a PostgreSQL maintenance window; it never runs schedules.",
|
|
6369
|
+
aliases: ["migrate-agent-schedule-slugs", "db-migrate-schedule-slugs"],
|
|
6370
|
+
args: [],
|
|
6371
|
+
options: { "dry-run": {
|
|
6372
|
+
type: "boolean",
|
|
6373
|
+
description: "Validate legacy schedule identities without writing.",
|
|
6374
|
+
default: false
|
|
6375
|
+
} },
|
|
6376
|
+
handler: async (_args, options) => {
|
|
6377
|
+
let db;
|
|
6378
|
+
try {
|
|
6379
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
6380
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-DfBo4hHU.js").then((n) => n.n);
|
|
6381
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
6382
|
+
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).");
|
|
6383
|
+
const dbType = config.database.type || "sqlite";
|
|
6384
|
+
if (dbType !== "postgres") throw new Error("db:migrate-agent-schedule-slugs currently supports PostgreSQL only; no changes were made.");
|
|
6385
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
6386
|
+
const { AGENT_SCHEDULE_SLUG_BACKFILL, migrateAgentScheduleSlugs, planAgentScheduleSlugMigration } = await import("@happyvertical/smrt-agents");
|
|
6387
|
+
db = await getDatabase({
|
|
6388
|
+
type: dbType,
|
|
6389
|
+
url: config.database.url
|
|
6390
|
+
});
|
|
6391
|
+
console.log("\n🗓️ AgentSchedule slug migration\n");
|
|
6392
|
+
console.log(`✓ Connected to ${formatDatabaseDisplayUrl(dbType, config.database.url)}\n`);
|
|
6393
|
+
if (options["dry-run"]) {
|
|
6394
|
+
const plan = await planAgentScheduleSlugMigration(db);
|
|
6395
|
+
console.log(`DRY RUN: ${plan.pending} AgentSchedule slug(s) would be backfilled; no schedules run and no changes made.\n`);
|
|
6396
|
+
return;
|
|
6397
|
+
}
|
|
6398
|
+
const postgres = config.migrations?.postgres;
|
|
6399
|
+
const result = await migrateAgentScheduleSlugs(db, {
|
|
6400
|
+
backfillName: AGENT_SCHEDULE_SLUG_BACKFILL,
|
|
6401
|
+
packageName: "@happyvertical/smrt-agents",
|
|
6402
|
+
lockTimeout: parsePostgresTimeoutMs(postgres?.lockTimeout, 3e4),
|
|
6403
|
+
statementTimeout: parsePostgresTimeoutMs(postgres?.statementTimeout, 6e4)
|
|
6404
|
+
});
|
|
6405
|
+
console.log(result.ran ? `✓ Backfilled ${result.updated} AgentSchedule slug(s); no schedules were run.\n` : "No schedule slug migration was needed.\n");
|
|
6406
|
+
} catch (error) {
|
|
6407
|
+
console.error(`\n❌ AgentSchedule slug migration failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
6408
|
+
process.exitCode = 1;
|
|
6409
|
+
} finally {
|
|
6410
|
+
await closeDatabaseConnection(db);
|
|
6411
|
+
}
|
|
6412
|
+
}
|
|
6413
|
+
};
|
|
6414
|
+
//#endregion
|
|
6364
6415
|
//#region src/commands/db-migrate-int8.ts
|
|
6365
6416
|
/**
|
|
6366
6417
|
* db:migrate-int8 Command
|
|
@@ -6542,6 +6593,9 @@ function buildDeclaredUuidColumnSet(schemaDefinitions) {
|
|
|
6542
6593
|
function declaredUuidKey(table, column) {
|
|
6543
6594
|
return `${table}|${column}`;
|
|
6544
6595
|
}
|
|
6596
|
+
function renamedSourceColumns(renameSpecs) {
|
|
6597
|
+
return new Set(renameSpecs.map((spec) => declaredUuidKey(spec.table, spec.from)));
|
|
6598
|
+
}
|
|
6545
6599
|
/**
|
|
6546
6600
|
* Decide which live TEXT columns to convert to native `uuid`.
|
|
6547
6601
|
*
|
|
@@ -6670,111 +6724,35 @@ var dbMigrateUuidCommand = {
|
|
|
6670
6724
|
const isPostgres = dbType === "postgres" || /^postgres/i.test(dbUrl);
|
|
6671
6725
|
const runRenames = renameSpecs.length > 0;
|
|
6672
6726
|
const skipConvert = Boolean(options["skip-convert"]);
|
|
6673
|
-
const
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
}
|
|
6691
|
-
const declaredUuid = await loadDeclaredUuidColumns();
|
|
6692
|
-
if (declaredUuid.size === 0) {
|
|
6693
|
-
console.log("No schema-declared UUID columns found in the loaded manifest.");
|
|
6694
|
-
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");
|
|
6695
|
-
if (openTxn && !dryRun) {
|
|
6696
|
-
await db.query("COMMIT");
|
|
6697
|
-
openTxn = false;
|
|
6698
|
-
}
|
|
6699
|
-
return;
|
|
6700
|
-
}
|
|
6701
|
-
const { rows: candidates } = await db.query(`SELECT table_name, column_name, (column_default IS NOT NULL) AS has_default
|
|
6702
|
-
FROM information_schema.columns
|
|
6703
|
-
WHERE table_schema = 'public'
|
|
6704
|
-
AND data_type = 'text'
|
|
6705
|
-
AND (column_name = 'id' OR column_name ~* '(_id|Id)$')
|
|
6706
|
-
ORDER BY table_name, column_name`);
|
|
6707
|
-
const liveColumns = [];
|
|
6708
|
-
for (const row of candidates) {
|
|
6709
|
-
const declared = declaredUuid.has(`${row.table_name}|${row.column_name}`);
|
|
6710
|
-
let nonUuid = 0;
|
|
6711
|
-
if (declared) {
|
|
6712
|
-
const t = quoteIdentifier(row.table_name);
|
|
6713
|
-
const c = quoteIdentifier(row.column_name);
|
|
6714
|
-
const { rows } = await db.query(`SELECT count(*)::text AS n
|
|
6715
|
-
FROM ${t}
|
|
6716
|
-
WHERE nullif(btrim(${c}), '') IS NOT NULL
|
|
6717
|
-
AND btrim(${c}) !~* '${UUID_RE}'`);
|
|
6718
|
-
nonUuid = Number(rows[0]?.n ?? "0");
|
|
6719
|
-
}
|
|
6720
|
-
liveColumns.push({
|
|
6721
|
-
table: row.table_name,
|
|
6722
|
-
column: row.column_name,
|
|
6723
|
-
hasDefault: Boolean(row.has_default),
|
|
6724
|
-
nonUuid
|
|
6725
|
-
});
|
|
6726
|
-
}
|
|
6727
|
-
const { convert, skipDirtyData, skipNotDeclared } = planUuidConversions(liveColumns, declaredUuid);
|
|
6728
|
-
console.log(`Found ${candidates.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length} skipped.`);
|
|
6729
|
-
if (skipDirtyData.length > 0) {
|
|
6730
|
-
console.log("\nSKIP (declared UUID but data still has non-UUID values — clean these first):");
|
|
6731
|
-
for (const s of skipDirtyData) console.log(` - ${s.table}.${s.column} (${s.nonUuid} non-uuid)`);
|
|
6732
|
-
}
|
|
6733
|
-
if (skipNotDeclared.length > 0) {
|
|
6734
|
-
console.log("\nSKIP (not declared UUID by the SMRT schema, left as TEXT):");
|
|
6735
|
-
for (const s of skipNotDeclared) console.log(` - ${s.table}.${s.column}`);
|
|
6736
|
-
}
|
|
6737
|
-
if (convert.length === 0) {
|
|
6738
|
-
console.log("\nNothing to convert. Done.\n");
|
|
6739
|
-
if (openTxn && !dryRun) {
|
|
6740
|
-
await db.query("COMMIT");
|
|
6741
|
-
openTxn = false;
|
|
6742
|
-
}
|
|
6743
|
-
return;
|
|
6744
|
-
}
|
|
6745
|
-
console.log(`\n${dryRun ? "DRY RUN — would run" : "Applying"} ${convert.length} conversion(s):`);
|
|
6746
|
-
if (!sharedTxn && !dryRun) {
|
|
6747
|
-
await db.query("BEGIN");
|
|
6748
|
-
openTxn = true;
|
|
6749
|
-
}
|
|
6750
|
-
for (const { table, column, hasDefault } of convert) {
|
|
6751
|
-
const t = quoteIdentifier(table);
|
|
6752
|
-
const c = quoteIdentifier(column);
|
|
6753
|
-
if (hasDefault) {
|
|
6754
|
-
const dropDefault = `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`;
|
|
6755
|
-
console.log(` ${dropDefault};`);
|
|
6756
|
-
if (!dryRun) await db.query(dropDefault);
|
|
6757
|
-
}
|
|
6758
|
-
const alter = `ALTER TABLE ${t} ALTER COLUMN ${c} TYPE uuid USING NULLIF(btrim(${c}), '')::uuid`;
|
|
6759
|
-
console.log(` ${alter};`);
|
|
6760
|
-
if (!dryRun) await db.query(alter);
|
|
6761
|
-
}
|
|
6762
|
-
if (!dryRun) {
|
|
6763
|
-
if (openTxn) {
|
|
6764
|
-
await db.query("COMMIT");
|
|
6765
|
-
openTxn = false;
|
|
6766
|
-
}
|
|
6767
|
-
console.log(`\n✓ Converted ${convert.length} column(s) to uuid.\n`);
|
|
6768
|
-
} else console.log("\nDry run complete — no changes applied.\n");
|
|
6769
|
-
} catch (error) {
|
|
6770
|
-
if (openTxn && !dryRun) {
|
|
6771
|
-
try {
|
|
6772
|
-
await db.query("ROLLBACK");
|
|
6773
|
-
} catch {}
|
|
6774
|
-
openTxn = false;
|
|
6775
|
-
}
|
|
6776
|
-
throw error;
|
|
6727
|
+
const runConvert = !skipConvert && isPostgres;
|
|
6728
|
+
if (skipConvert) {
|
|
6729
|
+
await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
|
|
6730
|
+
console.log("Skipping TEXT→uuid conversion (--skip-convert).\n");
|
|
6731
|
+
return;
|
|
6732
|
+
}
|
|
6733
|
+
if (!isPostgres) {
|
|
6734
|
+
await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
|
|
6735
|
+
console.log(`Database type "${dbType}" has no native uuid column type that needs converting; uuid conversion is a no-op.\n`);
|
|
6736
|
+
return;
|
|
6737
|
+
}
|
|
6738
|
+
const declaredUuid = await loadDeclaredUuidColumns();
|
|
6739
|
+
if (declaredUuid.size === 0) {
|
|
6740
|
+
console.log("No schema-declared UUID columns found in the loaded manifest.");
|
|
6741
|
+
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");
|
|
6742
|
+
await applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun);
|
|
6743
|
+
return;
|
|
6777
6744
|
}
|
|
6745
|
+
if (dryRun) {
|
|
6746
|
+
await applyRenameBackfills(db, isPostgres, renameSpecs, true, { ownTransaction: false });
|
|
6747
|
+
await convertPostgresUuidColumns(db, declaredUuid, true, true, renamedSourceColumns(renameSpecs), renameSpecs);
|
|
6748
|
+
return;
|
|
6749
|
+
}
|
|
6750
|
+
if (!db.transaction) throw new Error("PostgreSQL UUID migration requires DatabaseInterface.transaction(); refusing to run unpinned DDL.");
|
|
6751
|
+
if (runRenames && runConvert) await convertPostgresUuidColumns(db, declaredUuid, true, false, renamedSourceColumns(renameSpecs), renameSpecs);
|
|
6752
|
+
await db.transaction(async (tx) => {
|
|
6753
|
+
await applyRenameBackfills(tx, true, renameSpecs, false, { ownTransaction: false });
|
|
6754
|
+
await convertPostgresUuidColumns(tx, declaredUuid, false);
|
|
6755
|
+
});
|
|
6778
6756
|
} catch (error) {
|
|
6779
6757
|
console.error(`\n❌ uuid migration failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
6780
6758
|
process.exitCode = 1;
|
|
@@ -6786,6 +6764,403 @@ var dbMigrateUuidCommand = {
|
|
|
6786
6764
|
function nullifEmpty(isPostgres, quotedCol) {
|
|
6787
6765
|
return isPostgres ? `nullif(btrim(${quotedCol}), '')` : `nullif(trim(${quotedCol}), '')`;
|
|
6788
6766
|
}
|
|
6767
|
+
/** Execute a rename-only PostgreSQL mutation on the transaction callback. */
|
|
6768
|
+
async function applyRenameOnlyBackfills(db, isPostgres, renameSpecs, dryRun) {
|
|
6769
|
+
if (!isPostgres || dryRun) {
|
|
6770
|
+
await applyRenameBackfills(db, isPostgres, renameSpecs, dryRun, { ownTransaction: !dryRun });
|
|
6771
|
+
return;
|
|
6772
|
+
}
|
|
6773
|
+
if (renameSpecs.length === 0) {
|
|
6774
|
+
await applyRenameBackfills(db, true, renameSpecs, false, { ownTransaction: false });
|
|
6775
|
+
return;
|
|
6776
|
+
}
|
|
6777
|
+
if (!db.transaction) throw new Error("PostgreSQL UUID migration requires DatabaseInterface.transaction(); refusing to run unpinned DDL.");
|
|
6778
|
+
await db.transaction(async (tx) => {
|
|
6779
|
+
await applyRenameBackfills(tx, true, renameSpecs, false, { ownTransaction: false });
|
|
6780
|
+
});
|
|
6781
|
+
}
|
|
6782
|
+
function quoteLiteral(value) {
|
|
6783
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
6784
|
+
}
|
|
6785
|
+
/** PostgreSQL relation reference in the migration's fixed public schema. */
|
|
6786
|
+
function pgTable(table) {
|
|
6787
|
+
return `${quoteIdentifier("public")}.${quoteIdentifier(table)}`;
|
|
6788
|
+
}
|
|
6789
|
+
/**
|
|
6790
|
+
* Convert a deliberately narrow PostgreSQL dependency component. This is
|
|
6791
|
+
* intentionally catalog-driven: generated text bridges are consumer schema,
|
|
6792
|
+
* not SMRT manifest columns, and are never guessed from a name.
|
|
6793
|
+
*/
|
|
6794
|
+
async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun = true, excludedColumns = /* @__PURE__ */ new Set(), projectedRenames = []) {
|
|
6795
|
+
const { rows: candidateRows } = await db.query(`SELECT cols.table_name, cols.column_name, cols.column_default,
|
|
6796
|
+
relation.oid::text AS relation_oid, attribute.attnum AS attribute_number,
|
|
6797
|
+
format_type(attribute.atttypid, attribute.atttypmod) AS type_name
|
|
6798
|
+
FROM information_schema.columns cols
|
|
6799
|
+
JOIN pg_namespace namespace ON namespace.nspname = cols.table_schema
|
|
6800
|
+
JOIN pg_class relation ON relation.relnamespace = namespace.oid AND relation.relname = cols.table_name
|
|
6801
|
+
JOIN pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attname = cols.column_name
|
|
6802
|
+
WHERE cols.table_schema = 'public' AND cols.data_type = 'text'
|
|
6803
|
+
AND (column_name = 'id' OR column_name ~* '(_id|Id)$')
|
|
6804
|
+
ORDER BY table_name, column_name`);
|
|
6805
|
+
const liveColumns = [];
|
|
6806
|
+
const defaults = /* @__PURE__ */ new Map();
|
|
6807
|
+
for (const row of candidateRows) {
|
|
6808
|
+
const table = String(row.table_name);
|
|
6809
|
+
const column = String(row.column_name);
|
|
6810
|
+
if (excludedColumns.has(declaredUuidKey(table, column))) continue;
|
|
6811
|
+
let nonUuid = 0;
|
|
6812
|
+
if (declaredUuid.has(declaredUuidKey(table, column))) {
|
|
6813
|
+
const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
|
|
6814
|
+
WHERE nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL
|
|
6815
|
+
AND btrim(${quoteIdentifier(column)}) !~* '${UUID_RE}'`);
|
|
6816
|
+
nonUuid = Number(rows[0]?.n ?? 0);
|
|
6817
|
+
}
|
|
6818
|
+
liveColumns.push({
|
|
6819
|
+
table,
|
|
6820
|
+
column,
|
|
6821
|
+
hasDefault: row.column_default != null,
|
|
6822
|
+
nonUuid
|
|
6823
|
+
});
|
|
6824
|
+
defaults.set(declaredUuidKey(table, column), row.column_default == null ? null : String(row.column_default));
|
|
6825
|
+
}
|
|
6826
|
+
for (const spec of projectedRenames) {
|
|
6827
|
+
const destination = liveColumns.find((column) => column.table === spec.table && column.column === spec.to);
|
|
6828
|
+
if (!destination || !declaredUuid.has(declaredUuidKey(spec.table, spec.to))) continue;
|
|
6829
|
+
const { rows: source } = await db.query(`SELECT 1 FROM information_schema.columns
|
|
6830
|
+
WHERE table_schema = 'public'
|
|
6831
|
+
AND table_name = ${quoteLiteral(spec.table)}
|
|
6832
|
+
AND column_name = ${quoteLiteral(spec.from)}`);
|
|
6833
|
+
if (source.length === 0) continue;
|
|
6834
|
+
const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(spec.table)}
|
|
6835
|
+
WHERE ${nullifEmpty(true, quoteIdentifier(spec.from))} IS NOT NULL
|
|
6836
|
+
AND ${quoteIdentifier(spec.to)} IS NULL
|
|
6837
|
+
AND btrim(${quoteIdentifier(spec.from)}) !~* '${UUID_RE}'`);
|
|
6838
|
+
destination.nonUuid += Number(rows[0]?.n ?? 0);
|
|
6839
|
+
}
|
|
6840
|
+
const { convert, skipDirtyData, skipNotDeclared } = planUuidConversions(liveColumns, declaredUuid);
|
|
6841
|
+
console.log(`Found ${candidateRows.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length} skipped.`);
|
|
6842
|
+
for (const item of skipDirtyData) console.log(` SKIP ${item.table}.${item.column}: ${item.nonUuid} non-uuid value(s)`);
|
|
6843
|
+
for (const item of skipNotDeclared) console.log(` SKIP ${item.table}.${item.column}: not schema-declared UUID`);
|
|
6844
|
+
if (convert.length === 0) {
|
|
6845
|
+
console.log("\nNothing to convert. Done.\n");
|
|
6846
|
+
return;
|
|
6847
|
+
}
|
|
6848
|
+
const columns = convert.map((column) => ({
|
|
6849
|
+
...column,
|
|
6850
|
+
defaultExpression: defaults.get(declaredUuidKey(column.table, column.column)) ?? null,
|
|
6851
|
+
relationOid: String(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.relation_oid),
|
|
6852
|
+
attributeNumber: Number(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.attribute_number),
|
|
6853
|
+
typeName: String(candidateRows.find((row) => String(row.table_name) === column.table && String(row.column_name) === column.column)?.type_name)
|
|
6854
|
+
}));
|
|
6855
|
+
await assertSupportedSourceColumns(db, columns);
|
|
6856
|
+
const bridges = await snapshotGeneratedBridges(db, columns);
|
|
6857
|
+
const foreignKeys = await snapshotForeignKeys(db, columns, bridges);
|
|
6858
|
+
await assertSupportedBridgeDependencies(db, bridges, foreignKeys);
|
|
6859
|
+
const tables = [.../* @__PURE__ */ new Set([
|
|
6860
|
+
...columns.map((column) => column.table),
|
|
6861
|
+
...bridges.map((bridge) => bridge.table),
|
|
6862
|
+
...foreignKeys.map((foreignKey) => foreignKey.table)
|
|
6863
|
+
])].sort();
|
|
6864
|
+
if (dryRun) {
|
|
6865
|
+
if (!renderDryRun) return;
|
|
6866
|
+
console.log(`\nDRY RUN — dependency plan for ${columns.length} conversion(s):`);
|
|
6867
|
+
for (const table of tables) console.log(` LOCK TABLE ${pgTable(table)} IN ACCESS EXCLUSIVE MODE;`);
|
|
6868
|
+
renderUuidConversionSql(columns, bridges, foreignKeys);
|
|
6869
|
+
console.log("\nDry run complete — no changes applied.\n");
|
|
6870
|
+
return;
|
|
6871
|
+
}
|
|
6872
|
+
for (const table of tables) await db.query(`LOCK TABLE ${pgTable(table)} IN ACCESS EXCLUSIVE MODE`);
|
|
6873
|
+
await assertSupportedSourceColumns(db, columns);
|
|
6874
|
+
for (const column of columns) {
|
|
6875
|
+
const { rows } = await db.query(`SELECT cols.column_default, relation.oid::text AS relation_oid,
|
|
6876
|
+
attribute.attnum AS attribute_number,
|
|
6877
|
+
format_type(attribute.atttypid, attribute.atttypmod) AS type_name
|
|
6878
|
+
FROM information_schema.columns cols
|
|
6879
|
+
JOIN pg_namespace namespace ON namespace.nspname = cols.table_schema
|
|
6880
|
+
JOIN pg_class relation ON relation.relnamespace = namespace.oid AND relation.relname = cols.table_name
|
|
6881
|
+
JOIN pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attname = cols.column_name
|
|
6882
|
+
WHERE cols.table_schema = 'public'
|
|
6883
|
+
AND cols.table_name = ${quoteLiteral(column.table)}
|
|
6884
|
+
AND cols.column_name = ${quoteLiteral(column.column)}`);
|
|
6885
|
+
const lockedDefault = rows[0]?.column_default == null ? null : String(rows[0].column_default);
|
|
6886
|
+
const locked = rows[0];
|
|
6887
|
+
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.`);
|
|
6888
|
+
}
|
|
6889
|
+
const rescannedBridges = await snapshotGeneratedBridges(db, columns);
|
|
6890
|
+
const rescannedForeignKeys = await snapshotForeignKeys(db, columns, rescannedBridges);
|
|
6891
|
+
await assertSupportedBridgeDependencies(db, rescannedBridges, rescannedForeignKeys);
|
|
6892
|
+
if (JSON.stringify({
|
|
6893
|
+
bridges,
|
|
6894
|
+
foreignKeys
|
|
6895
|
+
}) !== JSON.stringify({
|
|
6896
|
+
bridges: rescannedBridges,
|
|
6897
|
+
foreignKeys: rescannedForeignKeys
|
|
6898
|
+
})) throw new Error("UUID dependency catalog changed while locks were acquired; refusing stale migration plan. Re-run the command.");
|
|
6899
|
+
renderUuidConversionSql(columns, bridges, foreignKeys);
|
|
6900
|
+
for (const foreignKey of foreignKeys) await db.query(`ALTER TABLE ${pgTable(foreignKey.table)} DROP CONSTRAINT ${quoteIdentifier(foreignKey.name)}`);
|
|
6901
|
+
for (const bridge of bridges) await db.query(`ALTER TABLE ${pgTable(bridge.table)} DROP COLUMN ${quoteIdentifier(bridge.column)}`);
|
|
6902
|
+
for (const column of columns) {
|
|
6903
|
+
const table = pgTable(column.table);
|
|
6904
|
+
const name = quoteIdentifier(column.column);
|
|
6905
|
+
if (column.defaultExpression) await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} DROP DEFAULT`);
|
|
6906
|
+
await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} TYPE uuid USING NULLIF(btrim(${name}), '')::uuid`);
|
|
6907
|
+
if (column.defaultExpression) await db.query(`ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT (${column.defaultExpression})::uuid`);
|
|
6908
|
+
}
|
|
6909
|
+
for (const bridge of bridges) {
|
|
6910
|
+
await db.query(`ALTER TABLE ${pgTable(bridge.table)} ADD COLUMN ${quoteIdentifier(bridge.column)} text GENERATED ALWAYS AS (${quoteIdentifier(bridge.sourceColumn)}::text) STORED`);
|
|
6911
|
+
if (bridge.statisticsTarget != null && bridge.statisticsTarget !== -1) await db.query(`ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET STATISTICS ${bridge.statisticsTarget}`);
|
|
6912
|
+
if (bridge.compression) await db.query(`ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET COMPRESSION ${bridge.compression === "l" ? "lz4" : "pglz"}`);
|
|
6913
|
+
}
|
|
6914
|
+
for (const { index, table } of uniqueBridgeIndexes(bridges)) {
|
|
6915
|
+
await db.query(index.definition);
|
|
6916
|
+
if (index.comment) await db.query(`COMMENT ON INDEX ${quoteIdentifier(index.schema)}.${quoteIdentifier(index.name)} IS ${quoteLiteral(index.comment)}`);
|
|
6917
|
+
if (index.clustered) await db.query(`ALTER TABLE ${pgTable(table)} CLUSTER ON ${quoteIdentifier(index.name)}`);
|
|
6918
|
+
if (index.replicaIdentity) await db.query(`ALTER TABLE ${pgTable(table)} REPLICA IDENTITY USING INDEX ${quoteIdentifier(index.name)}`);
|
|
6919
|
+
}
|
|
6920
|
+
for (const foreignKey of foreignKeys) {
|
|
6921
|
+
await db.query(`ALTER TABLE ${pgTable(foreignKey.table)} ADD CONSTRAINT ${quoteIdentifier(foreignKey.name)} ${foreignKey.definition}${foreignKey.validated ? "" : " NOT VALID"}`);
|
|
6922
|
+
if (foreignKey.comment) await db.query(`COMMENT ON CONSTRAINT ${quoteIdentifier(foreignKey.name)} ON ${pgTable(foreignKey.table)} IS ${quoteLiteral(foreignKey.comment)}`);
|
|
6923
|
+
}
|
|
6924
|
+
console.log(`\n✓ Converted ${columns.length} column(s) to uuid.\n`);
|
|
6925
|
+
}
|
|
6926
|
+
function uniqueBridgeIndexes(bridges) {
|
|
6927
|
+
return [...new Map(bridges.flatMap((bridge) => bridge.indexDefinitions.map((index) => [index.oid, {
|
|
6928
|
+
index,
|
|
6929
|
+
table: bridge.table
|
|
6930
|
+
}]))).values()];
|
|
6931
|
+
}
|
|
6932
|
+
async function assertSupportedSourceColumns(db, columns) {
|
|
6933
|
+
for (const column of columns) {
|
|
6934
|
+
const { rows: shape } = await db.query(`SELECT relispartition AS partitioned,
|
|
6935
|
+
EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = c.oid OR i.inhparent = c.oid) AS inherited
|
|
6936
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
6937
|
+
WHERE n.nspname = 'public' AND c.relname = ${quoteLiteral(column.table)} AND c.relkind = 'r'`);
|
|
6938
|
+
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.`);
|
|
6939
|
+
const { rows: views } = await db.query(`SELECT dependent.relname AS name
|
|
6940
|
+
FROM pg_depend dep
|
|
6941
|
+
JOIN pg_rewrite rule ON rule.oid = dep.objid
|
|
6942
|
+
JOIN pg_class dependent ON dependent.oid = rule.ev_class
|
|
6943
|
+
JOIN pg_class source ON source.oid = dep.refobjid
|
|
6944
|
+
JOIN pg_namespace source_ns ON source_ns.oid = source.relnamespace
|
|
6945
|
+
JOIN pg_attribute source_attr ON source_attr.attrelid = source.oid AND source_attr.attnum = dep.refobjsubid
|
|
6946
|
+
WHERE dep.classid = 'pg_rewrite'::regclass AND source_ns.nspname = 'public'
|
|
6947
|
+
AND source.relname = ${quoteLiteral(column.table)} AND source_attr.attname = ${quoteLiteral(column.column)}`);
|
|
6948
|
+
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.`);
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6951
|
+
function renderUuidConversionSql(columns, bridges, foreignKeys) {
|
|
6952
|
+
for (const foreignKey of foreignKeys) console.log(` ALTER TABLE ${pgTable(foreignKey.table)} DROP CONSTRAINT ${quoteIdentifier(foreignKey.name)};`);
|
|
6953
|
+
for (const bridge of bridges) console.log(` ALTER TABLE ${pgTable(bridge.table)} DROP COLUMN ${quoteIdentifier(bridge.column)};`);
|
|
6954
|
+
for (const column of columns) {
|
|
6955
|
+
const table = pgTable(column.table);
|
|
6956
|
+
const name = quoteIdentifier(column.column);
|
|
6957
|
+
if (column.defaultExpression) console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} DROP DEFAULT;`);
|
|
6958
|
+
console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} TYPE uuid USING NULLIF(btrim(${name}), '')::uuid;`);
|
|
6959
|
+
if (column.defaultExpression) console.log(` ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT (${column.defaultExpression})::uuid;`);
|
|
6960
|
+
}
|
|
6961
|
+
for (const bridge of bridges) {
|
|
6962
|
+
console.log(` ALTER TABLE ${pgTable(bridge.table)} ADD COLUMN ${quoteIdentifier(bridge.column)} text GENERATED ALWAYS AS (${quoteIdentifier(bridge.sourceColumn)}::text) STORED;`);
|
|
6963
|
+
if (bridge.statisticsTarget != null && bridge.statisticsTarget !== -1) console.log(` ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET STATISTICS ${bridge.statisticsTarget};`);
|
|
6964
|
+
if (bridge.compression) console.log(` ALTER TABLE ${pgTable(bridge.table)} ALTER COLUMN ${quoteIdentifier(bridge.column)} SET COMPRESSION ${bridge.compression === "l" ? "lz4" : "pglz"};`);
|
|
6965
|
+
}
|
|
6966
|
+
for (const { index, table } of uniqueBridgeIndexes(bridges)) {
|
|
6967
|
+
console.log(` ${index.definition};`);
|
|
6968
|
+
if (index.comment) console.log(` COMMENT ON INDEX ${quoteIdentifier(index.schema)}.${quoteIdentifier(index.name)} IS ${quoteLiteral(index.comment)};`);
|
|
6969
|
+
if (index.clustered) console.log(` ALTER TABLE ${pgTable(table)} CLUSTER ON ${quoteIdentifier(index.name)};`);
|
|
6970
|
+
if (index.replicaIdentity) console.log(` ALTER TABLE ${pgTable(table)} REPLICA IDENTITY USING INDEX ${quoteIdentifier(index.name)};`);
|
|
6971
|
+
}
|
|
6972
|
+
for (const foreignKey of foreignKeys) {
|
|
6973
|
+
console.log(` ALTER TABLE ${pgTable(foreignKey.table)} ADD CONSTRAINT ${quoteIdentifier(foreignKey.name)} ${foreignKey.definition}${foreignKey.validated ? "" : " NOT VALID"};`);
|
|
6974
|
+
if (foreignKey.comment) console.log(` COMMENT ON CONSTRAINT ${quoteIdentifier(foreignKey.name)} ON ${pgTable(foreignKey.table)} IS ${quoteLiteral(foreignKey.comment)};`);
|
|
6975
|
+
}
|
|
6976
|
+
}
|
|
6977
|
+
async function snapshotGeneratedBridges(db, columns) {
|
|
6978
|
+
const requested = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
|
|
6979
|
+
const { rows } = await db.query(`SELECT generated.oid::text AS table_oid, generated_attr.attnum AS attribute_number,
|
|
6980
|
+
def.oid::text AS attribute_default_oid,
|
|
6981
|
+
generated.relname AS table_name, generated_attr.attname AS column_name,
|
|
6982
|
+
pg_get_expr(def.adbin, def.adrelid) AS expression,
|
|
6983
|
+
format_type(generated_attr.atttypid, generated_attr.atttypmod) AS type_name,
|
|
6984
|
+
generated_attr.attnotnull AS not_null, generated_attr.attstorage AS storage,
|
|
6985
|
+
generated_attr.attstattarget AS statistics_target,
|
|
6986
|
+
generated_attr.attcompression AS compression,
|
|
6987
|
+
generated_attr.attoptions AS options,
|
|
6988
|
+
generated_attr.attacl IS NOT NULL AS has_acl,
|
|
6989
|
+
generated_attr.attcollation <> (SELECT typcollation FROM pg_type WHERE oid = generated_attr.atttypid) AS nondefault_collation,
|
|
6990
|
+
col_description(generated_attr.attrelid, generated_attr.attnum) AS column_comment,
|
|
6991
|
+
generated.relispartition AS partitioned,
|
|
6992
|
+
EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = generated.oid OR i.inhparent = generated.oid) AS inherited
|
|
6993
|
+
FROM pg_attrdef def
|
|
6994
|
+
JOIN pg_class generated ON generated.oid = def.adrelid
|
|
6995
|
+
JOIN pg_namespace generated_ns ON generated_ns.oid = generated.relnamespace
|
|
6996
|
+
JOIN pg_attribute generated_attr ON generated_attr.attrelid = generated.oid AND generated_attr.attnum = def.adnum
|
|
6997
|
+
WHERE generated_ns.nspname = 'public'
|
|
6998
|
+
AND generated.relkind = 'r' AND generated_attr.attgenerated = 's'`);
|
|
6999
|
+
const bridges = [];
|
|
7000
|
+
for (const row of rows) {
|
|
7001
|
+
const bridgeTable = String(row.table_name);
|
|
7002
|
+
const bridgeColumn = String(row.column_name);
|
|
7003
|
+
const expression = String(row.expression).replaceAll(" ", "");
|
|
7004
|
+
const sourceColumn = expression.match(/^\(?([a-zA-Z_][a-zA-Z0-9_$]*)\)?(?:::text)?$/)?.[1];
|
|
7005
|
+
if (!sourceColumn) {
|
|
7006
|
+
const sourceColumns = columns.filter((column) => column.table === bridgeTable);
|
|
7007
|
+
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.`);
|
|
7008
|
+
continue;
|
|
7009
|
+
}
|
|
7010
|
+
if (!requested.has(declaredUuidKey(bridgeTable, sourceColumn))) continue;
|
|
7011
|
+
const table = bridgeTable;
|
|
7012
|
+
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 || ![
|
|
7013
|
+
"id",
|
|
7014
|
+
"(id)::text",
|
|
7015
|
+
`${sourceColumn}::text`,
|
|
7016
|
+
`(${sourceColumn})::text`
|
|
7017
|
+
].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.`);
|
|
7018
|
+
const { rows: nonCanonical } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
|
|
7019
|
+
WHERE ${quoteIdentifier(sourceColumn)} IS NOT NULL
|
|
7020
|
+
AND ${quoteIdentifier(sourceColumn)} !~ '${UUID_RE}'`);
|
|
7021
|
+
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.`);
|
|
7022
|
+
bridges.push({
|
|
7023
|
+
tableOid: String(row.table_oid),
|
|
7024
|
+
attributeNumber: Number(row.attribute_number),
|
|
7025
|
+
attributeDefaultOid: String(row.attribute_default_oid),
|
|
7026
|
+
statisticsTarget: row.statistics_target == null ? null : Number(row.statistics_target),
|
|
7027
|
+
compression: String(row.compression),
|
|
7028
|
+
table,
|
|
7029
|
+
column: bridgeColumn,
|
|
7030
|
+
sourceColumn,
|
|
7031
|
+
indexDefinitions: await snapshotBridgeIndexes(db, table, bridgeColumn)
|
|
7032
|
+
});
|
|
7033
|
+
}
|
|
7034
|
+
return bridges.sort((a, b) => `${a.table}.${a.column}`.localeCompare(`${b.table}.${b.column}`));
|
|
7035
|
+
}
|
|
7036
|
+
/** Whether a generated expression actually references a converted source attr. */
|
|
7037
|
+
async function generatedColumnDependsOn(db, attributeDefaultOid, tableOid, sourceAttributeNumbers) {
|
|
7038
|
+
const { rows } = await db.query(`SELECT EXISTS (
|
|
7039
|
+
SELECT 1 FROM pg_depend dep
|
|
7040
|
+
WHERE dep.classid = 'pg_attrdef'::regclass
|
|
7041
|
+
AND dep.objid = ${quoteLiteral(attributeDefaultOid)}::oid
|
|
7042
|
+
AND dep.refclassid = 'pg_class'::regclass
|
|
7043
|
+
AND dep.refobjid = ${quoteLiteral(tableOid)}::oid
|
|
7044
|
+
AND dep.refobjsubid IN (${sourceAttributeNumbers.join(", ")})
|
|
7045
|
+
) AS depends_on_converted_column`);
|
|
7046
|
+
return Boolean(rows[0]?.depends_on_converted_column);
|
|
7047
|
+
}
|
|
7048
|
+
async function snapshotBridgeIndexes(db, table, column) {
|
|
7049
|
+
const { rows } = await db.query(`SELECT index_rel.oid::text AS oid, index_ns.nspname AS index_schema,
|
|
7050
|
+
index_rel.relname AS index_name, pg_get_indexdef(index_rel.oid) AS definition,
|
|
7051
|
+
obj_description(index_rel.oid, 'pg_class') AS comment,
|
|
7052
|
+
idx.indnkeyatts AS key_count, idx.indpred IS NOT NULL AS partial,
|
|
7053
|
+
idx.indexprs IS NOT NULL AS expression_index, idx.indisvalid AS valid,
|
|
7054
|
+
idx.indisready AS ready, idx.indisclustered AS clustered,
|
|
7055
|
+
idx.indisreplident AS replica_identity,
|
|
7056
|
+
index_rel.reltablespace <> 0 AS nondefault_tablespace,
|
|
7057
|
+
am.amname AS method
|
|
7058
|
+
FROM pg_index idx
|
|
7059
|
+
JOIN pg_class table_rel ON table_rel.oid = idx.indrelid
|
|
7060
|
+
JOIN pg_namespace ns ON ns.oid = table_rel.relnamespace
|
|
7061
|
+
JOIN pg_class index_rel ON index_rel.oid = idx.indexrelid
|
|
7062
|
+
JOIN pg_namespace index_ns ON index_ns.oid = index_rel.relnamespace
|
|
7063
|
+
JOIN pg_am am ON am.oid = index_rel.relam
|
|
7064
|
+
JOIN pg_attribute attr ON attr.attrelid = table_rel.oid AND attr.attnum = ANY(idx.indkey)
|
|
7065
|
+
WHERE ns.nspname = 'public' AND table_rel.relname = ${quoteLiteral(table)}
|
|
7066
|
+
AND attr.attname = ${quoteLiteral(column)}
|
|
7067
|
+
ORDER BY index_ns.nspname, index_rel.relname, index_rel.oid`);
|
|
7068
|
+
return rows.map((row) => {
|
|
7069
|
+
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.`);
|
|
7070
|
+
return {
|
|
7071
|
+
oid: String(row.oid),
|
|
7072
|
+
schema: String(row.index_schema),
|
|
7073
|
+
name: String(row.index_name),
|
|
7074
|
+
definition: String(row.definition),
|
|
7075
|
+
comment: row.comment == null ? null : String(row.comment),
|
|
7076
|
+
clustered: Boolean(row.clustered),
|
|
7077
|
+
replicaIdentity: Boolean(row.replica_identity)
|
|
7078
|
+
};
|
|
7079
|
+
});
|
|
7080
|
+
}
|
|
7081
|
+
async function snapshotForeignKeys(db, columns, bridges) {
|
|
7082
|
+
const converted = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
|
|
7083
|
+
const bridgeColumns = new Set(bridges.map((bridge) => declaredUuidKey(bridge.table, bridge.column)));
|
|
7084
|
+
const { rows } = await db.query(`SELECT con.oid, child.relname AS child_table, con.conname AS name,
|
|
7085
|
+
parent.relname AS parent_table, con.convalidated AS validated,
|
|
7086
|
+
pg_get_constraintdef(con.oid) AS definition,
|
|
7087
|
+
obj_description(con.oid, 'pg_constraint') AS comment,
|
|
7088
|
+
EXISTS (
|
|
7089
|
+
SELECT 1 FROM pg_trigger trigger
|
|
7090
|
+
WHERE trigger.tgconstraint = con.oid
|
|
7091
|
+
AND trigger.tgenabled <> 'O'
|
|
7092
|
+
) AS nondefault_trigger_mode,
|
|
7093
|
+
array_length(con.conkey, 1) AS child_keys,
|
|
7094
|
+
array_length(con.confkey, 1) AS parent_keys,
|
|
7095
|
+
child_attr.attname AS child_column, parent_attr.attname AS parent_column
|
|
7096
|
+
FROM pg_constraint con
|
|
7097
|
+
JOIN pg_class child ON child.oid = con.conrelid
|
|
7098
|
+
JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
|
|
7099
|
+
JOIN pg_class parent ON parent.oid = con.confrelid
|
|
7100
|
+
JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
|
|
7101
|
+
JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
|
|
7102
|
+
JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
|
|
7103
|
+
JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = parent_key.attnum
|
|
7104
|
+
WHERE con.contype = 'f' AND child_ns.nspname = 'public'`);
|
|
7105
|
+
const participating = [];
|
|
7106
|
+
for (const row of rows) {
|
|
7107
|
+
const child = declaredUuidKey(String(row.child_table), String(row.child_column));
|
|
7108
|
+
const parent = declaredUuidKey(String(row.parent_table), String(row.parent_column));
|
|
7109
|
+
if (!converted.has(child) && !converted.has(parent) && !bridgeColumns.has(child) && !bridgeColumns.has(parent)) continue;
|
|
7110
|
+
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.`);
|
|
7111
|
+
const bridgeEdge = bridgeColumns.has(child) || bridgeColumns.has(parent);
|
|
7112
|
+
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.`);
|
|
7113
|
+
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.`);
|
|
7114
|
+
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.`);
|
|
7115
|
+
participating.push({
|
|
7116
|
+
oid: String(row.oid),
|
|
7117
|
+
table: String(row.child_table),
|
|
7118
|
+
name: String(row.name),
|
|
7119
|
+
definition: String(row.definition),
|
|
7120
|
+
validated: Boolean(row.validated),
|
|
7121
|
+
comment: row.comment == null ? null : String(row.comment)
|
|
7122
|
+
});
|
|
7123
|
+
}
|
|
7124
|
+
return participating.sort((a, b) => `${a.table}.${a.name}`.localeCompare(`${b.table}.${b.name}`));
|
|
7125
|
+
}
|
|
7126
|
+
/**
|
|
7127
|
+
* A bridge column is dropped and rebuilt. pg_depend is the authoritative
|
|
7128
|
+
* catalog for every object which would be removed with it, including objects
|
|
7129
|
+
* whose definition does not expose the column name (expression indexes,
|
|
7130
|
+
* predicates, and extended statistics). Keep only dependencies we snapshot
|
|
7131
|
+
* and recreate exactly; fail closed for every other catalog object.
|
|
7132
|
+
*/
|
|
7133
|
+
async function assertSupportedBridgeDependencies(db, bridges, foreignKeys) {
|
|
7134
|
+
const foreignKeyOids = new Set(foreignKeys.map((foreignKey) => foreignKey.oid));
|
|
7135
|
+
for (const bridge of bridges) {
|
|
7136
|
+
const indexOids = new Set(bridge.indexDefinitions.map((index) => index.oid));
|
|
7137
|
+
const { rows } = await db.query(`SELECT dep.classid::regclass::text AS class_name,
|
|
7138
|
+
dep.objid::text AS object_oid,
|
|
7139
|
+
dep.objsubid AS object_subid,
|
|
7140
|
+
dep.deptype,
|
|
7141
|
+
coalesce(obj_class.relname, con.conname, stat.stxname, '') AS object_name
|
|
7142
|
+
FROM pg_depend dep
|
|
7143
|
+
LEFT JOIN pg_class obj_class
|
|
7144
|
+
ON dep.classid = 'pg_class'::regclass AND obj_class.oid = dep.objid
|
|
7145
|
+
LEFT JOIN pg_constraint con
|
|
7146
|
+
ON dep.classid = 'pg_constraint'::regclass AND con.oid = dep.objid
|
|
7147
|
+
LEFT JOIN pg_statistic_ext stat
|
|
7148
|
+
ON dep.classid = 'pg_statistic_ext'::regclass AND stat.oid = dep.objid
|
|
7149
|
+
WHERE dep.refclassid = 'pg_class'::regclass
|
|
7150
|
+
AND dep.refobjid = ${quoteLiteral(bridge.tableOid)}::oid
|
|
7151
|
+
AND dep.refobjsubid = ${bridge.attributeNumber}`);
|
|
7152
|
+
for (const row of rows) {
|
|
7153
|
+
const className = String(row.class_name);
|
|
7154
|
+
const objectOid = String(row.object_oid);
|
|
7155
|
+
const knownAttributeDefinition = className === "pg_attrdef" && objectOid === bridge.attributeDefaultOid;
|
|
7156
|
+
const knownIndex = className === "pg_class" && indexOids.has(objectOid);
|
|
7157
|
+
const knownForeignKey = className === "pg_constraint" && foreignKeyOids.has(objectOid);
|
|
7158
|
+
if (knownAttributeDefinition || knownIndex || knownForeignKey) continue;
|
|
7159
|
+
const name = row.object_name ? ` ${String(row.object_name)}` : "";
|
|
7160
|
+
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.`);
|
|
7161
|
+
}
|
|
7162
|
+
}
|
|
7163
|
+
}
|
|
6789
7164
|
/**
|
|
6790
7165
|
* Apply the R3 rename backfills: copy `old → new` where `new` is still empty,
|
|
6791
7166
|
* then drop `old`. Idempotent (a missing source column is skipped).
|
|
@@ -6802,7 +7177,7 @@ async function applyRenameBackfills(db, isPostgres, renameSpecs, dryRun, { ownTr
|
|
|
6802
7177
|
if (useOwnTxn) await db.query("BEGIN");
|
|
6803
7178
|
try {
|
|
6804
7179
|
for (const spec of renameSpecs) {
|
|
6805
|
-
const t = quoteIdentifier(spec.table);
|
|
7180
|
+
const t = isPostgres ? pgTable(spec.table) : quoteIdentifier(spec.table);
|
|
6806
7181
|
const from = quoteIdentifier(spec.from);
|
|
6807
7182
|
const to = quoteIdentifier(spec.to);
|
|
6808
7183
|
if (!await columnExists(db, isPostgres, spec.table, spec.from)) {
|
|
@@ -7904,14 +8279,19 @@ async function readAgentSurfaceReport(cwd) {
|
|
|
7904
8279
|
name: String(playbook.key),
|
|
7905
8280
|
detail: `${playbook.steps.length} step(s) · ${playbook.planes.join("+")} · ${playbook.sourceFile}`
|
|
7906
8281
|
})),
|
|
7907
|
-
notStatic: (surface?.diagnostics ?? []).
|
|
7908
|
-
|
|
7909
|
-
message: String(diagnostic.message)
|
|
7910
|
-
}))
|
|
8282
|
+
notStatic: (surface?.diagnostics ?? []).filter((diagnostic) => diagnostic.code !== "tool-name-collision").map(describeAgentSurfaceDiagnostic),
|
|
8283
|
+
toolNameCollisions: (surface?.diagnostics ?? []).filter((diagnostic) => diagnostic.code === "tool-name-collision").map(describeAgentSurfaceDiagnostic)
|
|
7911
8284
|
};
|
|
7912
8285
|
} catch {}
|
|
7913
8286
|
}
|
|
7914
8287
|
}
|
|
8288
|
+
/** Where a diagnostic points, and what it says, in one printable pair. */
|
|
8289
|
+
function describeAgentSurfaceDiagnostic(diagnostic) {
|
|
8290
|
+
return {
|
|
8291
|
+
sourceFile: diagnostic.line ? `${diagnostic.sourceFile}:${diagnostic.line}` : String(diagnostic.sourceFile),
|
|
8292
|
+
message: String(diagnostic.message)
|
|
8293
|
+
};
|
|
8294
|
+
}
|
|
7915
8295
|
/** Render the surface report as the lines `smrt doctor` prints. */
|
|
7916
8296
|
function renderAgentSurfaceReport(report) {
|
|
7917
8297
|
if (!report) return [" ⚠️ No knowledge artifact found — run a build to emit the agent surface"];
|
|
@@ -7927,6 +8307,10 @@ function renderAgentSurfaceReport(report) {
|
|
|
7927
8307
|
lines.push(` ⚠️ Not statically emittable: ${report.notStatic.length}`);
|
|
7928
8308
|
for (const entry of report.notStatic) lines.push(` - ${entry.sourceFile}: ${entry.message}`);
|
|
7929
8309
|
}
|
|
8310
|
+
if (report.toolNameCollisions.length > 0) {
|
|
8311
|
+
lines.push(` ⚠️ Tool name also claimed: ${report.toolNameCollisions.length}`);
|
|
8312
|
+
for (const entry of report.toolNameCollisions) lines.push(` - ${entry.sourceFile}: ${entry.message}`);
|
|
8313
|
+
}
|
|
7930
8314
|
return lines;
|
|
7931
8315
|
}
|
|
7932
8316
|
/**
|
|
@@ -9265,6 +9649,7 @@ export default testManifest;
|
|
|
9265
9649
|
if (agentSurfaceReport) {
|
|
9266
9650
|
check(`${agentSurfaceReport.modelTools.length + agentSurfaceReport.intents.length + agentSurfaceReport.playbooks.length} agent-addressable operation(s) reported`, true);
|
|
9267
9651
|
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`);
|
|
9652
|
+
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`);
|
|
9268
9653
|
} else check("Agent surface reported", false, void 0, "No knowledge artifact — run a build first");
|
|
9269
9654
|
console.log();
|
|
9270
9655
|
console.log("🏃 Runtime\n");
|
|
@@ -9343,6 +9728,7 @@ export default testManifest;
|
|
|
9343
9728
|
"db:generate": dbGenerateCommand,
|
|
9344
9729
|
"db:migrate-uuid": dbMigrateUuidCommand,
|
|
9345
9730
|
"db:migrate-int8": dbMigrateInt8Command,
|
|
9731
|
+
"db:migrate-agent-schedule-slugs": dbMigrateAgentScheduleSlugsCommand,
|
|
9346
9732
|
"db:drop-framework-base-tables": dbDropFrameworkBaseTablesCommand,
|
|
9347
9733
|
"db:prune": dbPruneCommand,
|
|
9348
9734
|
"db:permissions": dbPermissionsCommand,
|
package/dist/index.js
CHANGED
|
@@ -49,63 +49,63 @@ var _playgroundCommands = null;
|
|
|
49
49
|
var _workbenchCommands = null;
|
|
50
50
|
async function getGnodeCommands() {
|
|
51
51
|
if (!_gnodeCommands) {
|
|
52
|
-
const { gnodeCommands } = await import("./commands-
|
|
52
|
+
const { gnodeCommands } = await import("./commands-BiXRxejk.js");
|
|
53
53
|
_gnodeCommands = gnodeCommands;
|
|
54
54
|
}
|
|
55
55
|
return _gnodeCommands;
|
|
56
56
|
}
|
|
57
57
|
async function getGitCommands() {
|
|
58
58
|
if (!_gitCommands) {
|
|
59
|
-
const { gitCommands } = await import("./commands-
|
|
59
|
+
const { gitCommands } = await import("./commands-BiXRxejk.js");
|
|
60
60
|
_gitCommands = gitCommands;
|
|
61
61
|
}
|
|
62
62
|
return _gitCommands;
|
|
63
63
|
}
|
|
64
64
|
async function getGenerateCommands() {
|
|
65
65
|
if (!_generateCommands) {
|
|
66
|
-
const { generateCommands } = await import("./commands-
|
|
66
|
+
const { generateCommands } = await import("./commands-BiXRxejk.js");
|
|
67
67
|
_generateCommands = generateCommands;
|
|
68
68
|
}
|
|
69
69
|
return _generateCommands;
|
|
70
70
|
}
|
|
71
71
|
async function getInitCommands() {
|
|
72
72
|
if (!_initCommands) {
|
|
73
|
-
const { initCommands } = await import("./commands-
|
|
73
|
+
const { initCommands } = await import("./commands-BiXRxejk.js");
|
|
74
74
|
_initCommands = initCommands;
|
|
75
75
|
}
|
|
76
76
|
return _initCommands;
|
|
77
77
|
}
|
|
78
78
|
async function getUtilityCommands() {
|
|
79
79
|
if (!_utilityCommands) {
|
|
80
|
-
const { utilityCommands } = await import("./commands-
|
|
80
|
+
const { utilityCommands } = await import("./commands-BiXRxejk.js");
|
|
81
81
|
_utilityCommands = utilityCommands;
|
|
82
82
|
}
|
|
83
83
|
return _utilityCommands;
|
|
84
84
|
}
|
|
85
85
|
async function getDispatchCommands() {
|
|
86
86
|
if (!_dispatchCommands) {
|
|
87
|
-
const { dispatchCommands } = await import("./commands-
|
|
87
|
+
const { dispatchCommands } = await import("./commands-BiXRxejk.js");
|
|
88
88
|
_dispatchCommands = dispatchCommands;
|
|
89
89
|
}
|
|
90
90
|
return _dispatchCommands;
|
|
91
91
|
}
|
|
92
92
|
async function getDocsCommands() {
|
|
93
93
|
if (!_docsCommands) {
|
|
94
|
-
const { docsCommands } = await import("./commands-
|
|
94
|
+
const { docsCommands } = await import("./commands-BiXRxejk.js");
|
|
95
95
|
_docsCommands = docsCommands;
|
|
96
96
|
}
|
|
97
97
|
return _docsCommands;
|
|
98
98
|
}
|
|
99
99
|
async function getPlaygroundCommands() {
|
|
100
100
|
if (!_playgroundCommands) {
|
|
101
|
-
const { playgroundCommands } = await import("./commands-
|
|
101
|
+
const { playgroundCommands } = await import("./commands-BiXRxejk.js");
|
|
102
102
|
_playgroundCommands = playgroundCommands;
|
|
103
103
|
}
|
|
104
104
|
return _playgroundCommands;
|
|
105
105
|
}
|
|
106
106
|
async function getWorkbenchCommands() {
|
|
107
107
|
if (!_workbenchCommands) {
|
|
108
|
-
const { workbenchCommands } = await import("./commands-
|
|
108
|
+
const { workbenchCommands } = await import("./commands-BiXRxejk.js");
|
|
109
109
|
_workbenchCommands = workbenchCommands;
|
|
110
110
|
}
|
|
111
111
|
return _workbenchCommands;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.1",
|
|
4
4
|
"description": "Developer CLI for SMRT framework - introspection, testing, and project management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,20 +24,20 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@happyvertical/ai": "^0.89.
|
|
28
|
-
"@happyvertical/files": "^0.89.
|
|
29
|
-
"@happyvertical/logger": "^0.89.
|
|
30
|
-
"@happyvertical/
|
|
31
|
-
"@happyvertical/
|
|
27
|
+
"@happyvertical/ai": "^0.89.6",
|
|
28
|
+
"@happyvertical/files": "^0.89.6",
|
|
29
|
+
"@happyvertical/logger": "^0.89.6",
|
|
30
|
+
"@happyvertical/smrt-agents": "0.47.1",
|
|
31
|
+
"@happyvertical/smrt-config": "0.47.1",
|
|
32
|
+
"@happyvertical/smrt-core": "0.47.1",
|
|
33
|
+
"@happyvertical/smrt-dev-mcp": "0.47.1",
|
|
34
|
+
"@happyvertical/smrt-playground": "0.47.1",
|
|
35
|
+
"@happyvertical/smrt-types": "0.47.1",
|
|
36
|
+
"@happyvertical/sql": "^0.89.6",
|
|
37
|
+
"@happyvertical/utils": "^0.89.6",
|
|
32
38
|
"acorn": "^8.17.0",
|
|
33
39
|
"fast-glob": "3.3.3",
|
|
34
|
-
"tar": "^7.5.19"
|
|
35
|
-
"@happyvertical/smrt-core": "0.46.0",
|
|
36
|
-
"@happyvertical/smrt-playground": "0.46.0",
|
|
37
|
-
"@happyvertical/smrt-agents": "0.46.0",
|
|
38
|
-
"@happyvertical/smrt-types": "0.46.0",
|
|
39
|
-
"@happyvertical/smrt-config": "0.46.0",
|
|
40
|
-
"@happyvertical/smrt-dev-mcp": "0.46.0"
|
|
40
|
+
"tar": "^7.5.19"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "24.13.2",
|