@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 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 deliberately untouched
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, not a manifest one: a class may
2089
- * declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
2090
- * default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
2091
- * resolves to, the live database needs a matching UNIQUE index or every upsert
2092
- * against that table either errors (PostgreSQL) or duplicates rows.
2212
+ * The conflict target is a registry property of a persisted object, not a
2213
+ * manifest one: an object may declare `conflictColumns` explicitly, inherit
2214
+ * the CTI `(slug, context)` default, or get the STI
2215
+ * `(slug, context, _meta_type)` triple. Collections share their item table,
2216
+ * but never persist through their own conflict target, so they must not add a
2217
+ * synthetic default target to the parity check. Every collected target needs a
2218
+ * matching UNIQUE index or its object upsert either errors (PostgreSQL) or
2219
+ * duplicates rows.
2093
2220
  */
2094
2221
  function collectRegistryConflictTargets() {
2095
2222
  const targets = {};
2096
2223
  for (const className of ObjectRegistry.getQualifiedClassNames()) {
2224
+ if (isCollectionRegistration(className)) continue;
2097
2225
  const tableName = ObjectRegistry.getTableName(className);
2098
2226
  if (!tableName) continue;
2099
2227
  const columns = ObjectRegistry.getConflictColumns(className);
@@ -2108,6 +2236,19 @@ function collectRegistryConflictTargets() {
2108
2236
  return targets;
2109
2237
  }
2110
2238
  /**
2239
+ * Matches core's collection classification, including a registered collection
2240
+ * subclass whose immediate parent is another collection class. Unknown
2241
+ * registrations stay included so a malformed registry cannot hide a real
2242
+ * persistence target from a fail-closed parity check.
2243
+ */
2244
+ function isCollectionRegistration(className) {
2245
+ for (const name of [className, ...ObjectRegistry.getInheritanceChain(className)]) {
2246
+ const registered = ObjectRegistry.getClass(name);
2247
+ if (registered && isSmrtCollectionExtendsName(registered.extends)) return true;
2248
+ }
2249
+ return false;
2250
+ }
2251
+ /**
2111
2252
  * Run the parity check against the configured database.
2112
2253
  *
2113
2254
  * Never throws for an operational problem: connection and introspection
@@ -2441,7 +2582,7 @@ function assertSchemaContract(report) {
2441
2582
  * - Pending migrations
2442
2583
  * - Drift detection warnings
2443
2584
  */
2444
- var CANONICAL_UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2585
+ var CANONICAL_UUID_RE$1 = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2445
2586
  function getSchemaColumn(schema, columnName) {
2446
2587
  const columns = schema?.columns;
2447
2588
  if (!columns) return null;
@@ -2465,7 +2606,7 @@ async function countNonUuidTenantIds(db) {
2465
2606
  const row = (await db.query(`SELECT count(*)::text AS count
2466
2607
  FROM tenants
2467
2608
  WHERE nullif(btrim(id), '') IS NOT NULL
2468
- AND btrim(id) !~* '${CANONICAL_UUID_RE}'`))?.rows?.[0] ?? {};
2609
+ AND btrim(id) !~* '${CANONICAL_UUID_RE$1}'`))?.rows?.[0] ?? {};
2469
2610
  return Number(row.count ?? row.n ?? 0);
2470
2611
  }
2471
2612
  async function checkTenantIdUuidPreconditions(input) {
@@ -2603,6 +2744,14 @@ function summarizeSchemaDiff(diff) {
2603
2744
  recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
2604
2745
  });
2605
2746
  break;
2747
+ case "rename_data_pending":
2748
+ if (change.advisory?.severity !== "warning") break;
2749
+ drift.push({
2750
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2751
+ type: "rename_data_pending",
2752
+ recommendation: change.advisory?.message ?? "A declared column is empty while an undeclared column of a compatible type holds data — data appears to still live in the old column after a field rename. Run `smrt db:diff` for the suggested backfill SQL."
2753
+ });
2754
+ break;
2606
2755
  }
2607
2756
  return drift;
2608
2757
  }
@@ -2733,7 +2882,10 @@ var dbStatusCommand = {
2733
2882
  failedMigrations: summarizeFailedMigrations(failed, null),
2734
2883
  schemaContract,
2735
2884
  parity: null,
2736
- parityError: null
2885
+ parityError: null,
2886
+ orphanedForeignKeys: [],
2887
+ orphanProbeFailures: [],
2888
+ orphansError: null
2737
2889
  };
2738
2890
  let diff = {
2739
2891
  added_tables: [],
@@ -2741,8 +2893,8 @@ var dbStatusCommand = {
2741
2893
  changes: [],
2742
2894
  has_changes: false
2743
2895
  };
2896
+ const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2744
2897
  if (typeof db.getTableSchema === "function") {
2745
- const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2746
2898
  diff = await new SchemaComparer(db).compare(manifestSchemas);
2747
2899
  status.drift = summarizeSchemaDiff(diff);
2748
2900
  status.notes = summarizeSchemaNotes(diff);
@@ -2765,6 +2917,13 @@ var dbStatusCommand = {
2765
2917
  status.parityError = error instanceof Error ? error.message : String(error);
2766
2918
  }
2767
2919
  } else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
2920
+ try {
2921
+ const orphanReport = await collectForeignKeyOrphanCounts(db, manifestSchemas, { engineHint: dbType });
2922
+ status.orphanedForeignKeys = affectedOrphanCounts(orphanReport);
2923
+ status.orphanProbeFailures = orphanReport.skipped.filter((skip) => skip.kind === "probe_failed");
2924
+ } catch (error) {
2925
+ status.orphansError = error instanceof Error ? error.message : String(error);
2926
+ }
2768
2927
  const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
2769
2928
  status.migrations.failed = {
2770
2929
  total: failedAssessments.length,
@@ -2828,6 +2987,23 @@ var dbStatusCommand = {
2828
2987
  }
2829
2988
  console.log();
2830
2989
  }
2990
+ if (status.orphansError) {
2991
+ console.log(`⚠️ Foreign-key orphan check unavailable: ${status.orphansError}`);
2992
+ console.log();
2993
+ } else {
2994
+ if (status.orphanedForeignKeys.length > 0) {
2995
+ console.log(`⚠️ Foreign-key orphans found (${status.orphanedForeignKeys.length}):`);
2996
+ for (const count of status.orphanedForeignKeys) console.log(` • ${formatOrphanCountLine(count)}`);
2997
+ console.log(" Run `smrt db:orphans` for the full report.");
2998
+ console.log();
2999
+ }
3000
+ if (status.orphanProbeFailures.length > 0) {
3001
+ console.log(`⚠️ ${status.orphanProbeFailures.length} foreign-key orphan probe(s) failed (not counted as clean):`);
3002
+ for (const failure of status.orphanProbeFailures) console.log(` • ${failure.childTable}.${failure.childColumn} -> ${failure.parentTable}.${failure.parentColumn}: ${failure.reason}`);
3003
+ console.log(" Run `smrt db:orphans` for details.");
3004
+ console.log();
3005
+ }
3006
+ }
2831
3007
  if (options.parity) {
2832
3008
  console.log("🗄️ Live Schema Parity:");
2833
3009
  if (status.parityError) {
@@ -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 canonical UUID strings in TEXT columns and can be promoted to native
6704
+ * hold UUID-shaped strings in TEXT columns and can be promoted to native
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 a canonical UUID.
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 canonical UUIDs.
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
- if (col.nonUuid > 0) {
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 { 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)`);
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)} !~ '${UUID_RE}'`);
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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-BiXRxejk.js");
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.1",
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.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",
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",