Skip to content

fix: include-path contract + TUI change-operation parity - #62

Merged
damusix merged 8 commits into
masterfrom
v1/include-path-docs
Jul 29, 2026
Merged

fix: include-path contract + TUI change-operation parity#62
damusix merged 8 commits into
masterfrom
v1/include-path-docs

Conversation

@damusix

@damusix damusix commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Two reported problems, plus what auditing them turned up.

1. build.include paths are relative to paths.sql

The skill reference and two guides showed include: [sql/tables] next to paths.sql: ./sql, which expands to sql/sql/tables and matches nothing — while docs/getting-started/first-build.md:103 already documented the rule correctly. 24 entries across three files were teaching the opposite of the docs.

It costs real time because it fails silently. Measured:

include files matched reported status exit
01_tables 1 success 0
sql/01_tables 0 success 0

run build now names the entries that matched nothing, on both CLI and TUI:

⚠ Ignored 1 build.include entry that matched no files: sql/01_tables
⚠ Include paths are relative to paths.sql — use `01_tables`, not `sql/01_tables`.

Warned rather than failed: matching nothing is a settings mistake, not an execution failure, and a legitimately-empty build must still succeed. BatchResult gains an optional unmatchedInclude. Leading ./ and trailing / fail the same way and are now documented.

2. TUI changes failing silently

createChangeManager never set dialect, so consumers fell back to ?? 'postgres'. Dialect selects tracking-table names and whether a schema is applied, so on sqlite and mysql the TUI queried noorm.change instead of __noorm_change__. The failed read is swallowed into an empty history, and the operation then reports success over zero changes — a rewind rendering "Reverted 0 change(s) successfully!" while reverting nothing. The SDK always passed it, which is why the CLI was unaffected.

The regression test proves it: with the line removed you get SQLiteError: no such table: noorm.lock and an empty history.

Also subscribes app-wide to the observer error event. Core reports recovered failures through that channel, and noorm ui routes both logger streams to a null stream, so they previously reached nothing but .noorm/state/noorm.log.

3. Dry-run was displayed but not applied (data safety)

Found while auditing the above, and the most serious item here. The TUI renders a bold yellow DRY badge when dry-run is active, but ff/next/run/revert/rewind all called the manager with no options — so toggling dry-run showed the badge while changes applied for real against the database. Same defect class as the --dry-run CLI bug, on the interactive surface.

The fix needed two parts. exhaustive-deps is not enabled in this repo's eslint, and globalModes was missing from the useCallback deps — without adding it the callback closes over first-render state and the toggle still does nothing. The test mutates both halves independently and fails on each.

4. Vault key failures were invisible

loadPrivateKey throws on an unreadable key file or insecure permissions, and three vault screens called it bare from fire-and-forget keyboard handlers. The lifecycle unhandledRejection handler caught it and tore the app down via app:exit — so a corrupted key file quit the TUI with no message. Separately, useVaultSecretKeys treated every failure as "no vault secrets", making a decrypt error indistinguishable from an empty vault. It now reports failures that threw while staying silent when there is genuinely no vault.


Verified: lint, typecheck, lint:docs, build all clean. All four CI groups run locally against live pg/mysql/mssql — 2537 / 122 / 476 / 631 = 3766 pass, 0 fail. Every new regression test was proven to fail with its fix reverted, then restored.

Known gaps, deliberately not addressed here: db transfer's truncate-first control is dead (safe default false, so a missing capability rather than a live bug — wiring it means extending the transfer/import options UI); the change factory still omits secrets/globalSecrets, so TUI-run .sql.tmpl changes get an empty secrets context (fixing it makes createChangeManager async, touching every call site); requiresConfirmation is unenforced stack-wide outside three CLI commands; vault cp and db reset have no TUI screen; InitScreen reimplements performProjectInit rather than sharing it; a fatal error in noorm ui exits 0; and only ChangeFFScreen has dry-run coverage — the other four screens use the same harness but each needs its own selection interaction.

damusix added 8 commits July 28, 2026 20:08
The skill reference and two guides all showed `include: [sql/tables]`
alongside `paths.sql: ./sql`, which resolves to `./sql/sql/tables` and
matches nothing. `docs/getting-started/first-build.md` already documented
the rule correctly, so the examples were teaching the opposite of the docs.

24 entries corrected across three files, plus an explicit note on the
failure mode: a non-matching include is not an error — the build reports
success, runs zero files, and exits 0, so the misconfiguration is
indistinguishable from a working build until something downstream fails
on a missing table. Leading `./` and trailing `/` fail the same way.
`build.include` paths are relative to `paths.sql`, so the natural-looking
`sql/01_tables` expands to `sql/sql/01_tables` and matches nothing. The
build then ran zero files, reported `status: success`, and exited 0 — a
misconfiguration indistinguishable from a clean build until something
downstream failed on a missing table.

`findUnmatchedIncludePatterns` reports the entries that matched nothing;
the SDK attaches them to `BatchResult.unmatchedInclude` and `run build`
warns, naming each entry and the paths.sql-relative rule. Warned rather
than failed: matching nothing is a settings mistake, not an execution
failure, and a build that legitimately matches nothing must still succeed.
createChangeManager built its ChangeContext without `dialect`, so every
consumer fell back to `?? 'postgres'`. Dialect selects the tracking table
names and whether a schema is applied, so on sqlite and mysql projects the
TUI queried `noorm.change` instead of `__noorm_change__`. That read fails,
history swallows the failure into an empty result, and the operation then
reports success over zero changes — a rewind rendering "Reverted 0
change(s) successfully!" while nothing was reverted.

The SDK has always passed it, which is why the CLI was unaffected.

Also subscribes to the observer `error` event app-wide. Core reports
failures it recovers from through that channel, and `noorm ui` routes both
logger streams to a null stream, so those failures previously reached
nothing but .noorm/state/noorm.log.
The TUI renders a DRY badge when dry-run is active, but ff/next/run/
revert/rewind all called the manager with no options — so a user who
toggled dry-run watched the badge light up while the changes applied for
real. Same defect as the `--dry-run` CLI bug, on the interactive surface.

globalModes is in each callback's dependency list because exhaustive-deps
is not enabled here; without it the callback closes over the toggle state
from first render and flipping it has no effect.
The CLI names them; the TUI showed a bare "Run 0 SQL files on <config>?"
confirm prompt with nothing explaining why the set was empty.
Seeds the sqlite-shaped `__noorm_change__` and asserts the manager built
by createChangeManager reads it. With `dialect` removed the suite fails on
`no such table: noorm.lock` and an empty history, which is the shape the
bug presented as in the TUI.
`loadPrivateKey` throws on an unreadable key file or insecure permissions,
and three vault screens called it bare from fire-and-forget keyboard
handlers. The lifecycle manager's unhandledRejection handler caught the
throw and tore the app down through `app:exit`, so a corrupted key file
quit the TUI with no message rather than reporting the problem.

useVaultSecretKeys separately treated every failure as "no vault secrets":
a decrypt error, a permissions error, and an empty vault were
indistinguishable. It now reports failures that threw while staying silent
when there is genuinely no vault — a project without one must behave
exactly as before — and the three secret screens render it.
Drives the real screen to its confirm step, toggles the global modes the
way a user does, and asserts the options reaching ChangeManager. Fails on
both halves of the fix: dropping the options argument fails all four
cases, and dropping globalModes from the callback deps fails the three
where a mode is toggled after mount.
@damusix
damusix merged commit 29dc3cf into master Jul 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant