@happyvertical/smrt-cli 0.47.2 → 0.49.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 +14 -12
- package/agents/db-migrate-partial-apply.md +189 -0
- package/agents/type-drift.md +53 -0
- package/dist/{commands-uHfUPryV.js → commands-DEHkYYWF.js} +480 -3
- package/dist/index.js +9 -9
- package/package.json +8 -8
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 #
|
|
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**:
|
|
331
|
-
- **Aggregate manifest identity**: preload
|
|
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
|
|
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
|
|
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
|
|
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` —
|
|
346
|
-
- **External package load failures silenced**: one package failing doesn't
|
|
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
|
|
349
|
-
`smrt generate:mcp` is
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "0.49.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.
|
|
32
|
-
"@happyvertical/smrt-config": "0.
|
|
33
|
-
"@happyvertical/smrt-core": "0.
|
|
34
|
-
"@happyvertical/smrt-dev-mcp": "0.
|
|
35
|
-
"@happyvertical/smrt-playground": "0.
|
|
36
|
-
"@happyvertical/smrt-types": "0.
|
|
31
|
+
"@happyvertical/smrt-agents": "0.49.0",
|
|
32
|
+
"@happyvertical/smrt-config": "0.49.0",
|
|
33
|
+
"@happyvertical/smrt-core": "0.49.0",
|
|
34
|
+
"@happyvertical/smrt-dev-mcp": "0.49.0",
|
|
35
|
+
"@happyvertical/smrt-playground": "0.49.0",
|
|
36
|
+
"@happyvertical/smrt-types": "0.49.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"
|