@happyvertical/smrt-cli 0.47.1 → 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-BiXRxejk.js → commands-uHfUPryV.js} +441 -22
- package/dist/index.js +9 -9
- package/package.json +9 -8
package/AGENTS.md
CHANGED
|
@@ -10,6 +10,7 @@ smrt doctor # Umbrella diagnostics; can verify a generation sna
|
|
|
10
10
|
smrt doctor --db # Add the live-schema parity section (see below)
|
|
11
11
|
smrt db:status # Pending schema changes + failed migration classification
|
|
12
12
|
smrt db:status --parity # Same, plus live-schema parity (see below)
|
|
13
|
+
smrt db:orphans # agents/db-orphans.md
|
|
13
14
|
smrt db:migrate # Apply migrations
|
|
14
15
|
smrt db:migrate --postgres-safe # PostgreSQL concurrent-index mode (see below)
|
|
15
16
|
smrt db:migrate --force-migration <exact-id> [--force-migration <exact-id>...] # Force exact generated migrations in one atomic batch
|
|
@@ -19,7 +20,7 @@ smrt db:drop-framework-base-tables # One-time drop of the five #2644-orphaned fr
|
|
|
19
20
|
smrt db:drop-framework-base-tables --dry-run # Print the drop plan without executing
|
|
20
21
|
smrt db:diff # Show schema differences without generating migration files
|
|
21
22
|
smrt db:rollback # Roll back migrations by executing their recorded DOWN
|
|
22
|
-
smrt db:rollback --mark-only # Record-only flip; schema
|
|
23
|
+
smrt db:rollback --mark-only # Record-only flip; schema untouched
|
|
23
24
|
smrt db:prune # Prune framework system tables to their retention windows
|
|
24
25
|
smrt db:prune --dry-run # Same predicates, counted rather than deleted
|
|
25
26
|
smrt docs:agents # Generate .agents/smrt-framework.md
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# `db:orphans` — per-foreign-key orphan count report (#2753)
|
|
2
|
+
|
|
3
|
+
`db:migrate`'s orphan probe already gates `ADD CONSTRAINT` with a `LIMIT 1`
|
|
4
|
+
existence check and prints a suggested repair (see the PostgreSQL section of
|
|
5
|
+
`packages/cli/AGENTS.md`); it cannot say *how many* orphans exist or across
|
|
6
|
+
which relationships. `db:orphans` runs core's `collectForeignKeyOrphanCounts()`,
|
|
7
|
+
which probes every manifest-declared foreign key from
|
|
8
|
+
`ObjectRegistry.getAllSchemasAsDefinitions()` as a `COUNT(*)` aggregate —
|
|
9
|
+
reusing `renderForeignKeyOrphanDetector()`'s exact FROM/JOIN/WHERE predicate
|
|
10
|
+
via its `countOnly` option, never a second hand-written copy — and reports
|
|
11
|
+
child/parent table and column, the live orphan count, and whether the child
|
|
12
|
+
column is `NOT NULL` (so the two dispositions the repair-SQL builder already
|
|
13
|
+
distinguishes — manual repair vs. an executable null-out — are visible up
|
|
14
|
+
front, without hand-writing the same SQL against the database). Results print
|
|
15
|
+
sorted by count descending. A relationship whose child or parent table does
|
|
16
|
+
not exist live is skipped and listed separately rather than failing the whole
|
|
17
|
+
report.
|
|
18
|
+
|
|
19
|
+
Strictly read-only and diagnostic: it never repairs anything, and the orphan
|
|
20
|
+
counts themselves never affect the exit code — a database full of orphans
|
|
21
|
+
still exits 0, matching the existing "no foreign-key orphan repair, by
|
|
22
|
+
design" boundary. It exits non-zero only on a configuration or runtime error
|
|
23
|
+
(no database configured, or the report itself could not run). `--json` emits
|
|
24
|
+
the full report for scripting; `--verbose` also lists foreign keys with zero
|
|
25
|
+
orphans.
|
|
26
|
+
|
|
27
|
+
`ForeignKeyOrphanSkipped.kind` distinguishes a benign `missing_table` skip
|
|
28
|
+
(a manifest table not yet migrated) from `probe_failed` (the `COUNT(*)`
|
|
29
|
+
query itself errored — permissions, a malformed live column, a genuine SQL
|
|
30
|
+
failure). `smrt db:orphans` marks a `probe_failed` skip `[PROBE FAILED]` in
|
|
31
|
+
its Skipped section and never reports the "No orphan rows found" clean
|
|
32
|
+
summary while any probe has failed, since an incomplete scan is not the same
|
|
33
|
+
claim as a clean one.
|
|
34
|
+
|
|
35
|
+
`db:status` runs the same probe unconditionally (not gated behind `--parity`,
|
|
36
|
+
and independent of whether the configured adapter supports
|
|
37
|
+
`getTableSchema` — the probe only needs `db.query()`) and, when any manifest
|
|
38
|
+
foreign key has live orphans, prints one compact line per affected foreign
|
|
39
|
+
key plus a pointer to `smrt db:orphans` for the full report. A `probe_failed`
|
|
40
|
+
skip surfaces as its own distinct warning (`status.orphanProbeFailures`)
|
|
41
|
+
rather than being read as "no orphans found"; `missing_table` skips stay
|
|
42
|
+
silent in `db:status` (benign, and already visible in full via
|
|
43
|
+
`smrt db:orphans`). Never a new `SchemaDiff` finding kind, and never
|
|
44
|
+
touching `db:status`'s exit code. A total probe failure (the collector call
|
|
45
|
+
itself throws, e.g. it cannot list live tables at all) is reported as
|
|
46
|
+
"Foreign-key orphan check unavailable" rather than failing `db:status`.
|
|
@@ -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) {
|
|
@@ -6525,7 +6701,7 @@ var dbMigrateInt8Command = {
|
|
|
6525
6701
|
* drop the old column.
|
|
6526
6702
|
*
|
|
6527
6703
|
* 2. **TEXT → native uuid conversion**. Most existing `id`/FK columns already
|
|
6528
|
-
* hold
|
|
6704
|
+
* hold UUID-shaped strings in TEXT columns and can be promoted to native
|
|
6529
6705
|
* `uuid`. Conversion is gated on TWO independent checks, BOTH of which must
|
|
6530
6706
|
* pass for a column to be converted:
|
|
6531
6707
|
*
|
|
@@ -6540,7 +6716,7 @@ var dbMigrateInt8Command = {
|
|
|
6540
6716
|
* Tables not present in the manifest (non-SMRT tables in the `public`
|
|
6541
6717
|
* schema) drop out automatically.
|
|
6542
6718
|
*
|
|
6543
|
-
* (b) **Data-shape** — every non-empty value is already
|
|
6719
|
+
* (b) **Data-shape** — every non-empty value is already UUID-shaped.
|
|
6544
6720
|
* A declared-UUID column that still holds genuine non-uuid values
|
|
6545
6721
|
* (legacy unhyphenated ids, partially-migrated data, …) is SKIPPED and
|
|
6546
6722
|
* reported so the operator can clean it before re-running.
|
|
@@ -6573,7 +6749,8 @@ var dbMigrateInt8Command = {
|
|
|
6573
6749
|
* smrt db:migrate-uuid --rename "parent_id:source_asset_id" --table assets
|
|
6574
6750
|
* smrt db:migrate-uuid --rename "parent_slug:parent_id" --table tags --skip-convert
|
|
6575
6751
|
*/
|
|
6576
|
-
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}$";
|
|
6577
6754
|
/**
|
|
6578
6755
|
* Build the set of schema-declared UUID columns from a manifest's
|
|
6579
6756
|
* `getAllSchemasAsDefinitions()` output.
|
|
@@ -6597,11 +6774,84 @@ function renamedSourceColumns(renameSpecs) {
|
|
|
6597
6774
|
return new Set(renameSpecs.map((spec) => declaredUuidKey(spec.table, spec.from)));
|
|
6598
6775
|
}
|
|
6599
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
|
+
}
|
|
6849
|
+
/**
|
|
6600
6850
|
* Decide which live TEXT columns to convert to native `uuid`.
|
|
6601
6851
|
*
|
|
6602
6852
|
* A column is converted ONLY if BOTH gates pass:
|
|
6603
6853
|
* 1. it is in the schema-declared-UUID set, AND
|
|
6604
|
-
* 2. all of its non-empty values are already
|
|
6854
|
+
* 2. all of its non-empty values are already UUID-shaped.
|
|
6605
6855
|
*
|
|
6606
6856
|
* Declared-UUID columns with dirty data are reported in `skipDirtyData`;
|
|
6607
6857
|
* undeclared columns (schema-intentional TEXT, or non-SMRT tables) are reported
|
|
@@ -6619,11 +6869,13 @@ function planUuidConversions(liveColumns, declaredUuid) {
|
|
|
6619
6869
|
});
|
|
6620
6870
|
continue;
|
|
6621
6871
|
}
|
|
6622
|
-
|
|
6872
|
+
const duplicateNormalized = col.duplicateNormalized ?? 0;
|
|
6873
|
+
if (col.nonUuid > 0 || duplicateNormalized > 0) {
|
|
6623
6874
|
skipDirtyData.push({
|
|
6624
6875
|
table: col.table,
|
|
6625
6876
|
column: col.column,
|
|
6626
|
-
nonUuid: col.nonUuid
|
|
6877
|
+
nonUuid: col.nonUuid,
|
|
6878
|
+
...duplicateNormalized > 0 ? { duplicateNormalized } : {}
|
|
6627
6879
|
});
|
|
6628
6880
|
continue;
|
|
6629
6881
|
}
|
|
@@ -6809,17 +7061,48 @@ async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun
|
|
|
6809
7061
|
const column = String(row.column_name);
|
|
6810
7062
|
if (excludedColumns.has(declaredUuidKey(table, column))) continue;
|
|
6811
7063
|
let nonUuid = 0;
|
|
7064
|
+
let duplicateNormalized = 0;
|
|
6812
7065
|
if (declaredUuid.has(declaredUuidKey(table, column))) {
|
|
6813
7066
|
const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
|
|
6814
7067
|
WHERE nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL
|
|
6815
7068
|
AND btrim(${quoteIdentifier(column)}) !~* '${UUID_RE}'`);
|
|
6816
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
|
+
}
|
|
6817
7099
|
}
|
|
6818
7100
|
liveColumns.push({
|
|
6819
7101
|
table,
|
|
6820
7102
|
column,
|
|
6821
7103
|
hasDefault: row.column_default != null,
|
|
6822
|
-
nonUuid
|
|
7104
|
+
nonUuid,
|
|
7105
|
+
duplicateNormalized
|
|
6823
7106
|
});
|
|
6824
7107
|
defaults.set(declaredUuidKey(table, column), row.column_default == null ? null : String(row.column_default));
|
|
6825
7108
|
}
|
|
@@ -6837,10 +7120,14 @@ async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun
|
|
|
6837
7120
|
AND btrim(${quoteIdentifier(spec.from)}) !~* '${UUID_RE}'`);
|
|
6838
7121
|
destination.nonUuid += Number(rows[0]?.n ?? 0);
|
|
6839
7122
|
}
|
|
6840
|
-
const
|
|
6841
|
-
|
|
6842
|
-
|
|
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)}`);
|
|
6843
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}`);
|
|
6844
7131
|
if (convert.length === 0) {
|
|
6845
7132
|
console.log("\nNothing to convert. Done.\n");
|
|
6846
7133
|
return;
|
|
@@ -7017,7 +7304,7 @@ async function snapshotGeneratedBridges(db, columns) {
|
|
|
7017
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.`);
|
|
7018
7305
|
const { rows: nonCanonical } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
|
|
7019
7306
|
WHERE ${quoteIdentifier(sourceColumn)} IS NOT NULL
|
|
7020
|
-
AND ${quoteIdentifier(sourceColumn)} !~ '${
|
|
7307
|
+
AND ${quoteIdentifier(sourceColumn)} !~ '${CANONICAL_UUID_RE}'`);
|
|
7021
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.`);
|
|
7022
7309
|
bridges.push({
|
|
7023
7310
|
tableOid: String(row.table_oid),
|
|
@@ -7078,9 +7365,139 @@ async function snapshotBridgeIndexes(db, table, column) {
|
|
|
7078
7365
|
};
|
|
7079
7366
|
});
|
|
7080
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
|
+
}
|
|
7081
7497
|
async function snapshotForeignKeys(db, columns, bridges) {
|
|
7082
7498
|
const converted = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
|
|
7083
7499
|
const bridgeColumns = new Set(bridges.map((bridge) => declaredUuidKey(bridge.table, bridge.column)));
|
|
7500
|
+
await assertNoCrossSchemaForeignKeyPartners(db, /* @__PURE__ */ new Set([...converted, ...bridgeColumns]));
|
|
7084
7501
|
const { rows } = await db.query(`SELECT con.oid, child.relname AS child_table, con.conname AS name,
|
|
7085
7502
|
parent.relname AS parent_table, con.convalidated AS validated,
|
|
7086
7503
|
pg_get_constraintdef(con.oid) AS definition,
|
|
@@ -7097,11 +7514,12 @@ async function snapshotForeignKeys(db, columns, bridges) {
|
|
|
7097
7514
|
JOIN pg_class child ON child.oid = con.conrelid
|
|
7098
7515
|
JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
|
|
7099
7516
|
JOIN pg_class parent ON parent.oid = con.confrelid
|
|
7517
|
+
JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
|
|
7100
7518
|
JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
|
|
7101
7519
|
JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
|
|
7102
7520
|
JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
|
|
7103
7521
|
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'`);
|
|
7522
|
+
WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND parent_ns.nspname = 'public'`);
|
|
7105
7523
|
const participating = [];
|
|
7106
7524
|
for (const row of rows) {
|
|
7107
7525
|
const child = declaredUuidKey(String(row.child_table), String(row.child_column));
|
|
@@ -9730,6 +10148,7 @@ export default testManifest;
|
|
|
9730
10148
|
"db:migrate-int8": dbMigrateInt8Command,
|
|
9731
10149
|
"db:migrate-agent-schedule-slugs": dbMigrateAgentScheduleSlugsCommand,
|
|
9732
10150
|
"db:drop-framework-base-tables": dbDropFrameworkBaseTablesCommand,
|
|
10151
|
+
"db:orphans": dbOrphansCommand,
|
|
9733
10152
|
"db:prune": dbPruneCommand,
|
|
9734
10153
|
"db:permissions": dbPermissionsCommand,
|
|
9735
10154
|
"config:export": configExportCommand,
|
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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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-uHfUPryV.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.47.
|
|
3
|
+
"version": "0.47.2",
|
|
4
4
|
"description": "Developer CLI for SMRT framework - introspection, testing, and project management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"scripts",
|
|
16
16
|
"README.md",
|
|
17
17
|
"CLAUDE.md",
|
|
18
|
-
"AGENTS.md"
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"agents"
|
|
19
20
|
],
|
|
20
21
|
"exports": {
|
|
21
22
|
".": {
|
|
@@ -27,12 +28,12 @@
|
|
|
27
28
|
"@happyvertical/ai": "^0.89.6",
|
|
28
29
|
"@happyvertical/files": "^0.89.6",
|
|
29
30
|
"@happyvertical/logger": "^0.89.6",
|
|
30
|
-
"@happyvertical/smrt-agents": "0.47.
|
|
31
|
-
"@happyvertical/smrt-config": "0.47.
|
|
32
|
-
"@happyvertical/smrt-core": "0.47.
|
|
33
|
-
"@happyvertical/smrt-dev-mcp": "0.47.
|
|
34
|
-
"@happyvertical/smrt-playground": "0.47.
|
|
35
|
-
"@happyvertical/smrt-types": "0.47.
|
|
31
|
+
"@happyvertical/smrt-agents": "0.47.2",
|
|
32
|
+
"@happyvertical/smrt-config": "0.47.2",
|
|
33
|
+
"@happyvertical/smrt-core": "0.47.2",
|
|
34
|
+
"@happyvertical/smrt-dev-mcp": "0.47.2",
|
|
35
|
+
"@happyvertical/smrt-playground": "0.47.2",
|
|
36
|
+
"@happyvertical/smrt-types": "0.47.2",
|
|
36
37
|
"@happyvertical/sql": "^0.89.6",
|
|
37
38
|
"@happyvertical/utils": "^0.89.6",
|
|
38
39
|
"acorn": "^8.17.0",
|