@happyvertical/smrt-cli 0.47.1 → 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
@@ -10,16 +10,18 @@ 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:migrate # Apply migrations
13
+ smrt db:orphans # agents/db-orphans.md
14
+ smrt db:migrate # agents/type-drift.md
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
17
+ smrt db:migrate --apply-unblocked / --null-orphans # agents/db-migrate-partial-apply.md
16
18
  smrt db:migrate-uuid # Convert schema-declared UUID text columns after data remap
17
19
  smrt db:migrate-int8 # Explicitly widen pre-#2373 int4 columns after preflight
18
20
  smrt db:drop-framework-base-tables # One-time drop of the five #2644-orphaned framework-base tables
19
21
  smrt db:drop-framework-base-tables --dry-run # Print the drop plan without executing
20
22
  smrt db:diff # Show schema differences without generating migration files
21
23
  smrt db:rollback # Roll back migrations by executing their recorded DOWN
22
- smrt db:rollback --mark-only # Record-only flip; schema deliberately untouched
24
+ smrt db:rollback --mark-only # Record-only flip; schema untouched
23
25
  smrt db:prune # Prune framework system tables to their retention windows
24
26
  smrt db:prune --dry-run # Same predicates, counted rather than deleted
25
27
  smrt docs:agents # Generate .agents/smrt-framework.md
@@ -50,7 +52,8 @@ cycles are created without the cyclic clauses first, then receive named
50
52
  constraints after both tables exist. When a same-package constraint is missing
51
53
  on an existing table, `db:migrate` probes the exact child/parent columns for
52
54
  orphans before `ADD ... NOT VALID` and `VALIDATE CONSTRAINT`; orphaned data or a
53
- 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).
54
57
 
55
58
  `db:migrate --dry-run` and deprecated `db:setup --dry-run` print the same
56
59
  engine-specific dependency plan used for execution, including exact table DDL
@@ -326,33 +329,33 @@ drop `includeDroppedTables` (default `false`, both call sites explicit —
326
329
  ## Architecture
327
330
 
328
331
  - **Lazy command loading**: commands loaded on-demand via dynamic import (~100ms overhead on first use)
329
- - **Manifest discovery**: auto-finds `.smrt/manifest.json` + scans `node_modules/@happyvertical/smrt-*`
330
- - **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
331
334
  - **Class loading order**: config.entryPoint → package.json exports['.'] → package.json main → `./dist/index.js`
332
- - **Object method exposure**: custom methods on SMRT objects auto-become CLI commands
335
+ - **Object method exposure**: custom SMRT object methods auto-become CLI commands
333
336
 
334
337
  ## Key Files
335
338
 
336
- - `src/cli-generator.ts` — core dispatcher, lazy command loading, class loading
339
+ - `src/cli-generator.ts` — core dispatcher, lazy command/class loading
337
340
  - `src/commands/` — individual command implementations
338
341
  - `src/loaders/` — class-loader, local-loader, npm-loader, git-loader, template-loader
339
342
  - `src/discovery/manifest-discovery.ts` — manifest auto-discovery
340
- - `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
341
344
 
342
345
  ## Gotchas
343
346
 
344
- - **Test mode detection**: checks `NODE_ENV=test`, `VITEST=true`, `global.it`/`describe` — could conflict with other test runners
345
- - **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
346
349
  - **Generation command names are hyphenated**: `generate-mcp` and `generate-types` have
347
- no colon alias. Only `generate-routes` and `generate-register` declare one, so
348
- `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).
349
352
  - **Subcommand `--version` beats the global flag**: `parseCliArgs` in
350
353
  `@happyvertical/utils` dispatches to the built-in `version` command for a
351
354
  `--version` token anywhere in argv. `parseCliCommandArgs` rescopes that token to
352
355
  the named subcommand (`smrt generate-mcp --version 0.1.0`) and only leaves it
353
356
  global when it appears before or without a subcommand (#2279). `--help` is
354
357
  deliberately untouched.
355
- - **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
356
359
  - **Decorator check follows the Vite major**: doctor requires `oxc.decorator` in
357
360
  vite.config on Vite 8+ and accepts tsconfig `experimentalDecorators` only
358
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,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`.
@@ -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.