@happyvertical/smrt-cli 0.47.2 → 0.48.0

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
@@ -11,9 +11,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
13
  smrt db:orphans # agents/db-orphans.md
14
- smrt db:migrate # Apply migrations
14
+ smrt db:migrate # agents/type-drift.md
15
15
  smrt db:migrate --postgres-safe # PostgreSQL concurrent-index mode (see below)
16
16
  smrt db:migrate --force-migration <exact-id> [--force-migration <exact-id>...] # Force exact generated migrations in one atomic batch
17
+ smrt db:migrate --apply-unblocked / --null-orphans # agents/db-migrate-partial-apply.md
17
18
  smrt db:migrate-uuid # Convert schema-declared UUID text columns after data remap
18
19
  smrt db:migrate-int8 # Explicitly widen pre-#2373 int4 columns after preflight
19
20
  smrt db:drop-framework-base-tables # One-time drop of the five #2644-orphaned framework-base tables
@@ -51,7 +52,8 @@ cycles are created without the cyclic clauses first, then receive named
51
52
  constraints after both tables exist. When a same-package constraint is missing
52
53
  on an existing table, `db:migrate` probes the exact child/parent columns for
53
54
  orphans before `ADD ... NOT VALID` and `VALIDATE CONSTRAINT`; orphaned data or a
54
- failed probe stays manual with detector/repair SQL.
55
+ failed probe stays manual with detector/repair SQL by default
56
+ (agents/db-migrate-partial-apply.md).
55
57
 
56
58
  `db:migrate --dry-run` and deprecated `db:setup --dry-run` print the same
57
59
  engine-specific dependency plan used for execution, including exact table DDL
@@ -327,33 +329,33 @@ drop `includeDroppedTables` (default `false`, both call sites explicit —
327
329
  ## Architecture
328
330
 
329
331
  - **Lazy command loading**: commands loaded on-demand via dynamic import (~100ms overhead on first use)
330
- - **Manifest discovery**: auto-finds `.smrt/manifest.json` + scans `node_modules/@happyvertical/smrt-*`
331
- - **Aggregate manifest identity**: preload and schema discovery resolve package ownership per entry (`definition.packageName` before the container), so dependency objects retain their qualified registrations
332
+ - **Manifest discovery**: finds `.smrt/manifest.json` + scans `node_modules/@happyvertical/smrt-*`
333
+ - **Aggregate manifest identity**: preload/schema discovery resolve package ownership per entry (`definition.packageName` before the container), keeping dependency objects' qualified registrations
332
334
  - **Class loading order**: config.entryPoint → package.json exports['.'] → package.json main → `./dist/index.js`
333
- - **Object method exposure**: custom methods on SMRT objects auto-become CLI commands
335
+ - **Object method exposure**: custom SMRT object methods auto-become CLI commands
334
336
 
335
337
  ## Key Files
336
338
 
337
- - `src/cli-generator.ts` — core dispatcher, lazy command loading, class loading
339
+ - `src/cli-generator.ts` — core dispatcher, lazy command/class loading
338
340
  - `src/commands/` — individual command implementations
339
341
  - `src/loaders/` — class-loader, local-loader, npm-loader, git-loader, template-loader
340
342
  - `src/discovery/manifest-discovery.ts` — manifest auto-discovery
341
- - `src/commands/docs-claude.ts` — downstream AGENTS.md generation plus Claude compatibility alias; uses core knowledge discovery without object scanning
343
+ - `src/commands/docs-claude.ts` — downstream AGENTS.md generation + Claude alias; no object scanning
342
344
 
343
345
  ## Gotchas
344
346
 
345
- - **Test mode detection**: checks `NODE_ENV=test`, `VITEST=true`, `global.it`/`describe` — could conflict with other test runners
346
- - **External package load failures silenced**: one package failing doesn't prevent others from loading
347
+ - **Test mode detection**: checks `NODE_ENV=test`, `VITEST=true`, `global.it`/`describe` — may conflict with other test runners
348
+ - **External package load failures silenced**: one package failing doesn't block others
347
349
  - **Generation command names are hyphenated**: `generate-mcp` and `generate-types` have
348
- no colon alias. Only `generate-routes` and `generate-register` declare one, so
349
- `smrt generate:mcp` is an unknown command (#2279).
350
+ no colon alias. Only `generate-routes`/`generate-register` declare one, so
351
+ `smrt generate:mcp` is unknown (#2279).
350
352
  - **Subcommand `--version` beats the global flag**: `parseCliArgs` in
351
353
  `@happyvertical/utils` dispatches to the built-in `version` command for a
352
354
  `--version` token anywhere in argv. `parseCliCommandArgs` rescopes that token to
353
355
  the named subcommand (`smrt generate-mcp --version 0.1.0`) and only leaves it
354
356
  global when it appears before or without a subcommand (#2279). `--help` is
355
357
  deliberately untouched.
356
- - **Schema history nuance**: `db:status` / `db:history` should distinguish active live drift from superseded failed generated schema repairs instead of treating all failed rows as current blockers
358
+ - **Schema history nuance**: `db:status`/`db:history` should distinguish active live drift from superseded failed repairs, not treat every failed row as a current blocker
357
359
  - **Decorator check follows the Vite major**: doctor requires `oxc.decorator` in
358
360
  vite.config on Vite 8+ and accepts tsconfig `experimentalDecorators` only
359
361
  below it. The old unconditional tsconfig check flagged correct Vite 8 projects
@@ -0,0 +1,189 @@
1
+ # `db:migrate --apply-unblocked` and `--null-orphans` (#2748)
2
+
3
+ Both are opt-in and off by default; default `db:migrate` behavior is
4
+ unchanged either way. Implementation: `packages/cli/src/commands/
5
+ db-migrate-actions.ts` (pure partition/planning functions) and
6
+ `packages/cli/src/commands/utilities.ts`'s `db:migrate` handler (wiring,
7
+ I/O). Core side: `SchemaChange.orphanBlocked` / `.orphanNullable` /
8
+ `.engineUnsupported` (`packages/core/src/schema/types.ts`), set by the
9
+ FK advisory branches in `compareForeignKeys()`
10
+ (`packages/core/src/migrations/differ.ts`), and
11
+ `renderForeignKeyAddStatements()` (`packages/core/src/schema/
12
+ foreign-key-ddl.ts`).
13
+
14
+ ## `--apply-unblocked`: partial apply
15
+
16
+ Today's unflagged batch already excludes every individually blocked change
17
+ (a FK-with-orphans, a manual `type_upgrade`/`alter_column`) — those never
18
+ leave `SchemaDiff.changes` as executable SQL, so they never enter the atomic
19
+ tracker batch on their own. `--apply-unblocked` adds a second, opt-in
20
+ partition on top of that: it withholds not just the blocked changes
21
+ themselves but anything that *depends* on one, and applies everything else.
22
+
23
+ **Dependency rule.** `computeBlockedColumns()` reads every manual
24
+ intervention's blocked `table.column` identity, plus every report-only
25
+ `type_upgrade` **advisory** (the #2608 refused uuid convergence — the only
26
+ producer of that shape) — an advisory-only finding never enters
27
+ `manualInterventions` (it carries no SQL) but names a live column just as
28
+ concretely, and a dependent index/alter/drop on that column is exactly as
29
+ unsafe as one on a manual-intervention column (review finding, #2748: the
30
+ advisory bucket was previously excluded, so `--apply-unblocked` did not
31
+ honor its own stated guarantee for that class of blocked type upgrade).
32
+ `partitionUnblockedMigrations()` then withholds any `add_index` /
33
+ `add_foreign_key` / `alter_column` / `drop_column` that reads or writes one
34
+ of those columns:
35
+
36
+ - an index on a column whose type upgrade is blocked (manual intervention
37
+ or report-only advisory);
38
+ - a foreign key whose child *or* parent column is blocked (a parent column
39
+ mid-type-upgrade is just as unsafe to reference as the child);
40
+ - an `alter_column`/`drop_column` on the blocked column itself.
41
+
42
+ **A report-only `alter_column` advisory is deliberately excluded from that
43
+ second loop**, even though `SchemaAdvisory` allows the type. Unlike
44
+ `type_upgrade`, the only producer of an advisory-only `alter_column` is an
45
+ un-opted-into relaxation (`drop_default`/`drop_not_null` when
46
+ `--relax-columns` was not passed) — it says the live column is *stricter*
47
+ than the manifest, not that anything about the column blocks convergence,
48
+ and it reappears on every run regardless of `--apply-unblocked`. Blocking on
49
+ it reproduced the exact `engineUnsupported` defect for a different shape:
50
+ permanently withholding unrelated executable DDL on that column, worse than
51
+ omitting the flag (review finding, #2748, second pass). A genuinely blocked
52
+ `alter_column` — NOT NULL required with live NULLs and no default to
53
+ backfill — carries its refusal as comment-only SQL and already reaches
54
+ `manualInterventions` (keyed by `columnName`), so it was never affected.
55
+
56
+ `add_column` and an executable `type_upgrade` never depend on another
57
+ column's state — a new column add is self-contained, and a type upgrade is
58
+ the fix itself — so they always apply. Everything independent goes through
59
+ the exact same transaction/tracker path an unflagged run uses.
60
+
61
+ **An `add_foreign_key` blocked only because this engine cannot express the
62
+ constraint at all is not a column-state block.** SQLite (table rebuild
63
+ required) and DuckDB (no `ALTER TABLE ADD CONSTRAINT`) each carry
64
+ `engineUnsupported: true` on that advisory — distinct from a conflicting
65
+ constraint or an incompatible-type block, which genuinely do implicate the
66
+ column and stay blocking. `blockedColumnForAction()` excludes only the
67
+ `engineUnsupported` case: every real differ `add_foreign_key` advisory sets
68
+ `foreignKey`, so gating on its mere presence blocked this case too (review
69
+ finding, #2748) and, since no rerun on that engine ever resolves it, made
70
+ `--apply-unblocked` permanently withhold unrelated dependent DDL on that
71
+ column — strictly worse than omitting the flag.
72
+
73
+ **An orphan-blocked `add_foreign_key`'s block is a row-data condition, not
74
+ a column-state one — an `alter_column` relaxation on that same column is
75
+ its remediation, not something unsafe against it.** With `--relax-columns
76
+ --apply-unblocked`, a FK child column that is physically `NOT NULL` while
77
+ the manifest declares it nullable (with live orphan rows) stays
78
+ orphan-blocked — `--null-orphans` can't resolve a NOT NULL child. The
79
+ executable `DROP NOT NULL` relaxation for that same column is exactly what
80
+ makes the block eventually resolvable (the column becomes nullable, then a
81
+ later `--null-orphans` run can null the references). Gating that
82
+ relaxation on the orphan block (review finding, #2748) made the run
83
+ withhold it on every pass, so the column never became nullable, the
84
+ orphan block never cleared, and the batch failed forever — worse than
85
+ omitting the flag. `computeOrphanOnlyBlockedColumns()` identifies a
86
+ `table.column` whose *only* manual-intervention block is an orphan-blocked
87
+ FK on that column, and `partitionUnblockedMigrations()` excludes that key
88
+ from gating an `alter_column` specifically — an index, a different foreign
89
+ key, or a `drop_column` on that same orphan-blocked column still correctly
90
+ stays withheld.
91
+
92
+ Withheld items print under `🔒 Withheld — depends on a blocked change`, each
93
+ naming the `table.column` it depends on and the same reason text the manual
94
+ intervention above it carries. `--dry-run` shows the identical
95
+ applied/withheld partition (the partition is pure and runs before the
96
+ dry-run/apply branch splits). A rerun is a no-op for what already applied
97
+ and repeats the same withheld list until the underlying blocker is resolved
98
+ — nothing here is remembered between runs; the partition is recomputed from
99
+ the live diff every time.
100
+
101
+ ## `--null-orphans`: opt-in orphan-FK disposition
102
+
103
+ Targets one specific blocked-item shape: a foreign key `db:migrate` would
104
+ otherwise refuse only because live child rows don't match any parent row
105
+ (`SchemaChange.orphanBlocked`). `planOrphanDispositions()` splits manual
106
+ interventions into nullable (a disposition is possible) and not-nullable
107
+ (unconditional refusal) using `SchemaChange.orphanNullable` — never by
108
+ parsing `advisory.message` text.
109
+
110
+ For a nullable child column: the *exact* `UPDATE ... SET <column> = NULL
111
+ WHERE ...` the differ already generated for that relationship
112
+ (`advisory.suggestedSql[1]` — never re-derived, so the disposition can never
113
+ drift from what the differ decided the safe repair is) and the `ADD
114
+ CONSTRAINT ... NOT VALID` + `VALIDATE CONSTRAINT` pair from
115
+ `renderForeignKeyAddStatements()` (the same helper the differ's own safe-add
116
+ branch uses) are combined into **one** `add_foreign_key` migration action —
117
+ `sqlStatements: [repairSql, ...addStatements]` — and applied atomically
118
+ through the normal tracker path, not run as a separate direct query before
119
+ it (review finding, #2748: running the UPDATE outside the batch let a later
120
+ failure elsewhere in the same batch roll back the FK add while leaving the
121
+ just-nulled rows committed, silently splitting one promised disposition
122
+ into two). The resolved FK moves from `manualInterventions` into the
123
+ executable `migrations` bucket before the `--apply-unblocked` partition
124
+ runs, so a relationship this flag resolves is never a blocked column for
125
+ that dependency rule. Post-apply before/after counts are only printed once
126
+ the batch that carries this disposition has actually committed; on a full
127
+ rollback the generic atomic-failure message already covers it — nothing in
128
+ that batch, including this disposition, applied.
129
+
130
+ **The combined migration can still be withheld by `--apply-unblocked`
131
+ itself**, if the FK's *parent* column is separately blocked (e.g. a
132
+ report-only `type_upgrade` advisory) — resolving out of
133
+ `manualInterventions` only means this specific relationship's *child*-side
134
+ orphan block is gone, not that the dependency partition can't withhold it
135
+ for an unrelated reason. `filterUnresolvedOrphanDispositions()` drops a
136
+ pending post-apply report entry whose exact migration action ended up
137
+ withheld, matched by object identity (the same reference is pushed into
138
+ both `migrations` and the pending entry) rather than by table/column, so
139
+ the report never prints a `✓ ... resolved` line for a migration that never
140
+ executed (review finding, #2748) — that relationship is already covered by
141
+ the `🔒 Withheld` listing.
142
+
143
+ **"Committed" is checked per disposition, not for the whole batch.** In
144
+ PostgreSQL concurrent-index mode (`--postgres-safe` with
145
+ `migrations.postgres.useConcurrently`), the non-index batch — including
146
+ this disposition's combined migration — commits in its own transaction
147
+ before any deferred `CREATE INDEX CONCURRENTLY` migrations run separately
148
+ and non-transactionally; a later index build failure still increments
149
+ `errorCount` even though the non-index changes already committed. Gating
150
+ the report on `errorCount === 0` alone silently suppressed the resolution
151
+ line for a mutation that did commit (review finding, #2748). A batch-wide
152
+ proxy for "did the non-index phase commit" is not enough either: it can't
153
+ tell that apart from the non-index transaction itself rolling back (recall
154
+ finding, #2748) — both raise `errorCount`, but only one means this
155
+ disposition's own migration applied. The report instead re-derives each
156
+ pending disposition's synthetic migration name
157
+ (`getSyntheticMigrationNameForAction()`, the same helper that named it when
158
+ building the tracker batch) and checks that exact name's own `success` in
159
+ `tracker.applyAll()`'s per-migration results — the ground truth regardless
160
+ of what else in the batch failed — printing a caveat line only when some
161
+ other change in the batch failed but this disposition's own migration
162
+ still succeeded.
163
+
164
+ For a `NOT NULL` child column: refuse with the same "Manual repair required"
165
+ text `db:migrate` always prints for that case. Nulling is not a legal repair
166
+ there, and **this flag never deletes rows** either way — the only mutating
167
+ statement is the UPDATE that nulls references, never a DELETE.
168
+
169
+ `--dry-run` prints the exact UPDATE statement plus the live orphan count
170
+ (`orphanCountSql()` wraps the differ's read-only detector SELECT as a
171
+ `COUNT(*)`) before applying anything; a real run reports the count before
172
+ and after. Composes with `smrt db:orphans` (#2753, `packages/cli/agents/
173
+ db-orphans.md`) — same probe, same repair SQL, no duplicated logic.
174
+
175
+ **Fails closed on the count probe too** (review finding, #2748):
176
+ `countOrphanRows()` throws rather than swallowing a query failure into `0`.
177
+ An orphan-count probe that cannot run at all is exactly the "unexpected
178
+ shape" case this flag must report and withhold on — proceeding to null data
179
+ on an unverified count would violate the fail-closed requirement. A probe
180
+ failure withholds that one relationship's disposition (printed, left in
181
+ `manualInterventions`) without aborting the rest of the batch or the other
182
+ dispositions.
183
+
184
+ ## Fails closed on an unexpected shape
185
+
186
+ `planOrphanDispositions()` routes an orphan-blocked, nullable change to the
187
+ refusal bucket (never guesses SQL) if it is missing its `foreignKey`
188
+ definition or its `advisory.suggestedSql` pair — a differ-side contract
189
+ violation, not something this disposition should paper over.
@@ -0,0 +1,53 @@
1
+ # Float-width, timestamptz, and jsonb convergence (#2770, #2771, #2772)
2
+
3
+ ## Float-width drift (#2770)
4
+
5
+ `db:status`/`db:diff` report float-width drift (`real` vs `double precision`)
6
+ as `column_type_drift`, the same severity class as `legacy_integer_width`; a
7
+ live single-precision column backed by a double-precision declaration widens
8
+ automatically (lossless), but a double-precision live column backed by a
9
+ single-precision declaration stays advisory-only (narrowing loses precision).
10
+
11
+ ## text -> timestamptz convergence (#2771)
12
+
13
+ A `text` column on a table smrt itself creates, backed by a manifest
14
+ `TIMESTAMP` (-> `TIMESTAMPTZ` on PostgreSQL) declaration, converges instead of
15
+ staying blocked forever: `db:migrate` runs a server-side shape probe over
16
+ every non-null value first (`packages/core/src/schema/text-cast-probe.ts`)
17
+ and only plans the executable `ALTER COLUMN … TYPE timestamptz USING
18
+ col::timestamptz` when every value is confirmed to parse unambiguously. A
19
+ probe that finds an unparsable value fails closed with a masked sample
20
+ instead of running the cast — repair the value, then rerun; the migration is
21
+ a no-op once converged. This is independent of
22
+ `postgresTimestampMigration.legacyTimezone`
23
+ (`--postgres-timestamp-legacy-timezone=UTC`), which
24
+ stays reserved for ambiguous naive wall-clock strings the probe does not
25
+ accept. SQLite has no distinct storage for `text` vs `timestamptz`, so this
26
+ stays advisory-only there.
27
+
28
+ ## text -> jsonb convergence and uuid visibility (#2772)
29
+
30
+ A `text` column on a table smrt itself creates, backed by a manifest `JSON`
31
+ (-> `jsonb` on PostgreSQL) declaration, was previously invisible to
32
+ `db:status`/`db:diff` and had no `db:migrate` repair path (the #1335
33
+ text/json tolerance is directional: it stays silent for the reverse pairing —
34
+ a native `json`/`jsonb` live column backed by a text-convention manifest
35
+ field — but this direction now runs the same shared shape probe as #2771's
36
+ timestamptz path and converges the same way: a clean probe plans `ALTER
37
+ COLUMN … TYPE jsonb USING col::jsonb`; a dirty probe fails closed with a
38
+ masked sample. `db:status` also now reports an `info`-level finding for a
39
+ declared-`uuid` structural column still backed by live `text` (previously
40
+ silent by design), pointing at `db:migrate-uuid` — the tolerance itself is
41
+ unchanged, only its visibility. SQLite stays advisory-only for both.
42
+
43
+ ## Live-only column defaults on either conversion
44
+
45
+ PostgreSQL rejects `ALTER COLUMN … TYPE` outright whenever the column has
46
+ any existing default that can't auto-cast to the target type, so both
47
+ conversions above always `DROP DEFAULT` first when the *live* column has
48
+ one — restoring it afterward (`SET DEFAULT`) only when the *manifest* also
49
+ declares one. A live-only default (the manifest declares none) is the same
50
+ "relaxation" `db:status --parity`'s default-drift finding already gates
51
+ behind `relaxColumns`/`--relax-columns` elsewhere: without that opt-in, the
52
+ conversion stays a fail-closed advisory naming the blocking default instead
53
+ of executing.
@@ -22,8 +22,10 @@ import https from "node:https";
22
22
  import { homedir, tmpdir } from "node:os";
23
23
  import { extract } from "tar";
24
24
  import { importWorkspaceModule } from "@happyvertical/smrt-core/utils/import-workspace-module";
25
+ import { renderForeignKeyAddStatements } from "@happyvertical/smrt-core/schema";
25
26
  import { FRAMEWORK_BASE_TABLE_NAMES, buildIntegerWidthTableStatements, collectIntegerWidthTargets, dropFrameworkBaseTables, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, preflightIntegerWidthWidening, widenIntegerColumnsToBigInt } from "@happyvertical/smrt-core/migrations";
26
27
  import { buildArchitectureContext, buildKnowledgeIndex, buildReviewContext, checkKnowledgeFreshness, compactContextResult, diffKnowledgeIndex, renderFreshnessResult, renderKnowledgeIndexMarkdown } from "@happyvertical/smrt-dev-mcp/knowledge";
28
+ import { RUNTIME_TOOLS } from "@happyvertical/smrt-dev-mcp/runtime";
27
29
  //#region src/commands/db-command-utils.ts
28
30
  var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set([
29
31
  "access_token",
@@ -733,7 +735,11 @@ function partitionSchemaChanges(changes, getClassForTable) {
733
735
  className,
734
736
  sql: change.sql,
735
737
  ...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {},
736
- advisory: change.advisory
738
+ advisory: change.advisory,
739
+ ...change.foreignKey ? { foreignKey: change.foreignKey } : {},
740
+ ...change.orphanBlocked !== void 0 ? { orphanBlocked: change.orphanBlocked } : {},
741
+ ...change.orphanNullable !== void 0 ? { orphanNullable: change.orphanNullable } : {},
742
+ ...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {}
737
743
  };
738
744
  if (isAdvisoryOnlyChangeLike(change)) manualInterventions.push(action);
739
745
  else migrations.push(action);
@@ -861,6 +867,270 @@ function describeAdvisory(item) {
861
867
  default: return `${item.tableName}.${item.name}: ${item.alteration ?? "alter_column"}${item.actual ? ` (live: ${item.actual})` : ""}`;
862
868
  }
863
869
  }
870
+ /** `table.column` identity used to key a blocked column across actions. */
871
+ function blockedColumnKey(tableName, columnName) {
872
+ return `${tableName}.${columnName}`;
873
+ }
874
+ /**
875
+ * Column that a manual-intervention action blocks, if any. `type_mismatch`,
876
+ * a manual `type_upgrade`, and a manual `alter_column` each name exactly one
877
+ * live column via `mismatch.column`; a blocked `add_foreign_key` names its
878
+ * child column via `foreignKey.column` — unless the block reason is
879
+ * `engineUnsupported` (this engine cannot express `ALTER TABLE ADD
880
+ * CONSTRAINT` at all, e.g. SQLite/DuckDB): that says nothing about the
881
+ * column's own state, no rerun on this engine ever resolves it, and
882
+ * treating it as a blocked column would withhold unrelated dependent DDL
883
+ * on that column permanently (review finding, #2748). That case blocks no
884
+ * column identity and is excluded from dependency analysis.
885
+ */
886
+ function blockedColumnForAction(action) {
887
+ if (action.type === "add_foreign_key" && action.foreignKey && !action.engineUnsupported) return blockedColumnKey(action.tableName, action.foreignKey.column);
888
+ const column = action.mismatch?.column ?? action.columnName ?? action.column?.name;
889
+ return column ? blockedColumnKey(action.tableName, column) : void 0;
890
+ }
891
+ /**
892
+ * Every column a manual intervention blocks, keyed `table.column`, with the
893
+ * human-readable reason (the action's advisory message, or a description of
894
+ * the type mismatch) a dependent change is withheld for. `advisories`
895
+ * includes only report-only `type_upgrade` findings (the #2608 refused
896
+ * uuid convergence) — that shape is a genuine, permanent column-state block
897
+ * that names a column just as concretely as a manual intervention does, so
898
+ * omitting it let `--apply-unblocked` apply an index/alter/drop against a
899
+ * column whose type convergence is itself blocked (review finding, #2748).
900
+ *
901
+ * `alter_column` advisories are deliberately excluded even though the type
902
+ * exists on `SchemaAdvisory`: an advisory-only `alter_column` is produced
903
+ * only by an un-opted-into relaxation (`drop_default`/`drop_not_null` when
904
+ * `--relax-columns` was not passed) — it says the live column is *stricter*
905
+ * than the manifest, not that the column's state blocks anything, and it
906
+ * reappears on every run regardless of `--apply-unblocked`. Treating it as
907
+ * a blocked column reproduced the exact defect this function's
908
+ * `engineUnsupported` exclusion fixed for `add_foreign_key`: permanently
909
+ * withholding unrelated executable DDL on that column (review finding,
910
+ * #2748, second pass). A genuinely blocked `alter_column` (e.g. NOT NULL
911
+ * required with live NULLs and no default) carries executable-looking SQL
912
+ * as a comment and already reaches `manualInterventions` instead, where it
913
+ * is covered by the loop above via `columnName`.
914
+ */
915
+ function computeBlockedColumns(manualInterventions, advisories = []) {
916
+ const blocked = /* @__PURE__ */ new Map();
917
+ for (const action of manualInterventions) {
918
+ const key = blockedColumnForAction(action);
919
+ if (!key || blocked.has(key)) continue;
920
+ blocked.set(key, describeBlockedReason(action));
921
+ }
922
+ for (const advisory of advisories) {
923
+ if (advisory.type !== "type_upgrade") continue;
924
+ const key = blockedColumnKey(advisory.tableName, advisory.name);
925
+ if (blocked.has(key)) continue;
926
+ blocked.set(key, advisory.advisory.message ?? `${advisory.type} requires manual intervention`);
927
+ }
928
+ return blocked;
929
+ }
930
+ function describeBlockedReason(action) {
931
+ if (action.advisory?.message) return action.advisory.message;
932
+ if (action.mismatch) return `expected ${action.mismatch.expected}, found ${action.mismatch.actual}`;
933
+ return `${action.type} requires manual intervention`;
934
+ }
935
+ /**
936
+ * `table.column` keys whose only manual-intervention block is an
937
+ * orphan-blocked `add_foreign_key` on that same (child) column — a
938
+ * row-data condition (existing rows don't match a parent), not a
939
+ * column-state one. An un-opted-into `--relax-columns` relaxation on that
940
+ * column (`DROP NOT NULL`/`DROP DEFAULT`) is not unsafe against orphan
941
+ * rows; it is in fact the exact remediation that makes a NOT-NULL-child
942
+ * orphan block eventually resolvable by `--null-orphans` on a later run.
943
+ * Gating that `alter_column` on the orphan block made `--apply-unblocked
944
+ * --relax-columns` withhold it forever — the relaxation never applies, the
945
+ * live column never becomes nullable, the orphan block never clears, in a
946
+ * stable non-converging loop (review finding, #2748). Every other
947
+ * dependent shape on that column (an index, an unrelated foreign key, a
948
+ * `drop_column`) still correctly stays gated by the orphan block; this set
949
+ * narrows the exclusion to `alter_column` alone at the call site.
950
+ */
951
+ function computeOrphanOnlyBlockedColumns(manualInterventions) {
952
+ const orphanOnly = /* @__PURE__ */ new Set();
953
+ const otherwiseBlocked = /* @__PURE__ */ new Set();
954
+ for (const action of manualInterventions) {
955
+ const key = blockedColumnForAction(action);
956
+ if (!key) continue;
957
+ if (action.type === "add_foreign_key" && action.orphanBlocked) orphanOnly.add(key);
958
+ else otherwiseBlocked.add(key);
959
+ }
960
+ for (const key of otherwiseBlocked) orphanOnly.delete(key);
961
+ return orphanOnly;
962
+ }
963
+ /**
964
+ * Column(s) an executable migration action reads or writes, for dependency
965
+ * analysis against `computeBlockedColumns()`. An `add_index` depends on
966
+ * every indexed column; an `add_foreign_key` depends on its own child
967
+ * column AND the parent column it references (a parent whose type upgrade
968
+ * is blocked is just as unsafe to reference); `alter_column`/`drop_column`
969
+ * depend on the single column they touch. `add_column`/`type_upgrade`/
970
+ * `drop_index` never depend on another column's state — they are either the
971
+ * fix itself or fully self-contained.
972
+ */
973
+ function actionColumnDependencies(action) {
974
+ switch (action.type) {
975
+ case "add_index": return (action.index?.columns ?? []).map((columnName) => ({
976
+ tableName: action.tableName,
977
+ columnName
978
+ }));
979
+ case "add_foreign_key":
980
+ if (!action.foreignKey) return [];
981
+ return [{
982
+ tableName: action.tableName,
983
+ columnName: action.foreignKey.column
984
+ }, {
985
+ tableName: action.foreignKey.referencesTable,
986
+ columnName: action.foreignKey.referencesColumn
987
+ }];
988
+ case "alter_column":
989
+ case "drop_column": {
990
+ const columnName = action.columnName ?? action.column?.name;
991
+ return columnName ? [{
992
+ tableName: action.tableName,
993
+ columnName
994
+ }] : [];
995
+ }
996
+ default: return [];
997
+ }
998
+ }
999
+ /**
1000
+ * Partition executable migrations (the differ's "safe DDL" bucket) into
1001
+ * those independent of every blocked column and those that depend on one
1002
+ * (#2748's dependency rule): an index or foreign key on a column whose type
1003
+ * upgrade is blocked, or a foreign key whose orphan rows block it, stays
1004
+ * withheld; everything else is applied. Pure and order-preserving so a
1005
+ * dry-run preview and a real apply partition identically.
1006
+ *
1007
+ * A `drop_index` carries no column list of its own (a DB-side index being
1008
+ * removed; see `MigrationAction.indexName`'s doc comment), so it is never
1009
+ * itself a dependency target — EXCEPT when it is the drop half of the
1010
+ * #1165 shape-drift recreate pair (`drop_index` then `add_index` for the
1011
+ * SAME name, in that order, in the same batch). Withholding the `add_index`
1012
+ * half alone while letting its paired `drop_index` proceed would remove the
1013
+ * existing index/uniqueness enforcement with no replacement — reachable and
1014
+ * unsafe (review, #2748) — so a `drop_index` withholds together with any
1015
+ * `add_index` of the same name that this partition withheld.
1016
+ */
1017
+ function partitionUnblockedMigrations(migrations, blockedColumns, orphanOnlyBlockedColumns = /* @__PURE__ */ new Set()) {
1018
+ const directDependency = (action) => {
1019
+ const dependency = actionColumnDependencies(action).find((dep) => {
1020
+ const key = blockedColumnKey(dep.tableName, dep.columnName);
1021
+ if (!blockedColumns.has(key)) return false;
1022
+ if (action.type === "alter_column" && orphanOnlyBlockedColumns.has(key)) return false;
1023
+ return true;
1024
+ });
1025
+ if (!dependency) return void 0;
1026
+ const dependsOn = blockedColumnKey(dependency.tableName, dependency.columnName);
1027
+ return {
1028
+ dependsOn,
1029
+ reason: blockedColumns.get(dependsOn) ?? "depends on a blocked change"
1030
+ };
1031
+ };
1032
+ const withheldAddIndexDependency = /* @__PURE__ */ new Map();
1033
+ const direct = migrations.map((action) => {
1034
+ const dependency = directDependency(action);
1035
+ if (dependency && action.type === "add_index" && action.index?.name) withheldAddIndexDependency.set(action.index.name, dependency);
1036
+ return {
1037
+ action,
1038
+ dependency
1039
+ };
1040
+ });
1041
+ const applied = [];
1042
+ const withheld = [];
1043
+ for (const { action, dependency } of direct) {
1044
+ if (dependency) {
1045
+ withheld.push({
1046
+ action,
1047
+ ...dependency
1048
+ });
1049
+ continue;
1050
+ }
1051
+ const paired = action.type === "drop_index" && action.indexName ? withheldAddIndexDependency.get(action.indexName) : void 0;
1052
+ if (paired) {
1053
+ withheld.push({
1054
+ action,
1055
+ dependsOn: paired.dependsOn,
1056
+ reason: `paired with the withheld rebuild of index ${action.indexName} (${paired.reason})`
1057
+ });
1058
+ continue;
1059
+ }
1060
+ applied.push(action);
1061
+ }
1062
+ return {
1063
+ applied,
1064
+ withheld
1065
+ };
1066
+ }
1067
+ /**
1068
+ * Drop any pending post-apply report entry whose `action` reference was
1069
+ * withheld by `partitionUnblockedMigrations()` (review finding, #2748): a
1070
+ * `--null-orphans` combined null+add-FK migration can itself depend on a
1071
+ * separately blocked *parent* column and get withheld under
1072
+ * `--apply-unblocked` even though it already resolved out of
1073
+ * `manualInterventions`. Without this filter, the post-apply report printed
1074
+ * a `✓ ... resolved` line for a disposition that never executed — the same
1075
+ * relationship the `🔒 Withheld` listing (from `withheld`) already names.
1076
+ * Matches by object identity, not by table/column, since the caller pushed
1077
+ * the exact `MigrationAction` reference into both `migrations` and the
1078
+ * pending entry.
1079
+ */
1080
+ function filterUnresolvedOrphanDispositions(pending, withheld) {
1081
+ if (withheld.length === 0) return [...pending];
1082
+ const withheldActions = new Set(withheld.map((item) => item.action));
1083
+ return pending.filter((item) => !withheldActions.has(item.action));
1084
+ }
1085
+ /**
1086
+ * Split `manualInterventions` into orphan-blocked `add_foreign_key` actions
1087
+ * whose child column is nullable (an opt-in `--null-orphans` disposition can
1088
+ * null the references and proceed) and every other manual intervention,
1089
+ * including orphan-blocked FKs on a NOT NULL child column, which keep the
1090
+ * existing "Manual repair required" refusal (#2748) unconditionally — a
1091
+ * caller must never delete rows to make room for one of these.
1092
+ *
1093
+ * Reuses the differ's own rendered `advisory.suggestedSql` (detector at
1094
+ * index 0, repair at index 1) rather than re-deriving the orphan probe's
1095
+ * uuid-cast options, so the disposition always runs exactly the SQL the
1096
+ * differ already decided on.
1097
+ */
1098
+ function planOrphanDispositions(manualInterventions) {
1099
+ const nullable = [];
1100
+ const notNullable = [];
1101
+ for (const action of manualInterventions) {
1102
+ if (action.type !== "add_foreign_key" || !action.orphanBlocked) continue;
1103
+ if (!action.orphanNullable) {
1104
+ notNullable.push(action);
1105
+ continue;
1106
+ }
1107
+ const [detectorSql, repairSql] = action.advisory?.suggestedSql ?? [];
1108
+ if (!action.foreignKey || !detectorSql || !repairSql) {
1109
+ notNullable.push(action);
1110
+ continue;
1111
+ }
1112
+ nullable.push({
1113
+ action,
1114
+ tableName: action.tableName,
1115
+ column: action.foreignKey.column,
1116
+ detectorSql,
1117
+ repairSql
1118
+ });
1119
+ }
1120
+ return {
1121
+ nullable,
1122
+ notNullable
1123
+ };
1124
+ }
1125
+ /**
1126
+ * Wrap an orphan-probe `SELECT` (the differ's `renderForeignKeyOrphanDetector`
1127
+ * output, a bare `SELECT ... orphan_key ...` with no `LIMIT`) as a
1128
+ * `COUNT(*)` so `--null-orphans` can report before/after counts without
1129
+ * re-deriving the probe's own uuid-cast options.
1130
+ */
1131
+ function orphanCountSql(detectorSql) {
1132
+ return `SELECT COUNT(*) AS orphan_count FROM (${detectorSql}) AS smrt_orphan_probe`;
1133
+ }
864
1134
  //#endregion
865
1135
  //#region src/commands/postgres-timestamp-migration.ts
866
1136
  /**
@@ -7907,6 +8177,95 @@ function renderContextMarkdown(result) {
7907
8177
  return lines.join("\n");
7908
8178
  }
7909
8179
  //#endregion
8180
+ //#region src/commands/dev-runtime.ts
8181
+ /**
8182
+ * dev:runtime — client for the runtime dev-plane (#2782).
8183
+ *
8184
+ * With a running dev server that mounts the in-app plane (`--url` or
8185
+ * `SMRT_DEV_PLANE_URL`), the command calls the JSON route and prints the
8186
+ * envelope. Without one it falls back to the standalone Level 2 boot in this
8187
+ * process, so the same answer is available from a terminal either way.
8188
+ */
8189
+ /** Parse repeatable `--arg key=value` flags; numbers/booleans are coerced. */
8190
+ function parseToolArgs(raw) {
8191
+ const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
8192
+ const args = {};
8193
+ for (const entry of values) {
8194
+ const eq = entry.indexOf("=");
8195
+ if (eq <= 0) continue;
8196
+ const key = entry.slice(0, eq).trim();
8197
+ const value = entry.slice(eq + 1).trim();
8198
+ if (value === "true") args[key] = true;
8199
+ else if (value === "false") args[key] = false;
8200
+ else if (/^-?\d+(\.\d+)?$/.test(value)) args[key] = Number(value);
8201
+ else if (value.includes(",")) args[key] = value.split(",").map((v) => v.trim());
8202
+ else args[key] = value;
8203
+ }
8204
+ return args;
8205
+ }
8206
+ /** Call the in-app plane when a URL is configured; otherwise run locally. */
8207
+ async function runRuntimeTool(tool, args, options = {}) {
8208
+ const url = options.url?.trim() || process.env.SMRT_DEV_PLANE_URL?.trim();
8209
+ if (url) {
8210
+ const token = options.token?.trim() || process.env.SMRT_DEV_MCP_TOKEN?.trim();
8211
+ if (!token) throw new Error("A dev-plane URL is set but no token: pass --token or set SMRT_DEV_MCP_TOKEN");
8212
+ const response = await (options.fetchImpl ?? fetch)(`${url.replace(/\/+$/, "")}/${tool}`, {
8213
+ method: "POST",
8214
+ headers: {
8215
+ authorization: `Bearer ${token}`,
8216
+ "content-type": "application/json"
8217
+ },
8218
+ body: JSON.stringify(args)
8219
+ });
8220
+ if (!response.ok) throw new Error(`dev-plane responded ${response.status} for ${tool}`);
8221
+ return {
8222
+ source: "dev-plane",
8223
+ result: await response.json()
8224
+ };
8225
+ }
8226
+ const local = RUNTIME_TOOLS[tool];
8227
+ if (!local) throw new Error(`Unknown runtime tool "${tool}"; known: ${Object.keys(RUNTIME_TOOLS).join(", ")}`);
8228
+ return {
8229
+ source: "local",
8230
+ result: await local(args)
8231
+ };
8232
+ }
8233
+ var devRuntimeCommands = { "dev:runtime": {
8234
+ name: "dev:runtime",
8235
+ description: "Call a runtime dev-plane tool (runtime-registry, runtime-object, runtime-schema-diff, migration-status, job-health, schedule-health, dispatch-health, recent-changes, registry-drift) against a running dev server or, without one, the local Level 2 boot",
8236
+ args: ["tool"],
8237
+ options: {
8238
+ url: {
8239
+ type: "string",
8240
+ description: "Dev-plane base URL (e.g. http://127.0.0.1:5173/api/_dev); default SMRT_DEV_PLANE_URL"
8241
+ },
8242
+ token: {
8243
+ type: "string",
8244
+ description: "Bearer token; default SMRT_DEV_MCP_TOKEN"
8245
+ },
8246
+ arg: {
8247
+ type: "string",
8248
+ multiple: true,
8249
+ description: "Tool argument as key=value (repeatable; a,b,c becomes an array)"
8250
+ },
8251
+ json: {
8252
+ type: "boolean",
8253
+ description: "Print the raw envelope as JSON (default)",
8254
+ default: true
8255
+ }
8256
+ },
8257
+ handler: async (args, options) => {
8258
+ const tool = args[0];
8259
+ if (!tool) throw new Error("dev:runtime requires a tool name");
8260
+ const { source, result } = await runRuntimeTool(tool, parseToolArgs(options.arg), {
8261
+ url: options.url,
8262
+ token: options.token
8263
+ });
8264
+ console.error(`[smrt] dev:runtime ${tool} via ${source}`);
8265
+ console.log(JSON.stringify(result, null, 2));
8266
+ }
8267
+ } };
8268
+ //#endregion
7910
8269
  //#region src/runtime-check.ts
7911
8270
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
7912
8271
  "context",
@@ -8488,6 +8847,40 @@ function resolveDDLPreviewEngine(dbType) {
8488
8847
  }
8489
8848
  }
8490
8849
  /**
8850
+ * Run an orphan-count `SELECT COUNT(*)` (from `orphanCountSql()`) and read
8851
+ * back the count. Adapters disagree on the query-result envelope (some
8852
+ * return `{ rows }`, some a bare array), so normalize both the same way the
8853
+ * rest of the migration path does.
8854
+ *
8855
+ * Deliberately NOT best-effort: `--null-orphans` fails closed (#2748 review)
8856
+ * — if the probe itself cannot run (bad SQL shape, a permissions error, an
8857
+ * adapter that doesn't support the query), that is exactly the "unexpected
8858
+ * shape" case the flag must report and withhold on, not silently treat as
8859
+ * "zero orphans" and proceed to mutate data anyway. Callers that want the
8860
+ * disposition to abort on a probe failure should let this throw and catch
8861
+ * it themselves; there is no built-in fallback to 0.
8862
+ */
8863
+ async function countOrphanRows(db, countSql) {
8864
+ const result = await db.query(countSql);
8865
+ const raw = (Array.isArray(result) ? result : result?.rows ?? [])[0]?.orphan_count;
8866
+ const count = Number(raw);
8867
+ if (!Number.isFinite(count)) throw new Error(`orphan-count probe returned a non-numeric count: ${JSON.stringify(raw)}`);
8868
+ return count;
8869
+ }
8870
+ /**
8871
+ * One-line identity for a migration action withheld under
8872
+ * `--apply-unblocked`, for the withheld-changes report (#2748).
8873
+ */
8874
+ function describeWithheldMigrationAction(action) {
8875
+ switch (action.type) {
8876
+ case "add_index": return `${action.tableName}: index ${action.index?.name ?? "(unnamed)"}`;
8877
+ case "add_foreign_key": return `${action.tableName}: foreign key on ${action.foreignKey?.column ?? "(unknown column)"}`;
8878
+ case "alter_column": return `${action.tableName}.${action.columnName ?? action.column?.name ?? "(unknown column)"}: ${action.alteration ?? "alter_column"}`;
8879
+ case "drop_column": return `${action.tableName}.${action.columnName ?? "(unknown column)"}: drop column`;
8880
+ default: return `${action.tableName}: ${action.type}`;
8881
+ }
8882
+ }
8883
+ /**
8491
8884
  * Run the framework's deferred system-table compatibility pass after
8492
8885
  * `db:migrate` has created the tables it reshapes (issue #2376).
8493
8886
  *
@@ -9350,6 +9743,16 @@ export default testManifest;
9350
9743
  description: "Apply constraint relaxations the manifest implies: DROP NOT NULL / DROP DEFAULT on live columns stricter than the manifest, and DROP NOT NULL on orphan NOT NULL columns (PostgreSQL/DuckDB). Off by default; relaxations are always reported.",
9351
9744
  default: false
9352
9745
  },
9746
+ "apply-unblocked": {
9747
+ type: "boolean",
9748
+ description: "Withhold only the executable changes that depend on a blocked item (an index or foreign key on a column whose type upgrade is blocked, a foreign key whose orphan rows block it), instead of the default of applying every executable change regardless of dependency. A withheld change is reported with the blocked column it depends on. Off by default: every executable change in the batch still applies even when other items need manual intervention -- this flag makes that dependency-unsafe subset stop applying, it does not add anything that was previously withheld.",
9749
+ default: false
9750
+ },
9751
+ "null-orphans": {
9752
+ type: "boolean",
9753
+ description: "For a foreign key blocked only by orphan child rows: null the orphaned references when the child column is nullable, then add the foreign key. Refuses with the existing manual-repair message when the child column is NOT NULL. Never deletes rows. Off by default.",
9754
+ default: false
9755
+ },
9353
9756
  verbose: {
9354
9757
  type: "boolean",
9355
9758
  description: "Show detailed output",
@@ -9511,6 +9914,57 @@ export default testManifest;
9511
9914
  migrations.push(...partitionedChanges.migrations);
9512
9915
  manualInterventions.push(...partitionedChanges.manualInterventions);
9513
9916
  const advisories = partitionedChanges.advisories;
9917
+ const pendingOrphanDispositions = [];
9918
+ if (options["null-orphans"]) {
9919
+ const { nullable: orphanDispositions } = planOrphanDispositions(manualInterventions);
9920
+ if (orphanDispositions.length > 0) {
9921
+ console.log(`🧹 Orphan-FK disposition (--null-orphans): ${orphanDispositions.length} relationship(s)\n`);
9922
+ for (const item of orphanDispositions) {
9923
+ const countSql = orphanCountSql(item.detectorSql);
9924
+ let beforeCount;
9925
+ try {
9926
+ beforeCount = await countOrphanRows(db, countSql);
9927
+ } catch (error) {
9928
+ console.log(` ✗ ${item.tableName}.${item.column}: orphan-count probe failed, withholding this disposition: ${error instanceof Error ? error.message : String(error)}`);
9929
+ continue;
9930
+ }
9931
+ if (!item.action.foreignKey) continue;
9932
+ if (isDryRun) {
9933
+ console.log(` ${item.tableName}.${item.column}: ${beforeCount} orphan reference(s)`);
9934
+ console.log(` ${item.repairSql};`);
9935
+ console.log(` → would null ${beforeCount} reference(s), then add the foreign key\n`);
9936
+ }
9937
+ const index = manualInterventions.indexOf(item.action);
9938
+ if (index >= 0) manualInterventions.splice(index, 1);
9939
+ const combinedAction = {
9940
+ type: "add_foreign_key",
9941
+ tableName: item.action.tableName,
9942
+ className: item.action.className,
9943
+ foreignKey: item.action.foreignKey,
9944
+ sqlStatements: [item.repairSql, ...renderForeignKeyAddStatements(item.action.tableName, item.action.foreignKey)]
9945
+ };
9946
+ migrations.push(combinedAction);
9947
+ if (!isDryRun) pendingOrphanDispositions.push({
9948
+ tableName: item.tableName,
9949
+ column: item.column,
9950
+ countSql,
9951
+ beforeCount,
9952
+ action: combinedAction
9953
+ });
9954
+ }
9955
+ console.log();
9956
+ }
9957
+ }
9958
+ let withheldForDependency = [];
9959
+ if (options["apply-unblocked"]) {
9960
+ const partition = partitionUnblockedMigrations(migrations, computeBlockedColumns(manualInterventions, advisories), computeOrphanOnlyBlockedColumns(manualInterventions));
9961
+ withheldForDependency = partition.withheld;
9962
+ migrations.length = 0;
9963
+ migrations.push(...partition.applied);
9964
+ const stillPending = filterUnresolvedOrphanDispositions(pendingOrphanDispositions, withheldForDependency);
9965
+ pendingOrphanDispositions.length = 0;
9966
+ pendingOrphanDispositions.push(...stillPending);
9967
+ }
9514
9968
  const preForeignKeyMigrations = migrations.filter((migration) => migration.phase === "pre_foreign_key");
9515
9969
  const remainingMigrations = migrations.filter((migration) => migration.phase !== "pre_foreign_key");
9516
9970
  assertForceMigrationTargetsExist(forceSelection.forceMigrations, [
@@ -9540,12 +9994,17 @@ export default testManifest;
9540
9994
  console.log();
9541
9995
  console.log(" Manual migration required (backup, recreate, restore as needed).\n");
9542
9996
  }
9997
+ if (withheldForDependency.length > 0) {
9998
+ console.log(`🔒 Withheld — depends on a blocked change (--apply-unblocked, ${withheldForDependency.length}):\n`);
9999
+ for (const { action, dependsOn, reason } of withheldForDependency) console.log(` ${describeWithheldMigrationAction(action)}: depends on ${dependsOn} (${reason})`);
10000
+ console.log();
10001
+ }
9543
10002
  printSchemaAdvisories(advisories, {
9544
10003
  orphanTables: diff.orphan_tables ?? [],
9545
10004
  verbose: Boolean(options.verbose)
9546
10005
  });
9547
10006
  const tablesCreated = diff.added_tables.length > 0;
9548
- const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
10007
+ const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && withheldForDependency.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
9549
10008
  const upToDateMessage = advisories.some((a) => a.advisory.severity === "warning") ? "✅ No migrations needed - see the live schema findings above\n" : "✅ Database schema is up to date - no migrations needed\n";
9550
10009
  if (isDryRun) {
9551
10010
  if (schemaUpToDate && !repairData) {
@@ -9637,6 +10096,7 @@ export default testManifest;
9637
10096
  let skippedCount = 0;
9638
10097
  let errorCount = 0;
9639
10098
  let stiErrorCount = 0;
10099
+ let migrationResults = [];
9640
10100
  const schemaChangeCount = diff.added_tables.length + migrations.length + deferredForeignKeyMigrations.length;
9641
10101
  if (applySchemaMigrations && schemaChangeCount > 0) {
9642
10102
  const migrationDefs = [];
@@ -9746,6 +10206,7 @@ export default testManifest;
9746
10206
  console.log(` ✓ ${logInfo?.successMessage ?? result.name} (${checksum})`);
9747
10207
  }
9748
10208
  });
10209
+ migrationResults = results;
9749
10210
  const failed = migrationResultFailure(results);
9750
10211
  if (failed) throw migrationResultError(failed);
9751
10212
  successCount += results.filter((result) => result.success && result.applied !== false && result.execution_time_ms !== 0).length;
@@ -9759,6 +10220,21 @@ export default testManifest;
9759
10220
  console.error(deferredIndexMigrations > 0 ? " Non-index changes in this batch were committed; concurrent index builds that did not finish are recorded failed and will be retried on the next db:migrate." : " Rolled back all schema changes from this migration batch, including any successful steps shown above.");
9760
10221
  }
9761
10222
  }
10223
+ const succeededMigrationNames = new Set(migrationResults.filter((result) => result.success).map((result) => result.name));
10224
+ const resolvedDispositions = pendingOrphanDispositions.filter((pending) => {
10225
+ const migrationName = getSyntheticMigrationNameForAction(pending.action);
10226
+ return migrationName ? succeededMigrationNames.has(migrationName) : false;
10227
+ });
10228
+ if (resolvedDispositions.length > 0) {
10229
+ if (errorCount > 0) console.log(" (some changes in this batch failed; only fully-applied --null-orphans dispositions are listed below)");
10230
+ for (const pending of resolvedDispositions) try {
10231
+ const afterCount = await countOrphanRows(db, pending.countSql);
10232
+ console.log(` ✓ ${pending.tableName}.${pending.column}: nulled ${Math.max(pending.beforeCount - afterCount, 0)} orphan reference(s) (${afterCount} remaining)`);
10233
+ } catch (error) {
10234
+ console.log(` ✓ ${pending.tableName}.${pending.column}: orphan references nulled and foreign key added (post-apply count unavailable: ${error instanceof Error ? error.message : String(error)})`);
10235
+ }
10236
+ console.log();
10237
+ }
9762
10238
  if (schemaUpToDate && !repairData) console.log(upToDateMessage);
9763
10239
  if (repairData) {
9764
10240
  console.log("\n🔄 Repairing STI discriminators to qualified names...\n");
@@ -9849,7 +10325,7 @@ export default testManifest;
9849
10325
  } else console.log("ℹ️ No schema migrations were applied (none matched expected types)\n");
9850
10326
  }
9851
10327
  if (shouldFailDbMigrate({
9852
- manualInterventionCount: manualInterventions.length,
10328
+ manualInterventionCount: manualInterventions.length + withheldForDependency.length,
9853
10329
  tableErrorCount,
9854
10330
  migrationErrorCount: errorCount,
9855
10331
  stiErrorCount,
@@ -10139,6 +10615,7 @@ export default testManifest;
10139
10615
  },
10140
10616
  "runtime:check": runtimeCheckCommand,
10141
10617
  ...devKnowledgeCommands,
10618
+ ...devRuntimeCommands,
10142
10619
  "db:status": dbStatusCommand,
10143
10620
  "db:history": dbHistoryCommand,
10144
10621
  "db:diff": dbDiffCommand,
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-uHfUPryV.js");
52
+ const { gnodeCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
59
+ const { gitCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
66
+ const { generateCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
73
+ const { initCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
80
+ const { utilityCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
87
+ const { dispatchCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
94
+ const { docsCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
101
+ const { playgroundCommands } = await import("./commands-DEHkYYWF.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-uHfUPryV.js");
108
+ const { workbenchCommands } = await import("./commands-DEHkYYWF.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.2",
3
+ "version": "0.48.0",
4
4
  "description": "Developer CLI for SMRT framework - introspection, testing, and project management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,12 +28,12 @@
28
28
  "@happyvertical/ai": "^0.89.6",
29
29
  "@happyvertical/files": "^0.89.6",
30
30
  "@happyvertical/logger": "^0.89.6",
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",
31
+ "@happyvertical/smrt-agents": "0.48.0",
32
+ "@happyvertical/smrt-config": "0.48.0",
33
+ "@happyvertical/smrt-core": "0.48.0",
34
+ "@happyvertical/smrt-dev-mcp": "0.48.0",
35
+ "@happyvertical/smrt-playground": "0.48.0",
36
+ "@happyvertical/smrt-types": "0.48.0",
37
37
  "@happyvertical/sql": "^0.89.6",
38
38
  "@happyvertical/utils": "^0.89.6",
39
39
  "acorn": "^8.17.0",
@@ -73,7 +73,7 @@
73
73
  "clean": "rm -rf dist",
74
74
  "dev": "npm run build:watch",
75
75
  "test": "vitest run",
76
- "test:postgres": "node ../../scripts/run-with-ci-postgres.mjs -- pnpm exec vitest run src/commands/__tests__/db-migrate-uuid.test.ts src/commands/__tests__/db-migrate-force-postgres.test.ts src/commands/__tests__/sti-upgrade.test.ts",
76
+ "test:postgres": "node ../../scripts/run-with-ci-postgres.mjs -- pnpm exec vitest run src/commands/__tests__/db-migrate-uuid.test.ts src/commands/__tests__/db-migrate-force-postgres.test.ts src/commands/__tests__/sti-upgrade.test.ts src/commands/__tests__/db-migrate-partial-apply-postgres.test.ts",
77
77
  "test:watch": "vitest",
78
78
  "typecheck": "tsc -p tsconfig.typecheck.json",
79
79
  "cli": "tsx src/index.ts"