diff --git a/.claude/project/followups/CLOSED.md b/.claude/project/followups/CLOSED.md
index f86ef9be..6094f6e8 100644
--- a/.claude/project/followups/CLOSED.md
+++ b/.claude/project/followups/CLOSED.md
@@ -1 +1,2 @@
- 2026-06-06 mssql-build-non-idempotent-create-type — "MSSQL build non-idempotent: bare CREATE TYPE blocks db.reset() rebuild" — *(closed 2026-06-06)*
+- 2026-07-13 v1-38-sdk-integration-f-2 — "dt reader .dtz bad-path hangs instead of rejecting (unforwarded gunzip stream error)" — *(closed 2026-07-13)*
diff --git a/.claude/project/followups/INDEX.md b/.claude/project/followups/INDEX.md
index 2c8873bd..866a85f2 100644
--- a/.claude/project/followups/INDEX.md
+++ b/.claude/project/followups/INDEX.md
@@ -2,24 +2,26 @@
Auto-generated by `atomic followups render`. Do not edit.
-Open: 7 • Stale: 0 • Last rendered: 2026-07-08
+Open: 10 • Stale: 0 • Last rendered: 2026-07-14
## 📋 plans (1)
- [configurable-sql-function-policy](configurable-sql-function-policy.md) — Configurable per-config SQL function allow/deny list + TUI editor → src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS)
-## 🟡 risks (4)
+## 🟡 risks (6)
-- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (37d)
-- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (0d)
-- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (0d)
-- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (0d)
+- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (43d)
+- [db-transfer-no-fk-negation-collision](db-transfer-no-fk-negation-collision.md) — citty --no-fk/--no-identity never actually toggle (negation collision) (1d)
+- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (6d)
+- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (6d)
+- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (6d)
+- [v1-45-rewind-tiebreak-f1](v1-45-rewind-tiebreak-f1.md) — rewind tiebreak tests rely on wall-clock timing, not forced tie (0d)
## 🔵 nits (2)
-- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (37d)
-- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (0d)
+- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (43d)
+- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (6d)
-## ❓ questions (0)
+## ❓ questions (1)
-(none)
+- [v1-21-31-hygiene-f2](v1-21-31-hygiene-f2.md) — Add release-engine paragraph to ignatius CLAUDE.md (2d)
diff --git a/.claude/project/followups/db-transfer-no-fk-negation-collision.md b/.claude/project/followups/db-transfer-no-fk-negation-collision.md
new file mode 100644
index 00000000..3f932442
--- /dev/null
+++ b/.claude/project/followups/db-transfer-no-fk-negation-collision.md
@@ -0,0 +1,24 @@
+---
+id: db-transfer-no-fk-negation-collision
+title: citty --no-fk/--no-identity never actually toggle (negation collision)
+created: "2026-07-13"
+origin: |
+ docs/spec/v1-24-polish-batch.md, iter 1 implementer+reviewer (CP-3)
+kind: finding
+severity: risk
+review_by: "2026-09-11"
+status: open
+file: src/cli/db/transfer.ts:305-306
+---
+
+citty's raw-arg parser unconditionally strips any --no-X argv token and treats
+it as negating a flag literally named X, regardless of what is declared. Two of
+`db transfer`'s boolean flags are themselves named no-fk/no-identity (now noFk/noIdentity),
+so passing --no-fk on the CLI has never set args.noFk to true -- it silently sets an
+unrelated, undeclared args.fk = false instead. disableForeignKeys: args.noFk !== true
+therefore always evaluates true regardless of the flag. Confirmed empirically both
+before and after the v1-24 camelCase rename -- behavior is byte-identical, so that
+ticket's "flag surface must not change" bar was met, but the flags have likely never
+worked as documented. Needs a dedicated fix: likely renaming away from the no- prefix
+pattern (e.g. --skip-fk-check/--skip-identity) or adding explicit non-auto-negated
+boolean parsing.
diff --git a/.claude/project/followups/v1-21-31-hygiene-f2.md b/.claude/project/followups/v1-21-31-hygiene-f2.md
new file mode 100644
index 00000000..344a52bf
--- /dev/null
+++ b/.claude/project/followups/v1-21-31-hygiene-f2.md
@@ -0,0 +1,18 @@
+---
+id: v1-21-31-hygiene-f2
+title: Add release-engine paragraph to ignatius CLAUDE.md
+created: "2026-07-12"
+origin: |
+ docs/spec/v1-21-31-hygiene.md, iter 1 implementer (CP-10)
+kind: finding
+severity: question
+review_by: "2026-09-10"
+status: open
+file: /Users/alonso/projects/noorm/ignatius/CLAUDE.md
+---
+
+Ticket 31 (document release-engine split) asks for a matching paragraph in the ignatius repo's CLAUDE.md stating release-please as its engine and why it fits (conventional-commit-derived changelog, single-package manifest -- unlike monorepo's fixed-version group, which exists to keep two coupled packages in lockstep). Not applied during v1/21-31-hygiene because ignatius is a separate git repo (/Users/alonso/projects/noorm/ignatius) with no worktree isolation or review loop inside that branch's scope.
+
+Verbatim paragraph to add to ignatius/CLAUDE.md:
+
+"Ignatius releases via release-please (conventional-commit-derived changelog, single-package manifest) -- the right fit for a single-package repo, unlike monorepo's fixed-version group which exists specifically to keep two coupled packages in lockstep."
diff --git a/.claude/project/followups/v1-45-rewind-tiebreak-f1.md b/.claude/project/followups/v1-45-rewind-tiebreak-f1.md
new file mode 100644
index 00000000..651e5f67
--- /dev/null
+++ b/.claude/project/followups/v1-45-rewind-tiebreak-f1.md
@@ -0,0 +1,14 @@
+---
+id: v1-45-rewind-tiebreak-f1
+title: rewind tiebreak tests rely on wall-clock timing, not forced tie
+created: "2026-07-14"
+origin: |
+ docs/spec/v1-45-rewind-tiebreak.md, iter 1 reviewer (checkpoint 1)
+kind: finding
+severity: risk
+review_by: "2026-09-12"
+status: open
+file: tests/core/change/manager.test.ts:291-316
+---
+
+tests/core/change/manager.test.ts:291-316 (new tie-specific test) and the pre-existing test at ~line 250 force an appliedAt tie by relying on two sequential manager.run() calls landing within the same wall-clock second (SQLite CURRENT_TIMESTAMP second-precision), rather than deterministically forcing equal executed_at values. In the rare case the two calls straddle a second boundary, the test would still pass (non-tied ordering is unaffected by the tiebreak fix) but would not actually exercise the new appliedHistoryId tiebreak logic for that run -- false confidence, not a false failure. Fix: force the tie deterministically, e.g. by writing two history rows with an identical executed_at value directly, or by stubbing the clock during the two run() calls in this test file specifically.
diff --git a/.claude/rules/tui-development.md b/.claude/rules/tui-development.md
index 7cada4cb..a26ddf73 100644
--- a/.claude/rules/tui-development.md
+++ b/.claude/rules/tui-development.md
@@ -128,7 +128,7 @@ For fixed-position elements (like toast), use fixed width to reserve space and p
Use hooks from `src/tui/hooks/useObserver.ts` for event subscriptions. These handle cleanup automatically.
```tsx
-import { useOnEvent, useOnceEvent, useEmit, useEventPromise } from '../hooks/index.js';
+import { useOnEvent, useOnceEvent, useEmit } from '../hooks/index.js';
// Subscribe to events - cleanup on unmount
useOnEvent('changeset:complete', (data) => {
@@ -143,10 +143,6 @@ useOnceEvent('build:complete', (data) => setFinalResult(data), []);
// Emit events via memoized callback
const emitStart = useEmit('build:start');
emitStart({ schemaPath, fileCount });
-
-// Promise-based with state management
-const [result, error, pending, cancel] = useEventPromise('build:complete');
-if (pending) return ;
```
diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md
index c3d4117b..a19701d9 100644
--- a/.claude/rules/typescript.md
+++ b/.claude/rules/typescript.md
@@ -126,11 +126,13 @@ If you're going to re-throw the error unchanged in every case, skip `attempt` an
## Error Handling (ZERO TOLERANCE)
-- **NEVER use try-catch** - This is a critical violation
-- **ALWAYS use @logosdx/utils utilities**: `attempt`, `attemptSync`, `batch`, `circuitBreaker`, `debounce`, `throttle`, `memo`, `rateLimit`, `retry`, `withTimeout`, `ObserverEngine`, `FetchEngine`
+- **NEVER use try-catch** - This is a critical violation. The zero tolerance targets try-catch specifically, not throwing or `attempt`/`attemptSync` - see Function Structure above for when to wrap deliberately vs. let errors propagate.
+- **Mandated `@logosdx/utils` utilities**: `attempt`/`attemptSync` (the convention actually in use - 553 call sites across 175 files) and `retry` (used at `src/core/connection/factory.ts:93`). Use `attempt`/`attemptSync` per the Function Structure guidance above - only when the function does something with the error.
+- **Available in `@logosdx/utils` but not currently used**: `batch`, `circuitBreaker`, `debounce`, `throttle`, `memo`/`memoize`, `rateLimit`, `withTimeout`, `FetchEngine`. Reach for them if a real need arises; they are not mandated because nothing in `src/` imports them today.
+- `ObserverEngine` is `@logosdx/observer`, not `@logosdx/utils`.
```typescript
-// CORRECT
+// CORRECT - attempt() used deliberately: observes the error, emits, then stops
const [result, err] = await attempt(() => db.execute(sql));
if (err) {
@@ -139,6 +141,9 @@ if (err) {
return;
}
+// ALSO CORRECT - nothing to add by wrapping; let the error propagate
+return db.execute(sql);
+
// WRONG - Never do this
try {
const result = await db.execute(sql);
@@ -264,7 +269,7 @@ assert(condition, 'message', CustomError);
## Class Patterns
-Use private fields with `#` prefix. Namespace types under the class.
+Use private fields with `#` prefix.
```typescript
export class StateManager {
@@ -280,15 +285,6 @@ export class StateManager {
}
-}
-
-export namespace StateManager {
-
- export interface Config {
- name: string;
- connection: ConnectionConfig;
- }
-
}
```
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 53093efc..8ea1645f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -31,6 +31,9 @@ jobs:
bun install --frozen-lockfile
cd docs && bun install --frozen-lockfile
+ - name: Check --json doc placement
+ run: bun run lint:docs
+
- name: Build docs
env:
VITEPRESS_BASE: /
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c16acaf3..22642ecb 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -76,10 +76,18 @@ jobs:
- name: Build binaries
run: bun run build:binary
+ - name: Generate checksums
+ working-directory: packages/cli/bin
+ run: |
+ shasum -a 256 noorm-* > checksums.txt
+ cat checksums.txt
+
- name: Upload binaries to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
- files: packages/cli/bin/noorm-*
+ files: |
+ packages/cli/bin/noorm-*
+ packages/cli/bin/checksums.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml
index 0f9c01ea..0109b828 100644
--- a/.github/workflows/release-binary.yml
+++ b/.github/workflows/release-binary.yml
@@ -29,10 +29,18 @@ jobs:
- name: Build binaries
run: bun run build:binary
+ - name: Generate checksums
+ working-directory: packages/cli/bin
+ run: |
+ shasum -a 256 noorm-* > checksums.txt
+ cat checksums.txt
+
- name: Upload binaries to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
- files: packages/cli/bin/noorm-*
+ files: |
+ packages/cli/bin/noorm-*
+ packages/cli/bin/checksums.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/CLAUDE.md b/CLAUDE.md
index d9ef9f22..fa093989 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,13 +48,15 @@ The integration step needs postgres/mysql/mssql reachable (CI uses service conta
## Changesets
-This is a pnpm monorepo with two publishable packages. Changeset frontmatter must reference the correct workspace package name:
+This is a bun workspace monorepo with two publishable packages. Changeset frontmatter must reference the correct workspace package name:
- **`@noormdev/cli`** — `packages/cli` (CLI/TUI)
- **`@noormdev/sdk`** — `packages/sdk` (programmatic SDK)
Never use `noorm` or `@noormdev/main` — those are not workspace packages and will fail the Release workflow.
+Changesets is the release engine because it supports a fixed-version group: `@noormdev/cli` and `@noormdev/sdk` are coupled packages that always bump together on every release, which fits a two-package monorepo where the packages version in lockstep rather than independently.
+
## Tech Stack
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..ee92eec1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Danilo Alonso
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/TODO.md b/TODO.md
index 87b5a849..28cb11e8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -17,101 +17,10 @@ Core SDK is implemented and packaged (`@noormdev/sdk`). Remaining:
### Headless CLI Gaps
-40 handlers implemented. Missing commands:
-
-**Database:**
-
-- [x] `db reset` - Teardown + build (idempotent rebuild)
-- [x] `db drop` - Drop entire database
-- [x] `db create` - Create database if not exists
-
-**Configuration:**
-
-- [x] `config validate` - Validate config can connect
-- [x] `config list` - List available configs
-
-**SQL Execution:**
-
-- [x] `sql ` - Execute raw SQL
-- [x] `sql -f ` - Execute SQL from file
-
-**Changes:**
-
-- [x] `change next` - Apply next pending change
-
-**Runner:**
-
-- [x] `run files ` - Run multiple specific files
-- [x] `run exec` - Batch-execute selected SQL files (currently TUI-only `RunExecScreen`)
-
-
-### TUI Parity Gaps
-
-Surfaces that exist in the TUI but have no headless CLI equivalent. Discovered after the citty migration audit. Blocks CI/CD adoption for anything beyond `change`/`run`/`vault` workflows.
-
-**Identity:** (entire domain TUI-only — no `noorm identity` command)
-
-- [x] **CI provisioning + runtime** — full `noorm ci` namespace for CI/CD:
- - `noorm ci identity new` — generate a local keypair + env block for test CI
- - `noorm ci identity enroll --config ` — generate + register identity in a prod DB's `identities` table with vault propagation
- - `noorm ci init` — bootstrap ephemeral state.enc from env vars (identity + connection)
- - `noorm ci secrets --file ` — batch-load secrets from dotenv file into active config's vault
- - Absorbs and removes the former `noorm identity ci` diagnostic
-- [x] `identity init` - Generate or regenerate an identity headlessly
-- [x] `identity edit` - Edit identity metadata (name, email)
-- [x] `identity export` - Export public key
-- [x] `identity list` - List known users
-
-**Init:**
-
-- [x] `noorm init` - Bootstrap a project (identity setup + project setup) from CLI
-
-**Settings:** (entire domain TUI-only — no `noorm settings` command)
-
-- [x] `settings init` - Initialize `settings.yml`
-- [x] `settings build` - Build/regenerate settings
-- [x] `settings edit` - Interactive editor. Prompts for a field to edit (paths, strict, logging, stages, rules), applies the change, then loops back to the field picker. Exits on "Done" selection or `Esc`. One command covers everything.
-- [x] `settings secret` - Interactive secret **requirement** declaration (config enforcement, not value storage). Declares that a given secret must be set for a particular stage (or all stages). Loop pattern: pick action (add/edit/rm requirement), pick scope (universal or a stage), apply, loop. Exits on "Done" or `Esc`. Actual secret values live in `secret/*` / vault.
-
-**Secrets:** (entire `secret/*` domain TUI-only — distinct from `vault`)
-
-- [x] `secret list` - List secrets
-- [x] `secret set ` - Set a secret
-- [x] `secret rm ` - Remove a secret
-
-**Configuration (additional):**
-
-- [x] `config cp ` - Copy a config
-- [x] `config export ` - Export a config to file
-- [x] `config import ` - Import a config from file
-
-**Changes (additional):**
-
-- [x] `change add ` - Create a new change
-- [x] `change edit ` - Edit an existing change
-- [x] `change rm ` - Delete a change
-- [x] `change rewind ` - Rewind to a specific change
-- [x] `change history detail ` - Show per-file execution history
-
-**Database (additional):**
+All headless CLI commands are implemented except one:
- [ ] `db dt-modify ` - Modify a `.dt` file (currently only TUI `DtModifyScreen`)
-**Database Exploration:**
-
-- [x] `db explore views` + `db explore views ` - List and inspect views
-- [x] `db explore procedures` + `db explore procedures ` - List and inspect stored procedures
-- [x] `db explore functions` + `db explore functions ` - List and inspect functions
-- [x] `db explore types` + `db explore types ` - List and inspect custom types
-- [x] `db explore indexes` - List indexes
-- [x] `db explore fks` - List foreign keys
-
-**SQL Terminal:** (CLI `sql` is one-shot only)
-
-- [x] `sql repl` - Interactive SQL REPL (currently TUI-only `SqlTerminalScreen`)
-- [x] `sql history` - Show SQL execution history
-- [x] `sql clear` - Clear SQL execution history
-
### CI/CD Integration
diff --git a/bun.lockb b/bun.lockb
index f59b51af..3eee0052 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/docker-compose.yml b/docker-compose.test.yml
similarity index 100%
rename from docker-compose.yml
rename to docker-compose.test.yml
diff --git a/docs/cli/flags.md b/docs/cli/flags.md
index ca99e344..48fc937e 100644
--- a/docs/cli/flags.md
+++ b/docs/cli/flags.md
@@ -1,94 +1,100 @@
# CLI flag conventions
-`noorm`'s CLI is built on [citty](https://github.com/unjs/citty), which
-parses flags relative to the subcommand they belong to. A flag that
-"works on every command" still has to be placed where the parser
-expects it. This page covers the two rules that catch most users out.
+`noorm`'s CLI is built on [citty](https://github.com/unjs/citty), which parses flags
+relative to the subcommand they belong to — a flag typed before the subcommand name never
+reaches the leaf command's own parser. `noorm` works around that for the one flag that
+genuinely needs to run before dispatch, by stripping it out of `argv` before citty ever sees
+it. This page covers that flag and the rule for everything else.
## Global flags vs per-subcommand flags
-A flag is **global** when it's owned by the root `noorm` command and
-must appear *before* the subcommand. A flag is **per-subcommand** when
-it's owned by an individual command like `noorm sql query` and must
-appear *after* the subcommand name.
+A flag is **global** when `noorm` recognizes and hoists it regardless of where it appears —
+before or after the subcommand. A flag is **per-subcommand** when it's owned by an
+individual command like `noorm sql query` and only works *after* the subcommand name.
-`noorm` has very few true globals on purpose — most flags vary in
-meaning per command (`-c` is `--cwd` at the root, but `--config` on
-many subcommands), so per-subcommand parsing is the safer default.
+`noorm` hoists exactly one flag on purpose — most flags vary in meaning per command (`-c` is
+`--cwd` at the root, but `--config` on many subcommands), so per-subcommand parsing is the
+default for everything else, `--dry-run`/`--json`/`--yes` included.
-### Globals (before the subcommand)
+### The one global: `-c` / `--cwd `
| Flag | Purpose |
|------|---------|
-| `--cwd ` / `-c ` | Change working directory before resolving `.noorm/`. Same idea as `git -C`. |
-| `--help` / `-h` | Show top-level help (citty renders it). |
-| `--version` | Print the installed noorm version. |
+| `--cwd ` / `-c ` | Change working directory before resolving `.noorm/`. Same idea as `git -C`. Recognized only **before** the subcommand — see the note below. |
+
+`--cwd`/`-c` is global for two reasons:
+
+1. It's consumed **before dispatch** — it sets the working directory everything else
+ (project discovery, config resolution) resolves against, so it's genuinely the CLI's own
+ flag rather than any subcommand's.
+2. `-c` already means `--config` *after* the subcommand (see the overload note below).
+ Hoisting it lets `noorm -c run build` mean cwd while `noorm run build -c `
+ means config, without either shadowing the other.
Examples:
noorm -c packages/db run build
noorm --cwd /repos/myapp change ff
- noorm --help
+
+`--help`/`-h` and `--version` are also recognized outside this mechanism — that's citty's own
+framework behaviour, not a `noorm`-specific hoist.
+
+An unrecognized flag placed before the subcommand is an error, not a silent no-op:
+
+ $ noorm --bogus-flag config list
+ Error: Unrecognized flag '--bogus-flag' before the subcommand — noorm can't forward it there.
+ The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it.
+ Move '--bogus-flag' after the subcommand instead, e.g. noorm ... --bogus-flag.
### Per-subcommand flags (after the subcommand)
-Everything else lives on a specific subcommand. Common per-subcommand
-flags include `--json`, `--config `, `--force`, `--yes`,
-`--dry-run`, and `--help`. To see exactly which flags a command
-accepts, append `--help` to it:
+Everything else lives on a specific subcommand — `--config `, `--force`, `--dry-run`,
+`--json`, `--yes`/`-y`, and any command-specific flag. These are **not** hoisted, so they only
+work after the subcommand name:
+
+ noorm run build --dry-run
+ noorm config list --json
+ noorm sql repl --yes
+
+Placed before the subcommand instead, none of them are honoured — `noorm --dry-run run build`
+is rejected exactly like `noorm --bogus-flag config list` above, naming `--dry-run` as the
+offending flag. To see exactly which flags a command accepts, append `--help` to it:
noorm change ff --help
noorm db create --help
noorm sql query --help
-## The `--json` flag specifically
-
-`--json` is the single most common discovery snag. It is **not** a
-global flag — every subcommand that supports machine-readable output
-declares its own `--json`. That means it must appear after the
-subcommand name, not before.
-
-Works (correct placement):
-
- noorm config list --json
- noorm change ff --json
- noorm sql query "SELECT 1" --json
- noorm --config dev change ff --json
+## The `--config` / `-c` overload
-Does not work (citty parses `--json` as an unknown root flag, then
-fails to find a subcommand):
+`-c` is overloaded by position, which is the one sharp edge left in this system:
- noorm --json config list
- noorm --json change ff
- noorm --json sql query "SELECT 1"
+- `noorm -c run build` — global `--cwd`, because `-c` appears **before** the subcommand.
+- `noorm run build -c ` — per-command `--config`, because `-c` appears **after** the subcommand.
-The same rule applies to `--config` / `-c` as a per-command flag (the
-form that switches the active config for one invocation). Both
-placements happen to be valid for `--config` because the root accepts
-it via the citty inheritance hook — but for `--json`, only the
-post-subcommand form works.
+The first non-flag token (the subcommand name) is the boundary that decides which meaning
+applies. `--config` in long form has no global meaning at all — it only works after the
+subcommand, so `noorm --config prod run build` is not the same as `noorm run build --config prod`
+(the former is rejected as an unrecognized global flag).
## Mental model
-Think of `noorm` as having a single global (`-c`/`--cwd`) for "where
-should I run?" and everything else as command-scoped. When in doubt,
-put the flag right next to the subcommand it modifies:
+`noorm` has exactly one true global, `-c`/`--cwd`, and everything else is command-scoped. Put
+every flag right next to the subcommand it modifies:
noorm
-If a flag isn't being honored, the most likely cause is that it
-landed on the wrong side of the subcommand boundary.
+If a flag isn't being honored, it's placed before the subcommand boundary — or it's a typo,
+in which case `noorm` now says so instead of silently dropping it.
## Related
- [`noorm` help conventions](./help.md) — how to find a command's flags.
- [Headless reference](../headless.md) — full table of common flags.
-- [Troubleshooting](../guide/troubleshooting.md) — quick fixes for the
- `--json` placement issue and other common surprises.
+- [Troubleshooting](../guide/troubleshooting.md) — quick fixes for common surprises.
diff --git a/docs/cli/identity.md b/docs/cli/identity.md
index 9d3826fd..2c54d71e 100644
--- a/docs/cli/identity.md
+++ b/docs/cli/identity.md
@@ -44,7 +44,7 @@ Print your public key so teammates can add you to encrypted vaults:
```bash
noorm identity export
-noorm --json identity export
+noorm identity export --json
```
@@ -54,7 +54,7 @@ Show every identity discovered from database syncs (the audit trail of who has t
```bash
noorm identity list
-noorm --json identity list
+noorm identity list --json
```
diff --git a/docs/cli/secret.md b/docs/cli/secret.md
new file mode 100644
index 00000000..76dc0860
--- /dev/null
+++ b/docs/cli/secret.md
@@ -0,0 +1,71 @@
+# noorm secret
+
+
+Manage **config-scoped local secrets** — values stored on disk in `.noorm/state/state.enc`,
+tied to a single config, and never shared with the rest of the team. This is one of three
+places a secret value can live; see [Which tier?](#which-tier) below before reaching for it.
+
+
+## Subcommands
+
+| Command | Purpose |
+|---------|---------|
+| `noorm secret set ` | Store a secret for the active or named config |
+| `noorm secret list` | List secret keys for a config (values never shown) |
+| `noorm secret rm ` | Remove a secret from a config |
+
+
+## Flags
+
+Common to every subcommand:
+
+- `--config ` / `-c ` — target a config other than the active one
+- `--json` — emit machine-readable JSON on stdout
+
+`secret rm` additionally requires:
+
+- `--yes` / `-y` — confirm the deletion; omitted, the command exits non-zero without deleting
+
+
+## Which tier?
+
+noorm resolves `$.secrets.KEY` in a template from three tiers, config-local first:
+
+| Tier | Command | Scope | Shared with team? |
+|------|---------|-------|--------------------|
+| Config-local | `noorm secret set` | One config | No — per-user, per-machine |
+| Global-local | TUI only (`noorm ui` → Settings → Secrets) | Every config on this machine | No — per-user, per-machine |
+| Vault | [`noorm vault set`](../guide/environments/vault.md) | Every config, every teammate | Yes — encrypted in the database |
+
+If a value should be different for you than for the rest of the team, it belongs in
+`noorm secret` (or the TUI's global-local screen). If it should be the same for everyone —
+CI included — it belongs in `noorm vault set`. `noorm secret set` on a value the whole team
+needs is the mistake this page exists to prevent: it lands in your local `state.enc` only,
+so a teammate or a CI runner resolving the same key finds nothing and either fails loudly (a
+missing secret now throws — see [Templates](../guide/sql-files/templates.md)) or silently
+picks up a stale vault value instead of the one you just set.
+
+See [Secrets](../guide/environments/secrets.md) for config-scoped vs. global-local, and
+[Vault](../guide/environments/vault.md) for the full three-tier resolution hierarchy and
+team-provisioning workflow.
+
+
+## Examples
+
+ noorm secret set API_KEY "sk-live-..."
+ noorm secret set DB_PASSWORD "secret123" --config prod
+ noorm secret set API_KEY "sk-live-..." --json
+
+ noorm secret list
+ noorm secret list --config staging --json
+
+ noorm secret rm OLD_KEY --yes
+ noorm secret rm OLD_KEY --yes --config prod
+
+
+## Related
+
+- [`noorm settings secret`](./settings-secret.md) — edits which secrets a stage *requires*,
+ not their values.
+- [Secrets guide](../guide/environments/secrets.md) — full CLI + TUI workflow.
+- [Vault guide](../guide/environments/vault.md) — team-shared secrets and CI provisioning.
diff --git a/docs/cli/settings-secret.md b/docs/cli/settings-secret.md
index b35d42a5..991d353d 100644
--- a/docs/cli/settings-secret.md
+++ b/docs/cli/settings-secret.md
@@ -5,7 +5,7 @@ Interactive editor for the secret *requirement* declarations in
`settings.yml`. Manages which secrets each stage requires — not the
actual values.
-For secret values, see [`noorm secret set`](../headless.md#secrets-config-scoped).
+For secret values, see [`noorm secret set`](./secret.md).
## Environment
diff --git a/docs/design/v1-49-54-cli-field-defects.md b/docs/design/v1-49-54-cli-field-defects.md
new file mode 100644
index 00000000..53e42e62
--- /dev/null
+++ b/docs/design/v1-49-54-cli-field-defects.md
@@ -0,0 +1,242 @@
+# v1/49-54 — field-reported CLI defects
+
+Six issues filed 2026-07-25 against `noormdev/noorm` from one production MSSQL migration
+(~155 files, 9 phase directories, hand-rolled zsh deploy script → noorm).
+
+One reporting effort, six symptoms. The traces show they are not six independent bugs:
+four of them are the same structural failure wearing different clothes — **a contract is
+documented, the plumbing to honour it is absent, and the absence is silent.**
+
+## The shared shape
+
+| # | Documented contract | What the code does | How it fails |
+|---|---|---|---|
+| 49 | `--dry-run` previews without executing | `run build` never declares the flag | silent — flag ignored, build runs |
+| 50 | secrets resolve across three tiers | the three-tier resolver has zero callers | silent — renders `'undefined'` |
+| 51 | env vars outrank stored config | `db create` reads stored config raw | silent — wrong database created |
+| 52 | `--json` anywhere, text otherwise | text goes to stderr; `--json` dropped if leading | silent — empty stdout, exit 0 |
+
+Every one exits 0. That is the through-line worth fixing, not just the four instances.
+A flag that is ignored must never be indistinguishable from a flag that was honoured.
+
+## Concepts
+
+### Silent flag drop (citty dispatch)
+
+`citty` forwards only `rawArgs.slice(subCommandArgIndex + 1)` to a subcommand
+(`node_modules/citty/dist/index.mjs:217`). Any flag typed *before* the subcommand name is
+discarded before the leaf command parses. The root command declares no `args` at all
+(`src/cli/index.ts:44-71`), so nothing catches them.
+
+Verified empirically against the built CLI:
+
+ $ node dist/cli/index.js config list --json
+ {"configs":[]}
+
+ $ node dist/cli/index.js --json config list
+ No configurations found. # exit 0
+
+Both documented forms appear in the docs. `docs/headless.md:55,641` writes
+`noorm --dry-run run build` — the form that cannot work. The reporter used exactly that form.
+
+This single defect is why #49's repro looks the way it does and is half of #52.
+
+### Result output vs diagnostic output
+
+`Logger` has two writers (`src/core/logger/logger.ts:143`): `console` (stdout) and
+`diagnostics` (stderr). `#writeConsole` (`:497-543`) routes:
+
+- `--json` → NDJSON event lines to **stdout**
+- otherwise → human text to **stderr**
+
+So `noorm change list` writes its table to stderr, and `noorm change list --json` writes
+log noise to stdout *ahead of* the payload. Both directions are backwards. A command's
+**result** is stdout; its **diagnostics** are stderr, in both modes.
+
+`change list`/`history` compound it by calling `outputResult` only under `if (args.json)`
+(`src/cli/change/list.ts:57-61`, `history.ts:54-58`) — the text branch never reaches the
+one helper that writes to stdout correctly.
+
+### Connection resolution has two doors
+
+`resolveConfig` (`src/core/config/resolver.ts:211`) is canonical: it merges
+`DEFAULTS <- stage <- stored <- env <- flags` (env at `:253`). Everything reaching it via
+`withContext`/`createContext` honours `NOORM_CONNECTION_*`.
+
+`StateManager.getConfig(name)` (`src/core/state/manager.ts:349`) is a raw record read with
+no merge. `db create` (`src/cli/db/create.ts:46`) and `db drop` (`src/cli/db/drop.ts:50`)
+call it directly and then connect with the result. That is the whole of #51.
+
+`docs/dev/config.md:13-23` and `docs/headless.md:161-165` state the precedence as a blanket
+guarantee for `noorm`, with no per-command exceptions. The docs are right; two commands
+are wrong.
+
+### Secret tiers: documented, built, never connected
+
+`resolveSecret` and `buildSecretsContext` (`src/core/vault/resolve.ts:45,149`) implement the
+documented three-tier precedence (config-local > global-local > vault).
+`docs/dev/vault.md:169` names `buildSecretsContext()` as *the* template-render path.
+
+Neither function has a production caller. The render path instead calls
+`stateManager.getAllSecrets(configName)` (`src/core/state/manager.ts:479`) — config-local
+tier only, no vault merge — from `src/sdk/namespaces/run.ts:255` and five sibling sites.
+
+**Vault secrets never reach `$.secrets`, for any identity.** The reporter's diagnosis
+("the vault tier silently yielded nothing for this identity") was correct about the symptom
+and understated the cause: it yields nothing for everyone.
+
+Then two more layers of silence turn a missing secret into valid SQL:
+
+- `$.secrets` is a plain object (`src/core/template/context.ts:70`), so a missing key is
+ `undefined`, not an error.
+- `sqlQuote` (`src/core/template/utils.ts:85-95`) guards `null` but not `undefined`;
+ `String(undefined)` → `"undefined"` → `'undefined'`.
+
+Net: `CREATE LOGIN worker WITH PASSWORD = 'undefined';` applied successfully.
+
+## Business rules
+
+1. **A flag that cannot be honoured is an error, never a no-op.** Unknown or
+ misplaced flags fail loudly with the correct form named.
+2. **Results go to stdout; diagnostics go to stderr.** In every mode. `--json` stdout is
+ parseable without `tail -1`.
+3. **Env-only mode is a first-class configuration source for every command that connects.**
+ No command reads a stored config raw and then connects with it.
+4. **A secret that cannot be resolved has no correct rendering.** Fail, naming the key.
+ Never substitute a placeholder into SQL.
+5. **Scaffolding teaches its own layout.** A command that creates a directory structure
+ either populates it or documents it.
+
+## Decisions
+
+### D1 — Only `-c`/`--cwd` is a root-level flag
+
+A flag goes on the command that uses it. `-c`/`--cwd` is the sole exception: it is consumed
+before dispatch — it sets the working directory everything else (project discovery, config
+resolution) resolves against — and `-c` already means `--config` after the subcommand, so the
+split is genuinely load-bearing rather than a convenience.
+
+`--dry-run`, `--json`, `--yes` are **not** hoisted. They are per-subcommand, exactly like
+`--config` and `--force` always were. A flag placed before the subcommand that isn't
+`-c`/`--cwd` is rejected outright, naming the flag and the correct form — a flag that cannot
+be honoured is still an error, never a silent no-op (Business rule 1).
+
+Implemented by narrowing `extractGlobalCwd` (`src/cli/index.ts`) to strip only `-c ` /
+`--cwd ` / `--cwd=` from `rawArgs` before dispatch; every other flag-looking token
+seen before the subcommand falls through to the error path.
+
+Rejected: declaring the flags at every level. It multiplies declaration sites, and citty
+still would not forward a parent's parsed value to a child.
+
+### D2 — `run build` and `db teardown` declare and honour `--dry-run`
+
+Both omit `sharedArgs.dryRun` while their docs promise it
+(`docs/cli/run.md:26`, `docs/guide/database/teardown.md:255`). The core gate
+(`src/core/runner/runner.ts:608`) already works and is reached by five sibling commands;
+`build` simply never sets the flag. Threading it through `BuildOptions`
+(`src/sdk/types.ts:82-88`) reaches live code with no new branch.
+
+### D3 — `db create` / `db drop` resolve through `resolveConfig`
+
+Replace the raw `stateManager.getConfig` read with the canonical resolver, matching
+`run build`. No new precedence logic — the merge already exists and is tested
+(`tests/core/config/resolver.test.ts:234-260`).
+
+### D4 — Logger writers swap by purpose, not by mode
+
+Command results → stdout. Logger event stream → stderr, in both text and `--json` mode.
+`change list`/`history` build their text and pass it to `outputResult` unconditionally,
+matching the commands that already get this right (`src/cli/config/list.ts:40,59-63`).
+
+An empty result set prints an explicit empty-state line, so "no changesets" is never
+indistinguishable from "no output".
+
+### D5 — Secrets fail hard, and the vault tier gets connected
+
+Two parts, both required. Failing hard alone would turn the reporter's broken login into a
+loud error — correct, but they *had* set the secret via `vault set` as the docs instruct, so
+it must also resolve.
+
+- `$.secrets` becomes a Proxy that throws on unknown-key access, naming the key and the
+ tiers searched.
+- `sqlQuote` rejects `undefined` rather than stringifying it — defence in depth for any
+ other path that reaches it.
+- The render path calls `buildSecretsContext` instead of `getAllSecrets`, so the documented
+ three tiers actually apply.
+
+Optional probing needs an explicit form. `$.secrets.KEY ?? default` cannot survive a
+throwing `get` trap — `??` evaluates its left operand, which is the read that throws. So the
+contract is: **read throws, `in` does not.** A template probing for an optional secret writes
+
+ KEY in $.secrets ? $.secrets.KEY : default
+
+That is a feature, not a workaround. `?? default` silently accepts a missing secret, which
+is the exact coercion that turned an unresolved password into the string `undefined`.
+Requiring `in` makes "this secret is optional" a statement the template author wrote on
+purpose, and leaves every unguarded read loud.
+
+`examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl` used the `?.[key] ?? null` form to
+force a flag off when its secret is absent — a legitimate intent, expressed the unsafe way.
+It moves to the `in` guard.
+
+### D6 — `change add` scaffolds SQL stubs
+
+`createChange` (`src/core/change/scaffold.ts:111-130`) already creates `change/` and
+`revert/` directories plus `changelog.md`, but leaves both directories empty. An empty
+changeset is not merely inert — `parseChange` throws
+`ChangeValidationError` (`src/core/change/parser.ts:117`), which `#loadChange`
+(`src/core/change/manager.ts:624`) converts to `ChangeNotFoundError`. The user is told the
+change does not exist, when it does.
+
+Scaffold `change/001_.sql` and `revert/001_.sql` as commented stubs.
+
+`docs/headless.md:885` currently documents this as creating `change.sql` and `revert.sql` —
+files, not directories. It is wrong today and must be corrected regardless.
+
+### D7 — Docs corrections are part of the fix, not follow-up
+
+Three doc statements are actively false and each one cost the reporter time:
+
+- `docs/dev/secrets.md:113,151-153` — claims there is no headless `noorm secret set` and
+ directs users to `noorm vault set` instead. `src/cli/secret/set.ts:12-65` is fully
+ headless, and `docs/guide/environments/secrets.md:27-40` documents it correctly. The dev
+ doc contradicts both the code and its own user guide.
+- `docs/headless.md:885` — wrong `change add` output shape (D6).
+- `docs/headless.md:55,641` — `noorm --dry-run run build`, a form that never works;
+ `--dry-run` is per-subcommand only, matching `--config`/`--force` (D1).
+
+`noorm secret` also has no `docs/cli/` page while `noorm vault` has two.
+
+## Out of scope
+
+- Changing the changeset directory convention. `change/` + `revert/` stays (issue #53 says so).
+- Reworking the vault identity/enrolment model. D5 connects the existing resolver; it does
+ not redesign key distribution.
+- The 18 pre-existing test failures on `next` (test-double gaps in
+ `tests/sdk/run-build-filtering.test.ts`, `tests/sdk/context.test.ts`, `tests/rpc/*`).
+ D5 touches `src/sdk/namespaces/run.ts:255`, which is the line those doubles trip on, so
+ they are repaired as a side effect — but they are not the goal.
+
+## Open question — #54
+
+Filed as "not reliably reproducible", with the reporter explicitly asking to rule double
+execution in or out at the source rather than chase the flake. Investigation pending;
+this section is completed before the checkpoint that addresses it.
+
+## Change log
+
+### 2026-07-25 — D1 reversed: only `-c`/`--cwd` hoisted
+
+**What changed:** `--dry-run`, `--json`, `--yes` are no longer hoisted to any position. Only
+`-c`/`--cwd` is a root-level flag; every other flag, including these three, is per-subcommand
+and errors if placed before the subcommand — exactly like `--config`/`--force` always did.
+
+**Why:** the original D1 created an asymmetry with no principled justification: three flags
+worked in either position while `--config`/`--force` never did, for no reason beyond "these
+three seemed worth the convenience." The standard is now uniform — a flag goes on the command
+that uses it — with `-c`/`--cwd` as the sole exception, because it is consumed before dispatch
+and its short form collides with `--config` after the subcommand.
+
+**Superseded:** the original D1 hoisted `--dry-run`, `--json`, `--yes` out of `rawArgs` before
+citty dispatch and re-injected them into the leaf command's parsed args, the same mechanism
+`-c`/`--cwd` used.
diff --git a/docs/dev/README.md b/docs/dev/README.md
deleted file mode 100644
index d72e78a4..00000000
--- a/docs/dev/README.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# noorm Documentation
-
-
-## What is noorm?
-
-noorm is a database schema and change manager. It tracks which SQL files have run, manages database credentials securely, and coordinates team access to shared databases.
-
-Unlike ORMs that abstract away SQL, noorm embraces it. You write SQL for your target database. noorm handles the plumbing: tracking execution, managing credentials, coordinating team access.
-
-
-## Core Concepts
-
-
-### Identity
-
-Every database operation records who performed it. noorm supports two identity modes:
-
-- **Audit identity** - Simple name/email for tracking (auto-detected from git)
-- **Cryptographic identity** - X25519 keypair for secure credential sharing
-
-[Read more about Identity](./identity.md)
-
-
-### State
-
-All sensitive data lives in an encrypted state file. This includes database credentials, secrets for SQL templates, and your cryptographic identity.
-
-- AES-256-GCM encryption
-- Identity-based or machine-based key derivation
-- Automatic schema migrations
-
-[Read more about State Management](./state.md)
-
-
-### Configuration
-
-Configs define database connections. They merge from multiple sources with clear precedence:
-
-```
-CLI flags > Environment > Stored config > Stage defaults > Defaults
-```
-
-Per-channel access roles (`viewer`/`operator`/`admin`) gate dangerous operations. Stages enforce team-wide constraints.
-
-[Read more about Configuration](./config.md)
-
-
-## Quick Start
-
-```typescript
-import { StateManager } from './core/state'
-import { resolveConfig } from './core/config'
-import { resolveIdentity } from './core/identity'
-
-// Load encrypted state
-const state = new StateManager(process.cwd())
-await state.load()
-
-// Resolve current identity
-const identity = await resolveIdentity()
-console.log(`Running as: ${identity.name}`)
-
-// Get active config
-const config = resolveConfig(state)
-if (!config) {
- console.error('No config found. Run: noorm config add')
- process.exit(1)
-}
-
-console.log(`Using database: ${config.connection.database}`)
-```
-
-
-## Architecture
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ CLI (Ink/React) │
-│ Commands, UI components, user interaction │
-└────────────────────────────┬────────────────────────────┘
- │ subscribes to events
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Observer (Events) │
-│ file:*, build:*, config:*, state:*, template:*, lock:* │
-│ teardown:*, sql-terminal:*, change:*, explore:* │
-└────────────────────────────┬────────────────────────────┘
- │ emits events
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Core Modules │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
-│ │ Identity │ │ State │ │ Config │ │ Lock │ │
-│ │ X25519 │ │ Encrypted│ │ Merge & │ │Concur- │ │
-│ │ Keypairs │ │ Storage │ │ Validate │ │ rency │ │
-│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
-│ │ Template │ │ Runner │ │Change │ │ Logger │ │
-│ │ Eta, SQL │ │ Execute, │ │ Versioned│ │ Events │ │
-│ │ Helpers │ │ Tracking │ │ Changes │ │Rotation│ │
-│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Explore │ │ Teardown │ │ SQL │ │
-│ │ Schema │ │ Truncate │ │ Terminal │ │
-│ │ Browser │ │ & Drop │ │ REPL │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-└─────────────────────────────────────────────────────────┘
-```
-
-Core modules emit events. The CLI subscribes. This keeps business logic separate from UI concerns.
-
-
-## File Structure
-
-```
-.noorm/
-├── state.enc # Encrypted configs, secrets, identity (gitignored)
-├── settings.yml # Build rules, stages (version controlled)
-├── noorm.log # Operation log (gitignored)
-└── sql-history/ # SQL terminal history (gitignored)
- ├── dev.json # History index per config
- └── dev/ # Gzipped query results
- └── *.results.gz
-
-~/.noorm/
-├── identity.key # Private key (mode 600)
-└── identity.pub # Public key (mode 644)
-```
-
-
-## Documentation Index
-
-
-### Core
-
-| Document | Description |
-|----------|-------------|
-| [Data Model](./datamodel.md) | Complete type reference, database schemas, file formats |
-| [Identity](./identity.md) | Audit tracking, cryptographic identity, secure sharing |
-| [State](./state.md) | Encrypted storage, configs, secrets, persistence |
-| [Config](./config.md) | Resolution, validation, protection, stages |
-| [Secrets](./secrets.md) | Encrypted secrets, required vs optional, CLI workflow |
-| [Settings](./settings.md) | Build rules, stages, project-wide behavior |
-
-
-### Execution
-
-| Document | Description |
-|----------|-------------|
-| [Runner](./runner.md) | SQL execution, change detection, dry run, preview |
-| [Change](./change.md) | Versioned changes, forward/rollback, execution history |
-| [Template](./template.md) | Eta templating, data loading, helper inheritance |
-| [Lock](./lock.md) | Concurrent operation protection, table-based locking |
-
-
-### Database Tools
-
-| Document | Description |
-|----------|-------------|
-| [Explore](./explore.md) | Schema introspection, browse tables/views/functions |
-| [Teardown](./teardown.md) | Data truncation, schema teardown, reset operations |
-| [SQL Terminal](./sql-terminal.md) | Interactive SQL REPL, query history, result storage |
-
-
-### Operations
-
-| Document | Description |
-|----------|-------------|
-| [Logger](./logger.md) | File logging, log viewer, event classification, rotation |
-
-
-## Key Patterns
-
-**Error Handling** - Uses `attempt`/`attemptSync` from `@logosdx/utils` instead of try-catch:
-
-```typescript
-const [result, err] = await attempt(() => dangerousOperation())
-if (err) {
- observer.emit('error', { source: 'module', error: err })
- return
-}
-```
-
-**Events** - Core modules emit, CLI subscribes:
-
-```typescript
-// In core module
-observer.emit('config:created', { name })
-
-// In CLI
-observer.on('config:created', ({ name }) => {
- console.log(`Created: ${name}`)
-})
-```
-
-**Layered Configuration** - Multiple sources merge predictably:
-
-```typescript
-// defaults ← stage ← stored ← env ← flags
-const config = resolveConfig(state, { flags: { connection: { port: 5433 } } })
-```
diff --git a/docs/dev/change.md b/docs/dev/change.md
index 01358c01..c503305c 100644
--- a/docs/dev/change.md
+++ b/docs/dev/change.md
@@ -156,15 +156,19 @@ const change = await createChange('/project/changes', {
description: 'add-user-preferences',
date: new Date(), // Optional, defaults to today
})
-// Creates: 2025-12-18-add-user-preferences/
+// Creates: 2025-12-18-add-user-preferences/, already seeded with a runnable
+// stub in each folder: change/001_add-user-preferences.sql and
+// revert/001_add-user-preferences.sql (a comment naming what belongs there).
+// An empty change/+revert/ pair fails validation, so createChange never
+// returns one — see "Scaffolding a Runnable Stub" below.
-// Add a SQL file
+// Add a SQL file — sequence numbers continue from the stub, so this is 002
const updated = await addFile(change, 'change', {
name: 'create-preferences-table',
type: 'sql',
content: 'CREATE TABLE user_preferences (...);',
})
-// Creates: change/001_create-preferences-table.sql
+// Creates: change/002_create-preferences-table.sql
// Add a manifest file
await addFile(updated, 'change', {
@@ -172,13 +176,26 @@ await addFile(updated, 'change', {
type: 'txt',
paths: ['tables/preferences.sql', 'views/user_prefs.sql'],
})
-// Creates: change/002_schema-refs.txt
+// Creates: change/003_schema-refs.txt
// Remove a file
-await removeFile(change, 'change', '001_create-preferences-table.sql')
+await removeFile(change, 'change', '001_add-user-preferences.sql')
```
+### Scaffolding a Runnable Stub
+
+`createChange` doesn't leave `change/` and `revert/` empty. An empty pair fails
+`parseChange`'s validation, and the caller sees that misreported as "change not found"
+rather than "needs editing" — so `createChange` scaffolds one stub file into each folder via
+`addFile`, using the change's own slug as the filename: `001_.sql` in `change/`,
+`001_.sql` in `revert/`. Each stub is a single comment naming what belongs there
+("Add the SQL statements that apply this change" / "...undo this change"). A stub full of
+comments still isn't runnable content — `executeChange` rejects files that are empty or
+contain only comments/template placeholders, with a message that says so, before it ever
+executes anything.
+
+
## Change Status
Each change has a status based on its execution history:
diff --git a/docs/dev/ci.md b/docs/dev/ci.md
index 722cb8a9..814bec79 100644
--- a/docs/dev/ci.md
+++ b/docs/dev/ci.md
@@ -190,8 +190,8 @@ Same pipeline, but templates need vault-decrypted secrets:
Use `--json` for structured output:
```bash
-noorm --json ci init | jq '.stateFile'
-noorm --json run build | jq '.status'
+noorm ci init --json | jq '.stateFile'
+noorm run build --json | jq '.status'
```
diff --git a/docs/dev/config.md b/docs/dev/config.md
index ab8cc2a0..920e9f4a 100644
--- a/docs/dev/config.md
+++ b/docs/dev/config.md
@@ -112,7 +112,6 @@ NOORM_{PATH}_{TO}_{VALUE} → { path: { to: { value: '' } } }
|----------|---------|
| `NOORM_CONFIG` | Which config to use |
| `NOORM_YES` | Skip confirmations |
-| `NOORM_JSON` | JSON output mode |
```bash
# CI/CD example: use stored config with overridden host
diff --git a/docs/dev/headless.md b/docs/dev/headless.md
index 86c3a29e..0eca21c7 100644
--- a/docs/dev/headless.md
+++ b/docs/dev/headless.md
@@ -30,7 +30,7 @@ Not every command accepts every flag — append `--help` to any command to see t
**Example:**
```bash
-noorm --json --config prod change ff
+noorm change ff --config prod --json
noorm change ff --help # Per-command help, rendered by citty
```
@@ -51,7 +51,7 @@ If you've already set an active config (via `noorm config use ` or through
```bash
# These are equivalent if 'dev' is the active config
noorm change ff
-noorm --config dev change ff
+noorm change ff --config dev
```
@@ -61,7 +61,7 @@ Specify a config by name using `--config` or the `NOORM_CONFIG` env var:
```bash
# Via flag
-noorm --config production change ff
+noorm change ff --config production
# Via env var
export NOORM_CONFIG=production
@@ -120,7 +120,7 @@ Execute all SQL files in the schema directory.
```bash
noorm run build
-noorm --force run build # Skip checksums
+noorm run build --force # Skip checksums
```
**JSON output:**
@@ -386,7 +386,7 @@ Colored console output with status icons:
Use `--json` for machine-readable output:
```bash
-noorm --json change ff | jq '.executed'
+noorm change ff --json | jq '.executed'
```
JSON mode disables colors and outputs structured data.
@@ -436,7 +436,7 @@ jobs:
run: noorm change ff
- name: Export schema (optional)
- run: noorm --json -c prod db explore > schema.json
+ run: noorm -c prod db explore --json > schema.json
```
@@ -463,7 +463,7 @@ migrate:
stage: deploy
script:
- npm ci
- - noorm --config production change ff
+ - noorm change ff --config production
only:
- main
environment:
@@ -480,7 +480,7 @@ set -e
CONFIG="${1:-}" # Optional, falls back to active config
echo "Checking for pending changes..."
-PENDING=$(noorm --json ${CONFIG:+-c "$CONFIG"} change | jq '[.[] | select(.status=="pending")] | length')
+PENDING=$(noorm ${CONFIG:+-c "$CONFIG"} change --json | jq '[.[] | select(.status=="pending")] | length')
if [ "$PENDING" -gt 0 ]; then
echo "Applying $PENDING pending changes..."
@@ -516,7 +516,7 @@ noorm change ff
2. **Use `--json` for scripting** - Easier to parse than text output
```bash
- noorm --json change | jq '.[] | select(.status=="pending")'
+ noorm change --json | jq '.[] | select(.status=="pending")'
```
3. **Check exit codes** - Non-zero means failure
@@ -533,7 +533,7 @@ noorm change ff
6. **Test with `--dry-run`** - Preview operations before executing
```bash
- noorm --dry-run change ff
+ noorm change ff --dry-run
```
7. **Capture logs** - noorm appends to `.noorm/state/noorm.log` for debugging
@@ -692,7 +692,9 @@ Status listing previously lived on the bare `change` handler -- it was moved to
### TUI-Only Wizards
-Some commands (`config add`, `config edit`, `config rm`) don't have a sensible headless equivalent — they exist to walk users through credential entry interactively. These commands `import { app } from '../../tui/app.js'` and route the user into the appropriate TUI screen, then exit. See `src/cli/config/add.ts` for the canonical pattern.
+Some commands (`config add`, `config edit`) don't have a sensible headless equivalent — they exist to walk users through credential entry interactively. Rather than importing the TUI, they print `Interactive only — run: noorm ui` to stderr and exit 1, leaving the user to launch the TUI themselves. See `src/cli/config/add.ts` for the canonical pattern.
+
+`config rm` used to live in this bucket but is now real and headless: bootstrap state (`initState`/`getStateManager`), gate the deletion through `checkConfigPolicy` (denies or requires `--yes`/`NOORM_YES` per the config's `user`-channel role for `config:rm`), then call `StateManager.deleteConfig()` — the same core path the TUI's removal screen uses, so the locked-stage guard applies identically. See `src/cli/config/rm.ts`.
### Registering Commands at the Root
diff --git a/docs/dev/project-discovery.md b/docs/dev/project-discovery.md
index 05d0c64f..5db32448 100644
--- a/docs/dev/project-discovery.md
+++ b/docs/dev/project-discovery.md
@@ -91,12 +91,12 @@ async function main(): Promise {
If no project is found and the user is heading to the home screen, they're redirected to the init screen to create a project.
-## SDK Usage
+## Internal Usage Without `chdir`
-SDKs might want discovery without the automatic `chdir`:
+Internal callers (this module lives at `src/core/project.ts` — it is not exported from the published `@noormdev/sdk` package) might want discovery without the automatic `chdir`:
```typescript
-import { findProjectRoot } from 'noorm/core'
+import { findProjectRoot } from './core/project.js'
// Find project root
const { projectRoot, hasProject } = findProjectRoot()
diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md
index 6ce4c5c3..046d1f4b 100644
--- a/docs/dev/sdk.md
+++ b/docs/dev/sdk.md
@@ -13,17 +13,17 @@ The noorm SDK provides programmatic access to noorm-managed databases. Use it fo
## Installation
-The SDK is part of the main noorm package:
+The SDK is published as a standalone `@noormdev/sdk` package:
-```typescript
-import { createContext } from 'noorm/sdk'
+```bash
+pnpm add @noormdev/sdk
```
## Quick Start
```typescript
-import { createContext } from 'noorm/sdk'
+import { createContext } from '@noormdev/sdk'
// Create a typed context for the 'dev' config
const ctx = await createContext<{ users: { id: number; name: string } }>({
@@ -90,11 +90,9 @@ interface CreateContextOptions {
}
```
-> **Finding the project root**: Unlike the CLI, the SDK does not automatically walk up directories to find the project. Pass `projectRoot` explicitly, or use [Project Discovery](./project-discovery.md) to find it first:
+> **Finding the project root**: `projectRoot` defaults to `process.cwd()`. That's correct when your script runs from the project directory. When running from elsewhere (a monorepo tooling package, a CI step with a different working directory), pass the directory containing `.noorm/` explicitly:
> ```typescript
-> import { findProjectRoot } from 'noorm/core'
-> const { projectRoot } = findProjectRoot()
-> const ctx = await createContext({ projectRoot })
+> const ctx = await createContext({ projectRoot: '/path/to/project' })
> ```
```typescript
@@ -677,7 +675,7 @@ Encrypted team secrets stored in the database. All operations require a connecti
Initialize the vault for this database.
```typescript
-const [vaultKey, err] = await ctx.noorm.vault.init()
+const vaultKey = await ctx.noorm.vault.init()
```
##### `vault.status()`
@@ -693,7 +691,7 @@ const status = await ctx.noorm.vault.status()
Set a vault secret.
```typescript
-const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey)
+await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey)
```
##### `vault.get(key, privateKey)`
@@ -725,7 +723,7 @@ const keys = await ctx.noorm.vault.list()
Delete a vault secret.
```typescript
-const [deleted, err] = await ctx.noorm.vault.delete('OLD_KEY')
+const deleted = await ctx.noorm.vault.delete('OLD_KEY')
```
##### `vault.exists(key)`
@@ -749,7 +747,7 @@ const result = await ctx.noorm.vault.propagate(privateKey)
Copy vault secrets to another config's database.
```typescript
-const [result, err] = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey)
+const result = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey)
```
@@ -787,7 +785,7 @@ const dest = await createContext({ config: 'dev' })
await source.connect()
await dest.connect()
-const [result, err] = await source.noorm.transfer.to(dest.noorm.config, {
+const result = await source.noorm.transfer.to(dest.noorm.config, {
tables: ['users', 'posts'],
onConflict: 'skip',
})
@@ -801,10 +799,8 @@ await dest.disconnect()
Generate a transfer plan without executing.
```typescript
-const [plan, err] = await source.noorm.transfer.plan(dest.noorm.config)
-if (plan) {
- console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`)
-}
+const plan = await source.noorm.transfer.plan(dest.noorm.config)
+console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`)
```
@@ -815,7 +811,7 @@ if (plan) {
Export a table to a .dt file. Extension determines format: `.dt`, `.dtz` (gzipped), `.dtzx` (encrypted).
```typescript
-const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz')
+const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz')
```
##### `dt.importFile(filepath, options?)`
@@ -823,7 +819,7 @@ const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.d
Import a .dt file into the connected database.
```typescript
-const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', {
+const result = await ctx.noorm.dt.importFile('./exports/users.dtz', {
onConflict: 'skip',
})
```
@@ -831,14 +827,16 @@ const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', {
#### Event Subscriptions
-Subscribe to core events via the observer:
+Subscribe to core events via the process-global `noormObserver` bus:
```typescript
-ctx.noorm.observer.on('file:after', (event) => {
+import { noormObserver } from 'noorm/sdk'
+
+noormObserver.on('file:after', (event) => {
console.log(`Executed ${event.filepath} in ${event.durationMs}ms`)
})
-ctx.noorm.observer.on('change:complete', (event) => {
+noormObserver.on('change:complete', (event) => {
console.log(`Change ${event.name}: ${event.status}`)
})
```
@@ -850,7 +848,7 @@ ctx.noorm.observer.on('change:complete', (event) => {
### Test Suites (Jest/Vitest)
```typescript
-import { createContext, Context } from 'noorm/sdk'
+import { createContext, Context } from '@noormdev/sdk'
describe('User API', () => {
let ctx: Context
@@ -887,7 +885,7 @@ describe('User API', () => {
### Scripts and Tooling
```typescript
-import { createContext } from 'noorm/sdk'
+import { createContext } from '@noormdev/sdk'
// Data export script
const ctx = await createContext({ config: 'prod' })
@@ -907,7 +905,7 @@ await ctx.disconnect()
### Type Generation
```typescript
-import { createContext } from 'noorm/sdk'
+import { createContext } from '@noormdev/sdk'
const ctx = await createContext({ config: 'dev' })
await ctx.connect()
@@ -925,7 +923,7 @@ await ctx.disconnect()
### CI/CD Pipeline
```typescript
-import { createContext } from 'noorm/sdk'
+import { createContext } from '@noormdev/sdk'
const ctx = await createContext({ config: process.env.DB_CONFIG })
await ctx.connect()
@@ -948,36 +946,36 @@ await ctx.disconnect()
## Error Handling
+SDK methods throw named, `instanceof`-matchable errors and let them propagate — no `[value, error]`
+tuples on the `ctx.noorm.*` surface. Wrap a call in `attempt()` (from `@logosdx/utils`) only when
+you'll do something with the error (translate, recover, observe, knowingly ignore); otherwise let it
+propagate. Never use try-catch. Carve-outs that don't follow that pattern:
+`ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design (failure as data, not an
+exception); `ctx.transaction(...)` callbacks must throw to trigger Kysely's rollback.
+
```typescript
+import { attempt } from '@logosdx/utils'
import {
createContext,
RequireTestError,
ProtectedConfigError,
LockAcquireError,
-} from 'noorm/sdk'
+} from '@noormdev/sdk'
-try {
- const ctx = await createContext({ config: 'prod', requireTest: true })
-} catch (err) {
- if (err instanceof RequireTestError) {
- console.error('Cannot use production config in tests')
- }
+// attempt() used deliberately — inspect the named error and react.
+const [ctx, err] = await attempt(() => createContext({ config: 'prod', requireTest: true }))
+if (err instanceof RequireTestError) {
+ console.error('Cannot use production config in tests')
}
-try {
- await ctx.noorm.db.truncate()
-} catch (err) {
- if (err instanceof ProtectedConfigError) {
- console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give')
- }
+const [, truncateErr] = await attempt(() => ctx.noorm.db.truncate())
+if (truncateErr instanceof ProtectedConfigError) {
+ console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give')
}
-try {
- await ctx.noorm.lock.acquire()
-} catch (err) {
- if (err instanceof LockAcquireError) {
- console.error(`Lock held by ${err.holder}`)
- }
+const [, lockErr] = await attempt(() => ctx.noorm.lock.acquire())
+if (lockErr instanceof LockAcquireError) {
+ console.error(`Lock held by ${lockErr.holder}`)
}
```
@@ -991,19 +989,19 @@ Every `noorm` subcommand runs as a non-interactive CLI by default — there is n
```bash
# Build schema
-noorm --config dev run build
+noorm run build --config dev
# Fast-forward changes
-noorm --config dev change ff
+noorm change ff --config dev
# Apply single change
-noorm --config dev change run 2024-01-15-add-users
+noorm change run 2024-01-15-add-users --config dev
# Truncate database
-noorm --config test db truncate
+noorm db truncate --config test
# JSON output for scripting
-noorm --json --config dev change ff | jq '.status'
+noorm change ff --config dev --json | jq '.status'
```
@@ -1057,7 +1055,7 @@ jobs:
- run: npm ci
- name: Apply changes
run: |
- npx noorm --config ${{ vars.DB_CONFIG }} change ff
+ npx noorm change ff --config ${{ vars.DB_CONFIG }}
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
diff --git a/docs/dev/secrets.md b/docs/dev/secrets.md
index 5ef14447..970578ef 100644
--- a/docs/dev/secrets.md
+++ b/docs/dev/secrets.md
@@ -102,20 +102,30 @@ Masked previews show the secret length and first few characters (in verbose mode
### Setting Secrets
-Local secret values are managed through the TUI (`noorm ui` → Settings → Secrets → `a`). The set screen:
+Config-scoped local secret values are managed two ways:
-1. Shows missing required secrets as suggestions
-2. Accepts any key name (UPPER_SNAKE_CASE recommended)
-3. Uses masked input for password/api_key types
-4. Validates connection_string as URI
-5. Warns before overwriting existing values
+- **TUI** — `noorm ui` → Settings → Secrets → `a`. The set screen shows missing required
+ secrets as suggestions, accepts any key name (UPPER_SNAKE_CASE recommended), masks input
+ for password/api_key types, validates connection_string as a URI, and warns before
+ overwriting an existing value.
+- **CLI** — `noorm secret set ` (`src/cli/secret/set.ts`) writes the same
+ encrypted record headlessly, scoped to the active or `--config`-named config. The value is
+ a positional argument, so unlike the TUI it does land in shell history — reach for the
+ vault instead (below) if that's a concern.
-There is no `noorm secret set` headless command — values flow through the interactive form on purpose, so they never leak into shell history or process listings.
+```bash
+noorm secret set API_KEY "sk-live-..."
+noorm secret set DB_PASSWORD "secret123" --config prod
+```
### Deleting Secrets
-From the same Settings → Secrets screen, highlight the entry and press `d`. Required secrets cannot be deleted—only updated. When you try to delete a required secret, the TUI shows a warning toast indicating whether it's a **universal** or **stage-specific** secret:
+Through the TUI, from the same Settings → Secrets screen, highlight the entry and press
+`d`. Headlessly, `noorm secret rm --yes` removes a config-scoped local secret
+(`--yes` is required to prevent accidental deletion). Required secrets cannot be
+deleted—only updated. When you try to delete a required secret through the TUI, it shows a
+warning toast indicating whether it's a **universal** or **stage-specific** secret:
- `"DB_PASSWORD" is a universal secret and cannot be deleted`
- `"API_KEY" is a stage secret and cannot be deleted`
@@ -150,16 +160,19 @@ These screens edit `settings.yml` directly. The actual secret values are set on
## CI/CD Pipelines
-The local secret store is intentionally TUI-only — there is no headless `noorm secret` command, and values must never be passed as raw arguments. For non-interactive pipelines, push secrets through the [vault](../guide/environments/vault.md) instead, which is fully scriptable:
+`noorm secret set` is headless, but it's still the wrong tool for CI: it writes to the
+per-developer local store on disk, which is exactly what a fresh CI runner doesn't have. For
+non-interactive pipelines, push secrets through the [vault](../guide/environments/vault.md)
+instead — team-shared, stored in the database, and reachable from a clean checkout:
```bash
noorm vault set DB_PASSWORD "$DB_PASSWORD"
echo "$API_KEY" | noorm vault set API_KEY # Pipe to avoid process listings
-noorm --json vault list # Inspect what's stored
+noorm vault list --json # Inspect what's stored
noorm vault rm OLD_API_KEY
```
-The vault sits below local secrets in the resolution hierarchy (`$.secrets.KEY` checks local-config → global-local → vault), so individual developers can still override a vault value through the TUI without affecting the team.
+The vault sits below local secrets in the resolution hierarchy (`$.secrets.KEY` checks local-config → global-local → vault), so individual developers can still override a vault value through the TUI or `noorm secret set` without affecting the team.
## Using Secrets in Templates
@@ -274,7 +287,7 @@ const globalKeys = state.listGlobalSecrets() // ['API_KEY']
await state.deleteGlobalSecret('API_KEY')
```
-> **Note:** Global secret values (stored in state) are managed only via the StateManager API or the TUI's Settings → Secrets screen. There is no headless `noorm secret` command — values flow through the interactive form on purpose so they never reach shell history. Universal and per-stage secret *definitions* live in `settings.yml` and are edited from `noorm ui` → Settings → Secrets / Stages.
+> **Note:** Global secret values (stored in state) are managed only via the StateManager API or the TUI's Settings → Secrets screen. `noorm secret set|list|rm` is config-scoped only — there is no headless command for the global store. Universal and per-stage secret *definitions* live in `settings.yml` and are edited from `noorm ui` → Settings → Secrets / Stages.
See [State Management](./state.md) for complete StateManager documentation.
diff --git a/docs/dev/template.md b/docs/dev/template.md
index 2c1c639a..cdb1dd2d 100644
--- a/docs/dev/template.md
+++ b/docs/dev/template.md
@@ -162,11 +162,34 @@ The `$` object contains everything available in templates:
| `$.` | Functions from inherited `$helpers.ts` files |
| `$.` | Auto-loaded data from co-located files |
| `$.config` | Active configuration object |
-| `$.secrets` | Decrypted secrets for active config |
+| `$.secrets` | Decrypted secrets for active config. Reading an unresolved key throws `MissingSecretError` naming the key and the tiers searched (config-local, global-local, vault) — see [Secret Access](#secret-access). |
| `$.globalSecrets` | Decrypted global secrets |
| `$.env` | Environment variables |
+### Secret Access
+
+`$.secrets` is a `Proxy`, not a plain object (`src/core/template/context.ts`). Its `get` trap
+throws on any key not present in the merged result; its `has` trap does not, so `in` is the
+only safe way to check for an optional secret before reading it:
+
+```typescript
+'API_KEY' in $.secrets // false — does not throw
+$.secrets.API_KEY // throws MissingSecretError if unresolved
+$.secrets.API_KEY ?? 'x' // still throws — `??` evaluates the left operand first
+'API_KEY' in $.secrets ? $.secrets.API_KEY : 'x' // the correct optional-read form
+```
+
+`Object.keys`, object spread, and `JSON.stringify` never trigger `get` for an absent key —
+they resolve through `ownKeys`/`getOwnPropertyDescriptor`, which the proxy leaves at the
+default (forwarded to the underlying record) — so none of them throw.
+
+`sqlQuote` (`src/core/template/utils.ts`) throws `UndefinedSqlValueError` on `undefined` for
+the same reason: an `undefined` reaching SQL rendering is always an upstream bug (a missing
+secret, a bad lookup), never an intentional value. `null` is unaffected and still quotes to
+`NULL`.
+
+
### Built-in Helpers
| Helper | Description |
diff --git a/docs/dev/transfer.md b/docs/dev/transfer.md
index 97324e61..f37c7474 100644
--- a/docs/dev/transfer.md
+++ b/docs/dev/transfer.md
@@ -200,6 +200,8 @@ Foreign key checks are disabled on the destination before transfer and re-enable
| MySQL | `SET FOREIGN_KEY_CHECKS = 0` (session-wide) | `SET FOREIGN_KEY_CHECKS = 1` |
| MSSQL | `ALTER TABLE ... NOCHECK CONSTRAINT ALL` (per table) | `ALTER TABLE ... CHECK CONSTRAINT ALL` |
+If the post-transfer re-enable fails, the transfer itself still completes, but `TransferResult.fkChecksRestored` is `false` and the CLI prints a warning. Re-enable FK checks manually before trusting referential integrity on the destination.
+
## Cross-Dialect Transfers
@@ -453,6 +455,14 @@ interface TransferResult {
/** Total duration in milliseconds */
durationMs: number
+ /**
+ * Whether FK checks were re-enabled on the destination.
+ * false only when checks were disabled for this transfer and the
+ * re-enable attempt failed. status does not flip on a failed
+ * FK restore, so check this field after every transfer.
+ */
+ fkChecksRestored: boolean
+
}
interface TransferTableResult {
@@ -569,7 +579,7 @@ noorm db transfer --to backup --dry-run
noorm db transfer --to backup --truncate
# JSON output for scripting
-noorm --json db transfer --to backup
+noorm db transfer --to backup --json
# Export single table to .dt file
noorm db transfer --export ./backup/users.dt --tables users
diff --git a/docs/dev/vault.md b/docs/dev/vault.md
index 2c93bc17..2e336fc4 100644
--- a/docs/dev/vault.md
+++ b/docs/dev/vault.md
@@ -261,8 +261,7 @@ The `vault:initialized` observer event fires only on first init — repeat calls
Typical call-site pattern at the SDK boundary:
```typescript
-const [vaultKey, err] = await ctx.noorm.vault.init();
-if (err) throw err;
+const vaultKey = await ctx.noorm.vault.init();
if (vaultKey) {
// First-time init — seed initial team secrets.
diff --git a/docs/getting-started/building-your-sdk.md b/docs/getting-started/building-your-sdk.md
index 2e58f716..e3aba39f 100644
--- a/docs/getting-started/building-your-sdk.md
+++ b/docs/getting-started/building-your-sdk.md
@@ -370,7 +370,7 @@ export class Client {
*/
async reset(): Promise {
- await this.#ctx.reset();
+ await this.#ctx.noorm.db.reset();
}
@@ -379,7 +379,7 @@ export class Client {
*/
async truncate(): Promise {
- await this.#ctx.truncate();
+ await this.#ctx.noorm.db.truncate();
}
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index a616a088..929dd884 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -39,6 +39,21 @@ NOORM_INSTALL_DIR=~/my-tools curl -fsSL https://noorm.dev/install.sh | sh
Download [noorm-windows-x64.exe](https://github.com/noormdev/noorm/releases) from the latest `@noormdev/cli` release, rename it to `noorm.exe`, and add it to your PATH.
+### Integrity verification
+
+Every install path verifies the downloaded binary before it runs. The release publishes a `checksums.txt` alongside the binaries, and the installer (`install.sh`), the npm `postinstall` step, and `noorm update` each recompute the binary's SHA-256 and compare it against that file before making the binary executable. A mismatch aborts — a corrupted download or a tampered release asset never gets run.
+
+Verification is fail-closed: if `checksums.txt` can't be fetched (an offline mirror, a network block, an older release without it), the install stops rather than trusting an unverified binary. To override that one case — and only that case — set `NOORM_INSECURE=1`:
+
+```bash
+NOORM_INSECURE=1 curl -fsSL https://noorm.dev/install.sh | sh
+
+# self-update equivalent
+noorm update --insecure
+```
+
+`NOORM_INSECURE` skips verification only when the checksums can't be reached. A confirmed hash mismatch always fails, escape hatch or not — the flag can't wave through a binary that has actually been altered.
+
### Verify
```bash
diff --git a/docs/guide/automation/ci.md b/docs/guide/automation/ci.md
index 7f1ba36e..e1d3dc31 100644
--- a/docs/guide/automation/ci.md
+++ b/docs/guide/automation/ci.md
@@ -295,7 +295,7 @@ noorm lock acquire
trap "noorm lock release" EXIT
noorm change ff
-noorm --json db explore
+noorm db explore --json
```
diff --git a/docs/guide/changes/forward-revert.md b/docs/guide/changes/forward-revert.md
index 1a63a84c..3474898d 100644
--- a/docs/guide/changes/forward-revert.md
+++ b/docs/guide/changes/forward-revert.md
@@ -18,7 +18,7 @@ Apply every pending change in chronological order:
**Headless:**
```bash
noorm change ff
-noorm --json change ff
+noorm change ff --json
```
Fast-forward is the workhorse for deployments. It finds all unapplied changes, sorts them by date, and executes each one in sequence. If any change fails, execution stops immediately.
@@ -233,7 +233,7 @@ noorm change revert 2024-02-15-broken-change
After running `db teardown`, all changes are marked as `stale`. The next fast-forward re-applies everything:
```bash
-noorm -y db teardown
+noorm db teardown -y
noorm run build # Rebuild base schema
noorm change ff # Re-apply all changes
```
@@ -272,7 +272,7 @@ noorm run build
noorm change ff
# Verify the result
-noorm --json db explore
+noorm db explore --json
```
The build step handles the initial schema. The fast-forward applies any changes that have been merged since the last deploy.
diff --git a/docs/guide/changes/history.md b/docs/guide/changes/history.md
index bb43ef3b..a4cdd2ce 100644
--- a/docs/guide/changes/history.md
+++ b/docs/guide/changes/history.md
@@ -123,7 +123,7 @@ Execution History: 20 records
With JSON output for parsing:
```bash
-noorm --json change history
+noorm change history --json
```
```json
@@ -286,7 +286,7 @@ Now you know the issue---there's duplicate data that needs cleaning before the c
Compare with successful runs of the same change on other environments. Did something change between runs? Did the data differ?
```bash
-noorm --json change history --count 100 | jq '.[] | select(.name == "2025-01-16-add-payments")'
+noorm change history --count 100 --json | jq '.[] | select(.name == "2025-01-16-add-payments")'
```
diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md
index e71903f9..a77289a1 100644
--- a/docs/guide/changes/overview.md
+++ b/docs/guide/changes/overview.md
@@ -90,16 +90,39 @@ The three-digit prefix guarantees execution order. Names after the underscore ar
## Creating a Change
-Change creation is wizard-driven. Run `noorm ui`, press `g` from home to enter the changes screen, then `a` to add a new one and enter a name like `add-email-verification`. noorm scaffolds the folder structure for you:
+Scaffold a change headlessly with `noorm change add`:
+
+```bash
+noorm change add add-email-verification
+noorm change add # prompts for a description (TTY only)
+```
+
+Or from the TUI: run `noorm ui`, press `g` from home to enter the changes screen, then `a`
+and enter a name like `add-email-verification`. Either path scaffolds the same layout:
```
changes/2025-01-15-add-email-verification/
├── change/
+│ └── 001_add-email-verification.sql
├── revert/
+│ └── 001_add-email-verification.sql
└── changelog.md
```
-Now add your SQL files to `change/`. If you want rollback support, add corresponding scripts to `revert/`. Once the directory exists you can apply, revert, and inspect the change from the headless CLI (`noorm change ff`, `noorm change run`, `noorm change revert`, `noorm change history`).
+Unlike an empty folder pair, `001_add-email-verification.sql` is a real, runnable stub — one
+comment line naming what belongs there ("Add the SQL statements that apply this change" in
+`change/`, "...undo this change" in `revert/`). Replace the comment with your SQL. A comment
+alone isn't enough to run: `change run` rejects a changeset whose files contain only
+comments or template placeholders, with a message that tells you to edit the files — not the
+misleading "change not found" a genuinely empty folder pair used to produce.
+
+For a multi-statement change, add more files by hand following the same `NNN_description`
+convention (see [Naming Conventions](#naming-conventions) above) — `002_`, `003_`, and so on.
+Files run in filename order (a plain string sort, so keep sequence numbers zero-padded to
+three digits or the sort stops being numeric past `009_`).
+
+Once the directory exists you can apply, revert, and inspect the change from the headless
+CLI (`noorm change ff`, `noorm change run`, `noorm change revert`, `noorm change history`).
## Write Idempotent DDL
@@ -208,7 +231,7 @@ From the headless CLI, `noorm change list` prints every known change with its st
```bash
noorm change list # Human-friendly table
-noorm --json change list # Same data as JSON
+noorm change list --json # Same data as JSON
```
```json
diff --git a/docs/guide/database/explore.md b/docs/guide/database/explore.md
index 260d5525..3d6dcdae 100644
--- a/docs/guide/database/explore.md
+++ b/docs/guide/database/explore.md
@@ -52,7 +52,7 @@ The same subcommands run non-interactively — add `--json` to get machine-reada
noorm db explore # Overview
noorm db explore tables # List tables
noorm db explore tables detail users # Table detail
-noorm --json db explore > schema.json # JSON output
+noorm db explore --json > schema.json # JSON output
```
@@ -342,7 +342,7 @@ Use `--json` flag for machine-readable output.
### Overview
```bash
-noorm --json db explore
+noorm db explore --json
```
```json
@@ -361,7 +361,7 @@ noorm --json db explore
### Table List
```bash
-noorm --json db explore tables
+noorm db explore tables --json
```
```json
@@ -376,7 +376,7 @@ noorm --json db explore tables
### Table Detail
```bash
-noorm --json db explore tables detail users
+noorm db explore tables detail users --json
```
```json
@@ -402,7 +402,7 @@ noorm --json db explore tables detail users
### View Detail
```bash
-noorm --json db explore views detail active_users
+noorm db explore views detail active_users --json
```
```json
@@ -422,7 +422,7 @@ noorm --json db explore views detail active_users
### Function Detail
```bash
-noorm --json db explore functions detail calculate_total
+noorm db explore functions detail calculate_total --json
```
```json
diff --git a/docs/guide/database/teardown.md b/docs/guide/database/teardown.md
index cb79940b..5a9abcef 100644
--- a/docs/guide/database/teardown.md
+++ b/docs/guide/database/teardown.md
@@ -28,7 +28,7 @@ Both operations permanently destroy data. `truncate` wipes all rows. `teardown`
Truncate removes all data while preserving table structure. Fast reset for test cycles.
```bash
-noorm -y db truncate
+noorm db truncate -y
```
### What Gets Truncated
@@ -49,7 +49,7 @@ noorm -y db truncate
Teardown removes all database objects. Clean slate for full rebuilds.
```bash
-noorm -y db teardown
+noorm db teardown -y
```
::: warning Access Roles
@@ -175,7 +175,7 @@ The SDK provides teardown methods with test-oriented safety guards.
### Basic Test Setup
```typescript
-import { createContext, RequireTestError } from 'noorm/sdk'
+import { createContext, RequireTestError } from '@noormdev/sdk'
describe('user service', () => {
@@ -192,8 +192,8 @@ describe('user service', () => {
beforeEach(async () => {
// Fast reset between tests
- await ctx.truncate()
- await ctx.runFile('./seeds/test-data.sql')
+ await ctx.noorm.db.truncate()
+ await ctx.noorm.run.file('./seeds/test-data.sql')
})
afterAll(async () => {
@@ -231,11 +231,11 @@ beforeAll(async () => {
await ctx.connect()
// Full teardown + rebuild
- await ctx.reset()
+ await ctx.noorm.db.reset()
})
```
-The `reset()` method combines `teardown()` and `build({ force: true })` for complete schema reconstruction.
+The `reset()` method combines `ctx.noorm.db.teardown()` and `ctx.noorm.run.build({ force: true })` for complete schema reconstruction.
## Scripted Usage
@@ -246,19 +246,19 @@ For CI/CD pipelines, drive the same teardown commands non-interactively. Always
```bash
# Truncate all data
-noorm -y db truncate
+noorm db truncate -y
# Full teardown
-noorm -y db teardown
+noorm db teardown -y
# Preview what would be dropped
-noorm --dry-run db teardown
+noorm db teardown --dry-run
```
### JSON Output
```bash
-noorm --json db teardown
+noorm db teardown --json
```
```json
@@ -316,7 +316,7 @@ test:
set -e
# Reset to clean state before test run
-noorm -y db teardown --config test
+noorm db teardown --config test -y
noorm run build --config test
noorm change ff --config test
@@ -352,8 +352,8 @@ SQLite uses DELETE instead of TRUNCATE because SQLite does not support TRUNCATE.
```bash
# Safe teardown pattern
-noorm --dry-run db teardown # Preview first
-noorm -y db teardown # Execute after review
+noorm db teardown --dry-run # Preview first
+noorm db teardown -y # Execute after review
```
diff --git a/docs/guide/database/transfer.md b/docs/guide/database/transfer.md
index 2aa18c39..ff5f64f2 100644
--- a/docs/guide/database/transfer.md
+++ b/docs/guide/database/transfer.md
@@ -119,7 +119,7 @@ noorm db transfer --to backup --no-identity
### JSON Output
```bash
-noorm --json db transfer --to backup
+noorm db transfer --to backup --json
```
Transfer result:
@@ -223,7 +223,7 @@ Only transfers the specified tables. FK dependencies between selected tables are
### CI/CD test data setup
```bash
-noorm --json db transfer staging --to ci-test --truncate --on-conflict fail
+noorm db transfer staging --to ci-test --truncate --on-conflict fail --json
```
Clean transfer for test environments. JSON output for pipeline integration. Fails fast if anything goes wrong.
diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md
index 41cecc66..48baacfd 100644
--- a/docs/guide/environments/configs.md
+++ b/docs/guide/environments/configs.md
@@ -51,7 +51,7 @@ After completing the wizard, noorm validates the connection. If it succeeds, you
## Creating a Config Non-Interactively
-`noorm config add`, `edit`, and `rm` all launch the TUI wizard — they exist to guide credential entry interactively. For CI/CD pipelines and scripts that need to skip the wizard entirely, build the config from **environment variables** instead:
+`noorm config add` and `edit` launch the TUI wizard — they exist to guide credential entry interactively. Removal is the exception: `noorm config rm --yes` deletes headlessly, no wizard involved (see the [CLI Reference](/headless#config-rm-name)). For CI/CD pipelines and scripts that need to skip the config wizard entirely, build the config from **environment variables** instead:
```bash
export NOORM_CONNECTION_DIALECT=postgres
@@ -192,7 +192,6 @@ Environment variables override stored config values. This is how you inject secr
|----------|---------|
| `NOORM_CONFIG` | Which config to use |
| `NOORM_YES` | Skip confirmations (set to `1`) |
-| `NOORM_JSON` | JSON output mode (set to `1`) |
**Example: Override host for CI runner**
diff --git a/docs/guide/environments/secrets.md b/docs/guide/environments/secrets.md
index 5c19dfc5..ee6a7b3d 100644
--- a/docs/guide/environments/secrets.md
+++ b/docs/guide/environments/secrets.md
@@ -151,7 +151,7 @@ Types are hints for the CLI. All secrets are stored identically (encrypted strin
## Secrets in CI/CD
-The local secret store exists for per-developer overrides — it's deliberately TUI-only so that raw values never leak into shell history. For non-interactive environments, push values to the [vault](/guide/environments/vault) instead, which is scriptable end-to-end:
+The local secret store exists for per-developer overrides — `noorm secret set` is headless, but it writes to `.noorm/state/state.enc` on disk, which a fresh CI runner doesn't have. For non-interactive environments, push values to the [vault](/guide/environments/vault) instead, which is scriptable end-to-end and reachable from a clean checkout:
```bash
# Write a secret to the vault
@@ -161,7 +161,7 @@ noorm vault set DB_PASSWORD "$DB_PASSWORD"
echo "$DB_PASSWORD" | noorm vault set DB_PASSWORD
# List vault secrets as JSON
-noorm --json vault list
+noorm vault list --json
# Remove a vault secret
noorm vault rm OLD_API_KEY
diff --git a/docs/guide/environments/vault.md b/docs/guide/environments/vault.md
index 29226595..eb9b34d8 100644
--- a/docs/guide/environments/vault.md
+++ b/docs/guide/environments/vault.md
@@ -196,7 +196,7 @@ noorm vault init
noorm vault set API_KEY "$API_KEY"
# List with JSON output
-noorm --json vault list
+noorm vault list --json
# Copy secrets between environments
noorm vault cp --all staging production
diff --git a/docs/guide/sql-files/templates.md b/docs/guide/sql-files/templates.md
index 8cd6420c..d7343a4a 100644
--- a/docs/guide/sql-files/templates.md
+++ b/docs/guide/sql-files/templates.md
@@ -353,6 +353,30 @@ INSERT INTO app_config (key, value) VALUES
Secrets resolve through a three-tier hierarchy: config-specific local secrets override global local secrets, which override team-shared [vault](/guide/environments/vault) secrets. This means you can store production credentials in the vault and override them locally for development without affecting teammates. See the [secret resolution hierarchy](/guide/environments/vault#secret-resolution-hierarchy) for details.
+A key that isn't set in any of the three tiers has no correct SQL rendering, so reading it
+throws instead of quietly resolving to `undefined`:
+
+```sql
+-- Throws: Secret "STRIPE_KEY" not found (searched: config-local, global-local, vault)
+{%~ $.secrets.STRIPE_KEY %}
+```
+
+This is deliberate — a missing secret used to render as the literal six-character string
+`undefined`, which is valid SQL and easy to miss in a review. `$.secrets.KEY ?? fallback`
+can't paper over a missing key either, because `??` still evaluates the throwing read on its
+left side. To probe for a secret that's genuinely optional, check for it first with `in`,
+which never throws:
+
+```sql
+{% const hasKey = 'STRIPE_KEY' in $.secrets; %}
+{%~ hasKey ? $.quote($.secrets.STRIPE_KEY) : 'NULL' %}
+```
+
+`in` before read is how a template says "this secret is allowed to be missing" on purpose,
+rather than by accident. See
+[`examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl`](https://github.com/noormdev/noorm/blob/master/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl)
+for a full example that forces a feature flag off when its backing secret isn't configured.
+
### Generating Multiple Similar Objects
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index 7e30d85e..74f73fb0 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -6,19 +6,23 @@ for `noorm`. Each entry is a search target plus a one-paragraph fix
and a link to the deeper doc.
-## `noorm --json sql query "SELECT 1"` doesn't produce JSON
+## `noorm --config prod run build` fails or targets the wrong directory
-`--json` is a per-subcommand flag, not a global. It must appear *after*
-the subcommand name. citty (the parser) sees `--json` before the
-subcommand as a root flag that doesn't exist, then fails silently on
-output. Move the flag:
+`--config` (long form) is per-subcommand only — it has no global meaning. Neither do
+`--dry-run`, `--json`, or `--yes`: the only flag with a root-level meaning is `-c`/`--cwd`.
+Placed before the subcommand, `--config` (and everything else) is rejected outright rather
+than silently ignored:
+
+ # rejected: --config has no global form
+ noorm --config prod run build
# works
- noorm sql query "SELECT 1" --json
- noorm change ff --json
- noorm config list --json
+ noorm run build --config prod
+ noorm run build -c prod
-See [CLI flag conventions](../cli/flags.md) for the full rule.
+`-c` is the sharper trap: `noorm -c prod run build` is *not* the same command — before the
+subcommand, `-c` means `--cwd`, not `--config`. See [CLI flag conventions](../cli/flags.md)
+for the full rule.
## `noorm help db create` errors with "Unknown command help"
diff --git a/docs/headless.md b/docs/headless.md
index fbee29b4..ff67f2c5 100644
--- a/docs/headless.md
+++ b/docs/headless.md
@@ -5,7 +5,7 @@ Every `noorm` command runs as a non-interactive CLI by default. The Ink/React TU
```bash
noorm run build # CLI: build schema headlessly
-noorm --json change ff # CLI: fast-forward with JSON output
+noorm change ff --json # CLI: fast-forward with JSON output
noorm ui # TUI: launch the interactive terminal UI
```
@@ -27,7 +27,11 @@ For shell completion, run `noorm complete` and follow the printed instructions f
## Global Flags
-These flags must appear **before** the subcommand (like `git -C`). After the subcommand they revert to their per-command meaning.
+A flag goes on the command that uses it. `-c`/`--cwd ` is the one exception: it's
+recognized **before** the subcommand instead, because it's consumed before dispatch — it sets
+the working directory everything else (config discovery, project root) resolves against. An
+unrecognized or misplaced flag typed before the subcommand exits non-zero naming the correct
+form, rather than being silently dropped.
| Flag | Short | Description |
|------|-------|-------------|
@@ -39,8 +43,14 @@ noorm -c packages/db run build
# Initialize a nested project from elsewhere
noorm -c packages/db init
+
+# every other flag belongs on the subcommand
+noorm run build --dry-run
```
+`--cwd`/`-c` only has this global meaning *before* the subcommand — after it, `-c` is the
+per-command `--config` alias instead (see the note below).
+
## Common Flags
@@ -55,9 +65,7 @@ These options are reused across most commands. Run ` --help` to see exa
| `--dry-run` | — | Preview without executing |
| `--help` | `-h` | Show command help (citty native) |
-> **Note:** `-c` is overloaded by position. `noorm -c run` is the global cwd flag; `noorm run -c ` is the per-command config alias. The first non-flag token (the subcommand) is the boundary.
-
-> **`--json` placement:** `--json` is per-subcommand, not global — it must appear **after** the subcommand name (`noorm change ff --json`, not `noorm --json change ff`). See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md).
+> **Note:** `-c` is overloaded by position. `noorm -c run` is the global cwd flag; `noorm run -c ` is the per-command config alias. The first non-flag token (the subcommand) is the boundary. Every flag other than `-c`/`--cwd` — `--config`, `--force`, `--json`, `--dry-run`, `--yes`, etc. — has no global meaning; it only works after the subcommand. See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md).
## Configuration
@@ -70,7 +78,7 @@ These options are reused across most commands. Run ` --help` to see exa
noorm run build
# Use specific config
-noorm --config staging run build
+noorm run build --config staging
# Or via environment
export NOORM_CONFIG=staging
@@ -127,7 +135,6 @@ NOORM_PATHS_CHANGES # Changes directory (default: ./changes)
```bash
NOORM_CONFIG # Config name to use
NOORM_YES # Skip confirmations (1 or true)
-NOORM_JSON # Output JSON (1 or true)
NOORM_DEBUG # Enable debug logging
```
@@ -142,7 +149,6 @@ All `NOORM_*` environment variables are processed through a nesting convention t
|----------|---------|
| `NOORM_CONFIG` | Select which stored config to use |
| `NOORM_YES` | Skip confirmations (`1` or `true`) |
-| `NOORM_JSON` | Force JSON output (`1` or `true`) |
These are excluded from config nesting and consumed directly by the CLI.
@@ -180,6 +186,7 @@ Each config carries a per-channel access grant: `access: { user, mcp }`. The `us
| `db create`, `db reset` (truncate/teardown/reset) | deny | confirm | allow |
| `db drop` | deny | deny | confirm |
| `config rm` | deny | confirm | confirm |
+| `change rm` | deny | confirm | confirm |
Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a flag — a multi-statement input is classified by its highest class (a `SELECT` plus a `DROP` classifies as DDL). Unparseable or unrecognized statements classify as DDL, fail closed.
@@ -232,7 +239,7 @@ Set the active configuration.
```bash
noorm config use dev
-noorm --json config use production
+noorm config use production --json
```
**JSON output:**
@@ -247,7 +254,7 @@ noorm --json config use production
Create a new configuration.
-> Wizard-only. Launches the TUI wizard. To configure non-interactively, use `NOORM_*` environment variables or `config import`.
+> Wizard-only. Launches the TUI wizard. To configure non-interactively, use `NOORM_*` environment variables or `config import`. Exits 1 (message on stderr) — there is no headless equivalent.
```bash
noorm config add
@@ -258,23 +265,34 @@ noorm config add
Edit an existing configuration.
-> Wizard-only. Launches the TUI wizard. Use `NOORM_*` env vars or `config export`/`config import` to modify non-interactively.
+> Wizard-only. Launches the TUI wizard. Use `NOORM_*` env vars or `config export`/`config import` to modify non-interactively. Exits 1 (message on stderr) — there is no headless equivalent.
```bash
noorm config edit dev
```
-#### `config rm [name]`
+#### `config rm `
Remove a configuration.
-> Wizard-only. Launches the TUI wizard so you can confirm the removal interactively.
-
```bash
-noorm config rm staging
+noorm config rm staging --yes
+noorm config rm staging --yes --json
```
+**JSON output:**
+```json
+{
+ "name": "staging",
+ "deleted": true
+}
+```
+
+::: danger Destructive
+Requires `--yes` (or `NOORM_YES=1`) — there is no TTY confirmation prompt, so the flag is mandatory even at an interactive terminal. An unknown config name fails with exit 1. Gated by the config's `config:rm` access (see [Access Roles](#access-roles) above); a config linked to a locked stage cannot be removed until the stage is unlocked.
+:::
+
#### `config cp `
@@ -356,7 +374,7 @@ Create a new cryptographic identity. Generates an X25519 keypair and stores it a
```bash
noorm identity init --name "Alice" --email "alice@example.com"
noorm identity init --name "Alice" --email "alice@example.com" --force
-noorm --json identity init --name "Alice" --email "alice@example.com"
+noorm identity init --name "Alice" --email "alice@example.com" --json
```
| Flag | Description |
@@ -395,7 +413,7 @@ Print your public key so teammates can add you to encrypted vaults.
```bash
noorm identity export
-noorm --json identity export
+noorm identity export --json
```
@@ -405,7 +423,7 @@ List all known users discovered from database syncs (the audit trail of who has
```bash
noorm identity list
-noorm --json identity list
+noorm identity list --json
```
@@ -627,8 +645,8 @@ Execute all SQL files in the schema directory. Uses checksums to skip unchanged
```bash
noorm run build
-noorm --force run build # Skip checksums, run everything
-noorm --dry-run run build # Preview without executing
+noorm run build --force # Skip checksums, run everything
+noorm run build --dry-run # Preview without executing
```
**JSON output:**
@@ -650,7 +668,7 @@ Execute a single SQL or `.sql.tmpl` file.
```bash
noorm run file sql/01_tables/001_users.sql
noorm run file seed.sql.tmpl
-noorm --json run file sql/init.sql
+noorm run file sql/init.sql --json
```
**JSON output:**
@@ -670,7 +688,7 @@ Execute all SQL files in a directory in alphabetical order.
```bash
noorm run dir sql/01_tables/
noorm run dir seeds/
-noorm --json run dir migrations/
+noorm run dir migrations/ --json
```
@@ -704,7 +722,7 @@ Inspect the template context for a `.sql.tmpl` file without executing. Shows dat
```bash
noorm run inspect sql/users/001_create.sql.tmpl
-noorm --json run inspect sql/core/Crons.sql.tmpl
+noorm run inspect sql/core/Crons.sql.tmpl --json
```
**JSON output:**
@@ -731,8 +749,8 @@ Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute aga
```bash
noorm run preview sql/schema.sql.tmpl
noorm run preview sql/schema.sql.tmpl > rendered.sql
-noorm --json run preview sql/seed.sql.tmpl
-noorm --config staging run preview sql/migrations/002.sql.tmpl
+noorm run preview sql/seed.sql.tmpl --json
+noorm run preview sql/migrations/002.sql.tmpl --config staging
```
**JSON output:**
@@ -788,8 +806,8 @@ Fast-forward: apply all pending changes in order.
```bash
noorm change ff
-noorm --dry-run change ff
-noorm --force change ff
+noorm change ff --dry-run
+noorm change ff --force
```
**JSON output:**
@@ -812,7 +830,7 @@ Apply the next pending change only.
```bash
noorm change next
-noorm --json change next
+noorm change next --json
```
@@ -823,7 +841,7 @@ Apply a specific change by name. Omit the name on a TTY to pick interactively.
```bash
noorm change run # interactive picker (TTY)
noorm change run 2024-02-01-notifications
-noorm --json change run 2024-02-01-notifications
+noorm change run 2024-02-01-notifications --json
```
**JSON output:**
@@ -847,7 +865,7 @@ Revert a previously applied change by running its rollback SQL. Omit the name on
```bash
noorm change revert # interactive picker (TTY)
noorm change revert 2024-02-01-notifications
-noorm --json change revert 002_users
+noorm change revert 002_users --json
```
@@ -858,7 +876,7 @@ Revert the given change **and every change applied after it**, in reverse order.
```bash
noorm change rewind # interactive picker (TTY)
noorm change rewind 2024-02-01-notifications
-noorm --json change rewind 001_init
+noorm change rewind 001_init --json
```
@@ -872,7 +890,12 @@ noorm change add add-users-table
noorm change add "notification queue" --json
```
-Creates `changes/-/` with `change.sql` and `revert.sql` stubs.
+Creates `changes/-/` with `change/` and `revert/` directories, each
+holding a `001_.sql` stub (a single comment naming what belongs there — "Add the SQL
+statements that apply this change" / "...undo this change"), plus `changelog.md`. The stub
+comments alone don't count as content: running the change before editing them fails with an
+actionable message ("Files are empty or contain only template placeholders"), not "change not
+found".
#### `change edit [name]`
@@ -888,7 +911,7 @@ EDITOR=vim noorm change edit 001_init
#### `change rm [name]`
-Remove a change directory from disk. Does **not** touch database state. On a TTY, omit the name to pick interactively; `--yes` is only required on non-TTY.
+Remove a change directory from disk. Does **not** touch database state. Gated by the config's `change:rm` access (see [Access Roles](#access-roles) above): `viewer` is denied; `operator` and `admin` confirm via `--yes` or `NOORM_YES=1`. On a TTY, omit the name to pick interactively.
```bash
noorm change rm # picker + confirm (TTY)
@@ -902,7 +925,7 @@ View execution history with timestamps, direction, and duration.
```bash
noorm change history
-noorm --json change history
+noorm change history --json
```
**JSON output:**
@@ -930,7 +953,7 @@ Per-file execution detail for a single change. Omit the name on a TTY to pick in
```bash
noorm change history-detail # interactive picker (TTY)
noorm change history-detail 001_init
-noorm --json change history-detail 002_users
+noorm change history-detail 002_users --json
```
@@ -989,7 +1012,7 @@ noorm db explore procedures # List all procedures
noorm db explore indexes # List all indexes
noorm db explore types # List custom types
noorm db explore fks # List foreign keys
-noorm --json db explore # JSON overview
+noorm db explore --json # JSON overview
```
**JSON output (overview):**
@@ -1009,7 +1032,7 @@ noorm --json db explore # JSON overview
Wipe all data from application tables. Schema and noorm tracking tables are preserved.
```bash
-noorm -y db truncate
+noorm db truncate -y
```
@@ -1018,7 +1041,7 @@ noorm -y db truncate
Drop all database objects.
```bash
-noorm -y db teardown
+noorm db teardown -y
```
::: warning Access Roles
@@ -1081,7 +1104,7 @@ Check if database is locked.
```bash
noorm lock status
-noorm --json lock status
+noorm lock status --json
```
**JSON output:**
@@ -1103,7 +1126,7 @@ Acquire an exclusive lock.
```bash
noorm lock acquire
-noorm --json lock acquire
+noorm lock acquire --json
```
**CI/CD pattern with cleanup:**
@@ -1152,7 +1175,7 @@ Initialize the vault for the current database.
```bash
noorm vault init
-noorm --json vault init
+noorm vault init --json
```
@@ -1162,7 +1185,7 @@ Store an encrypted secret in the vault.
```bash
noorm vault set API_KEY "sk-live-abc123"
-noorm --json vault set API_KEY "sk-live-abc123"
+noorm vault set API_KEY "sk-live-abc123" --json
```
Upserts: creates if new, updates if the key exists.
@@ -1174,7 +1197,7 @@ List all secrets in the vault (keys and metadata only, never values).
```bash
noorm vault list
-noorm --json vault list
+noorm vault list --json
```
**JSON output:**
@@ -1198,7 +1221,7 @@ Remove a secret from the vault.
```bash
noorm vault rm OLD_API_KEY
-noorm --json vault rm OLD_API_KEY
+noorm vault rm OLD_API_KEY --json
```
@@ -1228,7 +1251,7 @@ Grant vault access to team members who don't have it yet. Encrypts the vault key
```bash
noorm vault propagate
-noorm --json vault propagate
+noorm vault propagate --json
```
**JSON output:**
@@ -1251,7 +1274,7 @@ Execute a raw SQL query.
noorm sql "SELECT * FROM users LIMIT 10"
noorm sql -f query.sql
noorm -c prod sql "SELECT count(*) FROM orders"
-noorm --json sql "SELECT id, name FROM users"
+noorm sql "SELECT id, name FROM users" --json
```
| Flag | Description |
@@ -1293,7 +1316,7 @@ Show SQL execution history.
```bash
noorm sql history
noorm sql history -n 20
-noorm --json sql history
+noorm sql history --json
noorm -c prod sql history
```
@@ -1350,7 +1373,7 @@ Project and database status overview.
```bash
noorm info
-noorm --json info
+noorm info --json
```
**JSON output:**
@@ -1387,7 +1410,7 @@ CLI version and diagnostic information.
```bash
noorm version
-noorm --json version
+noorm version --json
```
@@ -1397,7 +1420,7 @@ Check for and install noorm updates.
```bash
noorm update
-noorm --json update
+noorm update --json
```
@@ -1577,15 +1600,38 @@ noorm ci init --name prod
noorm lock acquire
trap "noorm lock release" EXIT
noorm change ff
-noorm --json db explore
+noorm db explore --json
+```
+
+
+## Output Streams
+
+A command's **result** — the human summary, or the `--json` payload — always goes to
+**stdout**. Everything else — progress lines, warnings, the logger's event stream — goes to
+**stderr**, in both text and `--json` mode. This holds regardless of whether the command
+succeeds or fails: a setup failure (bad config, no connection) prints nothing to stdout in
+text mode — the diagnostic goes to stderr — and a single JSON error document to stdout in
+`--json` mode, e.g. `{"success":false,"error":"..."}`.
+
+That means `--json` output is always exactly one parseable JSON document on stdout, with no
+log noise ahead of or after it — no `tail -1` needed:
+
+```bash
+noorm change history --json | jq '.[0].status'
```
+A command that finds nothing to report still writes a result — never silence. `noorm change
+list` on a project with no changesets prints `No changes found.` on stdout in text mode and
+`[]` on stdout in `--json` mode; either way, a CI step can tell "ran and found nothing" from
+"didn't run" by checking the exit code and the (always-present) stdout line, rather than
+guessing from empty output.
+
## Scripting with JSON
```bash
# Check for pending changes
-pending=$(noorm --json change list | jq '[.[] | select(.status == "pending")] | length')
+pending=$(noorm change list --json | jq '[.[] | select(.status == "pending")] | length')
if [ "$pending" -gt 0 ]; then
echo "Found $pending pending changes"
noorm change ff
@@ -1594,13 +1640,13 @@ fi
```bash
# Get table count
-tables=$(noorm --json db explore | jq '.tables')
+tables=$(noorm db explore --json | jq '.tables')
echo "Database has $tables tables"
```
```bash
# Verify vault access
-pending=$(noorm --json vault list | jq '.status.usersWithoutAccess')
+pending=$(noorm vault list --json | jq '.status.usersWithoutAccess')
if [ "$pending" -gt 0 ]; then
noorm vault propagate
fi
diff --git a/docs/index.md b/docs/index.md
index 57695968..3275c445 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -119,7 +119,7 @@ Or use the CLI directly. Commands run headlessly and emit structured JSON you ca
```bash
noorm run build # Build the schema from SQL files
noorm change ff # Apply all pending changes
-noorm --json db explore # Inspect the database as JSON
+noorm db explore --json # Inspect the database as JSON
noorm vault set API_KEY ... # Push a team secret to the encrypted vault
```
diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md
index 5a219068..c36b7ef7 100644
--- a/docs/reference/sdk.md
+++ b/docs/reference/sdk.md
@@ -925,7 +925,7 @@ console.log(result.sql);
#### transfer.to(destConfig, options?)
-Transfer data from this context's database to a destination config. Both contexts must be connected.
+Transfer data from this context's database to a destination config. Both contexts must be connected. Throws on failure.
```typescript
const source = await createContext({ config: 'staging' });
@@ -933,15 +933,13 @@ const dest = await createContext({ config: 'dev' });
await source.connect();
await dest.connect();
-const [result, err] = await source.noorm.transfer.to(dest.noorm.config, {
+const result = await source.noorm.transfer.to(dest.noorm.config, {
tables: ['users', 'posts'],
onConflict: 'skip',
batchSize: 5000,
});
-if (result) {
- console.log(`Transferred ${result.totalRows} rows (${result.status})`);
-}
+console.log(`Transferred ${result.totalRows} rows (${result.status})`);
await source.disconnect();
await dest.disconnect();
@@ -964,15 +962,13 @@ await dest.disconnect();
#### transfer.plan(destConfig, options?)
-Generate a transfer plan without executing. Inspects both databases and returns table ordering, row estimates, and warnings.
+Generate a transfer plan without executing. Inspects both databases and returns table ordering, row estimates, and warnings. Throws on failure.
```typescript
-const [plan, err] = await source.noorm.transfer.plan(dest.noorm.config);
-if (plan) {
- console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`);
- for (const warning of plan.warnings) {
- console.warn(warning);
- }
+const plan = await source.noorm.transfer.plan(dest.noorm.config);
+console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`);
+for (const warning of plan.warnings) {
+ console.warn(warning);
}
```
@@ -982,16 +978,14 @@ if (plan) {
#### dt.exportTable(tableName, filepath, options?)
-Export a table to a .dt file. The file extension determines the format: `.dt` (plain), `.dtz` (gzipped), `.dtzx` (encrypted).
+Export a table to a .dt file. The file extension determines the format: `.dt` (plain), `.dtz` (gzipped), `.dtzx` (encrypted). Throws on failure.
```typescript
-const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz');
-if (result) {
- console.log(`Exported ${result.rowsWritten} rows (${result.bytesWritten} bytes)`);
-}
+const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz');
+console.log(`Exported ${result.rowsWritten} rows (${result.bytesWritten} bytes)`);
// Encrypted export
-const [encrypted, encErr] = await ctx.noorm.dt.exportTable('users', './exports/users.dtzx', {
+const encrypted = await ctx.noorm.dt.exportTable('users', './exports/users.dtzx', {
passphrase: 'my-secret',
});
```
@@ -1007,15 +1001,13 @@ const [encrypted, encErr] = await ctx.noorm.dt.exportTable('users', './exports/u
#### dt.importFile(filepath, options?)
-Import a .dt file into the connected database.
+Import a .dt file into the connected database. Throws on failure.
```typescript
-const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', {
+const result = await ctx.noorm.dt.importFile('./exports/users.dtz', {
onConflict: 'skip',
});
-if (result) {
- console.log(`Imported ${result.rowsImported} rows, skipped ${result.rowsSkipped}`);
-}
+console.log(`Imported ${result.rowsImported} rows, skipped ${result.rowsSkipped}`);
```
**Options (`ImportOptions`):**
@@ -1064,19 +1056,10 @@ Database-stored encrypted secrets shared across team members. Unlike config-scop
Initialize the vault for this database. Creates the vault key and stores it encrypted for the current identity.
-Idempotent. Calling `init()` a second time against an already-initialized vault returns `[null, null]` — no state change, no error. Callers can `init()` defensively at startup without special-casing an error string.
-
-The three return shapes:
-
-| Shape | Meaning |
-|-------|---------|
-| `[Buffer, null]` | First-time init succeeded. The buffer is the vault key. |
-| `[null, null]` | Vault already initialized. No work done. Use `vault.get` / `vault.set` with the user's private key. |
-| `[null, Error]` | Actual failure (DB error, encryption error). |
+Idempotent. Calling `init()` a second time against an already-initialized vault returns `null` — no state change, no error. Callers can `init()` defensively at startup without special-casing an error string. Real failures (DB errors, encryption errors) throw.
```typescript
-const [vaultKey, err] = await ctx.noorm.vault.init();
-if (err) throw err;
+const vaultKey = await ctx.noorm.vault.init();
if (vaultKey) {
// First-time init — seed initial team secrets, etc.
@@ -1088,12 +1071,12 @@ else {
The `vault:initialized` observer event fires only on first init, never on repeat calls. Cross-reference with `vault.status()` if you need to distinguish "just initialized" from "was already there" alongside other status fields.
-**Returns:** `Promise<[Buffer | null, Error | null]>` — the vault key buffer on first init, `null` if already initialized, or an `Error` on failure.
+**Returns:** `Promise` — the vault key buffer on first init, `null` if already initialized. Throws on failure.
#### vault.status()
-Get vault status for the current identity. Useful alongside `vault.init()` when callers need to know whether a vault existed before they called `init()`: a `[null, null]` from `init()` plus `status.isInitialized === true` confirms idempotent no-op.
+Get vault status for the current identity. Useful alongside `vault.init()` when callers need to know whether a vault existed before they called `init()`: a `null` from `init()` plus `status.isInitialized === true` confirms idempotent no-op.
```typescript
const status = await ctx.noorm.vault.status();
@@ -1105,16 +1088,13 @@ console.log(`Initialized: ${status.isInitialized}, Has access: ${status.hasAcces
#### vault.set(key, value, privateKey)
-Set a vault secret. Requires the caller's private key for vault key decryption.
+Set a vault secret. Requires the caller's private key for vault key decryption. Throws on failure.
```typescript
-const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey);
-if (err) {
- console.error('Failed to set secret:', err.message);
-}
+await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey);
```
-**Returns:** `Promise<[void, Error | null]>`
+**Returns:** `Promise` — throws on failure.
#### vault.get(key, privateKey)
@@ -1156,16 +1136,16 @@ console.log('Available secrets:', keys.join(', '));
#### vault.delete(key)
-Delete a vault secret.
+Delete a vault secret. Throws on failure.
```typescript
-const [deleted, err] = await ctx.noorm.vault.delete('OLD_KEY');
+const deleted = await ctx.noorm.vault.delete('OLD_KEY');
if (deleted) {
console.log('Secret removed');
}
```
-**Returns:** `Promise<[boolean, Error | null]>`
+**Returns:** `Promise` — throws on failure.
#### vault.exists(key)
@@ -1193,20 +1173,18 @@ console.log(`Propagated to ${result.propagatedTo.length} new users`);
#### vault.copy(destConfig, keys, privateKey, options?)
-Copy vault secrets to another config's database. Useful for seeding a new environment with secrets from an existing one.
+Copy vault secrets to another config's database. Useful for seeding a new environment with secrets from an existing one. Throws on failure.
```typescript
-const [result, err] = await ctx.noorm.vault.copy(
+const result = await ctx.noorm.vault.copy(
destConfig,
['API_KEY', 'DB_TOKEN'],
privateKey,
);
-if (result) {
- console.log(`Copied ${result.copied} secrets`);
-}
+console.log(`Copied ${result.copied} secrets`);
```
-**Returns:** `Promise<[VaultCopyResult | null, Error | null]>`
+**Returns:** `Promise` — throws on failure.
### ctx.noorm.utils — Utilities
@@ -1235,14 +1213,16 @@ const checksum = await ctx.noorm.utils.checksum('sql/001_users.sql');
### Event Subscriptions
-Subscribe to core events via the observer:
+Subscribe to core events via the process-global `noormObserver` bus:
```typescript
-ctx.noorm.observer.on('file:after', (event) => {
+import { noormObserver } from '@noormdev/sdk';
+
+noormObserver.on('file:after', (event) => {
console.log(`Executed ${event.filepath} in ${event.durationMs}ms`);
});
-ctx.noorm.observer.on('change:complete', (event) => {
+noormObserver.on('change:complete', (event) => {
console.log(`Change ${event.name}: ${event.status}`);
});
```
@@ -1287,7 +1267,20 @@ const ctx = await createContext();
## Error Handling
+SDK methods throw named, `instanceof`-matchable errors and let them propagate — a call either
+returns its value or throws. There are no `[value, error]` tuples on the `ctx.noorm.*` surface.
+Wrap a call in `attempt()` (from `@logosdx/utils`) only when you're going to do something with the
+error: translate it, recover, observe, or knowingly ignore it. If you'd just re-throw it unchanged,
+skip `attempt` and let it propagate. Never wrap an SDK call in try-catch.
+
+Carve-outs that don't follow that plain throw-and-`attempt` pattern:
+`ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design — a probe reporting failure as
+data, not an exception. `ctx.transaction(...)` callbacks must throw to trigger Kysely's rollback —
+throwing is the rollback signal, so don't swallow it inside the callback. Raw `ctx.kysely` queries
+throw whatever the driver throws.
+
```typescript
+import { attempt } from '@logosdx/utils';
import {
createContext,
tvp,
@@ -1296,28 +1289,20 @@ import {
LockAcquireError,
} from '@noormdev/sdk';
-try {
- const ctx = await createContext({ config: 'prod', requireTest: true });
-} catch (err) {
- if (err instanceof RequireTestError) {
- console.error('Cannot use production config in tests');
- }
+// attempt() is used deliberately here — the caller inspects the named error and reacts to it.
+const [ctx, err] = await attempt(() => createContext({ config: 'prod', requireTest: true }));
+if (err instanceof RequireTestError) {
+ console.error('Cannot use production config in tests');
}
-try {
- await ctx.noorm.db.truncate();
-} catch (err) {
- if (err instanceof ProtectedConfigError) {
- console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give');
- }
+const [, truncateErr] = await attempt(() => ctx.noorm.db.truncate());
+if (truncateErr instanceof ProtectedConfigError) {
+ console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give');
}
-try {
- await ctx.noorm.lock.acquire();
-} catch (err) {
- if (err instanceof LockAcquireError) {
- console.error(`Lock held by ${err.holder}`);
- }
+const [, lockErr] = await attempt(() => ctx.noorm.lock.acquire());
+if (lockErr instanceof LockAcquireError) {
+ console.error(`Lock held by ${lockErr.holder}`);
}
```
diff --git a/docs/spec/config-access-roles.md b/docs/spec/config-access-roles.md
index b7ac700d..33a29f77 100644
--- a/docs/spec/config-access-roles.md
+++ b/docs/spec/config-access-roles.md
@@ -34,7 +34,7 @@ Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enfo
type Permission =
| 'explore'
| 'sql:read' | 'sql:write' | 'sql:ddl'
- | 'change:run' | 'change:ff' | 'change:revert'
+ | 'change:run' | 'change:ff' | 'change:revert' | 'change:rm'
| 'run:build' | 'run:file' | 'run:dir'
| 'db:create' | 'db:reset' | 'db:destroy'
| 'config:rm'
@@ -54,6 +54,7 @@ Matrix (cells: `allow` / `confirm` / `deny`), hard-coded in `src/core/policy/`:
| db:reset | deny | confirm | allow |
| db:destroy | deny | deny | confirm |
| config:rm | deny | confirm | confirm |
+| change:rm | deny | confirm | confirm |
type PolicyTarget = { name: string; access: ConfigAccess }
// Config satisfies PolicyTarget structurally once CP2 adds `access`.
@@ -116,7 +117,7 @@ New state migration in `src/core/version/state/migrations/` (registered in `src/
## Checkpoints
-Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fresh-process `bun test --serial` groups — a single whole-suite `bun test` cross-contaminates and does not reflect CI; DB containers from repo-root `docker-compose.yml` must be up), `bun run typecheck`, `bun run typecheck:tests`, `bun run lint`. Commit per green checkpoint.
+Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fresh-process `bun test --serial` groups — a single whole-suite `bun test` cross-contaminates and does not reflect CI; DB containers from repo-root `docker-compose.test.yml` must be up), `bun run typecheck`, `bun run typecheck:tests`, `bun run lint`. Commit per green checkpoint.
| CP | Scope | Key files | Done when |
|---|---|---|---|
@@ -151,3 +152,4 @@ Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fre
- 2026-07-07 — CP2: `Config.access` optional until CP6 (CP4/CP5-owned constructors); fail-closed rule added for absent access on the mcp channel. Stage `protected: true` override-block in `checkConfigCompleteness` replaced by the ceiling clamp (the old "stored wins" behavior was the bug the clamp fixes).
- 2026-07-07 — CP4: added `db:reset` permission (viewer deny / operator confirm / admin allow) for data-destructive-but-not-drop operations; the initial CP4 mapping of truncate/teardown/reset/importFile to `db:destroy` violated the migration section's behavior-preservation promise for open configs.
- 2026-07-08 — CP8 (post challenge-swarm): classifier now catches CTE-wrapped DML and a destructive-function denylist (viewer write-bypass was confirmed critical). User-channel enforcement moved to the core seam so run/change/transfer/sql-terminal are gated for SDK+TUI+CLI (the earlier "same checkPolicy" claim was only partly delivered). Correctness + hygiene fixes per the swarm. Downgrade guard, state.enc durability/atomicity, and denial observability explicitly deferred (alpha; pre-existing; separate workstreams).
+- 2026-07-13 — `change:rm` added (viewer deny / operator confirm / admin confirm — admin gets confirm not allow because deleting an applied change also deletes its DB tracking row), refs `docs/spec/v1-44-change-rm-gate.md`.
diff --git a/docs/spec/v1-45-rewind-tiebreak.md b/docs/spec/v1-45-rewind-tiebreak.md
new file mode 100644
index 00000000..a33629de
--- /dev/null
+++ b/docs/spec/v1-45-rewind-tiebreak.md
@@ -0,0 +1,61 @@
+# Spec: v1-45 rewind — apply-order tiebreak
+
+Ticket: `tickets/v1/45-rewind-tiebreak-order.md` (realm repo). Discovered 2026-07-13: un-skipping the two ticket-34-gated rewind tests (the SQLite date crash is fixed) exposed an independent ordering bug. Branch: `v1/45-rewind-tiebreak` off `next` @ `b971f2f`. Reviewers diff against `b971f2f`. **v1-blocker.**
+
+## Goal
+
+`ChangeManager.rewind()` (`src/core/change/manager.ts` ~369) sorts applied changes by `appliedAt` descending with no tiebreaker. Ties are routine — `change ff` applies several changes in one process within the same clock tick, and SQLite datetime is second-precision — and on a tie the sort preserves `list()` insertion order (oldest-first). Observed failures (reproduced by un-skipping the two tests):
+
+- `rewind(2)`: reverts oldest-first within the tie group (`result.changes[0]` was the *older* change) — dependency-violating for DDL reverts.
+- `rewind(name)`: `findIndex` against the mis-sorted list truncates the slice — reverted only the named change, leaving the newer change applied on top of a reverted base.
+
+Fix: the history table's autoincrement `id` (`src/core/change/types.ts:449/492` — `ChangeHistoryRecord.id`) is the true apply-order key. Investigate whether the objects `list()` returns carry it (they're hydrated in `src/core/change/history.ts` ~200-260); if not, plumb it through the list surface (additive optional field), then sort by `appliedAt` desc with `id` desc as tiebreak — or by `id` alone if every applied entry reliably has one (implementer verifies which; document the choice in the diff). Non-tied ordering must be provably unchanged.
+
+## Checkpoints
+
+| # | Checkpoint | Files | Agent | Verifies |
+|---|---|---|---|---|
+| 1 | Tiebreak fix + un-skip | `src/core/change/manager.ts`, possibly `src/core/change/history.ts` + types, `tests/core/change/manager.test.ts` | atomic-implementer (mode: feature) | The two `it.skip` rewind tests (~lines 261, 299 — comments reference this ticket) un-skipped and green; new tie-specific test: two changes with identical `appliedAt` revert id-descending; existing manager tests untouched and green; `bun run typecheck` clean. TDD: run the un-skipped tests RED first (they fail on `b971f2f` — verify), then fix. |
+
+## Non-goals
+
+- Changing what revert executes per change; revert-file ordering within a change; `change revert` (single) semantics.
+- History-table schema changes — `id` already exists.
+
+## Acceptance criteria (from ticket)
+
+- `rewind(2)` reverts newest-first; `rewind(name)` reverts everything down to and including the named change; tie-specific test green; non-tied behavior unchanged.
+
+## Risks
+
+| Risk | Likelihood | Mitigation |
+|---|---|---|
+| `list()` items genuinely lack the history id and plumbing it touches many call sites | medium | The field is additive/optional on the status type; only rewind consumes it. If the blast radius exceeds ~3 files, stop and record in STATE.md. |
+| Multiple history rows per change (re-applies) make id ambiguous | medium | Use the id of the row that produced `appliedAt` (the latest successful apply) — the hydration site in history.ts already selects that row for appliedAt; take id from the same record. |
+
+## Change log
+
+- 2026-07-13 — initial spec, authored by orchestrator pre-implementation.
+
+## Implementation log
+
+### shipped — 2026-07-14
+
+Built across 1 iteration of /subagent-implementation. Commits (chronological):
+
+- `a3c4eb6` — spec added
+- `8f770f8` — CP-1 tiebreak fix: appliedHistoryId plumbed through ChangeStatus/ChangeListItem (types.ts), sourced from the history row's autoincrement id in history.ts's getStatus()/getAllStatuses(), consumed as id-descending tiebreak in manager.ts rewind()'s sort (appliedAt stays primary key)
+- `0df96a2` — followup F-1 deferred
+
+**Out-of-scope work performed during this build:**
+
+- none — blast radius held to exactly the 3 files predicted (types.ts, history.ts, manager.ts) plus the test file.
+
+**Unforeseens — surprises that emerged during implementation:**
+
+- Ticket/spec text claimed "two" ticket-34-gated it.skip tests in tests/core/change/manager.test.ts (~261, ~299); only one existed (line 254 pre-edit) — the ticket-34 SQLite date-hydration fix had already landed on this branch's base (b971f2f) and the second skip it originally referenced lived in a different file (tests/cli/run/change-rewind.test.ts), outside this ticket's scoped test surface. Verified via baseline run (10 pass, 1 skip, 0 fail on b971f2f) before touching code. Resolved by un-skipping the one existing test and adding the new tie-specific test the checkpoint called for; both satisfy the ticket's acceptance criteria.
+- `ChangeListItem` is also constructed in `src/tui/utils/change-loader.ts` (three call sites) without setting `appliedHistoryId` — left the field optional/additive rather than touching that file, keeping blast radius at 3 files per the spec's risk budget. TUI callers unaffected since they don't consume the field.
+
+**Deferred items still open:**
+
+- `v1-45-rewind-tiebreak-f1` (project-level followup, risk): the new tie-specific test (and the pre-existing sibling at ~line 250) force the appliedAt tie via wall-clock timing coincidence rather than a deterministically forced equal executed_at — rare risk of false confidence on a run that straddles a second boundary. Not blocking; deferred rather than fixed in this iteration since it's a pre-existing test-technique pattern, not a regression introduced here.
diff --git a/docs/spec/v1-49-54-cli-field-defects.md b/docs/spec/v1-49-54-cli-field-defects.md
new file mode 100644
index 00000000..9df04cf4
--- /dev/null
+++ b/docs/spec/v1-49-54-cli-field-defects.md
@@ -0,0 +1,374 @@
+# Spec — v1/49-54 field-reported CLI defects
+
+Design: `docs/design/v1-49-54-cli-field-defects.md`
+Issues: noormdev/noorm #49, #50, #51, #52, #53, #54
+Branch: `v1/49-54-cli-field-bugs` off `next`
+
+## Baseline
+
+**Verify the way CI does — four separate `bun test` runs, never one whole-tree run.**
+`.github/workflows/ci.yml` splits them deliberately: *"Split test runs to prevent
+cross-contamination from mock.module."*
+
+ bun test --serial $(find tests/utils tests/core tests/sdk -name '*.test.ts' | grep -v tests/core/transfer | sort | tr '\n' ' ')
+ bun test --serial tests/core/transfer
+ bun test --serial tests/cli
+ bun test --serial tests/integration
+
+A single `bun test --serial` over the whole tree reports ~18 failures on `next` that are
+**not** product defects. `tests/cli/app-context.test.tsx:50` replaces the entire
+`src/core/index.js` barrel via `mock.module`; any module first loaded during that window
+permanently captures the stubbed `getStateManager`, and the `afterAll` restore cannot rebind
+what another module already closed over. Every later file that reaches a real `StateManager`
+then fails with `getAllSecrets is not a function` or `setConfig is not a function`. The
+affected tests all pass in isolation. Do not chase them, and do not "fix" a product file to
+satisfy one.
+
+Preconditions for a meaningful run:
+
+- `bun run build` first. CLI tests spawn `dist/cli/index.js`; without a build, 204 tests fail
+ on `MODULE_NOT_FOUND` and the signal is worthless.
+- All three test DBs up: postgres :15432, mysql :13306, mssql :11433 (`tests/sample.env`).
+- The `noorm_test_dest` databases created and the postgres fixtures deployed, exactly as the
+ CI workflow's setup steps do. Without them `tests/core/transfer` fails 8 tests on
+ `relation "todo_items" does not exist` — an environment gap, not a regression.
+
+Green means 0 failures across all four runs.
+
+## Contract
+
+| CP | Deliverable | Acceptance | Files |
+|----|-------------|-----------|-------|
+| 1 | Root-level flag placement | `noorm run build --dry-run` and `noorm config list --json` work. Any flag other than `-c`/`--cwd` placed before the subcommand is rejected, naming the correct form. `-c`/`--cwd` is the sole flag with a root-level meaning. Never exit 0 silently. | `src/cli/index.ts` |
+| 2 | `run build` and `db teardown` honour `--dry-run` | `noorm run build --dry-run` and `noorm db teardown --dry-run` both execute zero statements and list what would run. | `src/cli/run/build.ts`, `src/cli/db/teardown.ts`, `src/sdk/types.ts`, `src/sdk/namespaces/run.ts`, `src/sdk/namespaces/db.ts` |
+| 3 | `db create` / `db drop` honour `NOORM_CONNECTION_*` | With a persisted config active and env vars naming a different database, both target the env database. | `src/cli/db/create.ts`, `src/cli/db/drop.ts` |
+| 4 | Results on stdout, diagnostics on stderr | `noorm change list` prints its table to stdout; empty set prints an explicit empty-state line. `noorm change history --json \| jq .` parses with no `tail -1`. | `src/core/logger/logger.ts`, `src/cli/_utils.ts`, `src/cli/change/list.ts`, `src/cli/change/history.ts`, `src/cli/lock/status.ts`, `src/cli/db/explore.ts` |
+| 5 | Unresolved secret fails loudly | Rendering `$.secrets.MISSING` throws naming the key. `sqlQuote(undefined)` throws. `'undefined'` never reaches SQL. `KEY in $.secrets` still probes without throwing. | `src/core/template/context.ts`, `src/core/template/utils.ts` |
+| 6 | Vault tier reaches `$.secrets` | A secret set only via `noorm vault set` resolves in a template. Precedence config-local > global-local > vault. No reachable vault degrades to the local tiers without throwing. | `src/sdk/namespaces/run.ts`, `src/sdk/namespaces/changes.ts`, `src/sdk/namespaces/templates.ts`, `src/cli/run/preview.ts`, `src/cli/run/inspect.ts`, `src/tui/utils/run-context.ts`, `src/tui/screens/run/RunInspectScreen.tsx` |
+| 7 | `change add` scaffolds runnable stubs | `noorm change add x` then `noorm change run x` reports empty-SQL, not "change not found". | `src/core/change/scaffold.ts` |
+| 8 | Docs state what the code does | No doc claims a command or form that does not exist. `noorm secret` has a CLI reference page. | `docs/dev/secrets.md`, `docs/headless.md`, `docs/cli/secret.md`, `docs/cli/run.md`, `docs/guide/changes/overview.md` |
+| 9 | #54 — invariant + instrumentation | Build asserts each discovered file is unique and executes once. Duplicate `(operationId, filepath)` fails loudly instead of silently updating N rows. Connection target is logged. | `src/core/runner/runner.ts`, `src/core/runner/tracker.ts`, `src/core/connection/factory.ts` |
+| 10 | `run build` honours checksums | A second build over unchanged files skips them. `--force` still re-runs. The docs at `docs/headless.md:636` and `docs/dev/runner.md:13` become true. | `src/core/runner/tracker.ts` |
+
+## Checkpoint detail
+
+### CP1 — root-level flag placement
+
+`citty` forwards only `rawArgs.slice(subCommandArgIndex + 1)`
+(`node_modules/citty/dist/index.mjs:217`), so a flag before the subcommand name is
+discarded. The root command declares no `args` (`src/cli/index.ts:44-71`).
+
+A flag goes on the command that uses it. `-c`/`--cwd` is the sole exception, handled by
+`extractGlobalCwd` (`src/cli/index.ts`), which strips `-c `/`--cwd `/`--cwd=`
+from argv before dispatch — it must run before dispatch because it sets the working directory
+everything else (project discovery, config resolution) resolves against, and `-c` already
+means `--config` after the subcommand. `--dry-run`, `--json`, `--yes` are **not** hoisted;
+they are per-subcommand, exactly like `--config` and `--force` already were.
+
+Rule 1 of the design still applies: a flag that cannot be honoured is an error. Any flag
+other than `-c`/`--cwd` seen before the subcommand exits non-zero with a message naming the
+correct invocation. Silent acceptance is the defect this checkpoint fixes — do not replace
+one silence with another.
+
+Do not declare these flags at every command level; citty will not forward a parent's parsed
+value to a child, so that approach adds declaration sites without fixing the drop.
+
+### CP2 — dry-run
+
+`sharedArgs.dryRun` exists (`src/cli/_utils.ts:52`). The core gate
+(`src/core/runner/runner.ts:608`) already works and is exercised by `run file`, `run dir`,
+`run files`, `run exec`, `change run`. Only the wiring is missing:
+
+- `src/cli/run/build.ts:13-17` — `args` omits `dryRun`.
+- `src/sdk/types.ts:82-88` — `BuildOptions` has no `dryRun` field.
+- `src/sdk/namespaces/run.ts:199,219` — both hardcode `{ force: options?.force }`.
+- `src/cli/db/teardown.ts:13-18` — same omission; `ctx.noorm.db.teardown()` takes no options
+ at all, while `docs/guide/database/teardown.md:255` documents the flag.
+
+Copy the working chain from `run file` (`src/cli/run/file.ts:20,30`). Add no new branch.
+
+### CP3 — connection resolution
+
+`src/cli/db/create.ts:46` and `src/cli/db/drop.ts:50` call
+`stateManager.getConfig(configName)` — a raw record read (`src/core/state/manager.ts:349`)
+with no env merge — then connect with the result.
+
+Route both through `resolveConfig` (`src/core/config/resolver.ts:211`), which merges
+`DEFAULTS <- stage <- stored <- env <- flags`. Introduce no new precedence logic; the merge
+is already covered by `tests/core/config/resolver.test.ts:234-260`.
+
+These two commands are the complete set — every other connecting command already reaches
+`resolveConfig` via `withContext`/`createContext`. Commands that read a stored config by
+name for a non-connection purpose (`db transfer` destination, `vault cp`, `config *`) are
+correct as-is and must not be changed.
+
+### CP4 — output streams
+
+`Logger` holds two writers (`src/core/logger/logger.ts:143`): `console` → stdout,
+`diagnostics` → stderr. `#writeConsole` (`:497-543`) currently routes `--json` NDJSON to
+stdout and human text to stderr. Both are backwards.
+
+Required behaviour, in both modes:
+
+- A command's **result** goes to stdout.
+- The logger's **event stream** goes to stderr.
+
+So `noorm change history --json` writes exactly one parseable JSON document to stdout.
+
+`change list` (`:30-46`, `:57-61`) and `change history` (`:32-43`, `:54-58`) call
+`outputResult` only under `if (args.json)`. Build the text and call `outputResult`
+unconditionally, matching `src/cli/config/list.ts:40,59-63`, which already does this right.
+
+An empty result set prints an explicit empty-state line — issue #52's core complaint is that
+a CI step cannot distinguish "no changesets" from "no output".
+
+`src/cli/lock/status.ts:25-41` and `src/cli/db/explore.ts:29-45` carry the same defect and
+are in scope.
+
+### CP5 — secrets fail loudly
+
+Two independent silences turn a missing secret into valid SQL:
+
+- `src/core/template/context.ts:70` — `secrets: options.secrets ?? {}` is a plain object, so
+ a missing key reads as `undefined`.
+- `src/core/template/utils.ts:85-95` — `sqlQuote` guards `null` but not `undefined`;
+ `String(undefined)` → `"undefined"` → `'undefined'`.
+
+Make `$.secrets` a Proxy whose `get` throws on an unknown key, naming the key and the tiers
+searched. Make `sqlQuote` throw on `undefined` — defence in depth, since any other path
+reaching it with `undefined` is equally a bug.
+
+A `has` trap must keep `'KEY' in $.secrets` working, and `Object.keys`, spread, and
+`JSON.stringify` of the context must not throw. `$.secrets.KEY ?? fallback` does **not**
+survive and is not meant to: `??` evaluates its left operand, which is the throwing read.
+Optional probing is written `KEY in $.secrets ? $.secrets.KEY : default`. Requiring the
+explicit guard is the point — `?? default` silently accepts a missing secret, which is the
+coercion that produced `'undefined'` in the first place.
+
+The error message names only tiers that actually feed `$.secrets`. That is **config-local
+only**: every render-path caller populates it from `stateManager.getAllSecrets(configName)`
+(`src/core/state/manager.ts:479`), and global-local secrets live in a separate
+`$.globalSecrets` context property that is never merged in. Do not name global-local or
+vault. Add a test asserting the message does not overclaim.
+
+### CP6 — connect the vault tier
+
+`resolveSecret` and `buildSecretsContext` (`src/core/vault/resolve.ts:45,149`) implement the
+documented three-tier precedence. `docs/dev/vault.md:169` names `buildSecretsContext()` as
+*the* template-render path. Neither has a production caller — verified by grep; the only
+hits are docstrings, `docs/`, and `packages/*/CHANGELOG.md`.
+
+The render path instead calls `stateManager.getAllSecrets(configName)`
+(`src/core/state/manager.ts:479`) — config-local only. **Vault secrets reach no template,
+for any identity.** The field report's "silently yielded nothing for this identity"
+understates it.
+
+Replace `getAllSecrets` with `buildSecretsContext` at the render-context builders.
+`buildSecretsContext` is async and the current builders are sync — notably
+`RunNamespace.#createRunContext` (`src/sdk/namespaces/run.ts:247-259`). The callers are
+already async; make the builders async and await.
+
+Call sites: `src/sdk/namespaces/run.ts:255`, `src/sdk/namespaces/changes.ts:461`,
+`src/sdk/namespaces/templates.ts:47`, `src/cli/run/preview.ts:60`,
+`src/cli/run/inspect.ts:100`, `src/tui/utils/run-context.ts:62`,
+`src/tui/screens/run/RunInspectScreen.tsx:362,427`.
+
+The inspect/preview surfaces must resolve the same tiers as the apply path. A preview that
+shows different secrets than the apply uses is the same class of silent divergence this
+whole spec exists to remove.
+
+The counting sites (`src/cli/run/inspect.ts:163,220`) report a secret count; keep them
+consistent with whatever the render path now resolves.
+
+`src/tui/screens/config/ConfigExportScreen.tsx:152` exports the config-local store
+deliberately and is **out of scope** — do not change it.
+
+If the React screens make the async change genuinely invasive, stop and report rather than
+leaving the apply path and the inspect path disagreeing.
+
+### CP7 — `change add` stubs
+
+`createChange` (`src/core/change/scaffold.ts:88-149`) creates the dated directory, empty
+`change/` and `revert/` subdirectories, and `changelog.md`. Both directories stay empty.
+
+The resulting changeset is worse than inert. `parseChange` throws `ChangeValidationError`
+when both folders are empty (`src/core/change/parser.ts:115-121`); `#loadChange`
+(`src/core/change/manager.ts:605-630`) catches it, finds no DB history, and re-throws
+`ChangeNotFoundError` (`:624`). The user is told the change does not exist, when it does.
+
+Scaffold `change/001_.sql` and `revert/001_.sql` containing a comment naming
+what belongs in each. Match the loader's expectations exactly:
+`SEQUENCE_REGEX = /^(\d{3})_(.+)$/` (`src/core/change/parser.ts:49`), extensions from
+`SQL_EXTENSIONS` (`:40`), ordering by `localeCompare` on filename (`:434`).
+
+A stub containing only comments must still surface as an actionable message, not as a
+success. `executeChange` already rejects template-only/empty content
+(`src/core/change/executor.ts:148-157`) — verify the stub lands on that path and reads
+clearly.
+
+Update `tests/core/change/scaffold.test.ts:51-65`, which currently asserts
+`changeFiles`/`revertFiles` start empty.
+
+### CP8 — docs
+
+Each item below is a statement that is false today.
+
+- `docs/dev/secrets.md:113` and `:151-153` — claim there is no headless `noorm secret set`
+ and direct users to `noorm vault set` for config-scoped local secrets.
+ `src/cli/secret/set.ts:12-65` is fully headless (positional `key`/`value`, no TTY gate),
+ and `docs/guide/environments/secrets.md:27-40` documents it correctly. The dev doc
+ contradicts both the code and its own user guide. This is the misdirection named in #50.
+- `docs/headless.md:885` — says `change add` creates `change.sql` and `revert.sql`. It
+ creates `change/` and `revert/` *directories*. After CP7, document the stub files.
+- `docs/headless.md:55,641` — documents `--dry-run` before the subcommand. `--dry-run` is
+ per-subcommand only; correct after CP1 to the leaf-placed form.
+- `docs/cli/` has no `secret.md`, while `noorm vault` has both a dev and a guide page.
+ Add one covering `secret set|list|rm`, cross-linked to the vault page, stating plainly
+ which tier is which — the tier confusion is what cost the reporter the time.
+- `docs/guide/changes/overview.md:91-102` attributes changeset scaffolding to the TUI wizard
+ and never names `noorm change add`. Document the CLI command and the on-disk layout it
+ produces, including the `NNN_` convention and multi-file ordering (#53's explicit ask).
+
+Prose follows the `atomic-writing` skill. Do not invent behaviour — every statement must be
+checked against the code as it stands after CP1-CP7.
+
+### CP9 — #54: double execution ruled out
+
+**Finding: no double-execution mechanism exists.** Verified at every layer.
+
+- Kysely never retries a query — `MssqlConnection.executeQuery` calls `execSql` once
+ (`node_modules/kysely/dist/cjs/dialect/mssql/mssql-driver.js:130`).
+- tedious never resubmits a `Request`; its only retry
+ (`node_modules/tedious/lib/connection.js:2320`) is reached solely from the LOGIN7 catch
+ block, before any request is sent.
+- No connection reset on release — `src/core/connection/dialects/mssql.ts:133-149` sets
+ neither `resetConnectionsOnRelease` nor `tedious.resetConnectionOnRelease`, and `reset`
+ never replays a prior batch regardless.
+- Discovery cannot duplicate: one dirent per push (`src/core/runner/runner.ts:1355-1393`),
+ and `filterFilesByPaths` is `Array.prototype.filter` (`src/core/shared/files.ts:61`).
+- The execution loop is sequential `for` + `await` with no retry wrapper (`:700-742`).
+- The v1/17 per-file retry landed in `src/core/change/executor.ts`, which `runner.ts` does
+ not import.
+
+Do not implement a fix for double execution. Deliver the invariants that make any
+recurrence self-diagnosing, and the real defect CP10 names.
+
+1. Assert discovery uniqueness before execution — `files.length === new Set(files).size` —
+ and fail loudly naming duplicates. Uniqueness is currently emergent, not enforced. Also
+ assert `results.length === files.length` after the loop.
+2. `updateFileExecution()` (`src/core/runner/tracker.ts:497-518`) uses
+ `WHERE change_id = ? AND filepath = ?` and tolerates any row count. Fail when
+ `numUpdatedRows !== 1`, not merely when `=== 0`. **Why:** more than one match is exactly
+ what would mask an upstream duplicate from `needsRun`.
+3. `needsRun()`'s DB-error path (`:131-141`) returns `needsRun: true, reason: 'new'` on a
+ failed SELECT. Distinguish "no record" from "could not read"; a transient error must not
+ read as a new file.
+4. On file failure, report prior successful executions of that filepath — one extra SELECT
+ on the failure path only. This is the single line that would have answered #54 on sight.
+5. Emit the resolved connection target on every connecting command. `connection:open`
+ currently carries `{configName, dialect}` only (`src/core/connection/factory.ts:182`);
+ add host, port, and database. This permanently removes the "which database was I
+ actually talking to" ambiguity that #51 creates.
+
+Add a regression test asserting a build executes each discovered file exactly once.
+
+### CP10 — `run build` never skips anything
+
+The defect #54 actually surfaced. Higher severity than any of the six filed issues, present
+since the initial release, and contradicted by two shipped doc pages.
+
+`executeFiles` inserts a `pending` execution row for every discovered file *before* the loop
+(`src/core/runner/runner.ts:677` → `src/core/runner/tracker.ts:443-482`). `needsRun` then
+selects the newest row for that filepath by `executions.id desc`
+(`src/core/runner/tracker.ts:126`) — which is always the pending row this very run just
+inserted — and `:163-167` returns `{ needsRun: true, reason: 'new' }` for `pending`.
+
+**Checksum-based skipping is structurally unreachable for `run build`, `run dir`, and
+`run files`.** `run file` is unaffected: `executeSingleFile` inserts after execution
+(`runner.ts:1087`).
+
+Already known in-repo and never filed — `tests/cli/run/build.test.ts:147` calls it
+"a latent runner bug" in a comment.
+
+Both shipped docs promise the opposite:
+
+- `docs/headless.md:636` — "Uses checksums to skip unchanged files."
+- `docs/dev/runner.md:13` — "Files that haven't changed are skipped."
+
+Consequence, and the reason #54 looked like double execution: every `run build` re-executes
+every file, so a second build against a populated database is *guaranteed* to fail at the
+first non-idempotent DDL statement. For the reporter's schema that is `CREATE TYPE mls_id` —
+the first statement of the second phase, exactly as reported.
+
+Fix: exclude the current operation's own rows from the `needsRun` lookup — `change_id <>
+operationId`, or `executions.id <` the first id inserted by this run.
+
+**Do not "scope `needsRun` to the current operation".** An earlier draft of this spec said
+that; it is backwards and would cement the defect while appearing to fix it. The pending row
+that must be ignored *is* the current operation's.
+
+Acceptance: a second `run build` over unchanged files skips them and reports them as
+skipped; `--force` still re-runs everything; a changed file still re-runs. Verify against a
+live database, not only a mock — the defect lives in a SQL query's ordering.
+
+This changes behaviour that some tests may silently depend on. If a previously-passing test
+breaks because it assumed files always re-run, that is a real finding: report it rather than
+weakening the fix.
+
+## Test requirements
+
+TDD: failing test first, then implementation. Every checkpoint needs a test that fails for
+the reported reason before the fix.
+
+- CP1 — a flag works after the subcommand; the same flag placed before the subcommand exits
+ non-zero naming the correct form. `-c`/`--cwd` is the exception and still works before the
+ subcommand.
+- CP2 — dry-run build against a live DB creates zero objects; sibling non-dry-run creates them.
+- CP3 — env vars + a different active config; assert the env database is targeted.
+- CP4 — capture stdout and stderr separately. Assert result on stdout, empty-state line
+ present, and `--json` stdout parses as a single JSON document.
+- CP5 — missing key throws naming the key; `?? fallback` and `in` still work;
+ `sqlQuote(undefined)` throws.
+- CP6 — a vault-only secret resolves in a template; precedence order holds.
+- CP7 — `change add` then `change run` gives the empty-SQL message, not "change not found".
+- CP9 — a build executes each file exactly once; a duplicated input fails loudly.
+
+Integration tests requiring MSSQL use `TEST_MSSQL_*` from `tests/sample.env` (port 11433).
+
+## Non-goals
+
+- Changing the `change/` + `revert/` directory convention (#53 rules it out).
+- Redesigning vault identity or key distribution. CP6 connects the existing resolver.
+- Fixing the `mock.module` barrel leak in `tests/cli/app-context.test.tsx`. It is
+ pre-existing, CI already works around it by splitting runs, and a correct fix is test
+ architecture work rather than part of six CLI defects. Worth its own issue.
+- The `max-len` lint error at `tests/sdk/destructive-ops.test.ts:75` (203 chars). Present on
+ `next`, untouched by this branch.
+- `src/tui/screens/config/ConfigExportScreen.tsx` — exports the local store by design.
+
+## Change log
+
+- 2026-07-25 — initial spec from the six issues + six investigation traces and one
+ empirical CLI probe confirming the flag-position drop. CP9 pending strategist RCA.
+- 2026-07-25 — CP9 rewritten after RCA. Double execution ruled out at every layer
+ (kysely, tedious, tarn, discovery, filter, loop). **Corrected a defect in the previous
+ CP9 body:** it directed scoping `needsRun` *to* the current operation, which is backwards
+ and would have cemented the bug. Added CP10 for the dead skip path — the defect #54
+ actually surfaced, and the reason a second build fails on the first non-idempotent DDL.
+- 2026-07-25 — Baseline section rewritten. It previously recorded "3649 pass / 52 skip /
+ 18 fail" from a single whole-tree run and called the 18 test-double gaps. Both were wrong:
+ CI never runs the tree in one process, and the failures are `mock.module` barrel
+ contamination from `tests/cli/app-context.test.tsx`. Under CI's four-run split the branch
+ is 3719 pass / 52 skip / 0 fail.
+- 2026-07-25 — CP5 corrected on two counts, both found during implementation.
+ The body claimed `$.secrets.KEY ?? fallback` would keep working; it cannot, because `??`
+ evaluates the throwing read. Optional probing is the `in` guard, and that is the intended
+ contract rather than a concession. The body also contradicted CP6 on which tiers feed
+ `$.secrets` — it is config-local only.
+- 2026-07-25 — CP1 reversed. `--dry-run`, `--json`, `--yes` are no longer hoisted to any
+ position; only `-c`/`--cwd` is a root-level flag, and every other flag — including these
+ three — is per-subcommand and errors before the subcommand, matching `--config`/`--force`.
+ **Superseded:** CP1 previously hoisted `--dry-run`/`--json`/`--yes` out of `rawArgs` before
+ citty dispatch (the design doc's D1) so they worked in either position. That created an
+ asymmetry with `--config`/`--force`, which were never hoisted, for no reason beyond
+ convenience. The standard is now uniform: a flag goes on the command that uses it.
diff --git a/docs/wiki/CLAUDE.md b/docs/wiki/CLAUDE.md
index 13f4e3d0..433de0f7 100644
--- a/docs/wiki/CLAUDE.md
+++ b/docs/wiki/CLAUDE.md
@@ -7,15 +7,14 @@ description: Authoritative steering for the signals/wiki inferrer when operating
the inferrer reads this and treats it as authoritative>
## Framework
-# NestJS monorepo (not plain Express)
+# Bun workspace monorepo — TypeScript, Ink/React TUI, Citty CLI, Kysely SQL layer
## Domains
-# - src/billing/ and src/payments/ are one domain ("payments")
-# - src/internal-tools/ is scratch code — not a real domain
+# - docs/wiki/index.md's Domains table is already correct; no override needed here
## Build
-# - Build: pnpm turbo build
-# - Test: pnpm test:ci (not pnpm test — that runs watch mode)
+# - Build: bun run build
+# - Test: bun run test (CI splits into 4 serial groups — see .claude/rules and docs/wiki/index.md)
## Ignore for domains
# - vendor/
diff --git a/docs/wiki/core-change.md b/docs/wiki/core-change.md
index c488df2c..480bc591 100644
--- a/docs/wiki/core-change.md
+++ b/docs/wiki/core-change.md
@@ -15,11 +15,11 @@ Change directories hold a `manifest.json` and SQL files. Each change has a descr
- [`src/core/change/scaffold.ts`](../../src/core/change/scaffold.ts) — create/add/remove/rename/reorder change files on disk
- [`src/core/change/parser.ts`](../../src/core/change/parser.ts) — `parseChange`, `discoverChanges`, `resolveManifest`, `validateChange`, `parseSequence`, `parseDescription`
- [`src/core/change/executor.ts`](../../src/core/change/executor.ts) — `executeChange`, `revertChange`; applies SQL via the runner, records results. Each gates via `assertPolicy` (`core/policy`) against `ChangeContext.access`/`channel` before running (`change:run`/`change:revert` permissions)
-- [`src/core/change/history.ts`](../../src/core/change/history.ts) — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history
+- [`src/core/change/history.ts`](../../src/core/change/history.ts) — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history; selects the change row's `id` and surfaces it as `appliedHistoryId` on `ChangeStatus`
- [`src/core/change/tracker.ts`](../../src/core/change/tracker.ts) — `ChangeTracker`; `canRevert` logic, orphaned-change detection
-- [`src/core/change/manager.ts`](../../src/core/change/manager.ts) — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward)
+- [`src/core/change/manager.ts`](../../src/core/change/manager.ts) — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward), `rewind` (revert back to a target change, ordering applied changes by `appliedAt` descending then `appliedHistoryId` descending)
- [`src/core/change/validation.ts`](../../src/core/change/validation.ts) — `validateChangeContent`; structural content checks
-- [`src/core/change/types.ts`](../../src/core/change/types.ts) — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.)
+- [`src/core/change/types.ts`](../../src/core/change/types.ts) — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.); `ChangeStatus`/`ChangeListItem` carry an optional `appliedHistoryId: number | null`
## Docs
@@ -45,3 +45,4 @@ Change directories hold a `manifest.json` and SQL files. Each change has a descr
- `parseSequence` extracts a numeric prefix from filename for ordering.
- `DEFAULT_CHANGE_OPTIONS` and `DEFAULT_BATCH_OPTIONS` define timeout and retry defaults.
- Error classes extend `Error` with a `code` field; callers check `code` to distinguish failure modes.
+- `ChangeManager.rewind()` sorts applied changes by `appliedAt` descending, tiebreaking on `appliedHistoryId` (the history row's autoincrement id) descending when two changes share the same second-precision `appliedAt` — e.g. entries applied within the same `change ff` batch.
diff --git a/docs/wiki/index.md b/docs/wiki/index.md
index 7abe9127..2d85e4a5 100644
--- a/docs/wiki/index.md
+++ b/docs/wiki/index.md
@@ -4,14 +4,14 @@ type: Index
---
repo
-f4d4ca36e98785afdb428172b90890d2f69ca66e
+33101f0bf56f53ee3568f149bd7f99afd795ead2
1
# Project signals
## Framework & runtime
-- **Language:** TypeScript (80% LOC, 872 files), Bun runtime (>=1.2), Node >=22.13
+- **Language:** TypeScript (80% LOC, 887 files), Bun runtime (>=1.2), Node >=22.13
- **SQL layer:** Kysely query builder + executor; dialect-aware across PostgreSQL, MySQL, MSSQL, SQLite
- **TUI:** Ink 6 + React 19 ([`src/tui/`](../../src/tui)); Citty for CLI arg parsing ([`src/cli/`](../../src/cli))
- **Event bus:** `@logosdx/observer` (`ObserverEngine`); module-scope singleton in [`src/core/observer.ts`](../../src/core/observer.ts)
@@ -42,8 +42,8 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte
| Language | LOC | Files | % |
|----------|-----|-------|---|
-| TypeScript | 204246 | 886 | 80% |
-| Markdown | 43000 | 195 | 17% |
+| TypeScript | 204655 | 887 | 80% |
+| Markdown | 43261 | 198 | 17% |
| YAML | 1114 | 16 | 1% |
| JavaScript | 1005 | 22 | 1% |
| HTML | 955 | 26 | 2% |
@@ -54,7 +54,7 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte
- **CI:** GitHub Actions (`ubuntu-24.04`), Bun 1.3.11 pinned; 4 test groups + 3 example jobs per push to master/main
- **DB services (CI):** Postgres 17 on 15432, MySQL 8.0 on 13306, MSSQL 2022 on 11433
-- **DB services (local):** [`docker-compose.yml`](../../docker-compose.yml) at repo root (same ports)
+- **DB services (local):** [`docker-compose.test.yml`](../../docker-compose.test.yml) at repo root (same ports)
- **Publish:** Changesets-driven (`changeset publish`) via [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml); packages: `@noormdev/cli` and `@noormdev/sdk`
- **Binary release:** `bun build --compile` → GitHub Releases via [`.github/workflows/release-binary.yml`](../../.github/workflows/release-binary.yml)
- **Docs:** VitePress, deployed via [`.github/workflows/docs.yml`](../../.github/workflows/docs.yml)
@@ -71,10 +71,10 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte
| core-policy | [`src/core/policy/`](../../src/core/policy), [`tests/core/policy/`](../../tests/core/policy) | Access-control policy: role×permission matrix, SQL statement classifier, legacy `protected`→`access` migration | [`docs/wiki/core-policy.md`](core-policy.md) |
| sdk | [`src/sdk/`](../../src/sdk), [`src/core/dt/`](../../src/core/dt), [`packages/sdk/`](../../packages/sdk), [`tests/sdk/`](../../tests/sdk), [`tests/integration/sdk/`](../../tests/integration/sdk) | Programmatic API (`createContext`) + DT binary serialization format | [`docs/wiki/sdk.md`](sdk.md) |
| cli | [`src/cli/`](../../src/cli), [`packages/cli/`](../../packages/cli), [`skills/noorm/`](../../skills/noorm), [`tests/cli/`](../../tests/cli) | Citty CLI with 17 command groups, headless mode, binary distribution | [`docs/wiki/cli.md`](cli.md) |
-| tui | [`src/tui/`](../../src/tui), [`src/hooks/`](../../src/hooks), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md), [`tests/cli/components/`](../../tests/cli/components), [`tests/cli/hooks/`](../../tests/cli/hooks), [`tests/cli/screens/`](../../tests/cli/screens) | Ink/React TUI with focus manager, keyboard routing, per-domain screens | [`docs/wiki/tui.md`](tui.md) |
+| tui | [`src/tui/`](../../src/tui), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md), [`tests/cli/components/`](../../tests/cli/components), [`tests/cli/hooks/`](../../tests/cli/hooks), [`tests/cli/screens/`](../../tests/cli/screens) | Ink/React TUI with focus manager, keyboard routing, per-domain screens | [`docs/wiki/tui.md`](tui.md) |
| mcp-rpc | [`src/mcp/`](../../src/mcp), [`src/rpc/`](../../src/rpc), [`src/cli/mcp/`](../../src/cli/mcp), [`tests/core/mcp/`](../../tests/core/mcp), [`tests/core/rpc/`](../../tests/core/rpc) | MCP server over stdio wrapping flat RPC command registry, permission-gated dispatch | [`docs/wiki/mcp-rpc.md`](mcp-rpc.md) |
| worker-bridge | [`src/core/worker-bridge/`](../../src/core/worker-bridge), [`src/workers/`](../../src/workers), [`tests/core/worker-bridge/`](../../tests/core/worker-bridge), [`tests/workers/`](../../tests/workers) | Hub-and-spoke worker threads for DT serialization and DB connection worker | [`docs/wiki/worker-bridge.md`](worker-bridge.md) |
-| infra | [`.github/`](../../.github), [`scripts/`](../../scripts), [`examples/`](../../examples), [`docs/`](..), `tsup.*.config.ts`, [`docker-compose.yml`](../../docker-compose.yml), [`bunfig.toml`](../../bunfig.toml) | CI, build pipeline, binary release, example projects, VitePress docs | [`docs/wiki/infra.md`](infra.md) |
+| infra | [`.github/`](../../.github), [`scripts/`](../../scripts), [`examples/`](../../examples), [`docs/`](..), `tsup.*.config.ts`, [`docker-compose.test.yml`](../../docker-compose.test.yml), [`bunfig.toml`](../../bunfig.toml) | CI, build pipeline, binary release, example projects, VitePress docs | [`docs/wiki/infra.md`](infra.md) |
## Cross-cutting
diff --git a/docs/wiki/infra.md b/docs/wiki/infra.md
index 5a742067..067e2351 100644
--- a/docs/wiki/infra.md
+++ b/docs/wiki/infra.md
@@ -19,7 +19,6 @@ Build pipeline, CI, binary release, package publishing, and reference examples.
- [`scripts/build.mjs`](../../scripts/build.mjs) — builds both `@noormdev/cli` and `@noormdev/sdk` packages via tsup
- [`scripts/build-binary.mjs`](../../scripts/build-binary.mjs) — `bun build --compile` to produce standalone binary
- [`scripts/Dockerfile`](../../scripts/Dockerfile) — Docker image for binary builds
-- [`scripts/install.sh`](../../scripts/install.sh) — shell installer for binary distribution
- [`scripts/ralph-wiggum.sh`](../../scripts/ralph-wiggum.sh) — release automation helper
- [`tsup.cli.config.ts`](../../tsup.cli.config.ts) — tsup config for CLI package build
- [`tsup.sdk.config.ts`](../../tsup.sdk.config.ts) — tsup config for SDK package build
@@ -27,7 +26,7 @@ Build pipeline, CI, binary release, package publishing, and reference examples.
- [`tsconfig.sdk-types.json`](../../tsconfig.sdk-types.json) — SDK type extraction config
- [`tsconfig.test.json`](../../tsconfig.test.json) — test TypeScript config
- [`bunfig.toml`](../../bunfig.toml) — Bun runtime config
-- [`docker-compose.yml`](../../docker-compose.yml) — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433)
+- [`docker-compose.test.yml`](../../docker-compose.test.yml) — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433)
## Docs
diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md
index f4d4ca36..33101f0b 100644
--- a/docs/wiki/scan.md
+++ b/docs/wiki/scan.md
@@ -7,7 +7,7 @@
│ └── opentui/ (2)
│ ├── references/ (0 files, 8 dirs)
│ └── SKILL.md (a62967f, 195L, 7253ch, 7427B)
-├── .changeset/ (71)
+├── .changeset/ (73)
│ ├── README.md (bf33c79, 8L, 510ch, 510B)
│ ├── binary-release-automation.md (b25adb3, 6L, 110ch, 110B)
│ ├── bold-wolves-call.md (a1927b2, 10L, 442ch, 442B)
@@ -55,7 +55,7 @@
│ ├── mssql-teardown-sdk.md (37fb6c1, 8L, 917ch, 929B)
│ ├── noorm-ci-namespace.md (0e98b5d, 28L, 2748ch, 2762B)
│ ├── noorm-init-sql-repl.md (19e9978, 8L, 373ch, 377B)
-│ ├── pre.json (9860d10, 80L, 2219ch, 2219B)
+│ ├── pre.json (48b4181, 83L, 2304ch, 2304B)
│ ├── quiet-pandas-sleep.md (a17d93c, 7L, 220ch, 220B)
│ ├── rebuild-fix.md (174afad, 5L, 69ch, 69B)
│ ├── reset-ignores-preserve-tables-cli.md (5946a54, 13L, 594ch, 596B)
@@ -72,6 +72,8 @@
│ ├── tty-yes-flag-cli.md (a93a4e3, 8L, 723ch, 723B)
│ ├── tvp-support.md (48e6ffe, 8L, 328ch, 330B)
│ ├── typed-tuples-sdk.md (c803a7b, 6L, 216ch, 218B)
+│ ├── update-progress-stall.md (ab5f66a, 5L, 642ch, 644B)
+│ ├── update-resumable-download.md (4b63e28, 5L, 586ch, 588B)
│ ├── vault-init-idempotent-sdk.md (93cbe93, 8L, 545ch, 547B)
│ ├── version-command.md (379be83, 5L, 138ch, 138B)
│ ├── version-debug.md (268b067, 5L, 96ch, 96B)
@@ -83,8 +85,8 @@
│ ├── rules/ (4)
│ │ ├── documentation.md (69cdfde, 30L, 837ch, 837B)
│ │ ├── testing.md (3c3b98d, 58L, 1070ch, 1070B)
-│ │ ├── tui-development.md (e73a146, 163L, 4197ch, 4199B)
-│ │ └── typescript.md (56de6aa, 312L, 7494ch, 7522B)
+│ │ ├── tui-development.md (68c920f, 159L, 4031ch, 4033B)
+│ │ └── typescript.md (1515159, 308L, 8136ch, 8164B)
│ └── skills/ (2)
│ ├── noorm-design/ (7)
│ │ ├── assets/ (5 files, 1 dir)
@@ -98,9 +100,9 @@
├── .github/ (1)
│ └── workflows/ (4)
│ ├── ci.yml (1f9faff, 445L, 18826ch, 19874B)
-│ ├── docs.yml (4c7bee9, 48L, 1371ch, 1371B)
-│ ├── publish.yml (7f0ea77, 85L, 2128ch, 2128B)
-│ └── release-binary.yml (5123f48, 38L, 1053ch, 1053B)
+│ ├── docs.yml (1d7b2ac, 51L, 1456ch, 1456B)
+│ ├── publish.yml (b0bca54, 93L, 2354ch, 2354B)
+│ └── release-binary.yml (56d023f, 46L, 1333ch, 1333B)
├── docs/ (15)
│ ├── .vitepress/ (2)
│ │ ├── theme/ (5)
@@ -113,7 +115,7 @@
│ ├── cli/ (9)
│ │ ├── flags.md (9b7f8eb, 94L, 3136ch, 3152B)
│ │ ├── help.md (bbb20fe, 47L, 1421ch, 1429B)
-│ │ ├── identity.md (00a7538, 79L, 3191ch, 3209B)
+│ │ ├── identity.md (1578c85, 79L, 3191ch, 3209B)
│ │ ├── init.md (ca7879c, 70L, 2873ch, 2889B)
│ │ ├── run.md (654e92f, 123L, 3831ch, 3845B)
│ │ ├── settings-edit.md (56b510a, 21L, 588ch, 590B)
@@ -123,58 +125,57 @@
│ ├── design/ (2)
│ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B)
│ │ └── config-access-roles.md (bd73baa, 116L, 6566ch, 6670B)
-│ ├── dev/ (26)
-│ │ ├── README.md (40f97b8, 201L, 6544ch, 8050B)
+│ ├── dev/ (25)
│ │ ├── change.md (1ebd1fc, 509L, 15457ch, 15519B)
-│ │ ├── ci.md (2bd9b8a, 205L, 6796ch, 6804B)
+│ │ ├── ci.md (e602257, 205L, 6796ch, 6804B)
│ │ ├── config-sharing.md (61852f9, 269L, 8593ch, 8611B)
-│ │ ├── config.md (8e00bcc, 437L, 12957ch, 12993B)
+│ │ ├── config.md (82eff09, 436L, 12921ch, 12957B)
│ │ ├── datamodel.md (de196f7, 1040L, 29861ch, 29989B)
│ │ ├── explore.md (6895cb3, 325L, 8889ch, 8959B)
-│ │ ├── headless.md (d1846fa, 756L, 17719ch, 17855B)
+│ │ ├── headless.md (232109b, 758L, 18161ch, 18301B)
│ │ ├── identity.md (4a4ea8e, 350L, 12135ch, 12159B)
│ │ ├── index.md (3b3ccb3, 42L, 1430ch, 1430B)
│ │ ├── ink-cheatsheet.md (a188494, 1427L, 28669ch, 28689B)
│ │ ├── ink-testing-library-cheatsheet.md (67fa6e5, 737L, 14708ch, 14708B)
│ │ ├── lock.md (1325257, 330L, 8577ch, 8607B)
│ │ ├── logger.md (b8754a5, 521L, 15363ch, 16867B)
-│ │ ├── project-discovery.md (ba6c27b, 128L, 4178ch, 4184B)
+│ │ ├── project-discovery.md (c1fd8f0, 128L, 4327ch, 4335B)
│ │ ├── runner.md (ec9317d, 516L, 18416ch, 18438B)
-│ │ ├── sdk.md (3efc2f5, 1094L, 26569ch, 26615B)
-│ │ ├── secrets.md (2312d48, 297L, 10122ch, 10192B)
+│ │ ├── sdk.md (2e6ed89, 1092L, 27225ch, 27275B)
+│ │ ├── secrets.md (00a6e82, 297L, 10122ch, 10192B)
│ │ ├── settings.md (b87e542, 746L, 18539ch, 18551B)
│ │ ├── sql-terminal.md (381dcaa, 321L, 8991ch, 10355B)
│ │ ├── state.md (840d27d, 362L, 9331ch, 9361B)
│ │ ├── teardown.md (fc58de8, 359L, 11952ch, 11984B)
│ │ ├── template.md (e6310f7, 460L, 11515ch, 11603B)
-│ │ ├── transfer.md (47b8c7b, 663L, 22886ch, 23134B)
-│ │ ├── vault.md (3095975, 521L, 16055ch, 17667B)
+│ │ ├── transfer.md (9bd3975, 673L, 23433ch, 23681B)
+│ │ ├── vault.md (3a6045a, 520L, 16028ch, 17640B)
│ │ └── version.md (c0f51c6, 644L, 17504ch, 17516B)
│ ├── getting-started/ (4)
-│ │ ├── building-your-sdk.md (8389a71, 752L, 16897ch, 17383B)
+│ │ ├── building-your-sdk.md (be6b3e1, 752L, 16915ch, 17401B)
│ │ ├── concepts.md (470bf64, 347L, 11180ch, 11540B)
│ │ ├── first-build.md (6ea690f, 332L, 8338ch, 8434B)
-│ │ └── installation.md (447f8bb, 114L, 2936ch, 2998B)
+│ │ └── installation.md (d63e5d0, 129L, 3969ch, 4039B)
│ ├── guide/ (6)
│ │ ├── automation/ (3)
-│ │ │ ├── ci.md (04e95d2, 345L, 11915ch, 11951B)
+│ │ │ ├── ci.md (83fe635, 345L, 11915ch, 11951B)
│ │ │ ├── mcp.md (9543edb, 127L, 4891ch, 5175B)
│ │ │ └── non-interactive.md (556d953, 121L, 4392ch, 4406B)
│ │ ├── changes/ (3)
-│ │ │ ├── forward-revert.md (1754625, 285L, 8097ch, 8101B)
-│ │ │ ├── history.md (91ab04d, 320L, 9917ch, 9917B)
-│ │ │ └── overview.md (909a111, 348L, 13623ch, 13945B)
+│ │ │ ├── forward-revert.md (e339875, 285L, 8097ch, 8101B)
+│ │ │ ├── history.md (bbcbca7, 320L, 9917ch, 9917B)
+│ │ │ └── overview.md (5eb6602, 348L, 13623ch, 13945B)
│ │ ├── database/ (5)
│ │ │ ├── create.md (ec7375f, 142L, 4947ch, 4971B)
-│ │ │ ├── explore.md (929a4ee, 481L, 17845ch, 22315B)
-│ │ │ ├── teardown.md (4ed72b7, 364L, 10271ch, 10285B)
+│ │ │ ├── explore.md (aed5d6e, 481L, 17845ch, 22315B)
+│ │ │ ├── teardown.md (5c1954d, 364L, 10327ch, 10341B)
│ │ │ ├── terminal.md (a88f37a, 225L, 7075ch, 9051B)
-│ │ │ └── transfer.md (a6608ba, 369L, 10184ch, 10210B)
+│ │ │ └── transfer.md (415bbf3, 369L, 10184ch, 10210B)
│ │ ├── environments/ (4)
-│ │ │ ├── configs.md (847237a, 347L, 11140ch, 11168B)
-│ │ │ ├── secrets.md (bb92c69, 200L, 6828ch, 6840B)
+│ │ │ ├── configs.md (6b4f3b1, 346L, 11236ch, 11264B)
+│ │ │ ├── secrets.md (fef816e, 200L, 6828ch, 6840B)
│ │ │ ├── stages.md (76e78ed, 258L, 7545ch, 7551B)
-│ │ │ └── vault.md (bd35325, 257L, 7681ch, 8229B)
+│ │ │ └── vault.md (19c44c0, 257L, 7681ch, 8229B)
│ │ ├── sql-files/ (3)
│ │ │ ├── execution.md (4a53b9a, 249L, 8166ch, 8308B)
│ │ │ ├── organization.md (1d17d3d, 327L, 7430ch, 7892B)
@@ -198,16 +199,17 @@
│ │ │ └── logo.svg (8d46c28, 6L, 2529ch, 2529B)
│ │ └── install.sh (0cc90a2, 116L, 2925ch, 2925B)
│ ├── reference/ (1)
-│ │ └── sdk.md (59c676d, 1433L, 41839ch, 42006B)
-│ ├── spec/ (2)
+│ │ └── sdk.md (d4a45c6, 1418L, 42444ch, 42625B)
+│ ├── spec/ (3)
│ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B)
-│ │ └── config-access-roles.md (c153076, 153L, 17852ch, 17952B)
+│ │ ├── config-access-roles.md (d6c54a8, 155L, 18136ch, 18240B)
+│ │ └── v1-45-rewind-tiebreak.md (0e35550, 61L, 5465ch, 5507B)
│ ├── superpowers/ (1)
│ │ └── specs/ (1)
│ │ └── 2026-04-19-cli-ci-identity-design.md (6c9cc80, 938L, 32762ch, 32918B)
│ ├── bun.lockb (34225b2, 190L, 125711ch, 126677B)
-│ ├── headless.md (d10c758, 1612L, 38848ch, 38884B)
-│ ├── index.md (4d92e08, 178L, 6324ch, 6362B)
+│ ├── headless.md (3b951ee, 1622L, 39413ch, 39455B)
+│ ├── index.md (23a1967, 178L, 6324ch, 6362B)
│ ├── package.json (b4778f0, 24L, 657ch, 657B)
│ └── tui.md (99b066a, 407L, 13994ch, 18090B)
├── examples/ (3)
@@ -250,20 +252,20 @@
│ │ │ └── sql/ (11 files, 0 dirs)
│ │ ├── .gitignore (ccb61cd, 40L, 446ch, 446B)
│ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B)
-│ │ ├── CHANGELOG.md (ad9092a, 20L, 413ch, 413B)
+│ │ ├── CHANGELOG.md (8ba2d71, 39L, 655ch, 655B)
│ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B)
-│ │ ├── README.md (3b60a6a, 121L, 6801ch, 6837B)
+│ │ ├── README.md (c228f5b, 121L, 6833ch, 6869B)
│ │ ├── REPORT.md (4f3efcd, 161L, 12957ch, 13004B)
│ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B)
│ │ ├── mssql-problems.md (5083524, 328L, 23834ch, 23934B)
-│ │ ├── package.json (435022e, 23L, 631ch, 631B)
+│ │ ├── package.json (cf5a387, 23L, 631ch, 631B)
│ │ └── tsconfig.json (7be3bae, 27L, 736ch, 736B)
-│ ├── llm-memory-db-pg/ (16)
+│ ├── llm-memory-db-pg/ (17)
│ │ ├── .cursor/ (1)
│ │ │ └── rules/ (1 file, 0 dirs)
│ │ ├── .noorm/ (2)
│ │ │ ├── .gitignore (14188a3, 1L, 7ch, 7B)
-│ │ │ └── settings.yml (9311372, 49L, 1117ch, 1117B)
+│ │ │ └── settings.yml (015d7e5, 50L, 1111ch, 1111B)
│ │ ├── changes/ (2)
│ │ │ ├── 2026-05-10-add-memory-tag-color/ (1 file, 2 dirs)
│ │ │ └── .gitkeep (e3b0c44, 0L, 0ch, 0B)
@@ -300,13 +302,14 @@
│ │ │ └── mcp-discovery.test.ts (02d4035, 765L, 24559ch, 24603B)
│ │ ├── .gitignore (a94396a, 36L, 397ch, 397B)
│ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B)
-│ │ ├── CHANGELOG.md (96e019a, 20L, 410ch, 410B)
+│ │ ├── CHANGELOG.md (9927bb5, 39L, 652ch, 652B)
│ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B)
-│ │ ├── README.md (18a81f1, 169L, 9519ch, 9823B)
+│ │ ├── README.md (b97f95b, 205L, 10359ch, 10665B)
│ │ ├── REPORT-PHASE-1.md (59b7d41, 103L, 9610ch, 9670B)
-│ │ ├── REPORT.md (f22f51f, 140L, 17057ch, 17127B)
+│ │ ├── REPORT.md (98ee180, 140L, 17043ch, 17113B)
│ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B)
-│ │ ├── package.json (e2a5af9, 23L, 670ch, 670B)
+│ │ ├── package.json (56ab1f1, 23L, 670ch, 670B)
+│ │ ├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B)
│ │ └── tsconfig.json (4dc04b1, 30L, 735ch, 735B)
│ └── todo-db/ (10)
│ ├── .noorm/ (1)
@@ -342,35 +345,37 @@
│ │ ├── views/ (2 files, 0 dirs)
│ │ └── preload.ts (0d97cd6, 25L, 658ch, 660B)
│ ├── .gitignore (81531bd, 5L, 57ch, 57B)
-│ ├── CHANGELOG.md (1baf6d6, 26L, 470ch, 470B)
+│ ├── CHANGELOG.md (4e31aec, 45L, 712ch, 712B)
│ ├── bunfig.toml (e10e7cb, 5L, 89ch, 89B)
-│ ├── package.json (a96f8c6, 22L, 581ch, 581B)
+│ ├── package.json (1a9a77d, 22L, 581ch, 581B)
│ └── tsconfig.json (efeae48, 17L, 484ch, 484B)
├── packages/ (2)
-│ ├── cli/ (4)
+│ ├── cli/ (5)
│ │ ├── scripts/ (1)
-│ │ │ └── postinstall.js (4ddeede, 155L, 4208ch, 4210B)
-│ │ ├── CHANGELOG.md (490a0a0, 681L, 36171ch, 36347B)
+│ │ │ └── postinstall.js (67c8ecf, 348L, 10155ch, 10157B)
+│ │ ├── CHANGELOG.md (c98de3e, 710L, 41104ch, 41310B)
+│ │ ├── LICENSE (42eaf96, 21L, 1070ch, 1070B)
│ │ ├── noorm.js (e3d76e8, 41L, 1041ch, 1043B)
-│ │ └── package.json (605e00c, 31L, 540ch, 540B)
-│ └── sdk/ (2)
-│ ├── CHANGELOG.md (84c9272, 527L, 24058ch, 24198B)
-│ └── package.json (91878a9, 60L, 1093ch, 1093B)
+│ │ └── package.json (f0a76ab, 31L, 540ch, 540B)
+│ └── sdk/ (3)
+│ ├── CHANGELOG.md (8d2ebd5, 548L, 27765ch, 27931B)
+│ ├── LICENSE (42eaf96, 21L, 1070ch, 1070B)
+│ └── package.json (cb0a089, 61L, 1115ch, 1115B)
├── scripts/ (5)
│ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B)
│ ├── build-binary.mjs (f598b0c, 37L, 1284ch, 1288B)
│ ├── build.mjs (303daea, 40L, 1519ch, 1519B)
-│ ├── install.sh (2fb9002, 175L, 3660ch, 3664B)
+│ ├── check-json-placement.sh (5ef39f2, 34L, 1410ch, 1416B)
│ └── ralph-wiggum.sh (7182e0a, 319L, 8973ch, 8973B)
├── skills/ (1)
│ └── noorm/ (2)
│ ├── references/ (4)
-│ │ ├── cli.md (19df9f6, 1010L, 29280ch, 29322B)
+│ │ ├── cli.md (4e1c259, 1009L, 29310ch, 29354B)
│ │ ├── config.md (7d36099, 272L, 9415ch, 10115B)
-│ │ ├── sdk.md (d8c194a, 646L, 21778ch, 21863B)
+│ │ ├── sdk.md (8a62667, 650L, 22064ch, 22163B)
│ │ └── templates.md (20dbd54, 386L, 11061ch, 11161B)
-│ └── SKILL.md (d49c384, 65L, 3837ch, 3845B)
-├── src/ (8)
+│ └── SKILL.md (90e425a, 82L, 4768ch, 4784B)
+├── src/ (7)
│ ├── cli/ (20)
│ │ ├── change/ (13)
│ │ │ ├── _prompt.ts (a32b90c, 140L, 3465ch, 3467B)
@@ -383,8 +388,8 @@
│ │ │ ├── list.ts (a3b191d, 74L, 1777ch, 1781B)
│ │ │ ├── next.ts (55f3589, 110L, 3017ch, 3019B)
│ │ │ ├── revert.ts (af5236e, 133L, 3749ch, 3751B)
-│ │ │ ├── rewind.ts (e47452b, 132L, 3682ch, 3688B)
-│ │ │ ├── rm.ts (b986149, 137L, 3829ch, 3831B)
+│ │ │ ├── rewind.ts (beaeaba, 132L, 3684ch, 3690B)
+│ │ │ ├── rm.ts (d07fd8f, 157L, 4945ch, 4947B)
│ │ │ └── run.ts (5a0a0de, 133L, 3744ch, 3746B)
│ │ ├── ci/ (4)
│ │ │ ├── identity/ (3 files, 0 dirs)
@@ -392,18 +397,18 @@
│ │ │ ├── init.ts (015ad40, 217L, 6764ch, 6772B)
│ │ │ └── secrets.ts (5b51d3a, 231L, 6176ch, 6180B)
│ │ ├── config/ (10)
-│ │ │ ├── add.ts (cce6bfa, 27L, 732ch, 736B)
+│ │ │ ├── add.ts (0e20b1d, 27L, 732ch, 736B)
│ │ │ ├── cp.ts (cc57e45, 81L, 2209ch, 2211B)
-│ │ │ ├── edit.ts (76f315f, 34L, 888ch, 892B)
-│ │ │ ├── export.ts (508008b, 81L, 2213ch, 2215B)
+│ │ │ ├── edit.ts (00f6992, 34L, 888ch, 892B)
+│ │ │ ├── export.ts (ef4b07a, 86L, 2441ch, 2443B)
│ │ │ ├── import.ts (01f8e59, 105L, 3068ch, 3070B)
│ │ │ ├── index.ts (54b770a, 22L, 611ch, 613B)
-│ │ │ ├── list.ts (b93a381, 74L, 2064ch, 2068B)
-│ │ │ ├── rm.ts (a5c6a97, 34L, 865ch, 869B)
+│ │ │ ├── list.ts (cc81ab9, 74L, 1994ch, 1998B)
+│ │ │ ├── rm.ts (acf46db, 105L, 3098ch, 3098B)
│ │ │ ├── use.ts (f307d0e, 79L, 2169ch, 2173B)
-│ │ │ └── validate.ts (d832331, 130L, 3623ch, 3625B)
+│ │ │ └── validate.ts (018893b, 75L, 2086ch, 2088B)
│ │ ├── db/ (16)
-│ │ │ ├── create.ts (9d3ae5b, 107L, 2993ch, 2995B)
+│ │ │ ├── create.ts (b5426cf, 107L, 3023ch, 3025B)
│ │ │ ├── drop.ts (7625328, 103L, 2880ch, 2882B)
│ │ │ ├── explore-fks.ts (3a624f7, 79L, 1936ch, 1940B)
│ │ │ ├── explore-functions.ts (cd47d2a, 132L, 3143ch, 3147B)
@@ -415,9 +420,9 @@
│ │ │ ├── explore-views.ts (d002b8d, 128L, 3069ch, 3071B)
│ │ │ ├── explore.ts (f62b609, 82L, 2179ch, 2181B)
│ │ │ ├── index.ts (d0fc774, 28L, 610ch, 612B)
-│ │ │ ├── reset.ts (c1abc6f, 59L, 1396ch, 1398B)
+│ │ │ ├── reset.ts (b8343b5, 59L, 1414ch, 1416B)
│ │ │ ├── teardown.ts (292a1a3, 77L, 2094ch, 2096B)
-│ │ │ ├── transfer.ts (f45629b, 594L, 16502ch, 16506B)
+│ │ │ ├── transfer.ts (88e617b, 669L, 19239ch, 19251B)
│ │ │ └── truncate.ts (f272b7b, 63L, 1386ch, 1388B)
│ │ ├── dev/ (3)
│ │ │ ├── index.ts (a926fdd, 18L, 420ch, 422B)
@@ -464,7 +469,7 @@
│ │ │ ├── history.ts (7c2048c, 139L, 3970ch, 3980B)
│ │ │ ├── index.ts (427fc50, 38L, 1298ch, 1300B)
│ │ │ ├── query.ts (84227f9, 115L, 2969ch, 2971B)
-│ │ │ └── repl.ts (4888c5d, 131L, 3817ch, 3819B)
+│ │ │ └── repl.ts (f0fe0a7, 137L, 4076ch, 4080B)
│ │ ├── vault/ (7)
│ │ │ ├── cp.ts (2adf7aa, 195L, 5472ch, 5474B)
│ │ │ ├── index.ts (c96493f, 16L, 443ch, 445B)
@@ -473,59 +478,61 @@
│ │ │ ├── propagate.ts (b3b523d, 113L, 2877ch, 2879B)
│ │ │ ├── rm.ts (d13caa5, 91L, 2339ch, 2341B)
│ │ │ └── set.ts (e2d219e, 91L, 2372ch, 2374B)
-│ │ ├── _utils.ts (6e7f28c, 454L, 11208ch, 11208B)
-│ │ ├── index.ts (aacc1ea, 318L, 9027ch, 9031B)
+│ │ ├── _utils.ts (522f462, 478L, 12116ch, 12120B)
+│ │ ├── index.ts (937328c, 343L, 11213ch, 11217B)
│ │ ├── info.ts (63f9546, 351L, 10547ch, 10551B)
│ │ ├── init.ts (f11a60d, 176L, 5250ch, 5252B)
-│ │ ├── ui.ts (c22c772, 65L, 1675ch, 1679B)
-│ │ ├── update.ts (f53462e, 110L, 2930ch, 2934B)
+│ │ ├── ui.ts (28a7a9c, 70L, 1869ch, 1875B)
+│ │ ├── update.ts (a124ee5, 202L, 5826ch, 5834B)
│ │ └── version.ts (ca59073, 273L, 6880ch, 6890B)
│ ├── core/ (30)
│ │ ├── change/ (9)
-│ │ │ ├── executor.ts (caa792c, 1118L, 29801ch, 31271B)
-│ │ │ ├── history.ts (f0f083c, 1081L, 29641ch, 31253B)
+│ │ │ ├── executor.ts (51410bd, 1297L, 35479ch, 36963B)
+│ │ │ ├── history.ts (aea7694, 1251L, 36075ch, 37937B)
│ │ │ ├── index.ts (edd765a, 128L, 3031ch, 4739B)
-│ │ │ ├── manager.ts (3f21ad7, 616L, 15844ch, 18156B)
+│ │ │ ├── manager.ts (47ed983, 632L, 16631ch, 18943B)
│ │ │ ├── parser.ts (917e642, 570L, 13337ch, 14801B)
-│ │ │ ├── scaffold.ts (0f2d03f, 649L, 15170ch, 17122B)
+│ │ │ ├── scaffold.ts (a3b3c6c, 625L, 14745ch, 16697B)
│ │ │ ├── tracker.ts (da3742c, 245L, 7183ch, 7671B)
-│ │ │ ├── types.ts (ca31b61, 681L, 15684ch, 18370B)
+│ │ │ ├── types.ts (b863c8d, 697L, 16318ch, 19008B)
│ │ │ └── validation.ts (ca6f086, 90L, 2247ch, 2247B)
-│ │ ├── config/ (4)
-│ │ │ ├── index.ts (1e9a284, 124L, 3364ch, 3364B)
-│ │ │ ├── resolver.ts (351ba35, 450L, 12107ch, 12111B)
-│ │ │ ├── schema.ts (e9af3c9, 308L, 8239ch, 8733B)
-│ │ │ └── types.ts (9516dc0, 143L, 3478ch, 3726B)
-│ │ ├── connection/ (5)
+│ │ ├── config/ (5)
+│ │ │ ├── index.ts (f1bc7b6, 126L, 3419ch, 3419B)
+│ │ │ ├── resolver.ts (23dc477, 505L, 13561ch, 13569B)
+│ │ │ ├── schema.ts (224a2af, 344L, 9433ch, 9927B)
+│ │ │ ├── types.ts (9516dc0, 143L, 3478ch, 3726B)
+│ │ │ └── validate.ts (7ebdb26, 89L, 2464ch, 2464B)
+│ │ ├── connection/ (6)
│ │ │ ├── dialects/ (7 files, 0 dirs)
+│ │ │ ├── defaults.ts (42ecd2d, 27L, 685ch, 685B)
│ │ │ ├── factory.ts (7c5708c, 253L, 7370ch, 7370B)
-│ │ │ ├── index.ts (29dcf6f, 8L, 268ch, 268B)
-│ │ │ ├── manager.ts (8e53eb4, 360L, 8404ch, 8404B)
+│ │ │ ├── index.ts (28dfede, 9L, 327ch, 327B)
+│ │ │ ├── manager.ts (8152aa7, 348L, 8120ch, 8120B)
│ │ │ └── types.ts (873d17d, 75L, 1457ch, 1457B)
│ │ ├── db/ (5)
│ │ │ ├── dialects/ (5 files, 0 dirs)
│ │ │ ├── dual.ts (baaddd4, 174L, 4914ch, 4914B)
│ │ │ ├── index.ts (04bf6c4, 53L, 1283ch, 1283B)
-│ │ │ ├── operations.ts (52dc2c3, 296L, 7247ch, 7249B)
-│ │ │ └── types.ts (31a89d7, 87L, 1985ch, 1985B)
+│ │ │ ├── operations.ts (81c12ed, 309L, 7998ch, 8002B)
+│ │ │ └── types.ts (9375e79, 95L, 2330ch, 2330B)
│ │ ├── debug/ (2)
│ │ │ ├── index.ts (6e232b4, 21L, 385ch, 385B)
│ │ │ └── operations.ts (05257cd, 446L, 11657ch, 12391B)
│ │ ├── dt/ (16)
│ │ │ ├── dialects/ (4 files, 0 dirs)
│ │ │ ├── constants.ts (8158c4b, 75L, 1797ch, 1797B)
-│ │ │ ├── crypto.ts (d74cf8c, 107L, 3220ch, 3222B)
+│ │ │ ├── crypto.ts (7586f37, 123L, 3783ch, 3787B)
│ │ │ ├── deserialize.ts (1e84e10, 366L, 9011ch, 9017B)
│ │ │ ├── events.ts (360f2c4, 151L, 3984ch, 3984B)
-│ │ │ ├── index.ts (bcd5dc6, 1169L, 30231ch, 30253B)
+│ │ │ ├── index.ts (4a6e732, 1016L, 26666ch, 26686B)
│ │ │ ├── modify.ts (bad43c7, 678L, 17964ch, 17994B)
│ │ │ ├── paths.ts (76dade7, 103L, 2874ch, 2886B)
-│ │ │ ├── reader.ts (430fd8d, 208L, 5233ch, 5237B)
-│ │ │ ├── schema.ts (e3a68b5, 385L, 9650ch, 9652B)
+│ │ │ ├── reader.ts (98b263b, 212L, 5505ch, 5509B)
+│ │ │ ├── schema.ts (94fb91b, 384L, 9629ch, 9631B)
│ │ │ ├── serialize.ts (4c8ab00, 253L, 6001ch, 6011B)
│ │ │ ├── streamer.ts (453f365, 395L, 9296ch, 9296B)
-│ │ │ ├── type-map.ts (4cf9141, 142L, 3882ch, 3886B)
-│ │ │ ├── types.ts (52a9b23, 428L, 10149ch, 10149B)
+│ │ │ ├── type-map.ts (3d74dd0, 139L, 3762ch, 3766B)
+│ │ │ ├── types.ts (e11c4e4, 413L, 9366ch, 9366B)
│ │ │ ├── version.ts (6a13dc2, 216L, 5118ch, 5128B)
│ │ │ └── writer.ts (3525333, 233L, 5667ch, 5667B)
│ │ ├── explore/ (4)
@@ -540,38 +547,39 @@
│ │ │ ├── hash.ts (751360b, 97L, 2350ch, 2350B)
│ │ │ ├── index.ts (70bbcd4, 219L, 5484ch, 5484B)
│ │ │ ├── resolver.ts (67fae4f, 229L, 5068ch, 5068B)
-│ │ │ ├── storage.ts (29ef16f, 500L, 11805ch, 11805B)
+│ │ │ ├── storage.ts (cbe771c, 525L, 12605ch, 12607B)
│ │ │ ├── sync.ts (cd659cf, 438L, 11504ch, 11504B)
│ │ │ └── types.ts (1bb32e8, 207L, 5085ch, 5085B)
│ │ ├── lifecycle/ (4)
│ │ │ ├── handlers.ts (9847a66, 228L, 5414ch, 5414B)
│ │ │ ├── index.ts (2b435e9, 37L, 938ch, 938B)
-│ │ │ ├── manager.ts (6910dac, 604L, 13368ch, 13368B)
+│ │ │ ├── manager.ts (3ce681d, 573L, 12745ch, 12745B)
│ │ │ └── types.ts (acdd08c, 155L, 3586ch, 3586B)
│ │ ├── lock/ (4)
│ │ │ ├── errors.ts (33152dd, 134L, 3411ch, 3411B)
│ │ │ ├── index.ts (57fe77e, 46L, 1049ch, 1049B)
-│ │ │ ├── manager.ts (3e13178, 607L, 16367ch, 17343B)
+│ │ │ ├── manager.ts (4915e50, 597L, 16103ch, 16835B)
│ │ │ └── types.ts (57e8f2b, 119L, 2831ch, 2831B)
-│ │ ├── logger/ (11)
+│ │ ├── logger/ (12)
│ │ │ ├── classifier.ts (4fa4422, 179L, 3747ch, 3747B)
│ │ │ ├── color.ts (1dd10fa, 192L, 4033ch, 4045B)
│ │ │ ├── formatter.ts (07ecfee, 450L, 12942ch, 12944B)
-│ │ │ ├── index.ts (7d627c2, 66L, 1513ch, 1513B)
+│ │ │ ├── index.ts (9e238ba, 69L, 1613ch, 1613B)
│ │ │ ├── init.ts (fe47e04, 178L, 4739ch, 4739B)
-│ │ │ ├── logger.ts (15c3907, 801L, 18791ch, 19523B)
+│ │ │ ├── logger.ts (e7f6847, 800L, 18820ch, 19552B)
│ │ │ ├── queue.ts (b5b3918, 298L, 6507ch, 7483B)
│ │ │ ├── reader.ts (50e90ff, 150L, 3536ch, 3536B)
│ │ │ ├── redact.ts (4314060, 389L, 9109ch, 10329B)
│ │ │ ├── rotation.ts (3579fb5, 250L, 5628ch, 5628B)
+│ │ │ ├── timestamp.ts (e2c9452, 58L, 2205ch, 2212B)
│ │ │ └── types.ts (5036c8b, 127L, 2773ch, 2773B)
│ │ ├── policy/ (6)
-│ │ │ ├── check.ts (74b7467, 169L, 5317ch, 5331B)
+│ │ │ ├── check.ts (44c682a, 205L, 6648ch, 6666B)
│ │ │ ├── classify.ts (1d30fc5, 821L, 19394ch, 19426B)
-│ │ │ ├── index.ts (dbf341d, 20L, 618ch, 618B)
+│ │ │ ├── index.ts (919c2d8, 20L, 655ch, 655B)
│ │ │ ├── legacy-access.ts (f2b9b23, 35L, 1362ch, 1366B)
-│ │ │ ├── matrix.ts (b5378bb, 27L, 1324ch, 1327B)
-│ │ │ └── types.ts (fca41bb, 74L, 2275ch, 2281B)
+│ │ │ ├── matrix.ts (143d78b, 28L, 1400ch, 1403B)
+│ │ │ └── types.ts (5b2506c, 74L, 2289ch, 2295B)
│ │ ├── runner/ (6)
│ │ │ ├── checksum.ts (d21f46d, 99L, 2650ch, 2650B)
│ │ │ ├── index.ts (5156823, 55L, 1020ch, 1020B)
@@ -585,13 +593,13 @@
│ │ │ ├── index.ts (f096e30, 78L, 1597ch, 1597B)
│ │ │ ├── manager.ts (6b5a0dd, 1034L, 22970ch, 25656B)
│ │ │ ├── rules.ts (1b5776e, 288L, 6904ch, 6904B)
-│ │ │ ├── schema.ts (3447bca, 340L, 9392ch, 11470B)
+│ │ │ ├── schema.ts (9df771c, 335L, 9305ch, 11383B)
│ │ │ └── types.ts (fda4f91, 310L, 7363ch, 7367B)
│ │ ├── shared/ (5)
│ │ │ ├── dialect-quoting.ts (8f028eb, 66L, 1860ch, 1866B)
│ │ │ ├── errors.ts (9cb1445, 221L, 6038ch, 6528B)
-│ │ │ ├── files.ts (d9f4a27, 98L, 3001ch, 3001B)
-│ │ │ ├── index.ts (d39b0e1, 62L, 1331ch, 1331B)
+│ │ │ ├── files.ts (af32191, 83L, 2567ch, 2567B)
+│ │ │ ├── index.ts (b6f4b23, 62L, 1312ch, 1312B)
│ │ │ └── tables.ts (06b23dd, 487L, 12754ch, 14716B)
│ │ ├── sql-terminal/ (4)
│ │ │ ├── executor.ts (9c75cbc, 164L, 4951ch, 4955B)
@@ -600,15 +608,15 @@
│ │ │ └── types.ts (cb0a264, 123L, 2519ch, 2519B)
│ │ ├── state/ (6)
│ │ │ ├── encryption/ (2 files, 0 dirs)
-│ │ │ ├── index.ts (ad7758c, 70L, 1373ch, 1373B)
-│ │ │ ├── manager.ts (22356f6, 778L, 19894ch, 21362B)
+│ │ │ ├── index.ts (5f90995, 71L, 1445ch, 1445B)
+│ │ │ ├── manager.ts (459c2f5, 838L, 21789ch, 23257B)
│ │ │ ├── migrations.ts (2d1f7fc, 88L, 2913ch, 2913B)
│ │ │ ├── types.ts (638ec9f, 92L, 2562ch, 2564B)
│ │ │ └── version.ts (11a6a0b, 26L, 649ch, 649B)
│ │ ├── teardown/ (4)
│ │ │ ├── dialects/ (5 files, 0 dirs)
│ │ │ ├── index.ts (74cbb84, 27L, 549ch, 549B)
-│ │ │ ├── operations.ts (378e92c, 560L, 15529ch, 15561B)
+│ │ │ ├── operations.ts (514d1e6, 611L, 17692ch, 17730B)
│ │ │ └── types.ts (df8debf, 282L, 7405ch, 7407B)
│ │ ├── template/ (7)
│ │ │ ├── loaders/ (7 files, 0 dirs)
@@ -617,23 +625,24 @@
│ │ │ ├── helpers.ts (2c3adab, 182L, 4612ch, 4640B)
│ │ │ ├── index.ts (a611347, 69L, 1570ch, 1570B)
│ │ │ ├── types.ts (eeb0f2a, 214L, 4307ch, 4307B)
-│ │ │ └── utils.ts (953ff32, 153L, 3408ch, 3438B)
+│ │ │ └── utils.ts (b4c399e, 129L, 2822ch, 2852B)
│ │ ├── transfer/ (7)
│ │ │ ├── dialects/ (5 files, 0 dirs)
-│ │ │ ├── events.ts (c8d31f3, 68L, 1652ch, 1652B)
-│ │ │ ├── executor.ts (66a317b, 1099L, 26390ch, 26392B)
-│ │ │ ├── index.ts (a144bca, 233L, 6236ch, 6238B)
+│ │ │ ├── events.ts (a6a7783, 69L, 1687ch, 1687B)
+│ │ │ ├── executor.ts (ab388d9, 1109L, 26787ch, 26791B)
+│ │ │ ├── index.ts (58e8d72, 235L, 6324ch, 6326B)
│ │ │ ├── planner.ts (916e6d7, 660L, 17836ch, 17838B)
-│ │ │ ├── same-server.ts (365863e, 123L, 3024ch, 3024B)
-│ │ │ └── types.ts (9fa32bd, 177L, 4138ch, 4138B)
-│ │ ├── update/ (7)
+│ │ │ ├── same-server.ts (1e8aaa7, 114L, 2906ch, 2906B)
+│ │ │ └── types.ts (bdbbcbf, 188L, 4630ch, 4632B)
+│ │ ├── update/ (8)
│ │ │ ├── checker.ts (445cd79, 348L, 8642ch, 8642B)
+│ │ │ ├── checksum.ts (4d40218, 159L, 4902ch, 4918B)
│ │ │ ├── global-settings.ts (ced5668, 317L, 7229ch, 7229B)
│ │ │ ├── index.ts (1985bc9, 69L, 1446ch, 1446B)
-│ │ │ ├── install-mode.ts (3f9823e, 116L, 2653ch, 2661B)
-│ │ │ ├── registry.ts (d57d1b8, 182L, 4728ch, 4728B)
-│ │ │ ├── types.ts (c65c26b, 127L, 4012ch, 4012B)
-│ │ │ └── updater.ts (6d359a6, 279L, 7509ch, 7513B)
+│ │ │ ├── install-mode.ts (3331488, 167L, 4051ch, 4067B)
+│ │ │ ├── registry.ts (b789b99, 177L, 4593ch, 4593B)
+│ │ │ ├── types.ts (de1581f, 129L, 4183ch, 4183B)
+│ │ │ └── updater.ts (0aac442, 523L, 15551ch, 15581B)
│ │ ├── vault/ (8)
│ │ │ ├── copy.ts (647dcc5, 226L, 6214ch, 6214B)
│ │ │ ├── events.ts (a5a6714, 58L, 1255ch, 1255B)
@@ -641,7 +650,7 @@
│ │ │ ├── key.ts (e9e19ea, 263L, 6776ch, 6776B)
│ │ │ ├── propagate.ts (b458963, 251L, 6873ch, 6873B)
│ │ │ ├── resolve.ts (27db674, 195L, 5094ch, 5094B)
-│ │ │ ├── storage.ts (8d3fb1c, 585L, 15883ch, 15889B)
+│ │ │ ├── storage.ts (f4fb9cf, 593L, 15952ch, 15958B)
│ │ │ └── types.ts (dff608b, 109L, 2465ch, 2465B)
│ │ ├── version/ (5)
│ │ │ ├── schema/ (1 file, 1 dir)
@@ -650,36 +659,34 @@
│ │ │ ├── index.ts (8abf6c2, 302L, 7550ch, 8282B)
│ │ │ └── types.ts (5fcc885, 253L, 6917ch, 8381B)
│ │ ├── worker-bridge/ (6)
-│ │ │ ├── bridge.ts (e4d45b1, 126L, 3245ch, 3247B)
-│ │ │ ├── index.ts (15b198a, 13L, 360ch, 360B)
+│ │ │ ├── bridge.ts (28c4868, 99L, 2619ch, 2621B)
+│ │ │ ├── index.ts (edc4028, 12L, 315ch, 315B)
│ │ │ ├── order-buffer.ts (83665ff, 45L, 723ch, 723B)
│ │ │ ├── paths.ts (ba286e4, 49L, 1673ch, 1677B)
-│ │ │ ├── pool.ts (b8d2706, 108L, 2852ch, 2852B)
-│ │ │ └── types.ts (e699925, 53L, 1983ch, 1989B)
-│ │ ├── environment.ts (e138bc2, 131L, 2420ch, 2420B)
-│ │ ├── index.ts (7418a76, 418L, 8895ch, 8895B)
-│ │ ├── observer.ts (f715df0, 254L, 9767ch, 9767B)
+│ │ │ ├── pool.ts (aea709d, 86L, 2314ch, 2314B)
+│ │ │ └── types.ts (c142541, 46L, 1770ch, 1776B)
+│ │ ├── environment.ts (08ebb56, 141L, 3015ch, 3017B)
+│ │ ├── index.ts (f600b25, 418L, 8876ch, 8876B)
+│ │ ├── observer.ts (e59a0e5, 255L, 9794ch, 9794B)
│ │ ├── project-init.ts (40ae219, 166L, 4688ch, 4690B)
│ │ ├── project.ts (18af87e, 255L, 6533ch, 6533B)
│ │ └── theme.ts (f8e8f4a, 545L, 12937ch, 14702B)
-│ ├── hooks/ (1)
-│ │ └── observer.ts (cc4883f, 76L, 1791ch, 1791B)
│ ├── mcp/ (3)
│ │ ├── index.ts (d3d7f27, 26L, 852ch, 854B)
│ │ ├── init.ts (84b9cbf, 85L, 1953ch, 1953B)
-│ │ └── server.ts (621db73, 231L, 7413ch, 7417B)
+│ │ └── server.ts (7152803, 234L, 7541ch, 7545B)
│ ├── rpc/ (5)
│ │ ├── commands/ (7)
│ │ │ ├── changes.ts (49d45ad, 94L, 2627ch, 2627B)
-│ │ │ ├── config.ts (3ced138, 43L, 1379ch, 1379B)
+│ │ │ ├── config.ts (e7d7b46, 38L, 1403ch, 1403B)
│ │ │ ├── explore.ts (f24be95, 91L, 3239ch, 3239B)
│ │ │ ├── index.ts (1d30237, 30L, 734ch, 734B)
│ │ │ ├── query.ts (d55e353, 40L, 1456ch, 1458B)
│ │ │ ├── run.ts (2e5fb40, 57L, 1667ch, 1667B)
-│ │ │ └── session.ts (b747844, 53L, 1619ch, 1619B)
+│ │ │ └── session.ts (cc8dde9, 126L, 4052ch, 4056B)
│ │ ├── index.ts (32e718c, 21L, 630ch, 630B)
│ │ ├── registry.ts (02a1571, 109L, 2511ch, 2511B)
-│ │ ├── session.ts (67b8b8c, 196L, 5142ch, 5156B)
+│ │ ├── session.ts (9af1ad3, 196L, 5146ch, 5160B)
│ │ └── types.ts (5ce4cf7, 78L, 2041ch, 2043B)
│ ├── sdk/ (11)
│ │ ├── impersonate/ (4)
@@ -688,27 +695,27 @@
│ │ │ ├── scope.ts (b139628, 103L, 3170ch, 3414B)
│ │ │ └── types.ts (ace8416, 67L, 2106ch, 2594B)
│ │ ├── namespaces/ (11)
-│ │ │ ├── changes.ts (be87721, 487L, 12794ch, 14100B)
-│ │ │ ├── db.ts (726a447, 376L, 10050ch, 11358B)
-│ │ │ ├── dt.ts (09c4dc4, 106L, 2723ch, 3181B)
+│ │ │ ├── changes.ts (25533e3, 479L, 12747ch, 14053B)
+│ │ │ ├── db.ts (ad6ce07, 361L, 9747ch, 10843B)
+│ │ │ ├── dt.ts (fca374d, 117L, 3169ch, 3627B)
│ │ │ ├── index.ts (9ce85c2, 10L, 454ch, 454B)
-│ │ │ ├── lock.ts (dcd30d9, 154L, 3875ch, 4333B)
-│ │ │ ├── run.ts (585765d, 218L, 5973ch, 7069B)
+│ │ │ ├── lock.ts (d20eaae, 149L, 3811ch, 4269B)
+│ │ │ ├── run.ts (9f3c8c6, 261L, 7963ch, 9063B)
│ │ │ ├── secrets.ts (860ea8b, 42L, 967ch, 1213B)
│ │ │ ├── templates.ts (da4c186, 53L, 1492ch, 1738B)
-│ │ │ ├── transfer.ts (8dd6b7c, 72L, 2056ch, 2304B)
-│ │ │ ├── utils.ts (4f1abc0, 60L, 1491ch, 1737B)
-│ │ │ └── vault.ts (5e75287, 364L, 9201ch, 10299B)
+│ │ │ ├── transfer.ts (182f27c, 88L, 2562ch, 2810B)
+│ │ │ ├── utils.ts (a8b8dec, 65L, 1759ch, 2005B)
+│ │ │ └── vault.ts (436f71f, 407L, 10433ch, 11779B)
│ │ ├── stubs/ (1)
│ │ │ └── ansis.ts (16243d6, 19L, 488ch, 490B)
-│ │ ├── context.ts (3e9d43b, 564L, 16926ch, 19032B)
-│ │ ├── guards.ts (cb3eb1c, 128L, 3480ch, 3974B)
-│ │ ├── index.ts (f1819b9, 265L, 8033ch, 8769B)
-│ │ ├── noorm-ops.ts (7b2144a, 186L, 4074ch, 4744B)
+│ │ ├── context.ts (12fd24e, 535L, 16331ch, 18437B)
+│ │ ├── guards.ts (e27300d, 156L, 4421ch, 4919B)
+│ │ ├── index.ts (cb2bbba, 300L, 9189ch, 9927B)
+│ │ ├── noorm-ops.ts (9b88a4d, 178L, 3939ch, 4609B)
│ │ ├── sql.ts (397c9c9, 728L, 19985ch, 21507B)
-│ │ ├── state.ts (4de0bfb, 29L, 1057ch, 1301B)
+│ │ ├── state.ts (2e875de, 54L, 1779ch, 2267B)
│ │ ├── tvp.ts (05b7f0a, 168L, 4695ch, 5433B)
-│ │ └── types.ts (892f38a, 169L, 4431ch, 5407B)
+│ │ └── types.ts (8d8963e, 178L, 4777ch, 5755B)
│ ├── tui/ (14)
│ │ ├── components/ (10)
│ │ │ ├── dialogs/ (6 files, 0 dirs)
@@ -720,15 +727,15 @@
│ │ │ ├── secrets/ (6 files, 0 dirs)
│ │ │ ├── status/ (3 files, 0 dirs)
│ │ │ ├── terminal/ (3 files, 0 dirs)
-│ │ │ └── index.ts (5d55936, 96L, 2557ch, 2557B)
+│ │ │ └── index.ts (c5ec099, 95L, 2533ch, 2533B)
│ │ ├── hooks/ (14)
-│ │ │ ├── index.ts (b106954, 71L, 1436ch, 1436B)
+│ │ │ ├── index.ts (4220ca8, 69L, 1387ch, 1387B)
│ │ │ ├── useAsyncEffect.ts (9e1d1d5, 43L, 903ch, 903B)
│ │ │ ├── useChangeProgress.ts (12b8c8d, 111L, 3230ch, 3230B)
│ │ │ ├── useConnection.ts (516f590, 229L, 6526ch, 6526B)
│ │ │ ├── useLoadGuard.ts (e4d449c, 51L, 1054ch, 1054B)
│ │ │ ├── useLockStatus.ts (632d89f, 145L, 3655ch, 3655B)
-│ │ │ ├── useObserver.ts (59f91e2, 200L, 5388ch, 5390B)
+│ │ │ ├── useObserver.ts (e5db37d, 158L, 4195ch, 4197B)
│ │ │ ├── useRunProgress.ts (8174572, 279L, 6494ch, 6494B)
│ │ │ ├── useSecretSource.ts (2004e16, 74L, 1995ch, 1995B)
│ │ │ ├── useSettingsOperation.ts (cdd5226, 104L, 2795ch, 2811B)
@@ -754,18 +761,19 @@
│ │ │ ├── UpdateScreen.tsx (d71a562, 191L, 4892ch, 4892B)
│ │ │ ├── home.tsx (7f5fdf6, 635L, 21702ch, 21712B)
│ │ │ └── not-found.tsx (135b44e, 72L, 1958ch, 1958B)
-│ │ ├── utils/ (12)
+│ │ ├── utils/ (13)
│ │ │ ├── change-context.ts (88cb5c7, 70L, 2165ch, 2165B)
│ │ │ ├── change-loader.ts (4ad58de, 252L, 6631ch, 6633B)
│ │ │ ├── clipboard.ts (93a7f8c, 95L, 1991ch, 1991B)
-│ │ │ ├── config-validation.ts (b1a9ceb, 221L, 5883ch, 5893B)
-│ │ │ ├── connection.ts (e2bc3b6, 79L, 1950ch, 1950B)
+│ │ │ ├── config-validation.ts (0d56c80, 217L, 5890ch, 5898B)
+│ │ │ ├── connection.ts (e20a2e0, 82L, 2065ch, 2065B)
│ │ │ ├── date.ts (ff07926, 20L, 391ch, 391B)
│ │ │ ├── error.ts (97c198b, 33L, 736ch, 736B)
│ │ │ ├── identity.ts (a44606a, 32L, 870ch, 870B)
-│ │ │ ├── index.ts (77c3309, 29L, 976ch, 976B)
+│ │ │ ├── index.ts (4a375b7, 30L, 1038ch, 1038B)
│ │ │ ├── paths.ts (ee0cf63, 53L, 1329ch, 1329B)
│ │ │ ├── run-context.ts (207450d, 66L, 1951ch, 1951B)
+│ │ │ ├── settings-validation.ts (b155b66, 49L, 1148ch, 1152B)
│ │ │ └── string.ts (8c22fe4, 39L, 1182ch, 1182B)
│ │ ├── app-context.tsx (061c366, 1198L, 29675ch, 30909B)
│ │ ├── app.tsx (e0768a4, 401L, 11536ch, 11592B)
@@ -780,9 +788,11 @@
│ ├── compute.ts (c7b828a, 46L, 1233ch, 1233B)
│ └── connection.ts (128339f, 241L, 5955ch, 5957B)
├── tests/ (11)
-│ ├── cli/ (25)
+│ ├── cli/ (30)
+│ │ ├── change/ (1)
+│ │ │ └── rm.test.ts (dce3756, 167L, 5461ch, 5467B)
│ │ ├── ci/ (4)
-│ │ │ ├── identity-enroll.test.ts (daea467, 75L, 2166ch, 2168B)
+│ │ │ ├── identity-enroll.test.ts (99d0cfb, 106L, 3324ch, 3332B)
│ │ │ ├── identity-new.test.ts (2462d0f, 88L, 2587ch, 2587B)
│ │ │ ├── init.test.ts (4834a97, 192L, 5286ch, 5286B)
│ │ │ └── secrets.test.ts (53fa24b, 216L, 6180ch, 6180B)
@@ -794,85 +804,107 @@
│ │ │ ├── layout.test.tsx (811eaec, 110L, 2675ch, 2683B)
│ │ │ ├── lists.test.tsx (3807cc8, 220L, 6656ch, 6670B)
│ │ │ └── status.test.tsx (345fc65, 191L, 5245ch, 5245B)
-│ │ ├── config/ (2)
+│ │ ├── config/ (6)
+│ │ │ ├── add.test.ts (74a4ef8, 43L, 1116ch, 1126B)
+│ │ │ ├── edit.test.ts (6dbeeea, 62L, 1800ch, 1812B)
+│ │ │ ├── export.test.ts (cddd4ad, 129L, 4198ch, 4206B)
│ │ │ ├── import.test.ts (8d92923, 169L, 5938ch, 5948B)
-│ │ │ └── list.test.ts (745c3f4, 136L, 4211ch, 4215B)
-│ │ ├── db/ (1)
-│ │ │ └── drop.test.ts (b86f15d, 178L, 5982ch, 5992B)
+│ │ │ ├── list.test.ts (745c3f4, 136L, 4211ch, 4215B)
+│ │ │ └── rm.test.ts (53d70db, 201L, 6850ch, 6850B)
+│ │ ├── db/ (4)
+│ │ │ ├── create.test.ts (e6a0e70, 201L, 7006ch, 7018B)
+│ │ │ ├── drop.test.ts (b86f15d, 178L, 5982ch, 5992B)
+│ │ │ ├── reset.test.ts (75cac14, 257L, 8678ch, 8690B)
+│ │ │ └── transfer.test.ts (3ad7c26, 155L, 5752ch, 5770B)
│ │ ├── hooks/ (3)
-│ │ │ ├── useObserver.test.tsx (aa57ff1, 530L, 13868ch, 13872B)
+│ │ │ ├── useObserver.test.tsx (e827049, 392L, 10127ch, 10129B)
│ │ │ ├── useTransferProgress.test.tsx (5267f2e, 322L, 10736ch, 10744B)
│ │ │ └── useUpdateChecker.test.tsx (3073b2c, 283L, 8301ch, 8301B)
-│ │ ├── run/ (9)
+│ │ ├── run/ (10)
│ │ │ ├── _setup.ts (35ce510, 167L, 4749ch, 4753B)
│ │ │ ├── build.test.ts (a658b29, 125L, 3897ch, 3909B)
│ │ │ ├── change-ff-dryrun.test.ts (963e4be, 197L, 5697ch, 5701B)
│ │ │ ├── change-ff.test.ts (58aa688, 126L, 3522ch, 3526B)
+│ │ │ ├── change-rewind.test.ts (41e0605, 109L, 3291ch, 3297B)
│ │ │ ├── change-run.test.ts (049eec9, 111L, 2909ch, 2913B)
│ │ │ ├── dir.test.ts (b5724ae, 108L, 3146ch, 3152B)
│ │ │ ├── file.test.ts (bb58559, 115L, 3539ch, 3543B)
│ │ │ ├── files.test.ts (b4ef236, 182L, 5193ch, 5201B)
│ │ │ └── sql.test.ts (29d578f, 123L, 3370ch, 3372B)
-│ │ ├── screens/ (1)
+│ │ ├── screens/ (2)
+│ │ │ ├── config/ (2 files, 0 dirs)
│ │ │ └── init/ (4 files, 0 dirs)
│ │ ├── app-context.test.tsx (e15ad92, 628L, 16709ch, 16711B)
│ │ ├── app.test.tsx (3a6f3d3, 241L, 6170ch, 6181B)
│ │ ├── change-edit.test.ts (8125b19, 103L, 2985ch, 2985B)
│ │ ├── change-prompts.test.ts (9be33db, 107L, 2822ch, 2822B)
-│ │ ├── citty-help.test.ts (a81fe18, 61L, 1570ch, 1570B)
-│ │ ├── config-validation.test.ts (7abf580, 33L, 1092ch, 1092B)
+│ │ ├── citty-args.ts (f0b91d5, 27L, 816ch, 816B)
+│ │ ├── citty-help.test.ts (6b20ddc, 102L, 2787ch, 2787B)
+│ │ ├── config-validation.test.ts (54338bb, 101L, 2761ch, 2761B)
│ │ ├── debug-pid.test.tsx (d07de2e, 11L, 188ch, 188B)
│ │ ├── env-bootstrap.test.ts (048bf9c, 75L, 2314ch, 2314B)
│ │ ├── focus.test.tsx (77608ce, 662L, 15713ch, 15713B)
│ │ ├── init.test.ts (abbfba0, 124L, 3924ch, 3926B)
+│ │ ├── insecure-flag.test.ts (bdb8f17, 104L, 2560ch, 2562B)
│ │ ├── keyboard.test.tsx (83d53c1, 573L, 14942ch, 14942B)
+│ │ ├── lazy-startup.test.ts (f8758c9, 143L, 4488ch, 4488B)
│ │ ├── router.test.tsx (952530c, 489L, 13626ch, 13626B)
│ │ ├── screens.test.tsx (6edec68, 277L, 7530ch, 7530B)
│ │ ├── settings-edit.test.ts (a5ebc82, 56L, 1416ch, 1416B)
│ │ ├── settings-secret.test.ts (8a6f206, 53L, 1365ch, 1365B)
+│ │ ├── settings-validation.test.ts (d64e9b7, 34L, 903ch, 903B)
│ │ ├── sql-repl.test.ts (37b9c5a, 32L, 864ch, 864B)
│ │ ├── types.test.ts (c9a86fb, 136L, 4786ch, 4786B)
│ │ └── yes-flag.test.ts (768cd0f, 476L, 12490ch, 12492B)
-│ ├── core/ (26)
-│ │ ├── change/ (5)
+│ ├── core/ (28)
+│ │ ├── change/ (9)
│ │ │ ├── fixtures/ (0 files, 3 dirs)
+│ │ │ ├── executor-retry.test.ts (4ebda49, 287L, 11169ch, 11181B)
│ │ │ ├── executor.test.ts (0364934, 388L, 14082ch, 14086B)
+│ │ │ ├── history.test.ts (de402f0, 176L, 5072ch, 5076B)
+│ │ │ ├── manager.test.ts (44983e1, 388L, 14201ch, 14205B)
│ │ │ ├── parser.test.ts (3ce9b9e, 394L, 12279ch, 12279B)
│ │ │ ├── scaffold.test.ts (c8b3e10, 364L, 10559ch, 10559B)
+│ │ │ ├── tracker.test.ts (ab9c75c, 181L, 6273ch, 6273B)
│ │ │ └── types.test.ts (6dac816, 150L, 4669ch, 4669B)
-│ │ ├── config/ (4)
+│ │ ├── config/ (5)
│ │ │ ├── debug-process.test.ts (48b3b6d, 12L, 300ch, 300B)
-│ │ │ ├── env.test.ts (7c8ba98, 406L, 10934ch, 10934B)
+│ │ │ ├── env.test.ts (8677581, 496L, 13317ch, 13317B)
│ │ │ ├── resolver.test.ts (f440ae4, 830L, 24768ch, 24770B)
-│ │ │ └── schema.test.ts (fa74209, 402L, 11508ch, 11508B)
-│ │ ├── connection/ (2)
+│ │ │ ├── schema.test.ts (6380dec, 505L, 14744ch, 14745B)
+│ │ │ └── validate.test.ts (6e59573, 72L, 2253ch, 2253B)
+│ │ ├── connection/ (3)
+│ │ │ ├── defaults.test.ts (9203348, 50L, 1640ch, 1642B)
│ │ │ ├── factory.test.ts (d4ef387, 140L, 3916ch, 3916B)
│ │ │ └── manager.test.ts (7620812, 192L, 5447ch, 5447B)
+│ │ ├── db/ (1)
+│ │ │ └── dialects/ (3 files, 0 dirs)
│ │ ├── dt/ (13)
-│ │ │ ├── crypto.test.ts (396a0f4, 162L, 4548ch, 4548B)
+│ │ │ ├── crypto.test.ts (172197f, 241L, 7186ch, 7186B)
│ │ │ ├── deserialize.test.ts (f86c304, 309L, 10375ch, 10375B)
-│ │ │ ├── integration.test.ts (c419b58, 800L, 26994ch, 27020B)
+│ │ │ ├── integration.test.ts (c949c93, 800L, 27005ch, 27031B)
│ │ │ ├── modify.test.ts (aaebadd, 930L, 32501ch, 32515B)
│ │ │ ├── paths.test.ts (34268ed, 135L, 3543ch, 3543B)
-│ │ │ ├── reader.test.ts (88875d1, 261L, 6808ch, 6808B)
+│ │ │ ├── reader.test.ts (fabb685, 285L, 7821ch, 7821B)
│ │ │ ├── roundtrip.test.ts (95212dc, 418L, 13038ch, 13050B)
│ │ │ ├── schema.test.ts (1add531, 107L, 3266ch, 3270B)
│ │ │ ├── serialize.test.ts (6536fd0, 348L, 10713ch, 10715B)
│ │ │ ├── streamer.test.ts (ce5296c, 311L, 10016ch, 10028B)
│ │ │ ├── type-map.test.ts (0d0c9d7, 508L, 15680ch, 15680B)
│ │ │ ├── worker-pipeline.test.ts (39f13d6, 323L, 10611ch, 10619B)
-│ │ │ └── writer.test.ts (882ee19, 221L, 6611ch, 6611B)
+│ │ │ └── writer.test.ts (c97dc9e, 221L, 6635ch, 6635B)
│ │ ├── explore/ (2)
│ │ │ ├── dialects/ (4 files, 0 dirs)
│ │ │ └── operations.test.ts (64e9a5f, 589L, 16355ch, 16371B)
-│ │ ├── identity/ (7)
+│ │ ├── identity/ (8)
│ │ │ ├── crypto.test.ts (8801b11, 311L, 8701ch, 8701B)
│ │ │ ├── env.test.ts (a4f0c52, 146L, 4559ch, 4561B)
│ │ │ ├── factory.test.ts (61019f8, 348L, 10115ch, 10115B)
│ │ │ ├── hash.test.ts (4ccae4e, 226L, 5558ch, 5584B)
│ │ │ ├── overrides.test.ts (1a4a1c5, 100L, 2374ch, 2374B)
│ │ │ ├── resolver.test.ts (f034831, 442L, 12034ch, 12074B)
-│ │ │ └── storage.test.ts (dd036b0, 171L, 4452ch, 4452B)
+│ │ │ ├── storage-key-permission-guard.test.ts (fa15ec7, 85L, 2584ch, 2588B)
+│ │ │ └── storage.test.ts (3de274e, 224L, 6004ch, 6004B)
│ │ ├── lifecycle/ (3)
│ │ │ ├── handlers.test.ts (172ef91, 326L, 8624ch, 8624B)
│ │ │ ├── manager.test.ts (2f7ce32, 705L, 17663ch, 17663B)
@@ -881,7 +913,7 @@
│ │ │ ├── errors.test.ts (14e1d59, 215L, 5889ch, 5889B)
│ │ │ ├── manager.test.ts (37f122b, 581L, 16221ch, 16221B)
│ │ │ └── types.test.ts (2bdf84e, 48L, 1238ch, 1238B)
-│ │ ├── logger/ (8)
+│ │ ├── logger/ (9)
│ │ │ ├── classifier.test.ts (3eb2232, 188L, 5356ch, 5356B)
│ │ │ ├── formatter.test.ts (701d119, 316L, 8877ch, 8877B)
│ │ │ ├── logger.test.ts (00e1420, 512L, 13305ch, 13305B)
@@ -889,20 +921,23 @@
│ │ │ ├── queue.test.ts (2e16bbf, 295L, 6804ch, 6804B)
│ │ │ ├── reader.test.ts (f361ff0, 439L, 14445ch, 14445B)
│ │ │ ├── redact.test.ts (026b645, 554L, 15482ch, 15482B)
-│ │ │ └── rotation.test.ts (2aad64b, 360L, 9943ch, 9943B)
+│ │ │ ├── rotation.test.ts (2aad64b, 360L, 9943ch, 9943B)
+│ │ │ └── timestamp.test.ts (4026342, 72L, 2286ch, 2286B)
│ │ ├── mcp/ (2)
│ │ │ ├── init.test.ts (9cb2913, 70L, 2197ch, 2197B)
-│ │ │ └── server.test.ts (3f621da, 558L, 15851ch, 16255B)
-│ │ ├── policy/ (2)
-│ │ │ ├── check.test.ts (19551f1, 270L, 8111ch, 8111B)
-│ │ │ └── classify.test.ts (4006bc7, 446L, 13890ch, 13902B)
-│ │ ├── rpc/ (7)
+│ │ │ └── server.test.ts (800a66e, 571L, 16481ch, 16887B)
+│ │ ├── policy/ (3)
+│ │ │ ├── check.test.ts (c8ef6c0, 323L, 9827ch, 9827B)
+│ │ │ ├── classify.test.ts (4006bc7, 446L, 13890ch, 13902B)
+│ │ │ └── visibility.test.ts (2d5f6c6, 38L, 1052ch, 1052B)
+│ │ ├── rpc/ (8)
│ │ │ ├── commands.test.ts (a10d74f, 480L, 16121ch, 16125B)
│ │ │ ├── list-configs.test.ts (5ba9331, 115L, 3592ch, 3598B)
-│ │ │ ├── permissions.test.ts (e69d7c7, 57L, 1719ch, 1721B)
-│ │ │ ├── registry-integration.test.ts (ce3964c, 230L, 6153ch, 6159B)
+│ │ │ ├── permissions.test.ts (82cb5d4, 58L, 1739ch, 1741B)
+│ │ │ ├── registry-integration.test.ts (a704a85, 231L, 6183ch, 6189B)
│ │ │ ├── registry.test.ts (f674271, 109L, 3041ch, 3041B)
│ │ │ ├── session-not-found.test.ts (f1df070, 76L, 2649ch, 2651B)
+│ │ │ ├── session-status.test.ts (1e74c6a, 230L, 7438ch, 7440B)
│ │ │ └── session.test.ts (2fe1518, 194L, 5712ch, 5712B)
│ │ ├── runner/ (4)
│ │ │ ├── fixtures/ (6 files, 0 dirs)
@@ -923,10 +958,10 @@
│ │ │ └── history.test.ts (0db8ae6, 1013L, 34309ch, 34309B)
│ │ ├── state/ (2)
│ │ │ ├── encryption/ (1 file, 0 dirs)
-│ │ │ └── manager.test.ts (6b17f57, 894L, 29892ch, 31846B)
+│ │ │ └── manager.test.ts (93ec167, 1015L, 33746ch, 35700B)
│ │ ├── teardown/ (2)
│ │ │ ├── dialects/ (4 files, 0 dirs)
-│ │ │ └── operations.test.ts (dfc1c9e, 699L, 23788ch, 25528B)
+│ │ │ └── operations.test.ts (89b712f, 904L, 31368ch, 33364B)
│ │ ├── template/ (6)
│ │ │ ├── fixtures/ (0 files, 4 dirs)
│ │ │ ├── engine.test.ts (f3f1e18, 388L, 11697ch, 11697B)
@@ -934,19 +969,24 @@
│ │ │ ├── loaders.test.ts (115cc74, 257L, 6369ch, 6369B)
│ │ │ ├── security.test.ts (82855d4, 237L, 7609ch, 7609B)
│ │ │ └── utils.test.ts (d54a7a5, 165L, 3442ch, 3442B)
-│ │ ├── transfer/ (6)
+│ │ ├── transfer/ (7)
│ │ │ ├── dialects/ (4 files, 0 dirs)
│ │ │ ├── events.test.ts (ab6d02e, 364L, 10614ch, 10614B)
│ │ │ ├── executor.test.ts (602b084, 420L, 13400ch, 13400B)
+│ │ │ ├── fk-restore.test.ts (abd4f41, 162L, 4912ch, 5164B)
│ │ │ ├── planner.test.ts (a81c2b6, 341L, 10240ch, 10240B)
│ │ │ ├── policy-gate.test.ts (d7abf8f, 60L, 2425ch, 2429B)
│ │ │ └── same-server.test.ts (cf2bf8d, 369L, 10550ch, 10550B)
-│ │ ├── update/ (3)
+│ │ ├── update/ (5)
│ │ │ ├── checker.test.ts (4db209f, 191L, 5313ch, 5313B)
+│ │ │ ├── checksum.test.ts (ef4d8bd, 264L, 7720ch, 7732B)
│ │ │ ├── global-settings.test.ts (1190fee, 231L, 6358ch, 6358B)
-│ │ │ └── registry.test.ts (6f2fad2, 214L, 5987ch, 5987B)
-│ │ ├── vault/ (1)
-│ │ │ └── idempotent-init.test.ts (a903fc3, 236L, 6218ch, 6218B)
+│ │ │ ├── registry.test.ts (6f2fad2, 214L, 5987ch, 5987B)
+│ │ │ └── updater.test.ts (a8edc3f, 324L, 10516ch, 10536B)
+│ │ ├── vault/ (3)
+│ │ │ ├── idempotent-init.test.ts (a903fc3, 236L, 6218ch, 6218B)
+│ │ │ ├── key.test.ts (1b2034e, 188L, 5742ch, 5746B)
+│ │ │ └── storage.test.ts (6ce7799, 317L, 10648ch, 10650B)
│ │ ├── version/ (5)
│ │ │ ├── manager.test.ts (8ff56f8, 319L, 11020ch, 11020B)
│ │ │ ├── schema.test.ts (9fc95bd, 658L, 20341ch, 20341B)
@@ -954,9 +994,10 @@
│ │ │ ├── state.test.ts (28be6b2, 474L, 13484ch, 13484B)
│ │ │ └── types.test.ts (5f0f71f, 125L, 3021ch, 3021B)
│ │ ├── worker-bridge/ (3)
-│ │ │ ├── bridge.test.ts (a6225e4, 63L, 1725ch, 1725B)
+│ │ │ ├── bridge.test.ts (4ee1750, 54L, 1440ch, 1440B)
│ │ │ ├── order-buffer.test.ts (182cbed, 85L, 1988ch, 1992B)
│ │ │ └── pool.test.ts (f2754a9, 64L, 1798ch, 1798B)
+│ │ ├── environment.test.ts (958bc19, 142L, 3767ch, 3769B)
│ │ ├── project-init.test.ts (48fa3fe, 75L, 2387ch, 2387B)
│ │ └── project.test.ts (cd6cc41, 243L, 6575ch, 6575B)
│ ├── fixtures/ (3)
@@ -971,8 +1012,10 @@
│ │ │ └── sqlite/ (3 files, 0 dirs)
│ │ └── workers/ (2)
│ │ ├── adder.ts (3243e61, 15L, 443ch, 443B)
-│ │ └── echo.ts (90dfb9f, 19L, 574ch, 574B)
-│ ├── integration/ (10)
+│ │ └── echo.ts (e2e3fe3, 12L, 413ch, 413B)
+│ ├── integration/ (11)
+│ │ ├── change/ (1)
+│ │ │ └── postgres-transaction.test.ts (92fde1d, 285L, 9555ch, 9565B)
│ │ ├── cli/ (3)
│ │ │ ├── db.test.ts (3f4283c, 435L, 11442ch, 12906B)
│ │ │ ├── lock.test.ts (52b726a, 302L, 8178ch, 9398B)
@@ -991,17 +1034,20 @@
│ │ │ └── postgres.test.ts (1638202, 113L, 3593ch, 3595B)
│ │ ├── runner/ (1)
│ │ │ └── mssql-batches.test.ts (395ef9c, 268L, 8059ch, 8061B)
-│ │ ├── sdk/ (3)
-│ │ │ ├── db-reset.test.ts (a77c170, 117L, 3630ch, 3634B)
+│ │ ├── sdk/ (6)
+│ │ │ ├── db-reset.test.ts (99bb07e, 115L, 3607ch, 3611B)
+│ │ │ ├── dt-namespace.test.ts (3dee923, 146L, 6011ch, 6513B)
+│ │ │ ├── transfer-namespace.test.ts (cfe27cd, 180L, 6121ch, 6867B)
│ │ │ ├── tvf.test.ts (730359b, 279L, 7502ch, 8234B)
-│ │ │ └── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B)
+│ │ │ ├── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B)
+│ │ │ └── vault-namespace.test.ts (e7dffc5, 291L, 9810ch, 10312B)
│ │ ├── sql-terminal/ (4)
│ │ │ ├── mssql.test.ts (1c242a3, 776L, 23168ch, 23168B)
│ │ │ ├── mysql.test.ts (19a7a01, 658L, 18773ch, 18773B)
│ │ │ ├── postgres.test.ts (a32d0a8, 942L, 29279ch, 29279B)
│ │ │ └── sqlite.test.ts (4ea272e, 768L, 22486ch, 22486B)
│ │ ├── teardown/ (5)
-│ │ │ ├── mssql.test.ts (baa46aa, 641L, 22999ch, 23253B)
+│ │ │ ├── mssql.test.ts (c5dae52, 693L, 24835ch, 25335B)
│ │ │ ├── mysql.test.ts (730f424, 400L, 12896ch, 12896B)
│ │ │ ├── postgres.test.ts (399902f, 453L, 16737ch, 16981B)
│ │ │ ├── sdk-preserve.test.ts (8d2711e, 177L, 5673ch, 6601B)
@@ -1012,22 +1058,27 @@
│ │ │ └── postgres.test.ts (449c11f, 371L, 12407ch, 12409B)
│ │ └── version/ (1)
│ │ └── schema.test.ts (e1c16d7, 727L, 22493ch, 23469B)
-│ ├── sdk/ (9)
+│ ├── sdk/ (13)
│ │ ├── impersonate/ (3)
│ │ │ ├── dialect-strategy.test.ts (d630381, 146L, 3515ch, 4491B)
│ │ │ ├── impersonate.test.ts (339dd61, 297L, 7627ch, 8603B)
│ │ │ └── scope.test.ts (b9d359a, 122L, 3310ch, 3798B)
-│ │ ├── bundle-smoke.test.ts (66dd1dd, 319L, 8912ch, 10866B)
+│ │ ├── bundle-smoke.test.ts (e141dfa, 343L, 9619ch, 11817B)
│ │ ├── context.test.ts (341f429, 597L, 18688ch, 19432B)
-│ │ ├── db-namespace.test.ts (e7fb04c, 259L, 7807ch, 8727B)
-│ │ ├── destructive-ops.test.ts (4f030f6, 471L, 15487ch, 17903B)
-│ │ ├── guards.test.ts (01e310e, 318L, 9872ch, 9874B)
+│ │ ├── db-namespace.test.ts (fe640c4, 290L, 8715ch, 9849B)
+│ │ ├── destructive-ops.test.ts (9c2dc97, 642L, 21461ch, 24521B)
+│ │ ├── dts-surface.test.ts (7e8403b, 74L, 2344ch, 3080B)
+│ │ ├── guards.test.ts (d609680, 377L, 12339ch, 12341B)
│ │ ├── lifecycle.test.ts (371af51, 126L, 3617ch, 4353B)
-│ │ ├── noorm-ops.test.ts (bea2339, 321L, 8652ch, 9824B)
-│ │ └── sql.test.ts (959c16c, 1035L, 32675ch, 34387B)
-│ ├── utils/ (3)
+│ │ ├── noorm-ops.test.ts (4cbb4ce, 338L, 9250ch, 10666B)
+│ │ ├── run-build-filtering.test.ts (8444e13, 374L, 13147ch, 13663B)
+│ │ ├── sql.test.ts (959c16c, 1035L, 32675ch, 34387B)
+│ │ ├── transfer-dt-namespace.test.ts (8962723, 120L, 3859ch, 4349B)
+│ │ └── vault-namespace.test.ts (4505539, 368L, 11446ch, 11452B)
+│ ├── utils/ (4)
+│ │ ├── db-guard.test.ts (677fa3e, 143L, 3999ch, 4001B)
│ │ ├── db-splitter.test.ts (4db513c, 280L, 8506ch, 8506B)
-│ │ ├── db.ts (a3d4ce9, 851L, 23982ch, 23992B)
+│ │ ├── db.ts (757d071, 926L, 26530ch, 26546B)
│ │ └── mssql-batches.test.ts (551221c, 270L, 7309ch, 7313B)
│ ├── workers/ (2)
│ │ ├── compute.test.ts (66855f3, 72L, 2003ch, 2003B)
@@ -1040,17 +1091,17 @@
├── .npmrc (60376c8, 1L, 36ch, 36B)
├── .prettierignore (e3b0c44, 0L, 0ch, 0B)
├── .signalsignore (b0287a5, 17L, 662ch, 674B)
-├── CLAUDE.md (c380a2c, 201L, 8292ch, 8498B)
+├── CLAUDE.md (2cf97c7, 203L, 8579ch, 8785B)
├── CNAME (f3bed50, 1L, 9ch, 9B)
+├── LICENSE (42eaf96, 21L, 1070ch, 1070B)
├── README.md (6bbc797, 84L, 2482ch, 2502B)
-├── TODO.md (6a63836, 375L, 21659ch, 21929B)
-├── bun.lockb (feb08f9, 451L, 266552ch, 268632B)
+├── TODO.md (afd3ad2, 284L, 17713ch, 17967B)
+├── bun.lockb (ca7f592, 449L, 263701ch, 265728B)
├── bunfig.toml (dab752d, 5L, 89ch, 89B)
-├── docker-compose.yml (d2d79cb, 59L, 1534ch, 1534B)
+├── docker-compose.test.yml (d2d79cb, 59L, 1534ch, 1534B)
├── eslint.config.js (cd11fe6, 56L, 2111ch, 2111B)
-├── install.sh (0cc90a2, 116L, 2925ch, 2925B)
-├── package.json (2904b1a, 102L, 3036ch, 3036B)
-├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B)
+├── install.sh (5cb346d, 200L, 5892ch, 5892B)
+├── package.json (7036c9a, 97L, 2870ch, 2870B)
├── tsconfig.json (640d95f, 21L, 616ch, 616B)
├── tsconfig.sdk-types.json (47a2c39, 13L, 303ch, 303B)
├── tsconfig.test.json (a5e75f7, 10L, 221ch, 221B)
@@ -1060,22 +1111,22 @@
## Manifests
- docs/package.json: name=@noormdev/docs, scripts=[build, dev, preview]
-- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck]
-- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck]
-- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck]
-- package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:fix, prepublishOnly, release, start, start:init, test, test:coverage, test:watch, typecheck, typecheck:tests, version]
-- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.36, scripts=[postinstall]
-- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.36
+- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck]
+- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck]
+- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck]
+- package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:docs, lint:fix, prepublishOnly, release, start, test, test:coverage, test:watch, typecheck, typecheck:tests, version]
+- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.39, scripts=[postinstall]
+- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.39
## Languages
-- TypeScript: 204246 LOC (80%), 886 files (75%)
-- Markdown: 43000 LOC (17%), 195 files (16%)
-- YAML: 1114 LOC (0%), 16 files (1%)
-- JavaScript: 1005 LOC (0%), 22 files (1%)
+- TypeScript: 213975 LOC (81%), 933 files (75%)
+- Markdown: 42955 LOC (16%), 197 files (16%)
+- JavaScript: 1198 LOC (0%), 22 files (1%)
+- YAML: 1134 LOC (0%), 16 files (1%)
- HTML: 955 LOC (0%), 26 files (2%)
- CSS: 913 LOC (0%), 3 files (0%)
-- Shell: 726 LOC (0%), 4 files (0%)
-- JSON: 555 LOC (0%), 23 files (1%)
+- Shell: 669 LOC (0%), 4 files (0%)
+- JSON: 554 LOC (0%), 23 files (1%)
- Vue: 181 LOC (0%), 3 files (0%)
- TOML: 10 LOC (0%), 2 files (0%)
diff --git a/docs/wiki/tui.md b/docs/wiki/tui.md
index 744ea6ee..54c6a859 100644
--- a/docs/wiki/tui.md
+++ b/docs/wiki/tui.md
@@ -35,7 +35,6 @@ Ink/React-based terminal UI launched by `noorm ui`. Full-screen interactive inte
- [`src/tui/screens/secret/`](../../src/tui/screens/secret) — 4 secret screens
- [`src/tui/screens/settings/`](../../src/tui/screens/settings) — 17 settings screens
- [`src/tui/screens/vault/`](../../src/tui/screens/vault) — 5 vault screens
-- [`src/hooks/observer.ts`](../../src/hooks/observer.ts) — `useObserver` hook (non-tui hooks barrel)
- [`src/tui/screens/UpdateScreen.tsx`](../../src/tui/screens/UpdateScreen.tsx) — update available prompt
- [`src/tui/screens/MoreScreen.tsx`](../../src/tui/screens/MoreScreen.tsx) — extended help screen
diff --git a/examples/llm-memory-db-mssql/.noorm/settings.yml b/examples/llm-memory-db-mssql/.noorm/settings.yml
index 1005f706..f4ae761d 100644
--- a/examples/llm-memory-db-mssql/.noorm/settings.yml
+++ b/examples/llm-memory-db-mssql/.noorm/settings.yml
@@ -4,16 +4,16 @@ paths:
build:
include:
- - sql/00_types
- - sql/01_reference
- - sql/02_tables
- - sql/03_validators
- - sql/04_subtypes
- - sql/05_binary
- - sql/06_seeds
- - sql/07_functions
- - sql/08_views
- - sql/09_procedures
+ - 00_types
+ - 01_reference
+ - 02_tables
+ - 03_validators
+ - 04_subtypes
+ - 05_binary
+ - 06_seeds
+ - 07_functions
+ - 08_views
+ - 09_procedures
exclude: []
stages:
@@ -46,7 +46,7 @@ rules:
match:
isTest: true
include:
- - sql/06_seeds
+ - 06_seeds
strict:
enabled: false
diff --git a/examples/llm-memory-db-mssql/README.md b/examples/llm-memory-db-mssql/README.md
index a6bcc56f..64bdab9f 100644
--- a/examples/llm-memory-db-mssql/README.md
+++ b/examples/llm-memory-db-mssql/README.md
@@ -20,9 +20,9 @@ An end-to-end noorm example: an LLM-memory schema running on SQL Server 2022, ex
- Docker (for the MSSQL 2022 container).
- An existing noorm identity at `~/.noorm/identity.{key,pub,json}`. If you don't have one yet, run `noorm init` from an interactive terminal once (see `mssql-problems.md` gap #1 — `init` requires a TTY).
-The MSSQL container is declared in `docker-compose.yml` at the monorepo root and listens on port `11433`. From the monorepo root:
+The MSSQL container is declared in `docker-compose.test.yml` at the monorepo root and listens on port `11433`. From the monorepo root:
- docker compose up -d
+ docker compose -f docker-compose.test.yml up -d
## Setup
diff --git a/examples/llm-memory-db-mssql/tests/integration/observer.test.ts b/examples/llm-memory-db-mssql/tests/integration/observer.test.ts
index 8ad9140f..819ba0ed 100644
--- a/examples/llm-memory-db-mssql/tests/integration/observer.test.ts
+++ b/examples/llm-memory-db-mssql/tests/integration/observer.test.ts
@@ -1,14 +1,15 @@
/**
* Layer 3 — observer integration tests.
*
- * Asserts that ctx.noorm.observer surfaces the cross-cutting events
- * emitted by the runner and template engine while the SDK executes
- * real SQL against the test MSSQL database.
+ * Asserts that noormObserver (the process-global event bus) surfaces the
+ * cross-cutting events emitted by the runner and template engine while the
+ * SDK executes real SQL against the test MSSQL database.
*/
import { join } from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { attempt } from '@logosdx/utils';
+import { noormObserver } from '@noormdev/sdk';
import { bootstrap, resetApplicationData } from '../helpers/test-context';
@@ -32,7 +33,7 @@ describe('observer: file:* events during run.file', () => {
const events: { event: string; filepath: string }[] = [];
- const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => {
+ const cleanup = noormObserver.on(/^file:/, (payload) => {
const evt = payload.event;
const data: unknown = payload.data;
@@ -66,7 +67,7 @@ describe('observer: file:* events during run.file', () => {
const captured: { status: string; durationMs: number; filepath: string }[] = [];
- const cleanup = ctx.noorm.observer.on('file:after', (data) => {
+ const cleanup = noormObserver.on('file:after', (data) => {
captured.push({
status: data.status,
@@ -95,7 +96,7 @@ describe('observer: template:* events during .sql.tmpl render', () => {
const renders: { filepath: string; durationMs: number }[] = [];
- const cleanup = ctx.noorm.observer.on('template:render', (data) => {
+ const cleanup = noormObserver.on('template:render', (data) => {
renders.push({ filepath: data.filepath, durationMs: data.durationMs });
@@ -119,7 +120,7 @@ describe('observer: regex pattern matching across run.file', () => {
const events: string[] = [];
- const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => {
+ const cleanup = noormObserver.on(/^file:/, (payload) => {
const evt = payload.event;
if (typeof evt === 'string') events.push(evt);
@@ -178,7 +179,7 @@ describe('observer: error path', () => {
const captured: { filepath: string; status: string; error: string | undefined }[] = [];
- const cleanup = ctx.noorm.observer.on('file:after', (data) => {
+ const cleanup = noormObserver.on('file:after', (data) => {
captured.push({
filepath: data.filepath,
diff --git a/examples/llm-memory-db-mssql/tests/integration/vault.test.ts b/examples/llm-memory-db-mssql/tests/integration/vault.test.ts
index f9d2ddd7..1b079511 100644
--- a/examples/llm-memory-db-mssql/tests/integration/vault.test.ts
+++ b/examples/llm-memory-db-mssql/tests/integration/vault.test.ts
@@ -49,29 +49,17 @@ beforeAll(async () => {
privateKey = (await Bun.file(KEY_PATH).text()).trim();
- // Initialize the vault for this database. init() is documented as
- // returning [null, Error('Vault already initialized')] on a second
- // call (see mssql-problems.md gap #11) — that's fine, treat it as
- // idempotent. We wrap in attempt() because some failure paths
- // (sync DER parse errors on exotic identity setups) throw rather
- // than returning a tuple.
- const [initResult, initThrew] = await attempt(() => ctx.noorm.vault.init());
- if (initThrew) throw initThrew;
-
- const [, initErr] = initResult;
- if (initErr && !/already initialized/i.test(initErr.message)) {
-
- throw initErr;
-
- }
+ // Initialize the vault for this database. init() is idempotent — a
+ // second call returns null (not an error) because the vault already
+ // exists; real failures throw. If init() genuinely fails here (DB
+ // error, encryption error, exotic identity setup), letting it throw
+ // is correct — beforeAll failing surfaces a real regression instead
+ // of silently skipping.
+ await ctx.noorm.vault.init();
// Probe: round-trip set/delete to surface state.enc-vs-key mismatch
// immediately rather than during the first assertion.
- const [, probeErr] = await attempt(
- () => ctx.noorm.vault.set('__vault_probe__', 'ok', privateKey),
- );
- if (probeErr) throw probeErr;
-
+ await ctx.noorm.vault.set('__vault_probe__', 'ok', privateKey);
await ctx.noorm.vault.delete('__vault_probe__');
}, 15_000);
@@ -101,12 +89,10 @@ beforeEach(async () => {
describe('vault: init is idempotent', () => {
- it.skipIf(!HAS_IDENTITY_KEY)('a second init() on an already-initialized vault returns the already-initialized error and leaves status intact', async () => {
+ it.skipIf(!HAS_IDENTITY_KEY)('a second init() on an already-initialized vault returns null and leaves status intact', async () => {
- const [result, err] = await ctx.noorm.vault.init();
+ const result = await ctx.noorm.vault.init();
expect(result).toBeNull();
- expect(err).toBeInstanceOf(Error);
- expect(err?.message).toMatch(/already initialized/i);
const status = await ctx.noorm.vault.status();
expect(status.isInitialized).toBe(true);
@@ -120,8 +106,7 @@ describe('vault: set / get round-trip', () => {
it.skipIf(!HAS_IDENTITY_KEY)('stores an encrypted value and decrypts it back via get()', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'mssql-secret-value', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'mssql-secret-value', privateKey);
const value = await ctx.noorm.vault.get(TEST_KEY, privateKey);
expect(value).toBe('mssql-secret-value');
@@ -130,8 +115,7 @@ describe('vault: set / get round-trip', () => {
it.skipIf(!HAS_IDENTITY_KEY)('list() reflects a key after set() and exists() agrees', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey);
const keys = await ctx.noorm.vault.list();
expect(Array.isArray(keys)).toBe(true);
@@ -146,13 +130,11 @@ describe('vault: set / get round-trip', () => {
describe('vault: delete removes the secret', () => {
- it.skipIf(!HAS_IDENTITY_KEY)('returns [true, null] and exists() reports false after delete', async () => {
+ it.skipIf(!HAS_IDENTITY_KEY)('returns true and exists() reports false after delete', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey);
- const [deleted, delErr] = await ctx.noorm.vault.delete(TEST_KEY);
- expect(delErr).toBeNull();
+ const deleted = await ctx.noorm.vault.delete(TEST_KEY);
expect(deleted).toBe(true);
const stillThere = await ctx.noorm.vault.exists(TEST_KEY);
diff --git a/examples/llm-memory-db-pg/.noorm/settings.yml b/examples/llm-memory-db-pg/.noorm/settings.yml
index cd2e0410..23286708 100644
--- a/examples/llm-memory-db-pg/.noorm/settings.yml
+++ b/examples/llm-memory-db-pg/.noorm/settings.yml
@@ -1,23 +1,24 @@
build:
include:
- - sql/00_types
- - sql/01_reference
- - sql/02_tables
- - sql/03_subtypes
- - sql/04_binary
- - sql/05_seeds
- - sql/06_functions
- - sql/07_views
- - sql/08_procedures
- - sql/09_triggers
+ - 00_types
+ - 01_reference
+ - 02_tables
+ - 03_subtypes
+ - 04_binary
+ - 05_seeds
+ - 06_functions
+ - 07_views
+ - 08_procedures
+ - 09_triggers
paths:
sql: ./sql
changes: ./changes
rules:
- - match:
+ - description: Include test seeds
+ match:
isTest: true
include:
- - sql/05_seeds
+ - 05_seeds
stages:
dev:
description: Local development database
diff --git a/examples/llm-memory-db-pg/README.md b/examples/llm-memory-db-pg/README.md
index 8618fb13..9207cabf 100644
--- a/examples/llm-memory-db-pg/README.md
+++ b/examples/llm-memory-db-pg/README.md
@@ -48,12 +48,48 @@ The schema artifact this project implements lives at `tmp/llm-memory-db.pseudo`
## Setup
-From inside `examples/llm-memory-db-pg/`:
+The actual order matters — `noorm db create` requires an active named config, so configs must be imported before the database can be created. From inside `examples/llm-memory-db-pg/`, create `dev.json`:
+
+```json
+{
+ "name": "dev",
+ "connection": {
+ "dialect": "postgres",
+ "host": "localhost",
+ "port": 15432,
+ "database": "noorm_llm_dev",
+ "user": "noorm_test",
+ "password": "noorm_test"
+ },
+ "isTest": false
+}
+```
+
+Then create `test.json`:
+
+```json
+{
+ "name": "test",
+ "connection": {
+ "dialect": "postgres",
+ "host": "localhost",
+ "port": 15432,
+ "database": "noorm_llm_test",
+ "user": "noorm_test",
+ "password": "noorm_test"
+ },
+ "isTest": true
+}
+```
+
+These values match `.noorm/settings.yml`'s `stages.dev` and `stages.test` defaults. Then:
```bash
bun install
-noorm db create --name noorm_llm_dev
-noorm db create --name noorm_llm_test
+noorm config import dev.json
+noorm config import test.json
+noorm db create -c dev
+noorm db create -c test
noorm config use dev
noorm run build
noorm change ff
@@ -158,7 +194,7 @@ examples/llm-memory-db-pg/
Phase 1 used the noorm CLI to bootstrap `.noorm/`, declare two stages (`dev` + `test`), build the schema from SQL files, apply an evolutionary change, generate the SDK shape, and write the test suite. Phase 2 used the noorm MCP server to verify schema integrity, grade test quality, and add a meta-test that exercises the MCP itself.
-The full play-by-play is in `REPORT.md` (final deliverable) and `REPORT-PHASE-1.md` (intermediate report). Three real noorm SDK bugs surfaced during Phase 1 and were fixed at the source — see `../../postgres-problems.md` (monorepo root) for the full list of CLI/SDK/MCP issues encountered.
+The full play-by-play is in `REPORT.md` (final deliverable) and `REPORT-PHASE-1.md` (intermediate report). Three real noorm SDK bugs surfaced during Phase 1 and were fixed at the source — see `postgres-problems.md` (in this directory) for the full list of CLI/SDK/MCP issues encountered.
## What this is NOT
diff --git a/examples/llm-memory-db-pg/REPORT.md b/examples/llm-memory-db-pg/REPORT.md
index 26804770..dd8d2117 100644
--- a/examples/llm-memory-db-pg/REPORT.md
+++ b/examples/llm-memory-db-pg/REPORT.md
@@ -3,7 +3,7 @@
This is the Phase 2 deliverable for the LLM memory + task-tracker example built end-to-end against PostgreSQL using noorm. It captures: what was built, the coverage stats, every noorm CLI / SDK / MCP issue surfaced along the way, and the judgment calls that shaped the final shape.
-For Phase 1 details (schema authoring play-by-play, the three SDK bugs that were fixed mid-build, and the convention quirks that bit the early subagents) see `REPORT-PHASE-1.md` in this directory. The full external problem log lives at `../../postgres-problems.md` in the monorepo root.
+For Phase 1 details (schema authoring play-by-play, the three SDK bugs that were fixed mid-build, and the convention quirks that bit the early subagents) see `REPORT-PHASE-1.md` in this directory. The full external problem log lives at `postgres-problems.md` in this directory.
## Final state
@@ -55,7 +55,7 @@ All 38 tables, 18 views, 10 helper functions, and ALL 69 procs (`PROCEDURE` + sc
- **Domain coverage**: every entity domain (Memory, Note, Tag, Artifact, Milestone, Task, Agent, Project, Audit) has both an SQL-layer test (asserting on the proc/view contract) AND a domain-layer test (asserting on the facade plumbing through Zod + camelCase mapping).
- **Failure-mode coverage**: every state-machine rejection, every sentinel guard (Agent(0) / Project(0)), every exclusivity trigger pair, every cycle-detection guard, every FK guard, and every UNIQUE constraint has a paired failure test that asserts on a tight error pattern (regex match against the literal RAISE EXCEPTION message — tighter than `.rejects.toThrow()` alone, less brittle than coupling to driver-specific SQLSTATE codes).
-- **Integration coverage**: `ctx.noorm.observer` (4 tests), `ctx.noorm.lock` (5 tests including real cross-identity contention), `ctx.noorm.vault` (4 tests including double-init failure), `ctx.impersonate` (5 tests including SQLSTATE-pinned rejection + post-revert control).
+- **Integration coverage**: `noormObserver` (4 tests), `ctx.noorm.lock` (5 tests including real cross-identity contention), `ctx.noorm.vault` (4 tests including double-init failure), `ctx.impersonate` (5 tests including SQLSTATE-pinned rejection + post-revert control).
- **MCP coverage**: 8 tests in `tests/mcp-discovery.test.ts` exercise the full JSON-RPC surface (initialize handshake, tools/list, tools/call for both `noorm_help` and `run_noorm_cmd`).
diff --git a/postgres-problems.md b/examples/llm-memory-db-pg/postgres-problems.md
similarity index 100%
rename from postgres-problems.md
rename to examples/llm-memory-db-pg/postgres-problems.md
diff --git a/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts b/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts
index a19c9dc9..ad2d1bf0 100644
--- a/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts
+++ b/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts
@@ -2,6 +2,7 @@ import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { beforeAll, beforeEach, describe, it, expect } from 'bun:test';
+import { noormObserver } from '@noormdev/sdk';
import { bootstrap, truncateAll } from '../helpers/test-context';
@@ -19,13 +20,13 @@ beforeEach(async () => {
});
-describe('ctx.noorm.observer file:after', () => {
+describe('noormObserver file:after', () => {
it('fires with filepath, status, and durationMs when a file is run', async () => {
const events: Array<{ filepath: string; status: string; durationMs: number }> = [];
- const cleanup = ctx.noorm.observer.on('file:after', (data) => {
+ const cleanup = noormObserver.on('file:after', (data) => {
events.push({
filepath: data.filepath,
@@ -48,13 +49,13 @@ describe('ctx.noorm.observer file:after', () => {
});
-describe('ctx.noorm.observer change:complete', () => {
+describe('noormObserver change:complete', () => {
it('fires when a freshly created change is applied via the SDK', async () => {
const events: Array<{ name: string; direction: string; status: string; durationMs: number }> = [];
- const cleanup = ctx.noorm.observer.on('change:complete', (data) => {
+ const cleanup = noormObserver.on('change:complete', (data) => {
events.push({
name: data.name,
@@ -99,13 +100,13 @@ describe('ctx.noorm.observer change:complete', () => {
});
-describe('ctx.noorm.observer regex pattern matching', () => {
+describe('noormObserver regex pattern matching', () => {
it('routes multiple file:* events through a /^file:/ subscription during run.dir', async () => {
const fileEvents: string[] = [];
- const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => {
+ const cleanup = noormObserver.on(/^file:/, (payload) => {
// RegExp listeners receive { event, data }.
const evt = payload.event;
@@ -140,13 +141,13 @@ describe('ctx.noorm.observer regex pattern matching', () => {
});
-describe('ctx.noorm.observer file:after status=failed', () => {
+describe('noormObserver file:after status=failed', () => {
it('emits file:after with status="failed" when the executed SQL throws', async () => {
const events: Array<{ filepath: string; status: string; error?: string }> = [];
- const cleanup = ctx.noorm.observer.on('file:after', (data) => {
+ const cleanup = noormObserver.on('file:after', (data) => {
events.push({
filepath: data.filepath,
diff --git a/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts b/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts
index 623ce3c2..6354bb52 100644
--- a/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts
+++ b/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts
@@ -24,8 +24,7 @@ beforeAll(async () => {
const status = await ctx.noorm.vault.status();
if (!status.isInitialized) {
- const [, err] = await ctx.noorm.vault.init();
- if (err) throw err;
+ await ctx.noorm.vault.init();
}
@@ -50,15 +49,13 @@ afterAll(async () => {
describe('ctx.noorm.vault.init', () => {
- it('reports already-initialized via the [result, err] tuple on repeat init', async () => {
+ it('returns null on repeat init and leaves status intact', async () => {
- // First call happened in beforeAll. A repeat init() returns a
- // non-null Error in the tuple's second slot ("Vault already
- // initialized") rather than throwing — callers can safely call
- // it without a try/catch and just check the tuple.
- const [, err] = await ctx.noorm.vault.init();
- expect(err).not.toBeNull();
- expect(err?.message).toContain('already initialized');
+ // First call happened in beforeAll. A repeat init() is idempotent
+ // — it returns null rather than treating "already initialized" as
+ // an error. Real failures (DB errors, encryption errors) throw.
+ const result = await ctx.noorm.vault.init();
+ expect(result).toBeNull();
const status = await ctx.noorm.vault.status();
expect(status.isInitialized).toBe(true);
@@ -72,8 +69,7 @@ describe('ctx.noorm.vault.set / get', () => {
it('stores a value and returns it via get()', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'test-value', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'test-value', privateKey);
const value = await ctx.noorm.vault.get(TEST_KEY, privateKey);
expect(value).toBe('test-value');
@@ -86,8 +82,7 @@ describe('ctx.noorm.vault.list', () => {
it('includes a key after it is set', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey);
const keys = await ctx.noorm.vault.list();
expect(Array.isArray(keys)).toBe(true);
@@ -99,13 +94,11 @@ describe('ctx.noorm.vault.list', () => {
describe('ctx.noorm.vault.delete', () => {
- it('returns [true, null] and exists() reports false afterwards', async () => {
+ it('returns true and exists() reports false afterwards', async () => {
- const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey);
- expect(setErr).toBeNull();
+ await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey);
- const [deleted, delErr] = await ctx.noorm.vault.delete(TEST_KEY);
- expect(delErr).toBeNull();
+ const deleted = await ctx.noorm.vault.delete(TEST_KEY);
expect(deleted).toBe(true);
const stillThere = await ctx.noorm.vault.exists(TEST_KEY);
diff --git a/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl b/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl
index ce2272e3..5482bee7 100644
--- a/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl
+++ b/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl
@@ -8,7 +8,10 @@
{% for (const flag of $.featureFlags) {
const secretName = flag.required_secret;
- const secretValue = secretName ? ($.secrets?.[secretName] ?? null) : null;
+ // `in` before read: $.secrets throws on an unresolved key so a missing
+ // secret can never render as the string "undefined". Probing for an
+ // optional secret has to say so explicitly.
+ const secretValue = secretName && (secretName in $.secrets) ? $.secrets[secretName] : null;
const hasSecret = !secretName || secretValue !== null;
const effectiveEnabled = !!flag.enabled && hasSecret;
const config = {
diff --git a/install.sh b/install.sh
index 1d21bd66..9d8436ed 100755
--- a/install.sh
+++ b/install.sh
@@ -4,10 +4,55 @@ set -e
# noorm installer
# Usage: curl -fsSL https://noorm.dev/install.sh | sh
# or: curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/install.sh | sh
+#
+# NOORM_INSECURE: set to a truthy value (anything other than empty, "0", or "false")
+# to skip checksum verification when checksums.txt is unreachable, has no entry
+# for this platform, or no sha256 tool is available. Does NOT bypass a confirmed
+# checksum mismatch -- a verified bad hash always aborts the install.
REPO="noormdev/noorm"
BINARY_NAME="noorm"
+# NOORM_INSECURE truthy check -- mirrors the TS isInsecureMode/isYesMode convention:
+# any value other than empty, "0", or "false" (case-insensitive) counts as truthy.
+is_insecure() {
+ case "$NOORM_INSECURE" in
+ ''|0|false|FALSE|False) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+# Compare $file's sha256 against its entry (matched by $asset name) in $checksums.
+# Sets $verify_result to "match", "mismatch", or "unverifiable" (no entry / no
+# hashing tool present -- the caller decides whether NOORM_INSECURE allows that).
+verify_checksum() {
+ file="$1"
+ checksums="$2"
+ asset="$3"
+
+ expected=$(awk -v a="$asset" '$2 == a { print $1 }' "$checksums" | head -n1)
+
+ if [ -z "$expected" ]; then
+ verify_result="unverifiable"
+ return
+ fi
+
+ if command -v shasum >/dev/null 2>&1; then
+ actual=$(shasum -a 256 "$file" | awk '{ print $1 }')
+ elif command -v sha256sum >/dev/null 2>&1; then
+ actual=$(sha256sum "$file" | awk '{ print $1 }')
+ else
+ verify_result="unverifiable"
+ return
+ fi
+
+ if [ "$expected" = "$actual" ]; then
+ verify_result="match"
+ else
+ verify_result="mismatch"
+ fi
+}
+
main() {
os=$(detect_os)
arch=$(detect_arch)
@@ -34,6 +79,45 @@ main() {
exit 1
fi
+ asset="noorm-${suffix}"
+ url_checksums="https://github.com/${REPO}/releases/download/${tag}/checksums.txt"
+ checksums_file=$(mktemp)
+
+ if curl -fsSL -o "$checksums_file" "$url_checksums"; then
+ have_checksums=1
+ else
+ have_checksums=0
+ fi
+
+ if [ "$have_checksums" -eq 1 ]; then
+ verify_checksum "$tmpfile" "$checksums_file" "$asset"
+ else
+ verify_result="unverifiable"
+ fi
+
+ case "$verify_result" in
+ match)
+ echo "Checksum verified for ${asset}."
+ ;;
+ mismatch)
+ echo "Error: checksum mismatch for ${asset} -- downloaded binary does not match checksums.txt." >&2
+ rm -f "$tmpfile" "$checksums_file"
+ exit 1
+ ;;
+ unverifiable)
+ if is_insecure; then
+ echo "Warning: could not verify checksum for ${asset} (checksums.txt unreachable, no matching entry, or no sha256 tool available) -- proceeding unverified because NOORM_INSECURE is set." >&2
+ else
+ echo "Error: could not verify checksum for ${asset} (checksums.txt unreachable, no matching entry, or no sha256 tool available)." >&2
+ echo "Set NOORM_INSECURE=1 to bypass this check and install unverified (never bypasses a confirmed mismatch)." >&2
+ rm -f "$tmpfile" "$checksums_file"
+ exit 1
+ fi
+ ;;
+ esac
+
+ rm -f "$checksums_file"
+
chmod +x "$tmpfile"
mkdir -p "$install_dir"
mv "$tmpfile" "${install_dir}/${BINARY_NAME}"
diff --git a/package.json b/package.json
index 4b699060..635688e6 100644
--- a/package.json
+++ b/package.json
@@ -16,12 +16,12 @@
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/cli/index.js",
- "start:init": "node dist/cli/index.js init",
"test": "bun test --serial",
"test:watch": "bun test --serial --watch",
"test:coverage": "bun test --serial --coverage",
"lint": "eslint src tests",
"lint:fix": "eslint src tests --fix",
+ "lint:docs": "bash scripts/check-flag-placement.sh",
"typecheck": "tsc --noEmit",
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
"clean": "rm -rf dist coverage",
@@ -39,7 +39,7 @@
"terminal"
],
"author": "",
- "license": "ISC",
+ "license": "MIT",
"engines": {
"node": ">=22.13",
"bun": ">=1.2"
@@ -58,17 +58,12 @@
"ansis": "^4.2.0",
"better-sqlite3": "^12.8.0",
"citty": "^0.2.2",
- "consola": "^3.4.2",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.20",
"eta": "^4.5.1",
"ink": "^6.8.0",
- "ink-select-input": "^6.2.0",
- "ink-spinner": "^5.0.0",
- "ink-text-input": "^6.0.0",
"json5": "^2.2.3",
"kysely": "^0.28.12",
- "node-machine-id": "^1.1.12",
"react": "^19.2.4",
"sql-parser-cst": "^0.39.0",
"voca": "^1.4.1",
diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE
new file mode 100644
index 00000000..ee92eec1
--- /dev/null
+++ b/packages/cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Danilo Alonso
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 1143d8c3..99bd9120 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -23,7 +23,7 @@
"cli",
"noorm"
],
- "license": "ISC",
+ "license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/noormdev/noorm"
diff --git a/packages/cli/scripts/postinstall.js b/packages/cli/scripts/postinstall.js
index d5456b86..3a8cb7df 100644
--- a/packages/cli/scripts/postinstall.js
+++ b/packages/cli/scripts/postinstall.js
@@ -8,7 +8,8 @@
* postinstall to fetch the correct binary for the user's OS and architecture.
*/
-import { createWriteStream, existsSync, chmodSync, mkdirSync } from 'fs';
+import { createHash } from 'crypto';
+import { createReadStream, createWriteStream, existsSync, chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'fs';
import { get as httpsGet } from 'https';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
@@ -18,6 +19,85 @@ const PACKAGE_ROOT = resolve(__dirname, '..');
const BIN_DIR = resolve(PACKAGE_ROOT, 'bin');
const REPO = 'noormdev/noorm';
+const ROOT_PACKAGE_NAME = '@noormdev/main';
+
+const BINARY_NAME = process.platform === 'win32' ? 'noorm.exe' : 'noorm';
+const DEST = resolve(BIN_DIR, BINARY_NAME);
+const DOWNLOAD_DEST = `${DEST}.download`;
+const CHECKSUMS_TMP = resolve(BIN_DIR, 'checksums.txt.tmp');
+
+/**
+ * A confirmed-bad or unverifiable binary. Distinguishes a hard-fail from
+ * every other (soft) failure so the top-level `main().catch()` can tell
+ * them apart without matching on error message text.
+ */
+class ChecksumFailure extends Error {
+
+ constructor(message) {
+
+ super(message);
+ this.name = 'ChecksumFailure';
+
+ }
+
+}
+
+/**
+ * Local mirror of `attemptSync` from `@logosdx/utils`, which this script
+ * cannot import: postinstall runs before the workspace is built, and in a
+ * consumer's tree the dependency need not be installed at all.
+ *
+ * Exists so the callers below keep the repo's error-tuple convention instead
+ * of ad-hoc try/catch control flow -- the one unavoidable try/catch is
+ * encapsulated here, exactly as the real utility encapsulates it.
+ */
+function attemptSync(fn) {
+
+ try {
+ return [fn(), null];
+ }
+ catch (err) {
+ return [null, err];
+ }
+
+}
+
+/**
+ * Detects the noorm monorepo source checkout, where this script must not run.
+ *
+ * The download is pinned to the release tag matching this package's version,
+ * which in a source checkout is routinely not a published release -- every
+ * commit after a version bump points at a tag that does not exist yet, and
+ * tags cut before the release workflow started emitting checksums.txt have
+ * nothing to verify against. Either way the download fails, and a failed
+ * verification is a hard failure by design (see main().catch below), so it
+ * takes `bun install` down with it.
+ *
+ * Nothing in the repo needs it: the CLI is built from source here
+ * (`bun run build:packages`, or `bun run build:binary` for a real binary) and
+ * the downloaded asset is never executed. Skipping is the whole fix.
+ *
+ * Keyed on the private workspace root two levels up, which the npm tarball
+ * never ships -- an installed copy lives at node_modules/@noormdev/cli, whose
+ * grandparent is node_modules itself and has no package.json.
+ */
+function isSourceCheckout() {
+
+ const rootPackage = resolve(PACKAGE_ROOT, '..', '..', 'package.json');
+
+ if (!existsSync(rootPackage)) {
+ return false;
+ }
+
+ // An unreadable or malformed package.json two levels up says nothing about
+ // being in the source repo -- fall through to the normal download path
+ // rather than silently skipping a consumer's install.
+ const [pkg] = attemptSync(() => JSON.parse(readFileSync(rootPackage, 'utf8')));
+
+ return pkg?.name === ROOT_PACKAGE_NAME;
+
+}
+
/**
* Resolves the platform suffix used in binary asset names.
*
@@ -107,26 +187,182 @@ function download(url, dest, redirects = 0) {
}
+/**
+ * Determine whether the user has opted out of binary checksum verification.
+ *
+ * Mirrors the TS `isInsecureMode` truthy-string parsing (src/cli/_utils.ts)
+ * so the escape hatch behaves identically from a shell's point of view:
+ * any non-empty NOORM_INSECURE value except `0` or `false` (case-insensitive)
+ * is truthy.
+ *
+ * This only ever widens the "we couldn't verify" case -- checksums.txt
+ * unreachable, or missing an entry for this asset -- into a warning. It
+ * never downgrades a confirmed checksum mismatch, which always fails.
+ */
+function isInsecureMode() {
+
+ const env = process.env.NOORM_INSECURE;
+
+ if (env === undefined || env === '') return false;
+ if (env === '0') return false;
+ if (env.toLowerCase() === 'false') return false;
+
+ return true;
+
+}
+
+/**
+ * Parse a `shasum -a 256`-style checksums file into an asset -> sha256 map.
+ *
+ * Mirrors src/core/update/checksum.ts's parseChecksums regex exactly (that
+ * module can't be imported here -- this script runs before the package is
+ * built). Matches the exact format the release workflow produces: a 64-char
+ * hex hash, whitespace, an optional `*` binary-mode marker, then the
+ * filename. Blank/malformed lines are skipped rather than thrown on.
+ */
+function parseChecksums(text) {
+
+ const out = {};
+
+ for (const line of text.split('\n')) {
+
+ const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
+
+ if (match && match[1] && match[2]) {
+ out[match[2]] = match[1].toLowerCase();
+ }
+
+ }
+
+ return out;
+
+}
+
+/**
+ * Compute the sha256 hex digest of a file on disk.
+ *
+ * Streams the file through the hash rather than buffering it whole --
+ * release binaries run tens of MB.
+ */
+function sha256File(path) {
+
+ return new Promise((resolve, reject) => {
+
+ const hash = createHash('sha256');
+ const stream = createReadStream(path);
+
+ stream.on('data', (chunk) => hash.update(chunk));
+ stream.on('end', () => resolve(hash.digest('hex')));
+ stream.on('error', reject);
+
+ });
+
+}
+
+/**
+ * Deletes the download-in-progress temp files, if present. Safe to call
+ * from any terminal path (success, hard-fail, soft-fail) -- guards on
+ * existsSync so a file that was never created, or already renamed away
+ * on success, is a no-op.
+ */
+function cleanupTempFiles() {
+
+ if (existsSync(DOWNLOAD_DEST)) unlinkSync(DOWNLOAD_DEST);
+ if (existsSync(CHECKSUMS_TMP)) unlinkSync(CHECKSUMS_TMP);
+
+}
+
+/**
+ * Verifies the freshly downloaded binary at DOWNLOAD_DEST against
+ * checksums.txt fetched from the same release tag.
+ *
+ * Same invariant as every other verification call site in this ticket
+ * (src/core/update/checksum.ts, install.sh): a confirmed hash mismatch is
+ * ALWAYS a hard failure -- NOORM_INSECURE cannot downgrade it. Only "we
+ * couldn't get a trustworthy answer" (checksums.txt unreachable, or
+ * fetched but missing an entry for this asset) is bypassable.
+ *
+ * @throws {ChecksumFailure} on a confirmed mismatch, or when verification
+ * could not complete and NOORM_INSECURE is not set.
+ */
+async function verifyChecksum(checksumsUrl, assetName) {
+
+ // A failed checksums.txt fetch is captured here, not thrown -- it
+ // folds into the "could not verify" branch below, which NOORM_INSECURE
+ // governs, same as a fetch that succeeds but has no entry for us.
+ const fetched = await download(checksumsUrl, CHECKSUMS_TMP)
+ .then(() => true)
+ .catch(() => false);
+
+ let expected;
+
+ if (fetched) {
+
+ const { readFile } = await import('fs/promises');
+ const text = await readFile(CHECKSUMS_TMP, 'utf8');
+ expected = parseChecksums(text)[assetName];
+
+ }
+
+ if (expected) {
+
+ const actual = await sha256File(DOWNLOAD_DEST);
+
+ if (actual.toLowerCase() !== expected.toLowerCase()) {
+
+ throw new ChecksumFailure(
+ `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})`
+ );
+
+ }
+
+ return;
+
+ }
+
+ if (isInsecureMode()) {
+
+ console.warn(
+ `Warning: could not verify checksum for ${assetName} (checksums.txt unreachable or missing ` +
+ 'an entry for this asset); proceeding unverified because NOORM_INSECURE is set.'
+ );
+
+ return;
+
+ }
+
+ throw new ChecksumFailure(
+ `could not verify checksum for ${assetName}: checksums.txt unreachable or has no entry for this asset ` +
+ '(set NOORM_INSECURE=1 to install anyway)'
+ );
+
+}
+
/**
* Main postinstall routine.
*
- * Downloads the correct binary, saves it to bin/, and makes it executable.
- * Exits cleanly on failure so npm install doesn't break.
+ * Downloads the correct binary to a temp path, verifies its checksum, and
+ * only then promotes it to bin/. Exits cleanly on failure so npm install
+ * doesn't break -- except a confirmed-bad or unverifiable binary, see
+ * main().catch() below.
*/
async function main() {
+ if (isSourceCheckout()) {
+ console.log('noorm source checkout detected - skipping release binary download (build one with `bun run build:binary`).');
+ return;
+ }
+
const suffix = getPlatformSuffix();
const version = await getVersion();
const assetName = `noorm-${suffix}`;
const tag = `@noormdev/cli@${version}`;
const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
-
- const binaryName = process.platform === 'win32' ? 'noorm.exe' : 'noorm';
- const dest = resolve(BIN_DIR, binaryName);
+ const checksumsUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.txt`;
// Skip if binary already exists (e.g. reinstall)
- if (existsSync(dest)) {
- console.log(`noorm binary already exists at ${dest}`);
+ if (existsSync(DEST)) {
+ console.log(`noorm binary already exists at ${DEST}`);
return;
}
@@ -134,18 +370,38 @@ async function main() {
console.log(`Downloading noorm ${version} for ${suffix}...`);
- await download(url, dest);
+ await download(url, DOWNLOAD_DEST);
+
+ // Verify BEFORE trusting the binary -- chmod+rename below is the point
+ // this script starts treating the file as the live noorm CLI.
+ await verifyChecksum(checksumsUrl, assetName);
if (process.platform !== 'win32') {
- chmodSync(dest, 0o755);
+ chmodSync(DOWNLOAD_DEST, 0o755);
}
+ renameSync(DOWNLOAD_DEST, DEST);
+ cleanupTempFiles();
+
console.log(`✓ noorm ${version} installed`);
}
main().catch((err) => {
+ cleanupTempFiles();
+
+ if (err instanceof ChecksumFailure) {
+
+ // Deliberate, scoped exception to this script's "never fail npm
+ // install" philosophy: every other failure mode below still exits
+ // 0, but a confirmed-bad or unverifiable binary must not be
+ // silently trusted and installed as the user's noorm CLI.
+ console.error(`Error: ${err.message}`);
+ process.exit(1);
+
+ }
+
console.error(`Warning: Could not download noorm binary: ${err.message}`);
console.error('You can download it manually from:');
console.error(` https://github.com/${REPO}/releases`);
diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE
new file mode 100644
index 00000000..ee92eec1
--- /dev/null
+++ b/packages/sdk/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Danilo Alonso
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 9e70e0c5..a2672a78 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -13,7 +13,8 @@
}
},
"files": [
- "dist"
+ "dist",
+ "!dist/**/*.map"
],
"sideEffects": false,
"peerDependencies": {
@@ -52,7 +53,7 @@
"kysely",
"noorm"
],
- "license": "ISC",
+ "license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/noormdev/noorm"
diff --git a/scripts/check-flag-placement.sh b/scripts/check-flag-placement.sh
new file mode 100755
index 00000000..cdf23ae6
--- /dev/null
+++ b/scripts/check-flag-placement.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+# check-flag-placement.sh - guard against root-level flags (other than -c/--cwd) in docs
+#
+# `extractGlobalCwd` (src/cli/index.ts) hoists only `-c`/`--cwd` out of argv
+# before citty dispatch. citty forwards only `rawArgs.slice(subCommandArgIndex
+# + 1)` to the resolved subcommand, so every other flag typed before the
+# subcommand — `--config`, `--force`, `--dry-run`, `--json`, `--yes`/`-y` — is
+# rejected outright with "Unrecognized flag … before the subcommand". Only
+# placing the flag after the subcommand (`noorm run build --dry-run`) works.
+#
+# `-c`/`--cwd` is the sole exception, for two reasons: it is consumed before
+# dispatch (it sets the working directory everything else resolves against),
+# and `-c` already means `--config` after the subcommand — see the
+# "`--config` / `-c` overload" note in docs/cli/flags.md. Hoisting any other
+# flag would either collide with that overload or just add a declaration
+# site without a reason: a flag goes on the command that uses it.
+#
+# This script fails the build if a broken root-level form reappears in
+# doc/skill/example prose outside the files that deliberately show it as a
+# contrasted gotcha ("does not work" vs. "works") rather than instructing
+# the reader to actually type it.
+#
+# Usage:
+# bash scripts/check-flag-placement.sh
+#
+set -e
+
+PATTERNS=('noorm --config ' 'noorm --force' 'noorm --dry-run' 'noorm --json' 'noorm --yes' 'noorm -y ')
+TARGETS=(README.md docs skills examples)
+
+# Files that intentionally show the broken form, contrasted directly against
+# the correct form under a "does not work"/overload-explainer heading:
+# - docs/guide/troubleshooting.md: the flag-placement entry contrasts broken vs. working forms
+# - docs/cli/flags.md: the placement explainer and `--config`/`-c` overload section contrast the two forms
+# - docs/design/v1-49-54-cli-field-defects.md: this checkpoint's own design doc, which
+# narrates the "form that cannot work" as the defect under investigation
+# - examples/llm-memory-db-pg/{REPORT.md,REPORT-PHASE-1.md,postgres-problems.md}: dated
+# investigation logs from a consuming example project (predate this branch — see git log),
+# already stating the "must come after the subcommand" rule as a quirk they hit
+# - examples/llm-memory-db-mssql/mssql-problems.md: same — a dated repro transcript, not
+# instructional prose, predating this branch
+EXEMPT_REGEX='^(docs/guide/troubleshooting\.md|docs/cli/flags\.md|docs/design/v1-49-54-cli-field-defects\.md|examples/llm-memory-db-pg/(REPORT\.md|REPORT-PHASE-1\.md|postgres-problems\.md)|examples/llm-memory-db-mssql/mssql-problems\.md):'
+
+FAILED=0
+
+for PATTERN in "${PATTERNS[@]}"; do
+
+ MATCHES=$(grep -rn -- "$PATTERN" "${TARGETS[@]}" 2>/dev/null | grep -Ev "$EXEMPT_REGEX") || true
+
+ if [ -n "$MATCHES" ]; then
+
+ echo "Found root-level '$PATTERN' placement (broken — flag must come after the subcommand):"
+ echo "$MATCHES"
+ FAILED=1
+
+ fi
+
+done
+
+if [ "$FAILED" -ne 0 ]; then
+ exit 1
+fi
+
+echo "OK: no broken root-level flag placement found."
diff --git a/scripts/install.sh b/scripts/install.sh
deleted file mode 100755
index 4d45fe17..00000000
--- a/scripts/install.sh
+++ /dev/null
@@ -1,175 +0,0 @@
-#!/bin/sh
-# noorm CLI installer
-#
-# Usage:
-# curl -fsSL https://noorm.dev/install.sh | sh
-# curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/scripts/install.sh | sh
-#
-# Options (via environment):
-# NOORM_VERSION=1.0.0 Pin a specific version (default: latest)
-# NOORM_INSTALL_DIR=~/.noorm/bin Override install directory
-#
-set -e
-
-REPO="noormdev/noorm"
-INSTALL_DIR="${NOORM_INSTALL_DIR:-$HOME/.noorm/bin}"
-BINARY_NAME="noorm"
-
-# --- Helpers ---
-
-info() {
- printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2"
-}
-
-success() {
- printf ' \033[1;32m✓\033[0m %s\n' "$1"
-}
-
-fail() {
- printf ' \033[1;31m✗\033[0m %s\n' "$1" >&2
- exit 1
-}
-
-# --- Detect platform ---
-
-detect_platform() {
-
- OS="$(uname -s)"
- ARCH="$(uname -m)"
-
- case "$OS" in
- Darwin) OS="darwin" ;;
- Linux) OS="linux" ;;
- MINGW*|MSYS*|CYGWIN*)
- fail "Windows is not supported by this installer. Download the binary from:"
- fail " https://github.com/$REPO/releases"
- ;;
- *)
- fail "Unsupported operating system: $OS"
- ;;
- esac
-
- case "$ARCH" in
- x86_64|amd64) ARCH="x64" ;;
- arm64|aarch64) ARCH="arm64" ;;
- *)
- fail "Unsupported architecture: $ARCH"
- ;;
- esac
-
-}
-
-# --- Resolve version ---
-
-resolve_version() {
-
- if [ -n "$NOORM_VERSION" ]; then
- VERSION="$NOORM_VERSION"
- info "Version:" "$VERSION (pinned)"
- return
- fi
-
- info "Resolving:" "latest version..."
-
- # Find the latest @noormdev/cli release tag
- VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" \
- | grep -o '"tag_name": *"@noormdev/cli@[^"]*"' \
- | head -1 \
- | sed 's/.*@noormdev\/cli@//' \
- | sed 's/"//')
-
- if [ -z "$VERSION" ]; then
- fail "Could not determine latest version. Set NOORM_VERSION manually."
- fi
-
- info "Version:" "$VERSION"
-
-}
-
-# --- Download ---
-
-download_binary() {
-
- ASSET_NAME="noorm-${OS}-${ARCH}"
- TAG="@noormdev/cli@${VERSION}"
- DOWNLOAD_URL="https://github.com/$REPO/releases/download/${TAG}/${ASSET_NAME}"
-
- info "Platform:" "${OS}-${ARCH}"
- info "Downloading:" "$DOWNLOAD_URL"
-
- TMPDIR_DL="$(mktemp -d)"
- TMPFILE="${TMPDIR_DL}/${BINARY_NAME}"
-
- HTTP_CODE=$(curl -fsSL -w '%{http_code}' -o "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null || true)
-
- if [ "$HTTP_CODE" != "200" ] || [ ! -s "$TMPFILE" ]; then
- rm -rf "$TMPDIR_DL"
- fail "Download failed (HTTP $HTTP_CODE). Check that version $VERSION has a binary for ${OS}-${ARCH}."
- fi
-
-}
-
-# --- Install ---
-
-install_binary() {
-
- mkdir -p "$INSTALL_DIR"
- mv "$TMPFILE" "${INSTALL_DIR}/${BINARY_NAME}"
- chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
- rm -rf "$TMPDIR_DL"
-
- success "Installed noorm to ${INSTALL_DIR}/${BINARY_NAME}"
-
-}
-
-# --- PATH check ---
-
-check_path() {
-
- case ":$PATH:" in
- *":${INSTALL_DIR}:"*)
- # Already in PATH
- ;;
- *)
- echo ""
- info "Add to PATH:" "Add this to your shell profile (~/.zshrc, ~/.bashrc, etc.):"
- echo ""
- echo " export PATH=\"${INSTALL_DIR}:\$PATH\""
- echo ""
- ;;
- esac
-
-}
-
-# --- Verify ---
-
-verify_install() {
-
- if [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
- success "noorm $VERSION is ready"
- else
- fail "Installation verification failed"
- fi
-
-}
-
-# --- Main ---
-
-main() {
-
- echo ""
- echo " noorm installer"
- echo ""
-
- detect_platform
- resolve_version
- download_binary
- install_binary
- check_path
- verify_install
-
- echo ""
-
-}
-
-main
diff --git a/skills/noorm/SKILL.md b/skills/noorm/SKILL.md
index a8d895f4..4c527245 100644
--- a/skills/noorm/SKILL.md
+++ b/skills/noorm/SKILL.md
@@ -1,6 +1,6 @@
---
name: noorm
-description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, or change/revert directory structures. Trigger for any database task in a noorm-managed project — the SDK conventions (error tuples, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right.
+description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, or change/revert directory structures. Trigger for any database task in a noorm-managed project — the SDK conventions (SDK methods throw named errors, `attempt()` wraps deliberately, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right.
---
# NoORM
@@ -48,6 +48,23 @@ Priority chain (highest wins):
Env-only mode (no stored config needed): set `NOORM_CONNECTION_DIALECT` + `NOORM_CONNECTION_DATABASE` as environment variables. This is the standard approach for CI/CD pipelines.
+### Error Handling
+
+SDK methods throw named errors and let them propagate — this is the primary contract, not an
+opt-in. Wrap a call in `attempt()`/`attemptSync()` only when you're going to do something with
+the error (translate it, recover, observe, or knowingly ignore it); if you'd just re-throw it
+unchanged, skip `attempt` and let it propagate.
+
+Carve-outs that don't follow the throw contract:
+
+- `ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design — a connection probe
+ reporting failure as data, not an exception.
+- Transaction callbacks (`ctx.transaction(...)`) must throw to roll back — that's Kysely's own
+ transaction contract.
+- Raw `ctx.kysely` queries throw like any Kysely call.
+
+Never wrap a `ctx.noorm.*` call in try-catch.
+
## Common Mistakes
These are the patterns LLMs get wrong most often with noorm:
@@ -61,5 +78,5 @@ These are the patterns LLMs get wrong most often with noorm:
| `ctx.proc()` on SQLite | Check `ctx.dialect` first | SQLite has no stored procedures, functions, or TVFs |
| `ctx.tvf()` on MySQL | Check `ctx.dialect` first | MySQL doesn't support TVFs either |
| `Procs = { name: ArgsType }` | `Procs = { name: [ArgsType, ReturnType] }` | Tuple format enables return type inference |
-| Forget to check error tuples | Always check `err` before using `result` | Silent failures otherwise |
+| Assume every `ctx.noorm.*` call returns a tuple | SDK methods throw named errors — wrap with `attempt()` only when you'll do something with the error | Unhandled throws stop the caller immediately; that's the contract |
| Use raw Kysely without noorm | Use `ctx.kysely` from a noorm Context | Misses config resolution, checksums, change tracking |
diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md
index 2041316a..dc215ee1 100644
--- a/skills/noorm/references/cli.md
+++ b/skills/noorm/references/cli.md
@@ -71,7 +71,6 @@ NOORM_PATHS_CHANGES # Changes directory (default: ./changes)
```bash
NOORM_CONFIG # Config name to use (alternative to --config flag)
NOORM_YES # Skip confirmations: 1 or true
-NOORM_JSON # Force JSON output: 1 or true
NOORM_HEADLESS # Signal CI mode (routes logs to stdout; detected automatically in CI)
NOORM_DEBUG # Enable debug logging
```
@@ -101,7 +100,7 @@ Set the active configuration.
```bash
noorm config use dev
-noorm --json config use production
+noorm config use production --json
```
**JSON:** `{ "activeConfig": "production" }`
@@ -115,7 +114,7 @@ Create a new cryptographic identity. Generates an X25519 keypair and stores it a
```bash
noorm identity init --name "Alice" --email "alice@example.com"
noorm identity init --name "Alice" --email "alice@example.com" --force
-noorm --json identity init --name "Alice" --email "alice@example.com"
+noorm identity init --name "Alice" --email "alice@example.com" --json
```
**JSON:** `{ "name": "Alice", "email": "alice@example.com", "fingerprint": "...", "publicKey": "..." }`
@@ -135,7 +134,7 @@ Print your public key so teammates can add you to encrypted vaults.
```bash
noorm identity export
-noorm --json identity export
+noorm identity export --json
```
### identity list
@@ -144,7 +143,7 @@ List all known users discovered from database syncs (the audit trail of who has
```bash
noorm identity list
-noorm --json identity list
+noorm identity list --json
```
---
@@ -290,8 +289,8 @@ Execute all SQL files in the schema directory. Uses checksums to skip unchanged
```bash
noorm run build
-noorm --force run build # Skip checksums, rebuild everything
-noorm --dry-run run build # Preview without executing
+noorm run build --force # Skip checksums, rebuild everything
+noorm run build --dry-run # Preview without executing
```
**JSON:**
@@ -313,7 +312,7 @@ Execute a single SQL or `.sql.tmpl` file.
```bash
noorm run file sql/01_tables/001_users.sql
noorm run file seed.sql.tmpl
-noorm --json run file sql/init.sql
+noorm run file sql/init.sql --json
```
**JSON:** `{ "filepath": "seed.sql", "status": "success", "durationMs": 45 }`
@@ -335,7 +334,7 @@ Show the template context for a `.sql.tmpl` file without rendering it. Lists ava
```bash
noorm run inspect sql/users/001_create.sql.tmpl
-noorm --json run inspect sql/core/Crons.sql.tmpl
+noorm run inspect sql/core/Crons.sql.tmpl --json
```
**JSON:**
@@ -362,8 +361,8 @@ Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute aga
```bash
noorm run preview sql/schema.sql.tmpl # Raw SQL to stdout
noorm run preview sql/schema.sql.tmpl > rendered.sql # Pipe to file
-noorm --json run preview sql/seed.sql.tmpl # JSON envelope
-noorm --config staging run preview sql/migrations/002.sql.tmpl
+noorm run preview sql/seed.sql.tmpl --json # JSON envelope
+noorm run preview sql/migrations/002.sql.tmpl --config staging
```
**JSON:** `{ "filepath": "...", "sql": "CREATE TABLE ...", "durationMs": 12 }`
@@ -394,7 +393,7 @@ List every known change with its status (pending, success, failed, reverted, sta
```bash
noorm change list
-noorm --json change list
+noorm change list --json
noorm -c staging change list
```
@@ -406,8 +405,8 @@ Fast-forward: apply all pending changes in order.
```bash
noorm change ff
-noorm --dry-run change ff # Preview only
-noorm --force change ff # Skip checksum checks
+noorm change ff --dry-run # Preview only
+noorm change ff --force # Skip checksum checks
```
**JSON:**
@@ -430,7 +429,7 @@ Apply the next pending change only (rather than all of them).
```bash
noorm change next
-noorm --json change next
+noorm change next --json
```
Useful when you want to apply changes one at a time and verify each before continuing.
@@ -442,7 +441,7 @@ Apply a specific change by name. Omit the name on a TTY to pick from pending / r
```bash
noorm change run # Interactive picker
noorm change run 2024-02-01-notifications
-noorm --json change run 001_init
+noorm change run 001_init --json
noorm -c staging change run 001_init
```
@@ -467,7 +466,7 @@ Revert a previously applied change by running its rollback SQL. Omit the name on
```bash
noorm change revert # Interactive picker
noorm change revert 2024-02-01-notifications
-noorm --json change revert 002_users
+noorm change revert 002_users --json
```
### change rewind
@@ -478,7 +477,7 @@ Revert applied changes back to (and including) a named change — the inverse of
noorm change rewind # Interactive picker
noorm change rewind 001_init
noorm change rewind 002_users --dry-run
-noorm --json change rewind 003_roles
+noorm change rewind 003_roles --json
```
### change add
@@ -522,7 +521,7 @@ View execution history with timestamps, direction, duration, and identity.
```bash
noorm change history
noorm --count 50 change history
-noorm --json change history
+noorm change history --json
```
**JSON:**
@@ -551,7 +550,7 @@ Per-file execution history for a specific change — every operation record plus
noorm change history-detail # Interactive picker
noorm change history-detail 001_init
noorm change history-detail 003_roles --count 5
-noorm --json change history-detail 002_users
+noorm change history-detail 002_users --json
```
---
@@ -564,7 +563,7 @@ Database schema inspection.
noorm db explore # Overview counts
noorm db explore tables # List all tables
noorm db explore tables detail users # Describe specific table
-noorm --json db explore # JSON overview
+noorm db explore --json # JSON overview
```
**JSON (overview):** `{ "tables": 12, "views": 3, "indexes": 8, "functions": 2, "procedures": 0 }`
@@ -586,7 +585,7 @@ noorm --json db explore # JSON overview
Wipe all data from application tables. Schema and noorm tracking tables are preserved.
```bash
-noorm -y db truncate
+noorm db truncate -y
```
### db teardown
@@ -594,7 +593,7 @@ noorm -y db truncate
Drop all database objects. Gated by the config's `db:reset` access role: `viewer` denied, `operator` must confirm (`--yes`/`NOORM_YES=1`), `admin` runs unconfirmed. No flag overrides a `viewer` denial.
```bash
-noorm -y db teardown
+noorm db teardown -y
```
### db transfer
@@ -698,7 +697,7 @@ Execute raw SQL queries directly.
noorm sql "SELECT * FROM users LIMIT 10"
noorm sql -f query.sql # Read from file
noorm -c prod sql "SELECT count(*) FROM orders"
-noorm --json sql "SELECT id, name FROM users"
+noorm sql "SELECT id, name FROM users" --json
```
**JSON:**
@@ -734,7 +733,7 @@ Show recent SQL execution history for a config. Reads persisted history from `.n
```bash
noorm sql history
noorm sql history -n 20
-noorm --json sql history
+noorm sql history --json
noorm -c prod sql history
```
@@ -756,7 +755,7 @@ Project and database status overview.
```bash
noorm info
-noorm --json info
+noorm info --json
```
Surfaces: CLI version, schema versions, active config, connection details, identity, database object counts.
@@ -767,17 +766,17 @@ CLI version and diagnostic information.
```bash
noorm version
-noorm --json version
+noorm version --json
```
-### help
+### --help
-Show help for any command.
+Show help for any command. There is no `help` subcommand — append `--help` (or `-h`) to any command instead.
```bash
-noorm help
-noorm help config use
-noorm help change ff
+noorm --help
+noorm config use --help
+noorm change ff --help
```
---
@@ -948,7 +947,7 @@ trap "noorm lock release" EXIT
noorm change ff
# Verify
-noorm --json db explore
+noorm db explore --json
```
## Scripting Patterns
@@ -956,7 +955,7 @@ noorm --json db explore
### Check for pending changes before deploying
```bash
-pending=$(noorm --json change | jq '[.[] | select(.status == "pending")] | length')
+pending=$(noorm change --json | jq '[.[] | select(.status == "pending")] | length')
if [ "$pending" -gt 0 ]; then
echo "Found $pending pending changes"
noorm change ff
@@ -966,14 +965,14 @@ fi
### Verify table count after migration
```bash
-tables=$(noorm --json db explore | jq '.tables')
+tables=$(noorm db explore --json | jq '.tables')
echo "Database has $tables tables"
```
### Check for failures in change history
```bash
-failures=$(noorm --json change history | jq '[.[] | select(.status == "failed")] | length')
+failures=$(noorm change history --json | jq '[.[] | select(.status == "failed")] | length')
if [ "$failures" -gt 0 ]; then
echo "WARNING: $failures failed changes in history"
exit 1
@@ -989,14 +988,14 @@ noorm run preview sql/hotfix.sql.tmpl | psql -h localhost -d myapp
### Check template context before rendering
```bash
-files=$(noorm --json run inspect sql/seed.sql.tmpl | jq '.context.dataFiles | length')
+files=$(noorm run inspect sql/seed.sql.tmpl --json | jq '.context.dataFiles | length')
echo "Template has $files data files available"
```
### Check vault access and propagate if needed
```bash
-pending=$(noorm --json vault list | jq '.status.usersWithoutAccess')
+pending=$(noorm vault list --json | jq '.status.usersWithoutAccess')
if [ "$pending" -gt 0 ]; then
noorm vault propagate
fi
diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md
index 1cef8d0b..2b6e8b14 100644
--- a/skills/noorm/references/sdk.md
+++ b/skills/noorm/references/sdk.md
@@ -369,22 +369,22 @@ Lock options: `timeout` (lock duration, default 5min), `wait` (block until avail
Team-shared encrypted secrets stored in the database.
```typescript
-await ctx.noorm.vault.init();
+await ctx.noorm.vault.init(); // Buffer | null — null when already initialized (idempotent, not an error)
const status = await ctx.noorm.vault.status();
// CRUD (requires private key for encryption/decryption)
-const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey);
+await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey); // throws on failure
const value = await ctx.noorm.vault.get('API_KEY', privateKey); // string | null
const all = await ctx.noorm.vault.getAll(privateKey); // Record
const keys = await ctx.noorm.vault.list(); // string[]
-const [deleted, err] = await ctx.noorm.vault.delete('API_KEY'); // [boolean, Error | null]
+const deleted = await ctx.noorm.vault.delete('API_KEY'); // boolean — throws on failure
const exists = await ctx.noorm.vault.exists('API_KEY');
// Team access management
await ctx.noorm.vault.propagate(privateKey);
// Copy between configs
-const [result, err] = await ctx.noorm.vault.copy(destConfig, keys, privateKey, {
+const result = await ctx.noorm.vault.copy(destConfig, keys, privateKey, {
force: true, // Overwrite existing
});
```
@@ -413,15 +413,15 @@ const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl');
Data transfer between database configurations.
```typescript
-// Execute transfer
-const [result, err] = await ctx.noorm.transfer.to(destConfig, {
+// Execute transfer — throws on failure
+const result = await ctx.noorm.transfer.to(destConfig, {
tables: ['users', 'posts'], // Specific tables (default: all)
onConflict: 'skip', // 'fail' | 'skip' | 'update' | 'replace'
truncate: false, // Clear destination first?
});
-// Plan without executing
-const [plan, err] = await ctx.noorm.transfer.plan(destConfig, options);
+// Plan without executing — throws on failure
+const plan = await ctx.noorm.transfer.plan(destConfig, options);
```
### dt
@@ -429,16 +429,16 @@ const [plan, err] = await ctx.noorm.transfer.plan(destConfig, options);
Portable data files: `.dt` (plain), `.dtz` (compressed), `.dtzx` (encrypted).
```typescript
-// Export
-const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz', {
+// Export — throws on failure
+const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz', {
passphrase: 'secret', // Use .dtzx encryption
schema: 'public', // PostgreSQL schema
batchSize: 5000, // Rows per batch (default: 1000)
});
// result: { rowsWritten, bytesWritten }
-// Import
-const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', {
+// Import — throws on failure
+const result = await ctx.noorm.dt.importFile('./exports/users.dtz', {
onConflict: 'skip', // 'fail' | 'skip' | 'update' | 'replace'
truncate: true, // Clear table first
batchSize: 1000,
@@ -610,35 +610,39 @@ import {
## Observer Events
-Subscribe to real-time progress events emitted by core operations:
+Subscribe to real-time progress events emitted by core operations via the
+process-global `noormObserver` bus — a singleton shared across every
+`Context` in the process, not scoped to one `ctx`:
```typescript
+import { noormObserver } from '@noormdev/sdk';
+
// File execution progress
-ctx.noorm.observer.on('file:before', (data) => {
+noormObserver.on('file:before', (data) => {
console.log('Running:', data.filepath);
});
-ctx.noorm.observer.on('file:after', (data) => {
+noormObserver.on('file:after', (data) => {
console.log(data.filepath, data.status, data.durationMs + 'ms');
});
-ctx.noorm.observer.on('file:skip', (data) => {
+noormObserver.on('file:skip', (data) => {
console.log('Skipped:', data.filepath, data.reason);
});
// Change lifecycle
-ctx.noorm.observer.on('change:start', (data) => {
+noormObserver.on('change:start', (data) => {
console.log(`Applying ${data.name} (${data.files.length} files)`);
});
-ctx.noorm.observer.on('change:complete', (data) => {
+noormObserver.on('change:complete', (data) => {
console.log(data.name, data.direction, data.status);
});
// Build progress
-ctx.noorm.observer.on('build:start', (data) => {
+noormObserver.on('build:start', (data) => {
console.log(`Building ${data.fileCount} files from ${data.sqlPath}`);
});
// Pattern matching for multiple events
-ctx.noorm.observer.on(/^file:/, ({ event, data }) => {
+noormObserver.on(/^file:/, ({ event, data }) => {
console.log(`[${event}]`, data);
});
```
diff --git a/src/cli/_utils.ts b/src/cli/_utils.ts
index 7cbd137d..aeb43613 100644
--- a/src/cli/_utils.ts
+++ b/src/cli/_utils.ts
@@ -20,7 +20,7 @@ import { createContext } from '../sdk/index.js';
import { ensureSchemaVersion, type NoormDatabase } from '../core/version/index.js';
import { loadPrivateKey, loadIdentityMetadata } from '../core/identity/storage.js';
import { registerIdentity } from '../core/identity/sync.js';
-import { isDev } from '../core/environment.js';
+import { isDev, isEnvTruthy } from '../core/environment.js';
import { getConfig } from '../core/config/index.js';
/**
@@ -33,6 +33,7 @@ export interface CliArgs {
force?: boolean;
dryRun?: boolean;
yes?: boolean;
+ insecure?: boolean;
[key: string]: unknown;
}
@@ -74,9 +75,32 @@ export const sharedArgs = {
*/
export function isYesMode(args: CliArgs): boolean {
- if (args.yes) return true;
+ return !!args.yes || isEnvTruthy(process.env['NOORM_YES']);
- const env = process.env['NOORM_YES'];
+}
+
+/**
+ * Determine whether the user has opted out of binary checksum verification.
+ *
+ * Returns true when either the `--insecure` flag is set or the
+ * `NOORM_INSECURE` environment variable holds a truthy value. Mirrors
+ * `isYesMode`'s exact truthy-string parsing so both escape hatches behave
+ * identically from a shell's point of view.
+ *
+ * This only ever widens the "we couldn't verify" case — checksums.txt
+ * unreachable — into a warning. It never downgrades a confirmed checksum
+ * mismatch, which always fails regardless of this flag.
+ *
+ * @example
+ * if (isInsecureMode(args)) {
+ * process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable.\n');
+ * }
+ */
+export function isInsecureMode(args: CliArgs): boolean {
+
+ if (args.insecure) return true;
+
+ const env = process.env['NOORM_INSECURE'];
if (env === undefined || env === '') return false;
if (env === '0') return false;
@@ -134,6 +158,7 @@ async function createCliLogger(projectRoot: string, json: boolean): Promise(opts: {
const logger = await createCliLogger(projectRoot, !!args.json);
await logger.start();
- const [ctx, ctxError] = await attempt(() => createContext({ config: args.config }));
+ const [ctx, ctxError] = await attempt(() => createContext({ config: args.config, yes: isYesMode(args) }));
if (ctxError) {
outputError(args, `Failed to create context: ${ctxError.message}`, logger);
@@ -330,9 +355,12 @@ export async function withVaultContext(opts: {
/**
* Output a success result as either JSON or text.
*
- * When logger is provided and args.json is false, the text message is
- * routed through logger.info so it appears in the same stream as event
- * output. Otherwise it writes directly to stdout.
+ * The result always goes to stdout, in every mode — that is what
+ * distinguishes a command's result from the logger's event stream, which
+ * always goes to stderr (see Logger#writeConsole). `--json` funnels
+ * through `logger.result()` when a logger is available (stdout + log
+ * file); plain text writes directly to stdout, never through
+ * `logger.info`, so it can't be pulled onto the diagnostics stream.
*/
export function outputResult(
args: CliArgs,
@@ -357,16 +385,7 @@ export function outputResult(
}
else {
- if (logger) {
-
- logger.info(text);
-
- }
- else {
-
- process.stdout.write(text + '\n');
-
- }
+ process.stdout.write(text + '\n');
}
diff --git a/src/cli/change/history.ts b/src/cli/change/history.ts
index 8be6c18b..6eb2ba93 100644
--- a/src/cli/change/history.ts
+++ b/src/cli/change/history.ts
@@ -25,37 +25,17 @@ const historyCommand = defineCommand({
const [history, error] = await withContext({
args,
- fn: (ctx, logger) => {
-
- return ctx.noorm.changes.history(count).then((records) => {
-
- if (!args.json) {
-
- logger.info(`Execution History: ${records.length} records`);
-
- for (const record of records) {
-
- const date = new Date(record.executedAt).toLocaleString();
- logger.info(` ${record.name} - ${record.status} (${date})`);
-
- }
-
- }
-
- return records;
-
- });
-
- },
+ fn: (ctx) => ctx.noorm.changes.history(count),
});
if (error) process.exit(1);
- if (args.json) {
-
- outputResult(args, history, '');
+ const text = [
+ `Execution History: ${history.length} records`,
+ ...history.map((record) => ` ${record.name} - ${record.status} (${new Date(record.executedAt).toLocaleString()})`),
+ ].join('\n');
- }
+ outputResult(args, history, text);
process.exit(0);
diff --git a/src/cli/change/list.ts b/src/cli/change/list.ts
index 949f5170..92a2fef5 100644
--- a/src/cli/change/list.ts
+++ b/src/cli/change/list.ts
@@ -23,42 +23,21 @@ const listCommand = defineCommand({
const [changes, error] = await withContext({
args,
- fn: (ctx, logger) => {
-
- return ctx.noorm.changes.status().then((res) => {
-
- if (!args.json) {
-
- for (const cs of res) {
-
- logger.info(`${cs.name} (${cs.status})`);
-
- }
-
- const pending = res.filter((c) => c.status === 'pending').length;
-
- if (pending > 0) {
-
- logger.info(`${pending} pending change(s)`);
-
- }
-
- }
-
- return res;
-
- });
-
- },
+ fn: (ctx) => ctx.noorm.changes.status(),
});
if (error) process.exit(1);
- if (args.json) {
+ const pending = changes.filter((c) => c.status === 'pending').length;
- outputResult(args, changes, '');
+ const text = changes.length === 0
+ ? 'No changes found.'
+ : [
+ ...changes.map((cs) => `${cs.name} (${cs.status})`),
+ ...(pending > 0 ? [`${pending} pending change(s)`] : []),
+ ].join('\n');
- }
+ outputResult(args, changes, text);
process.exit(0);
diff --git a/src/cli/change/rewind.ts b/src/cli/change/rewind.ts
index e3bce86e..a9fd0e03 100644
--- a/src/cli/change/rewind.ts
+++ b/src/cli/change/rewind.ts
@@ -67,7 +67,7 @@ const rewindCommand = defineCommand({
const summaryMsg = `Rewind: ${res.status} (${res.executed} reverted, ${res.failed} failed)`;
- if (res.status === 'failed') {
+ if (res.status !== 'success') {
logger.error(summaryMsg);
@@ -116,7 +116,7 @@ const rewindCommand = defineCommand({
}
- process.exit(result.status === 'failed' ? 2 : 0);
+ process.exit(result.status === 'success' ? 0 : 2);
},
});
diff --git a/src/cli/change/rm.ts b/src/cli/change/rm.ts
index 624739b8..ff1baa53 100644
--- a/src/cli/change/rm.ts
+++ b/src/cli/change/rm.ts
@@ -4,21 +4,30 @@
* Offline operation: no database connection required. Reads settings
* to locate the changes directory, then removes the named change.
*
- * On a TTY the command prompts the user to pick a change (if omitted)
- * and confirms the deletion interactively; `--yes` skips the confirm.
- * On a non-TTY (CI, piped) both the name and `--yes` are required so
- * the command never hangs or deletes silently.
+ * Gated by the resolved config change:rm access role, best-effort:
+ * when an active or --config config is resolvable, viewer is denied
+ * outright and operator/admin require confirmation. When no config is
+ * resolvable at all (fresh project, config add never run), the gate
+ * is a no-op and confirmation defaults to required, matching the
+ * pre-ticket behavior of this command.
+ *
+ * Confirmation has no TTY prompt substitute: --yes or NOORM_YES is the
+ * only mechanism, mirroring the headless-only confirmation stance used
+ * by config rm. On a TTY the command still prompts the user to pick a
+ * change when the name is omitted; that picker is unrelated UX, not
+ * the confirm gate.
*/
import { join } from 'node:path';
import { stat } from 'node:fs/promises';
-import * as p from '@clack/prompts';
import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { deleteChange } from '../../core/change/index.js';
import { getSettingsManager } from '../../core/settings/index.js';
-import { outputResult, outputError, sharedArgs } from '../_utils.js';
+import { initState, getStateManager } from '../../core/state/index.js';
+import { checkConfigPolicy } from '../../core/policy/index.js';
+import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js';
import { selectChangeFromFs, requireTty } from './_prompt.js';
const rmCommand = defineCommand({
@@ -29,12 +38,36 @@ const rmCommand = defineCommand({
description: 'Change name to delete (omit to pick interactively on a TTY)',
required: false,
},
+ config: sharedArgs.config,
yes: sharedArgs.yes,
json: sharedArgs.json,
},
async run({ args }) {
const projectRoot = process.cwd();
+
+ const [, initErr] = await attempt(() => initState(projectRoot));
+
+ if (initErr) {
+
+ outputError(args, `Failed to load state: ${initErr.message}`);
+ process.exit(1);
+
+ }
+
+ const stateManager = getStateManager(projectRoot);
+ const configName = args.config ?? stateManager.getActiveConfigName();
+ const config = configName ? stateManager.getConfig(configName) : null;
+
+ const check = config ? checkConfigPolicy('user', config, 'change:rm') : null;
+
+ if (check && !check.allowed) {
+
+ outputError(args, check.blockedReason ?? `Config "${configName}" cannot delete changes.`);
+ process.exit(1);
+
+ }
+
const settingsManager = getSettingsManager(projectRoot);
const [, settingsErr] = await attempt(() => settingsManager.load());
@@ -76,29 +109,16 @@ const rmCommand = defineCommand({
}
- if (!args.yes) {
+ const requiresConfirmation = check ? check.requiresConfirmation : true;
- if (!process.stdin.isTTY) {
+ if (requiresConfirmation && !isYesMode(args)) {
- outputError(
- args,
- `Pass --yes to confirm deletion of change: ${changeName}`,
- );
- process.exit(1);
+ const message = check
+ ? `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`
+ : `Pass --yes to confirm deletion of change: ${changeName}`;
- }
-
- const confirmed = await p.confirm({
- message: `Delete change "${changeName}"? This cannot be undone.`,
- initialValue: false,
- });
-
- if (p.isCancel(confirmed) || !confirmed) {
-
- process.stderr.write('Cancelled.\n');
- process.exit(1);
-
- }
+ outputError(args, message);
+ process.exit(1);
}
diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts
index 28444a86..47f7aec4 100644
--- a/src/cli/ci/identity/enroll.ts
+++ b/src/cli/ci/identity/enroll.ts
@@ -40,7 +40,7 @@ const enrollCommand = defineCommand({
description: 'Email address for the CI identity',
required: true,
},
- 'public-key': {
+ publicKey: {
type: 'string',
description: 'Pre-generated X25519 public key (hex). If omitted, a new keypair is generated.',
},
@@ -50,7 +50,7 @@ const enrollCommand = defineCommand({
const name = args.name?.trim();
const email = args.email?.trim();
- const providedPublicKey = args['public-key']?.trim();
+ const providedPublicKey = args.publicKey?.trim();
if (!name || !email || !args.config) {
diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts
index e6f6774d..00bfac44 100644
--- a/src/cli/config/add.ts
+++ b/src/cli/config/add.ts
@@ -14,8 +14,8 @@ const addCommand = defineCommand({
},
async run() {
- process.stdout.write('Interactive only — run: noorm ui\n');
- process.exit(0);
+ process.stderr.write('Interactive only — run: noorm ui\n');
+ process.exit(1);
},
});
diff --git a/src/cli/config/edit.ts b/src/cli/config/edit.ts
index 2c367932..1b13e22b 100644
--- a/src/cli/config/edit.ts
+++ b/src/cli/config/edit.ts
@@ -21,8 +21,8 @@ const editCommand = defineCommand({
},
async run() {
- process.stdout.write('Interactive only — run: noorm ui\n');
- process.exit(0);
+ process.stderr.write('Interactive only — run: noorm ui\n');
+ process.exit(1);
},
});
diff --git a/src/cli/config/export.ts b/src/cli/config/export.ts
index 70c6498b..fc49fe14 100644
--- a/src/cli/config/export.ts
+++ b/src/cli/config/export.ts
@@ -5,7 +5,7 @@
* or to a file when --output is provided. Intended for backup and
* cross-machine transfer workflows where the user explicitly owns the data.
*/
-import { writeFile } from 'node:fs/promises';
+import { chmod, writeFile } from 'node:fs/promises';
import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
@@ -50,7 +50,9 @@ const exportCommand = defineCommand({
if (args.output) {
- const [, writeErr] = await attempt(() => writeFile(args.output as string, json, 'utf8'));
+ const [, writeErr] = await attempt(() =>
+ writeFile(args.output as string, json, { encoding: 'utf8', mode: 0o600 }),
+ );
if (writeErr) {
@@ -59,6 +61,9 @@ const exportCommand = defineCommand({
}
+ // Ensure permissions are correct (writeFile mode may not work on all platforms)
+ await attempt(() => chmod(args.output as string, 0o600));
+
process.stdout.write(`Config '${args.name}' exported to ${args.output}\n`);
}
diff --git a/src/cli/config/list.ts b/src/cli/config/list.ts
index 1dc2abed..46dfe8cf 100644
--- a/src/cli/config/list.ts
+++ b/src/cli/config/list.ts
@@ -8,7 +8,7 @@ import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { initState, getStateManager } from '../../core/state/index.js';
-import { guarded } from '../../core/policy/index.js';
+import { formatAccessTag } from '../../core/policy/index.js';
import { outputResult, outputError, sharedArgs } from '../_utils.js';
const listCommand = defineCommand({
@@ -45,7 +45,7 @@ const listCommand = defineCommand({
const lines = configs.map((c) => {
const active = c.isActive ? ' (active)' : '';
- const accessTag = guarded(c) ? `user:${c.access.user} mcp:${c.access.mcp === false ? 'off' : c.access.mcp}` : null;
+ const accessTag = formatAccessTag(c);
const flags = [
c.isTest ? 'test' : null,
accessTag,
diff --git a/src/cli/config/rm.ts b/src/cli/config/rm.ts
index 93e8ac02..7df69b4e 100644
--- a/src/cli/config/rm.ts
+++ b/src/cli/config/rm.ts
@@ -1,34 +1,105 @@
/**
- * noorm config rm — directs the user to the TUI.
+ * noorm config delete command -- headless deletion of a configuration.
*
- * Removing a config requires confirmation and interactive state management.
- * The CLI directs the user to the TUI for now; this may be wired to
- * @clack/prompts in a future change.
+ * Gated by the config's access role for this permission (viewer denied,
+ * operator and admin require confirmation) and routed through the same
+ * core deletion path the TUI's ConfigRemoveScreen calls, so ticket 29's
+ * locked-stage guard is enforced identically here.
*/
+import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
+import { initState, getStateManager } from '../../core/state/index.js';
+import { getSettingsManager } from '../../core/settings/index.js';
+import { checkConfigPolicy } from '../../core/policy/index.js';
+import { SettingsProvider } from '../../core/config/resolver.js';
+import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js';
+
const rmCommand = defineCommand({
meta: {
name: 'rm',
- description: 'Remove a configuration (interactive, via TUI)',
+ description: 'Remove a configuration',
},
args: {
name: {
type: 'positional',
description: 'Configuration name to remove',
- required: false,
+ required: true,
},
+ yes: sharedArgs.yes,
+ json: sharedArgs.json,
},
- async run() {
+ async run({ args }) {
+
+ const projectRoot = process.cwd();
+
+ const [, initErr] = await attempt(() => initState(projectRoot));
+
+ if (initErr) {
+
+ outputError(args, `Failed to load state: ${initErr.message}`);
+ process.exit(1);
+
+ }
+
+ const stateManager = getStateManager(projectRoot);
+ const config = stateManager.getConfig(args.name);
+
+ if (!config) {
+
+ outputError(args, `Config "${args.name}" not found.`);
+ process.exit(1);
+
+ }
+
+ const settingsManager = getSettingsManager(projectRoot);
+ const [, settingsErr] = await attempt(() => settingsManager.load());
+
+ if (settingsErr) {
+
+ outputError(args, `Failed to load settings: ${settingsErr.message}`);
+ process.exit(1);
+
+ }
+
+ const check = checkConfigPolicy('user', config, 'config:rm');
+
+ if (!check.allowed) {
+
+ outputError(args, check.blockedReason ?? `Config "${args.name}" cannot be removed.`);
+ process.exit(1);
+
+ }
+
+ if (check.requiresConfirmation && !isYesMode(args)) {
+
+ outputError(
+ args,
+ `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`,
+ );
+ process.exit(1);
+
+ }
+
+ const settingsProvider = new SettingsProvider(settingsManager);
+ const [, deleteErr] = await attempt(() => stateManager.deleteConfig(args.name, settingsProvider));
+
+ if (deleteErr) {
+
+ outputError(args, deleteErr.message);
+ process.exit(1);
+
+ }
- process.stdout.write('Interactive only — run: noorm ui\n');
+ outputResult(args, { name: args.name, deleted: true }, `Deleted: ${args.name}`);
process.exit(0);
},
});
(rmCommand as typeof rmCommand & { examples: string[] }).examples = [
- 'noorm ui # then navigate to config > rm',
+ 'noorm config rm staging --yes',
+ 'noorm config rm staging --yes --json',
];
export default rmCommand;
diff --git a/src/cli/config/validate.ts b/src/cli/config/validate.ts
index 9fa86ec7..3b4b5fa7 100644
--- a/src/cli/config/validate.ts
+++ b/src/cli/config/validate.ts
@@ -8,16 +8,9 @@ import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { initState, getStateManager } from '../../core/state/index.js';
-import { testConnection } from '../../core/connection/factory.js';
+import { validateConfigChecks } from '../../core/config/validate.js';
import { outputResult, outputError, sharedArgs } from '../_utils.js';
-interface CheckResult {
- key: string;
- label: string;
- status: 'success' | 'error';
- detail: string;
-}
-
const validateCommand = defineCommand({
meta: {
name: 'validate',
@@ -54,55 +47,7 @@ const validateCommand = defineCommand({
}
- const checks: CheckResult[] = [];
- let valid = true;
-
- // Connection test
- const connResult = await testConnection(config.connection);
-
- checks.push({
- key: 'connection',
- label: 'Connection',
- status: connResult.ok ? 'success' : 'error',
- detail: connResult.ok ? 'Connection successful' : (connResult.error ?? 'Connection failed'),
- });
-
- if (!connResult.ok) valid = false;
-
- // Required fields
- const requiredChecks = [
- { key: 'name', label: 'Name', value: config.name },
- { key: 'database', label: 'Database', value: config.connection.database },
- ];
-
- for (const check of requiredChecks) {
-
- const isSet = Boolean(check.value);
- checks.push({
- key: check.key,
- label: check.label,
- status: isSet ? 'success' : 'error',
- detail: isSet ? check.value : 'Not set',
- });
-
- if (!isSet) valid = false;
-
- }
-
- // Host check for non-SQLite
- if (config.connection.dialect !== 'sqlite') {
-
- const hasHost = Boolean(config.connection.host);
- checks.push({
- key: 'host',
- label: 'Host',
- status: hasHost ? 'success' : 'error',
- detail: hasHost ? config.connection.host! : 'Not set',
- });
-
- if (!hasHost) valid = false;
-
- }
+ const { checks, valid } = await validateConfigChecks(config);
// Output
const statusText = valid ? 'VALID' : 'INVALID';
diff --git a/src/cli/db/create.ts b/src/cli/db/create.ts
index 293c8b08..513d3426 100644
--- a/src/cli/db/create.ts
+++ b/src/cli/db/create.ts
@@ -4,10 +4,12 @@
* Creates the database and bootstraps noorm tracking tables.
* Uses server-level connection so the target database need not exist yet.
*/
-import { attempt } from '@logosdx/utils';
+import { attempt, attemptSync } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { initState, getStateManager } from '../../core/state/index.js';
+import { getSettingsManager } from '../../core/settings/index.js';
+import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js';
import { checkDbStatus, createDb } from '../../core/db/index.js';
import { outputResult, outputError, sharedArgs } from '../_utils.js';
@@ -34,24 +36,41 @@ const createCommand = defineCommand({
}
const stateManager = getStateManager(projectRoot);
- const configName = args.config ?? stateManager.getActiveConfigName();
+ const settingsManager = getSettingsManager(projectRoot);
- if (!configName) {
+ const [, settingsErr] = await attempt(() => settingsManager.load());
- outputError(args, 'No active configuration. Use: noorm config use ');
+ if (settingsErr) {
+
+ outputError(args, `Failed to load settings: ${settingsErr.message}`);
process.exit(1);
}
- const config = stateManager.getConfig(configName);
+ // resolveConfig merges DEFAULTS <- stage <- stored <- env <- flags, the
+ // same path every other connecting command reaches via withContext, so
+ // NOORM_CONNECTION_* outranks a persisted config here too.
+ const [config, resolveErr] = attemptSync(() => resolveConfig(stateManager, {
+ name: args.config,
+ settings: new SettingsProvider(settingsManager),
+ }));
+
+ if (resolveErr) {
+
+ outputError(args, resolveErr.message);
+ process.exit(1);
+
+ }
if (!config) {
- outputError(args, `Config "${configName}" not found.`);
+ outputError(args, 'No active configuration. Use: noorm config use ');
process.exit(1);
}
+ const configName = config.name;
+
// Check current status
const status = await checkDbStatus(config.connection);
@@ -74,7 +93,7 @@ const createCommand = defineCommand({
}
// Create database
- const result = await createDb(config.connection, configName);
+ const result = await createDb(config.connection, configName, { precheckedStatus: status });
if (!result.ok) {
diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts
index 733182c0..335b1573 100644
--- a/src/cli/db/drop.ts
+++ b/src/cli/db/drop.ts
@@ -6,10 +6,12 @@
* access: viewer/operator are denied outright; admin requires --yes to
* satisfy the matrix's confirmation requirement.
*/
-import { attempt } from '@logosdx/utils';
+import { attempt, attemptSync } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { initState, getStateManager } from '../../core/state/index.js';
+import { getSettingsManager } from '../../core/settings/index.js';
+import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js';
import { destroyDb } from '../../core/db/index.js';
import { checkConfigPolicy } from '../../core/policy/index.js';
import { outputResult, outputError, sharedArgs } from '../_utils.js';
@@ -38,24 +40,41 @@ const dropCommand = defineCommand({
}
const stateManager = getStateManager(projectRoot);
- const configName = args.config ?? stateManager.getActiveConfigName();
+ const settingsManager = getSettingsManager(projectRoot);
- if (!configName) {
+ const [, settingsErr] = await attempt(() => settingsManager.load());
- outputError(args, 'No active configuration. Use: noorm config use ');
+ if (settingsErr) {
+
+ outputError(args, `Failed to load settings: ${settingsErr.message}`);
process.exit(1);
}
- const config = stateManager.getConfig(configName);
+ // resolveConfig merges DEFAULTS <- stage <- stored <- env <- flags, the
+ // same path every other connecting command reaches via withContext, so
+ // NOORM_CONNECTION_* outranks a persisted config here too.
+ const [config, resolveErr] = attemptSync(() => resolveConfig(stateManager, {
+ name: args.config,
+ settings: new SettingsProvider(settingsManager),
+ }));
+
+ if (resolveErr) {
+
+ outputError(args, resolveErr.message);
+ process.exit(1);
+
+ }
if (!config) {
- outputError(args, `Config "${configName}" not found.`);
+ outputError(args, 'No active configuration. Use: noorm config use ');
process.exit(1);
}
+ const configName = config.name;
+
const check = checkConfigPolicy('user', config, 'db:destroy');
if (!check.allowed) {
diff --git a/src/cli/db/explore.ts b/src/cli/db/explore.ts
index 41198f4a..1dc360f4 100644
--- a/src/cli/db/explore.ts
+++ b/src/cli/db/explore.ts
@@ -30,36 +30,21 @@ const exploreCommand = defineCommand({
const [overview, error] = await withContext({
args,
- fn: (ctx, logger) => {
-
- return ctx.noorm.db.overview().then((res) => {
-
- if (!args.json) {
-
- logger.info('Database Overview', {
- tables: res.tables,
- views: res.views,
- functions: res.functions,
- procedures: res.procedures,
- types: res.types,
- });
-
- }
-
- return res;
-
- });
-
- },
+ fn: (ctx) => ctx.noorm.db.overview(),
});
if (error) process.exit(1);
- if (args.json) {
-
- outputResult(args, overview, '');
+ const text = [
+ 'Database Overview',
+ ` Tables: ${overview.tables}`,
+ ` Views: ${overview.views}`,
+ ` Functions: ${overview.functions}`,
+ ` Procedures: ${overview.procedures}`,
+ ` Types: ${overview.types}`,
+ ].join('\n');
- }
+ outputResult(args, overview, text);
process.exit(0);
diff --git a/src/cli/db/reset.ts b/src/cli/db/reset.ts
index 5f80d53d..c045bd62 100644
--- a/src/cli/db/reset.ts
+++ b/src/cli/db/reset.ts
@@ -6,7 +6,7 @@
*/
import { defineCommand } from 'citty';
-import { withContext, outputResult, sharedArgs } from '../_utils.js';
+import { withContext, outputResult, isYesMode, sharedArgs } from '../_utils.js';
const resetCommand = defineCommand({
meta: {
@@ -20,7 +20,7 @@ const resetCommand = defineCommand({
},
async run({ args }) {
- if (!args.yes) {
+ if (!isYesMode(args)) {
process.stderr.write('Error: This is a destructive operation. Pass --yes to confirm.\n');
process.exit(1);
diff --git a/src/cli/db/teardown.ts b/src/cli/db/teardown.ts
index a3209635..2f69da39 100644
--- a/src/cli/db/teardown.ts
+++ b/src/cli/db/teardown.ts
@@ -13,16 +13,19 @@ const teardownCommand = defineCommand({
args: {
config: sharedArgs.config,
force: sharedArgs.force,
+ dryRun: sharedArgs.dryRun,
yes: sharedArgs.yes,
json: sharedArgs.json,
},
async run({ args }) {
+ const dryRun = Boolean(args.dryRun);
+
const [result, error] = await withContext({
args,
fn: (ctx, logger) => {
- return ctx.noorm.db.teardown().then((res) => {
+ return ctx.noorm.db.teardown({ dryRun }).then((res) => {
const droppedCount = res.dropped.tables.length +
res.dropped.views.length +
@@ -31,7 +34,9 @@ const teardownCommand = defineCommand({
if (!args.json) {
- logger.info(`Dropped ${droppedCount} objects`);
+ const verb = dryRun ? 'Would drop' : 'Dropped';
+
+ logger.info(`${verb} ${droppedCount} objects`);
logger.info(` Tables: ${res.dropped.tables.length}`);
logger.info(` Views: ${res.dropped.views.length}`);
logger.info(` Functions: ${res.dropped.functions.length}`);
@@ -58,6 +63,7 @@ const teardownCommand = defineCommand({
outputResult(args, {
dropped: result.dropped,
count: droppedCount,
+ ...(dryRun ? { dryRun: true } : {}),
}, '');
}
@@ -70,6 +76,7 @@ const teardownCommand = defineCommand({
(teardownCommand as typeof teardownCommand & { examples: string[] }).examples = [
'noorm db teardown',
'noorm db teardown --yes',
+ 'noorm db teardown --dry-run',
'noorm db teardown --force --yes',
'noorm db teardown --json --yes',
];
diff --git a/src/cli/db/transfer.ts b/src/cli/db/transfer.ts
index a7d081de..7ebaf324 100644
--- a/src/cli/db/transfer.ts
+++ b/src/cli/db/transfer.ts
@@ -3,10 +3,12 @@
*
* Transfers data to another DB config, or exports/imports .dt files.
*/
+import * as p from '@clack/prompts';
import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
import { resolveExportExtension, resolveExportPath, ensureExportDirectory } from '../../core/dt/index.js';
+import { MIN_PASSPHRASE_LENGTH } from '../../core/dt/crypto.js';
import { getStateManager } from '../../core/state/index.js';
import type { TransferOptions, ConflictStrategy } from '../../core/transfer/index.js';
import { withContext, outputResult, outputError, sharedArgs, type CliArgs } from '../_utils.js';
@@ -22,11 +24,11 @@ type TransferArgs = CliArgs & {
compress?: boolean;
passphrase?: string;
tables?: string;
- 'on-conflict'?: string;
- 'batch-size'?: string;
+ onConflict?: string;
+ batchSize?: string;
truncate?: boolean;
- 'no-fk'?: boolean;
- 'no-identity'?: boolean;
+ noFk?: boolean;
+ noIdentity?: boolean;
dryRun?: boolean;
};
@@ -44,6 +46,96 @@ function isConflictStrategy(value: unknown): value is ValidStrategy {
}
+// ---------------------------------------------------------------------------
+// Passphrase resolution — flag, masked prompt, or CI escape hatch
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve the passphrase for a .dtzx export, enforcing MIN_PASSPHRASE_LENGTH.
+ *
+ * A bare `--passphrase` flag leaks via ps/shell history, so an interactive
+ * TTY session (and no `--json`) prompts via a masked @clack/prompts input
+ * instead. Non-interactive callers without the flag get an immediate,
+ * actionable exit rather than hanging on a stdin read — the documented CI
+ * escape hatch is still the flag.
+ */
+async function resolveExportPassphrase(passphrase: string | undefined, args: TransferArgs): Promise {
+
+ if (passphrase) {
+
+ if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
+
+ outputError(args, `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`);
+ process.exit(1);
+
+ }
+
+ return passphrase;
+
+ }
+
+ if (!process.stdin.isTTY || args.json) {
+
+ outputError(args, '--passphrase required for .dtzx encrypted export (non-interactive session; pass --passphrase or run interactively for a masked prompt)');
+ process.exit(1);
+
+ }
+
+ const result = await p.password({
+ message: 'Passphrase for .dtzx encryption',
+ validate: (value) => (
+ value && value.length >= MIN_PASSPHRASE_LENGTH
+ ? undefined
+ : `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`
+ ),
+ });
+
+ if (p.isCancel(result)) {
+
+ p.cancel('Cancelled.');
+ process.exit(0);
+
+ }
+
+ return result;
+
+}
+
+/**
+ * Resolve the passphrase for a .dtzx import.
+ *
+ * No length floor — legacy archives may have been encrypted with a
+ * passphrase shorter than MIN_PASSPHRASE_LENGTH, and a wrong passphrase
+ * already fails via GCM auth tag verification. Same masked-prompt /
+ * CI-escape-hatch idiom as the export path.
+ */
+async function resolveImportPassphrase(passphrase: string | undefined, args: TransferArgs): Promise {
+
+ if (passphrase) return passphrase;
+
+ if (!process.stdin.isTTY || args.json) {
+
+ outputError(args, '--passphrase required for .dtzx encrypted import (non-interactive session; pass --passphrase or run interactively for a masked prompt)');
+ process.exit(1);
+
+ }
+
+ const result = await p.password({
+ message: 'Passphrase for .dtzx decryption',
+ validate: (value) => (value ? undefined : 'Passphrase is required'),
+ });
+
+ if (p.isCancel(result)) {
+
+ p.cancel('Cancelled.');
+ process.exit(0);
+
+ }
+
+ return result;
+
+}
+
const transferCommand = defineCommand({
meta: {
name: 'transfer',
@@ -72,17 +164,17 @@ const transferCommand = defineCommand({
},
passphrase: {
type: 'string',
- description: 'Passphrase for .dtzx encryption/decryption (implies compression)',
+ description: `Passphrase for .dtzx (implies compression, min ${MIN_PASSPHRASE_LENGTH} chars on export). Omit on TTY for masked prompt.`,
},
tables: {
type: 'string',
description: 'Comma-separated list of tables (default: all)',
},
- 'on-conflict': {
+ onConflict: {
type: 'string',
description: 'Conflict strategy: fail, skip, update, replace (default: fail)',
},
- 'batch-size': {
+ batchSize: {
type: 'string',
description: 'Rows per batch for cross-server transfers (default: 1000)',
},
@@ -90,11 +182,11 @@ const transferCommand = defineCommand({
type: 'boolean',
description: 'Truncate destination tables before transfer',
},
- 'no-fk': {
+ noFk: {
type: 'boolean',
description: 'Do not disable foreign key checks',
},
- 'no-identity': {
+ noIdentity: {
type: 'boolean',
description: 'Do not preserve identity/auto-increment values',
},
@@ -104,7 +196,7 @@ const transferCommand = defineCommand({
const destConfigName = args.to;
const exportPath = args.export;
const importPath = args.import;
- const passphrase = args.passphrase;
+ let passphrase = args.passphrase;
const compress = args.compress === true;
if (compress && !exportPath) {
@@ -130,22 +222,20 @@ const transferCommand = defineCommand({
}
- if (exportPath?.endsWith('.dtzx') && !passphrase) {
+ if (exportPath?.endsWith('.dtzx')) {
- outputError(args, '--passphrase required for .dtzx encrypted export');
- process.exit(1);
+ passphrase = await resolveExportPassphrase(passphrase, args);
}
- if (importPath?.endsWith('.dtzx') && !passphrase) {
+ if (importPath?.endsWith('.dtzx')) {
- outputError(args, '--passphrase required for .dtzx encrypted import');
- process.exit(1);
+ passphrase = await resolveImportPassphrase(passphrase, args);
}
// Validate --on-conflict once before mode dispatch
- const rawConflict = args['on-conflict'] ?? 'fail';
+ const rawConflict = args.onConflict ?? 'fail';
if (!isConflictStrategy(rawConflict)) {
@@ -157,7 +247,7 @@ const transferCommand = defineCommand({
const onConflict: ConflictStrategy = rawConflict;
const tableList = args.tables ? String(args.tables).split(',').map((t) => t.trim()) : undefined;
- const batchSize = args['batch-size'] ? parseInt(String(args['batch-size']), 10) : undefined;
+ const batchSize = args.batchSize ? parseInt(String(args.batchSize), 10) : undefined;
if (exportPath) {
@@ -212,8 +302,8 @@ const transferCommand = defineCommand({
tables: tableList,
onConflict,
batchSize,
- disableForeignKeys: args['no-fk'] !== true,
- preserveIdentity: args['no-identity'] !== true,
+ disableForeignKeys: args.noFk !== true,
+ preserveIdentity: args.noIdentity !== true,
truncateFirst: args.truncate === true,
dryRun: args.dryRun === true,
};
@@ -239,14 +329,7 @@ const transferCommand = defineCommand({
if (planError) process.exit(1);
- const [planResult, planErr] = plan;
-
- if (planErr) {
-
- outputError(args, planErr.message);
- process.exit(1);
-
- }
+ const planResult = plan;
if (args.json) {
@@ -322,14 +405,7 @@ const transferCommand = defineCommand({
if (transferError) process.exit(1);
- const [result, transferErr] = transferResult;
-
- if (transferErr) {
-
- outputError(args, transferErr.message);
- process.exit(1);
-
- }
+ const result = transferResult;
if (args.json) {
@@ -339,6 +415,7 @@ const transferCommand = defineCommand({
tables: result?.tables,
totalRows: result?.totalRows,
durationMs: result?.durationMs,
+ fkChecksRestored: result?.fkChecksRestored,
}, '');
}
@@ -352,6 +429,15 @@ const transferCommand = defineCommand({
process.stdout.write(` Tables: ${successCount} success, ${failedCount} failed\n`);
process.stdout.write(` Duration: ${((result?.durationMs ?? 0) / 1000).toFixed(2)}s\n`);
+ if (result?.fkChecksRestored === false) {
+
+ process.stderr.write(
+ 'WARNING: foreign key checks were NOT restored on the destination — '
+ + 'referential integrity may be disabled. Re-enable manually.\n',
+ );
+
+ }
+
const failures = result?.tables.filter((t) => t.status === 'failed') ?? [];
for (const f of failures) {
@@ -375,7 +461,8 @@ const transferCommand = defineCommand({
'noorm db transfer --export ./backup/ --compress',
'noorm db transfer --export ./backup/users.dt --tables users',
'noorm db transfer --import ./backup/users.dt',
- 'noorm db transfer --import ./backup/users.dtzx --passphrase mySecret',
+ 'noorm db transfer --import ./backup/users.dtzx --passphrase correct-horse-battery',
+ 'noorm db transfer --export ./backup/users.dtzx # omits --passphrase, prompts interactively',
];
export default transferCommand;
@@ -427,17 +514,11 @@ async function handleExport(opts: {
ext,
});
- const [result, err] = await ctx.noorm.dt.exportTable(tableName, filepath, {
+ const result = await ctx.noorm.dt.exportTable(tableName, filepath, {
passphrase,
batchSize,
});
- if (err) {
-
- throw err;
-
- }
-
totalRows += result?.rowsWritten ?? 0;
totalBytes += result?.bytesWritten ?? 0;
tableResults.push({
@@ -542,19 +623,13 @@ async function handleImport(opts: {
}
- const [result, err] = await ctx.noorm.dt.importFile(importPath, {
+ const result = await ctx.noorm.dt.importFile(importPath, {
passphrase,
batchSize,
onConflict,
truncate,
});
- if (err) {
-
- throw err;
-
- }
-
return result;
},
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 9d13acbd..1e3901ae 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -12,25 +12,6 @@ import { resolve } from 'node:path';
import tab from '@bomb.sh/tab/citty';
import { defineCommand, runMain, renderUsage, type CommandDef } from 'citty';
-import change from './change/index.js';
-import ci from './ci/index.js';
-import config from './config/index.js';
-import db from './db/index.js';
-import dev from './dev/index.js';
-import identity from './identity/index.js';
-import info from './info.js';
-import init from './init.js';
-import lock from './lock/index.js';
-import mcp from './mcp/index.js';
-import run from './run/index.js';
-import secret from './secret/index.js';
-import settings from './settings/index.js';
-import sql from './sql/index.js';
-import ui from './ui.js';
-import update from './update.js';
-import vault from './vault/index.js';
-import version from './version.js';
-
import { initProjectContext, setOriginalCwd } from '../core/project.js';
import { loadIdentityFromEnv } from '../core/identity/env.js';
import { setKeyOverride, setIdentityOverride } from '../core/identity/storage.js';
@@ -41,31 +22,51 @@ import { setKeyOverride, setIdentityOverride } from '../core/identity/storage.js
*/
export type CommandWithExamples = CommandDef & { examples?: string[] };
+/**
+ * Zero-cost stand-in for the `complete` subcommand that `@bomb.sh/tab`
+ * would otherwise register by walking the entire `subCommands` tree
+ * (forcing every lazy thunk below to resolve on every invocation, the
+ * exact cost this file exists to avoid). Same meta the adapter itself
+ * uses, so `noorm --help` / bare `noorm` list `complete` identically to
+ * before. Overwritten in place by the real `tab(main)` call below when
+ * the invocation actually is a completion request.
+ */
+const completeStub = defineCommand({
+ meta: {
+ name: 'complete',
+ description: 'Generate shell completion scripts',
+ },
+ run() {},
+});
+
+declare const __CLI_VERSION__: string;
+
const main = defineCommand({
meta: {
name: 'noorm',
- version: '0.0.0', // replaced at bundle time via --define __CLI_VERSION__
+ version: typeof __CLI_VERSION__ !== 'undefined' ? __CLI_VERSION__ : '0.0.0-dev',
description: 'Database schema & changeset manager. Global: -c, --cwd runs the subcommand in (must precede the subcommand, like git -C).',
},
subCommands: {
- change,
- ci,
- config,
- db,
- dev,
- identity,
- info,
- init,
- lock,
- mcp,
- run,
- secret,
- settings,
- sql,
- ui,
- update,
- vault,
- version,
+ change: () => import('./change/index.js').then((m) => m.default),
+ ci: () => import('./ci/index.js').then((m) => m.default),
+ config: () => import('./config/index.js').then((m) => m.default),
+ db: () => import('./db/index.js').then((m) => m.default),
+ dev: () => import('./dev/index.js').then((m) => m.default),
+ identity: () => import('./identity/index.js').then((m) => m.default),
+ info: () => import('./info.js').then((m) => m.default),
+ init: () => import('./init.js').then((m) => m.default),
+ lock: () => import('./lock/index.js').then((m) => m.default),
+ mcp: () => import('./mcp/index.js').then((m) => m.default),
+ run: () => import('./run/index.js').then((m) => m.default),
+ secret: () => import('./secret/index.js').then((m) => m.default),
+ settings: () => import('./settings/index.js').then((m) => m.default),
+ sql: () => import('./sql/index.js').then((m) => m.default),
+ ui: () => import('./ui.js').then((m) => m.default),
+ update: () => import('./update.js').then((m) => m.default),
+ vault: () => import('./vault/index.js').then((m) => m.default),
+ version: () => import('./version.js').then((m) => m.default),
+ complete: completeStub,
},
});
@@ -73,11 +74,14 @@ const main = defineCommand({
* Walk argv one positional at a time to find the target command.
*
* Stops at the first flag (token starting with `-`) or unknown subcommand.
- * Returns the resolved command definition object.
+ * Returns the resolved command definition plus the ordered chain of parent
+ * command names (root to immediate parent) walked to reach it, so callers
+ * can rebuild the full USAGE breadcrumb instead of citty's single-level one.
*/
-async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise {
+async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise<{ cmd: CommandDef; parentNames: string[] }> {
let current: unknown = rootDef;
+ const parentNames: string[] = [];
for (const arg of argv) {
@@ -92,23 +96,58 @@ async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise Promise)() : current as CommandDef;
+ const cmd = typeof current === 'function' ? await (current as () => Promise)() : current as CommandDef;
+
+ return { cmd, parentNames };
}
/**
- * Strip `-c ` / `--cwd ` / `--cwd=` that appear before
- * the first subcommand. Mirrors `git -C ` semantics: the flag is only
- * recognized as global when it precedes the subcommand, so per-command
- * aliases (like `--config -c`) keep working after the subcommand token.
+ * `--help`/`-h`/`--version`/`-v` are already recognized at any position:
+ * `entry()` scans for `--help`/`-h` directly (below) and citty's own
+ * `runMain` handles a bare `--version`/`-v` before ever reaching
+ * `runCommand`. They must pass through `extractGlobalCwd` untouched
+ * rather than trip its "unrecognized flag" error.
+ */
+const BUILTIN_ROOT_FLAGS = new Set(['--help', '-h', '--version', '-v']);
+
+/**
+ * Strip `-c ` / `--cwd ` / `--cwd=` from the argv that
+ * precedes the subcommand name — the sole flag with a genuine root-level
+ * meaning. Every other flag belongs on the command that uses it and is
+ * rejected outright if it appears before the subcommand, for two reasons:
+ *
+ * 1. `--cwd` is consumed before dispatch: it sets the working directory
+ * everything else (project discovery, config resolution) resolves
+ * against, so it is the CLI's own flag rather than any subcommand's.
+ * 2. `-c` already means `--config` after the subcommand (see the
+ * "`--config` / `-c` overload" note in docs/cli/flags.md). Hoisting
+ * any other flag to the root doesn't hit that collision, but it does
+ * reintroduce the asymmetry this function used to have with
+ * `--config`/`--force` — which were never hoisted — for no reason
+ * beyond "why not". A flag goes on the command that uses it.
*
- * Returns the resolved cwd (or null) plus argv with global flags removed.
+ * citty forwards only `rawArgs.slice(subCommandArgIndex + 1)` to the
+ * resolved subcommand (`node_modules/citty/dist/index.mjs:217`), so any
+ * flag before that index is silently dropped before a leaf command's own
+ * arg parser ever sees it. Mirrors `git -C ` semantics: `--cwd` is
+ * only recognized as global when it precedes the subcommand, so a
+ * per-command flag of the same short name (`--config -c`) keeps working
+ * untouched once seen after it.
+ *
+ * Any other flag-looking token seen before the subcommand can't be
+ * honoured for the same reason an unrecognised flag couldn't — silently
+ * forwarding it would be the defect this function exists to avoid — so
+ * it is reported as an error instead of being dropped.
*/
-function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[] } {
+function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[]; error: null } | { cwd: null; rest: null; error: string } {
const rest: string[] = [];
let cwd: string | null = null;
@@ -119,7 +158,7 @@ function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[]
const arg = argv[i]!;
- if (seenSubcommand) {
+ if (seenSubcommand || BUILTIN_ROOT_FLAGS.has(arg)) {
rest.push(arg);
i++;
@@ -153,12 +192,17 @@ function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[]
}
- rest.push(arg);
- i++;
+ return {
+ cwd: null,
+ rest: null,
+ error: `Unrecognized flag '${arg}' before the subcommand — noorm can't forward it there. `
+ + 'The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it. '
+ + `Move '${arg}' after the subcommand instead, e.g. noorm ... ${arg}.`,
+ };
}
- return { cwd, rest };
+ return { cwd, rest, error: null };
}
@@ -223,10 +267,18 @@ function rewriteBareSqlArgv(argv: string[]): string[] {
/**
* Print citty's usage followed by a custom EXAMPLES block from
* the command's top-level `examples` property (if present).
+ *
+ * citty's renderUsage only concatenates one level (`parent.meta.name + ' ' +
+ * cmd.meta.name`), so a synthetic parent joining the full breadcrumb is
+ * built here rather than always passing the absolute root command.
*/
-async function printHelpWithExamples(cmd: CommandWithExamples, rootDef: CommandDef): Promise {
+async function printHelpWithExamples(cmd: CommandWithExamples, parentNames: string[]): Promise {
- const usage = await renderUsage(cmd, rootDef);
+ const parent: CommandDef | undefined = parentNames.length > 0
+ ? { meta: { name: parentNames.join(' ') } }
+ : undefined;
+
+ const usage = await renderUsage(cmd, parent);
process.stdout.write(usage + '\n');
if (cmd.examples && cmd.examples.length > 0) {
@@ -253,7 +305,16 @@ async function printHelpWithExamples(cmd: CommandWithExamples, rootDef: CommandD
async function entry(): Promise {
const rawArgv = process.argv.slice(2);
- const { cwd: explicitCwd, rest: cleanArgv } = extractGlobalCwd(rawArgv);
+ const parsedCwd = extractGlobalCwd(rawArgv);
+
+ if (parsedCwd.error !== null) {
+
+ process.stderr.write(`Error: ${parsedCwd.error}\n`);
+ process.exit(1);
+
+ }
+
+ const { cwd: explicitCwd, rest: cleanArgv } = parsedCwd;
if (explicitCwd !== null) {
@@ -268,17 +329,20 @@ async function entry(): Promise {
setOriginalCwd(process.cwd());
process.chdir(resolvedCwd);
- process.argv = [process.argv[0]!, process.argv[1]!, ...rewriteBareSqlArgv(cleanArgv)];
}
else {
initProjectContext();
- const rewritten = rewriteBareSqlArgv(process.argv.slice(2));
- process.argv = [process.argv[0]!, process.argv[1]!, ...rewritten];
}
+ // Both branches funnel through the same reassignment. `cleanArgv` is
+ // rawArgv with only the -c/--cwd tokens (if any) removed, so this is
+ // a no-op rewrite when --cwd was absent — using it unconditionally
+ // keeps one code path instead of two that must stay in sync.
+ process.argv = [process.argv[0]!, process.argv[1]!, ...rewriteBareSqlArgv(cleanArgv)];
+
// Install env-based identity overrides at process startup so that
// every downstream loadPrivateKey() / loadIdentityMetadata() call
// returns the env values without touching ~/.noorm/. Validated by
@@ -292,16 +356,24 @@ async function entry(): Promise {
}
- // Register shell completion as the `complete` subcommand on main.
- // The adapter walks main.subCommands to generate completions.
- await tab(main);
-
const rawArgs = process.argv.slice(2);
+ // Only walk the entire subCommands tree (resolving every lazy thunk
+ // above) when the invocation is actually a completion request. The
+ // always-present completeStub above keeps `complete` listed in every
+ // other help/usage output at zero resolution cost; tab(main) replaces
+ // it in place (same main.subCommands object reference) before runMain
+ // dispatches to it.
+ if (rawArgs[0] === 'complete') {
+
+ await tab(main);
+
+ }
+
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
- const cmd = await resolveCommand(main as CommandDef, rawArgs);
- await printHelpWithExamples(cmd as CommandWithExamples, main as CommandDef);
+ const { cmd, parentNames } = await resolveCommand(main as CommandDef, rawArgs);
+ await printHelpWithExamples(cmd as CommandWithExamples, parentNames);
process.exit(0);
}
diff --git a/src/cli/lock/status.ts b/src/cli/lock/status.ts
index 2ae89f91..7144180c 100644
--- a/src/cli/lock/status.ts
+++ b/src/cli/lock/status.ts
@@ -18,53 +18,27 @@ const statusCommand = defineCommand({
const [status, error] = await withContext({
args,
- fn: (ctx, logger) => {
-
- return ctx.noorm.lock.status().then((res) => {
-
- if (!args.json) {
-
- if (res.isLocked && res.lock) {
-
- logger.info(`Locked by ${res.lock.lockedBy}`, {
- since: res.lock.lockedAt.toISOString(),
- expires: res.lock.expiresAt.toISOString(),
- });
-
- }
- else {
-
- logger.info('No active lock');
-
- }
-
- }
-
- return res;
-
- });
-
- },
+ fn: (ctx) => ctx.noorm.lock.status(),
});
if (error) process.exit(1);
- if (args.json) {
-
- const output = status.isLocked && status.lock
- ? {
- isLocked: true,
- lock: {
- lockedBy: status.lock.lockedBy,
- lockedAt: status.lock.lockedAt.toISOString(),
- expiresAt: status.lock.expiresAt.toISOString(),
- },
- }
- : { isLocked: false, lock: null };
-
- outputResult(args, output, '');
-
- }
+ const output = status.isLocked && status.lock
+ ? {
+ isLocked: true,
+ lock: {
+ lockedBy: status.lock.lockedBy,
+ lockedAt: status.lock.lockedAt.toISOString(),
+ expiresAt: status.lock.expiresAt.toISOString(),
+ },
+ }
+ : { isLocked: false, lock: null };
+
+ const text = status.isLocked && status.lock
+ ? `Locked by ${status.lock.lockedBy} (since ${status.lock.lockedAt.toISOString()}, expires ${status.lock.expiresAt.toISOString()})`
+ : 'No active lock';
+
+ outputResult(args, output, text);
process.exit(0);
diff --git a/src/cli/run/_render-secrets.ts b/src/cli/run/_render-secrets.ts
new file mode 100644
index 00000000..df1b9fc8
--- /dev/null
+++ b/src/cli/run/_render-secrets.ts
@@ -0,0 +1,86 @@
+/**
+ * Shared "resolve $.secrets for an offline render" helper for
+ * `run preview` and `run inspect`.
+ *
+ * Both commands are documented as offline — they render a template
+ * without executing it, and must keep working with no database
+ * reachable. But the vault tier can only be read through a connection,
+ * and a preview that shows different secrets than `run file`/`run build`
+ * would actually apply is the same class of silent divergence CP6 exists
+ * to remove.
+ *
+ * The probe below connects with retry/backoff disabled
+ * (`ConnectionRetryOptions`, `src/core/connection/factory.ts`), so an
+ * unreachable database degrades to the local tiers in well under a
+ * second instead of the ~6-7s the default 3-attempt policy adds. When
+ * the probe fails, `vaultProbeFailed` tells the caller to say so —
+ * silently showing only local secrets recreates exactly the class of
+ * bug this branch fixes.
+ */
+import { attempt } from '@logosdx/utils';
+
+import { createContext } from '../../sdk/index.js';
+import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js';
+import type { StateManager } from '../../core/state/index.js';
+import type { NoormDatabase } from '../../core/shared/index.js';
+
+/** One attempt, no wait — a best-effort probe must not inherit the
+ * connecting-command retry/backoff policy. */
+const PROBE_RETRY_OPTIONS = { retries: 1, delay: 0 };
+
+/** Shared wording so the text and `--json` shapes never drift apart. */
+export const RENDER_SECRETS_NOTICE =
+ 'Vault-backed secrets were not resolved (no reachable database) — showing local-only secrets.';
+
+export interface RenderSecretsResult {
+ secrets: Record;
+
+ /**
+ * True when a config was active but the vault tier could not be
+ * reached (no connection, no vault access). False when there was no
+ * config to probe against in the first place — nothing was skipped,
+ * so there's nothing to disclose.
+ */
+ vaultProbeFailed: boolean;
+}
+
+/**
+ * Resolve `$.secrets` through all three tiers for a preview/inspect
+ * render, without blocking on an unreachable database.
+ *
+ * @example
+ * ```typescript
+ * const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, 'prod');
+ * ```
+ */
+export async function resolveRenderSecrets(
+ stateManager: StateManager,
+ configName: string | null | undefined,
+): Promise {
+
+ if (!configName) return { secrets: {}, vaultProbeFailed: false };
+
+ const [vault, connErr] = await attempt(() => createContext({ config: configName }));
+
+ if (connErr) {
+
+ return { secrets: await buildSecretsContext(stateManager, configName), vaultProbeFailed: true };
+
+ }
+
+ const [, connectErr] = await attempt(() => vault.connect(PROBE_RETRY_OPTIONS));
+
+ if (connectErr) {
+
+ return { secrets: await buildSecretsContext(stateManager, configName), vaultProbeFailed: true };
+
+ }
+
+ const vaultKey = await resolveVaultKey(vault.kysely, vault.dialect);
+ const secrets = await buildSecretsContext(stateManager, configName, vault.kysely, vaultKey, vault.dialect);
+
+ await attempt(() => vault.disconnect());
+
+ return { secrets, vaultProbeFailed: false };
+
+}
diff --git a/src/cli/run/build.ts b/src/cli/run/build.ts
index ca3b3d0a..10cfeda9 100644
--- a/src/cli/run/build.ts
+++ b/src/cli/run/build.ts
@@ -13,18 +13,27 @@ const buildCommand = defineCommand({
args: {
config: sharedArgs.config,
force: sharedArgs.force,
+ dryRun: sharedArgs.dryRun,
json: sharedArgs.json,
},
async run({ args }) {
+ const dryRun = Boolean(args.dryRun);
+
const [result, error] = await withContext({
args,
fn: (ctx, logger) => {
- return ctx.noorm.run.build({ force: args.force }).then((res) => {
+ return ctx.noorm.run.build({ force: args.force, dryRun }).then((res) => {
if (!args.json) {
+ if (dryRun) {
+
+ logger.info('Dry run: rendering files to tmp/ (no DB writes)');
+
+ }
+
for (const file of res.files) {
if (file.status === 'failed') {
@@ -38,6 +47,11 @@ const buildCommand = defineCommand({
logger.info(`${file.filepath} (skipped: ${file.skipReason})`);
}
+ else if (dryRun) {
+
+ logger.info(`${file.filepath} (${file.status}, dry-run)`);
+
+ }
}
@@ -47,16 +61,19 @@ const buildCommand = defineCommand({
filesSkipped: res.filesSkipped,
filesFailed: res.filesFailed,
durationMs: res.durationMs,
+ ...(dryRun ? { dryRun: true } : {}),
};
+ const headerPrefix = dryRun ? 'Build (dry-run)' : 'Build';
+
if (res.status === 'success') {
- logger.info('Build completed', summary);
+ logger.info(`${headerPrefix} completed`, summary);
}
else {
- logger.error('Build completed', summary);
+ logger.error(`${headerPrefix} completed`, summary);
}
@@ -73,7 +90,11 @@ const buildCommand = defineCommand({
if (args.json) {
- outputResult(args, result, '');
+ // Annotate the JSON payload with the dry-run flag, matching
+ // `change ff --dry-run`, so CI can detect a no-write result
+ // without parsing human output.
+ const payload = dryRun ? { ...result, dryRun: true } : result;
+ outputResult(args, payload, '');
}
@@ -85,6 +106,7 @@ const buildCommand = defineCommand({
(buildCommand as typeof buildCommand & { examples: string[] }).examples = [
'noorm run build',
'noorm run build --force',
+ 'noorm run build --dry-run',
'noorm run build --json',
];
diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts
index 68e6390c..75cc7a39 100644
--- a/src/cli/run/inspect.ts
+++ b/src/cli/run/inspect.ts
@@ -19,6 +19,7 @@ import { outputError, outputResult, sharedArgs } from '../_utils.js';
import { buildContext } from '../../core/template/context.js';
import { loadHelpers } from '../../core/template/helpers.js';
import { getStateManager } from '../../core/state/index.js';
+import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js';
// Built-in helper names (always present in context)
const BUILTIN_HELPERS = new Set(['quote', 'escape', 'uuid', 'now', 'json', 'include']);
@@ -89,6 +90,7 @@ const inspectCommand = defineCommand({
const activeConfigName = args.config ?? stateManager.getActiveConfigName();
const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined;
+ const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName);
// Load context and helpers in parallel
const [results, err] = await attempt(async () => {
@@ -97,7 +99,7 @@ const inspectCommand = defineCommand({
buildContext(fullPath, {
projectRoot,
config: activeConfig as unknown as Record,
- secrets: activeConfigName ? stateManager.getAllSecrets(activeConfigName) : {},
+ secrets,
globalSecrets: stateManager.getAllGlobalSecrets(),
}),
loadHelpers(templateDir, projectRoot),
@@ -160,9 +162,10 @@ const inspectCommand = defineCommand({
})),
builtins,
configKeys: activeConfig ? Object.keys(activeConfig) : [],
- secretCount: activeConfigName ? Object.keys(stateManager.getAllSecrets(activeConfigName)).length : 0,
+ secretCount: Object.keys(secrets).length,
globalSecretCount: Object.keys(stateManager.getAllGlobalSecrets()).length,
},
+ ...(vaultProbeFailed ? { vaultProbeFailed: true, notice: RENDER_SECRETS_NOTICE } : {}),
};
const textLines = [
@@ -217,9 +220,14 @@ const inspectCommand = defineCommand({
if (activeConfigName) {
- const secretCount = Object.keys(stateManager.getAllSecrets(activeConfigName)).length;
const globalCount = Object.keys(stateManager.getAllGlobalSecrets()).length;
- textLines.push(`Secrets: ${secretCount} config, ${globalCount} global`);
+ textLines.push(`Secrets: ${Object.keys(secrets).length} resolved, ${globalCount} global`);
+
+ if (vaultProbeFailed) {
+
+ textLines.push(RENDER_SECRETS_NOTICE);
+
+ }
}
diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts
index 4f149e14..41d96f2e 100644
--- a/src/cli/run/preview.ts
+++ b/src/cli/run/preview.ts
@@ -19,6 +19,7 @@ import { attempt } from '@logosdx/utils';
import { outputError, outputResult, sharedArgs } from '../_utils.js';
import { processFile } from '../../core/template/engine.js';
import { getStateManager } from '../../core/state/index.js';
+import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js';
const previewCommand = defineCommand({
meta: {
@@ -52,12 +53,13 @@ const previewCommand = defineCommand({
const activeConfigName = args.config ?? stateManager.getActiveConfigName();
const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined;
+ const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName);
// Render the template
const [result, err] = await attempt(() => processFile(fullPath, {
projectRoot,
config: activeConfig as unknown as Record,
- secrets: activeConfigName ? stateManager.getAllSecrets(activeConfigName) : {},
+ secrets,
globalSecrets: stateManager.getAllGlobalSecrets(),
}));
@@ -74,11 +76,21 @@ const previewCommand = defineCommand({
filepath: args.path,
sql: result.sql,
durationMs: result.durationMs,
+ ...(vaultProbeFailed ? { vaultProbeFailed: true, notice: RENDER_SECRETS_NOTICE } : {}),
}, '');
}
else {
+ // Diagnostic, not result — stdout is reserved for the raw SQL
+ // below so `noorm run preview x.sql.tmpl > rendered.sql` stays
+ // pipeable.
+ if (vaultProbeFailed) {
+
+ process.stderr.write(RENDER_SECRETS_NOTICE + '\n');
+
+ }
+
// Output raw SQL so it can be piped to a file or other tool
process.stdout.write(result.sql);
diff --git a/src/cli/sql/repl.ts b/src/cli/sql/repl.ts
index 08b436bd..b3ab664b 100644
--- a/src/cli/sql/repl.ts
+++ b/src/cli/sql/repl.ts
@@ -7,13 +7,15 @@
*
* If --config is provided, the active config is switched before the TUI
* launches so the REPL starts against the intended database.
+ *
+ * Ink and React are loaded lazily inside run() (alongside the TUI app)
+ * so that resolving this module's meta never pays their import cost —
+ * only actually launching the REPL does.
*/
import { Writable } from 'node:stream';
import { attempt } from '@logosdx/utils';
import { defineCommand } from 'citty';
-import { render } from 'ink';
-import React from 'react';
import { observer } from '../../core/observer.js';
import { enableAutoLoggerInit } from '../../core/logger/init.js';
@@ -91,7 +93,11 @@ const replCommand = defineCommand({
}
// === Launch TUI at db/sql ===
- const { App } = await import('../../tui/app.js');
+ const [{ render }, { default: React }, { App }] = await Promise.all([
+ import('ink'),
+ import('react'),
+ import('../../tui/app.js'),
+ ]);
enableAutoLoggerInit(process.cwd(), {
console: nullStream,
diff --git a/src/cli/ui.ts b/src/cli/ui.ts
index a8ef5d61..6436501c 100644
--- a/src/cli/ui.ts
+++ b/src/cli/ui.ts
@@ -3,12 +3,15 @@
*
* This is the only CLI subcommand that renders the Ink/React TUI.
* The TUI always starts at the home route; deep-linking is not supported.
+ *
+ * Ink, React, and the TUI app are all loaded lazily inside run() so that
+ * resolving this module's meta (e.g. for `noorm --help`'s root command
+ * listing, or `noorm ui --help`) never pays their import cost — only
+ * actually launching the UI does.
*/
import { Writable } from 'node:stream';
import { defineCommand } from 'citty';
-import { render } from 'ink';
-import React from 'react';
import { observer } from '../core/observer.js';
import { enableAutoLoggerInit } from '../core/logger/init.js';
@@ -28,9 +31,11 @@ const uiCommand = defineCommand({
},
async run() {
- // Lazy import the TUI app so citty --help on other commands
- // doesn't pay the cost of loading Ink + all screens.
- const { App } = await import('../tui/app.js');
+ const [{ render }, { default: React }, { App }] = await Promise.all([
+ import('ink'),
+ import('react'),
+ import('../tui/app.js'),
+ ]);
enableAutoLoggerInit(process.cwd(), {
console: nullStream,
diff --git a/src/cli/update.ts b/src/cli/update.ts
index e1bb30d9..e2338792 100644
--- a/src/cli/update.ts
+++ b/src/cli/update.ts
@@ -10,7 +10,7 @@ import { attempt } from '@logosdx/utils';
import { observer } from '../core/observer.js';
import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js';
import { installUpdate } from '../core/update/updater.js';
-import { outputError, outputResult, sharedArgs } from './_utils.js';
+import { isInsecureMode, outputError, outputResult, sharedArgs } from './_utils.js';
/** Render a byte count as MB with one decimal (e.g. 41.2). */
function toMb(bytes: number): string {
@@ -26,6 +26,10 @@ const updateCommand = defineCommand({
},
args: {
json: sharedArgs.json,
+ insecure: {
+ type: 'boolean',
+ description: 'Skip checksum verification when checksums.txt is unreachable (never bypasses a confirmed mismatch)',
+ },
},
async run({ args }) {
@@ -115,10 +119,18 @@ const updateCommand = defineCommand({
}
+ const insecure = isInsecureMode(args);
+
+ if (insecure) {
+
+ process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable (--insecure).\n');
+
+ }
+
observer.on('update:progress', onProgress);
observer.on('update:retry', onRetry);
- const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion));
+ const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion, { insecure }));
observer.off('update:progress', onProgress);
observer.off('update:retry', onRetry);
@@ -184,6 +196,7 @@ const updateCommand = defineCommand({
(updateCommand as typeof updateCommand & { examples: string[] }).examples = [
'noorm update',
'noorm update --json',
+ 'noorm update --insecure',
];
export default updateCommand;
diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts
index 646d35ce..ec36e662 100644
--- a/src/core/change/executor.ts
+++ b/src/core/change/executor.ts
@@ -22,6 +22,7 @@
*/
import path from 'node:path';
import { readFile, writeFile as fsWriteFile, mkdir } from 'node:fs/promises';
+import type { Kysely } from 'kysely';
import { sql } from 'kysely';
import { attempt, attemptSync } from '@logosdx/utils';
@@ -31,8 +32,10 @@ import { formatIdentity } from '../identity/resolver.js';
import { processFile, isTemplate } from '../template/index.js';
import { assertPolicy } from '../policy/index.js';
import type { Permission } from '../policy/index.js';
+import type { Dialect } from '../connection/types.js';
import { computeChecksum, computeCombinedChecksum } from '../runner/checksum.js';
import { getSqlErrorMessage } from '../shared/index.js';
+import type { NoormDatabase } from '../shared/index.js';
import { getLockManager } from '../lock/index.js';
import { ChangeHistory } from './history.js';
import { ChangeTracker } from './tracker.js';
@@ -58,9 +61,6 @@ const DEFAULT_OPTIONS: Required> & { output: strin
output: null,
};
-/** Default SQL template - files with only this content are considered empty */
-const SQL_TEMPLATE = '-- TODO: Add SQL statements here';
-
/**
* Gate a change entrypoint against the config's access policy.
*
@@ -237,6 +237,7 @@ export async function executeChange(
files,
'change',
changeChecksum,
+ opts.force,
history,
start,
),
@@ -376,6 +377,7 @@ export async function revertChange(
files,
'revert',
revertChecksum,
+ opts.force,
history,
start,
),
@@ -405,8 +407,46 @@ export async function revertChange(
// Internal: Execute Files
// ─────────────────────────────────────────────────────────────
+/**
+ * Dialects where wrapping a change's execution in a DB transaction
+ * actually rolls back DDL alongside the history rows written for it.
+ * Postgres only, for now: MySQL's DDL
+ * implicitly commits (a wrapping transaction would silently do nothing),
+ * MSSQL's GO-batch-split execution (`runner/mssql-batches.ts`) hasn't been
+ * verified to compose safely with a wrapping transaction, and SQLite is
+ * excluded on purpose so the per-file-skip scenario this ticket's unit
+ * tests depend on (file A commits independently of file B's failure)
+ * keeps working rather than collapsing into all-or-nothing.
+ */
+const TRANSACTIONAL_DIALECTS = new Set(['postgres']);
+
+/**
+ * Sentinel used to carry a failed `ChangeResult` out of a rolled-back
+ * Postgres transaction.
+ *
+ * Thrown (never returned) from inside `context.db.transaction().execute()`
+ * so Kysely rolls back everything issued in the callback — DDL and the
+ * operation/file history rows alike. Caught immediately outside the
+ * transaction and unwrapped back into the result the caller sees.
+ */
+class ChangeRollback extends Error {
+
+ constructor(readonly result: ChangeResult) {
+
+ super('change rolled back');
+
+ }
+
+}
+
/**
* Execute files with tracking.
+ *
+ * Dispatches to `runFileBatch` either directly against `context.db`
+ * (non-transactional dialects — identical to CP1's behavior) or inside a
+ * `context.db.transaction()` (Postgres), so a failed Postgres change
+ * leaves no partial state: neither the DDL nor the operation/file history
+ * rows persist.
*/
async function executeFiles(
context: ChangeContext,
@@ -414,13 +454,105 @@ async function executeFiles(
files: ChangeFile[],
direction: 'change' | 'revert',
checksum: string,
+ force: boolean,
history: ChangeHistory,
startTime: number,
): Promise {
- // Expand .txt manifests to actual file list
+ const dialect = context.dialect ?? 'postgres';
const expandedFiles = await expandFiles(files, context.sqlDir);
+ if (!TRANSACTIONAL_DIALECTS.has(dialect)) {
+
+ return runFileBatch(
+ context,
+ change,
+ expandedFiles,
+ direction,
+ checksum,
+ force,
+ history,
+ context.db,
+ startTime,
+ );
+
+ }
+
+ // On Postgres a FAILED change leaves NO persisted history at all —
+ // the operation and file rows created inside this transaction roll
+ // back together with the DDL. The caller still sees the failure via
+ // the returned ChangeResult (unwrapped from ChangeRollback below),
+ // but it's invisible in the DB — intentional per spec (atomicity over
+ // a persisted failure record). On retry, the top-level `needsRun`
+ // finds no record for the change and reruns it fresh. Do NOT try to
+ // persist a failure record outside the transaction — that would
+ // defeat the guarantee this branch exists for.
+ const [result, err] = await attempt(() =>
+ context.db.transaction().execute(async (trx) => {
+
+ const trxHistory = new ChangeHistory(trx, context.configName, dialect);
+
+ const batchResult = await runFileBatch(
+ context,
+ change,
+ expandedFiles,
+ direction,
+ checksum,
+ force,
+ trxHistory,
+ trx,
+ startTime,
+ );
+
+ if (batchResult.status !== 'success') {
+
+ throw new ChangeRollback(batchResult);
+
+ }
+
+ return batchResult;
+
+ }),
+ );
+
+ if (err) {
+
+ if (err instanceof ChangeRollback) {
+
+ return { ...err.result, operationId: undefined };
+
+ }
+
+ return createFailedResult(change.name, direction, err.message, startTime);
+
+ }
+
+ return result;
+
+}
+
+/**
+ * Run one execution batch — operation creation, per-file execution with
+ * history tracking, and finalization — against a given executor handle.
+ *
+ * Extracted from `executeFiles` so the identical batch logic runs either
+ * directly against `context.db` (non-transactional dialects) or inside a
+ * `context.db.transaction()` callback against `trx` (Postgres): only the
+ * `executor` (where SQL runs) and `history` (where tracking rows are
+ * written) vary between the two call sites.
+ */
+async function runFileBatch(
+ context: ChangeContext,
+ change: Change,
+ expandedFiles: ChangeFile[],
+ direction: 'change' | 'revert',
+ checksum: string,
+ force: boolean,
+ history: ChangeHistory,
+ executor: Kysely,
+ startTime: number,
+): Promise {
+
// Create operation record
const [operationId, createErr] = await attempt(() =>
history.createOperation({
@@ -509,6 +641,53 @@ async function executeFiles(
});
const fileStart = performance.now();
+ const relPath = path.relative(context.projectRoot, file.path);
+ const fileChecksum = fileChecksums.get(file.path) ?? '';
+
+ // Per-file skip: a prior success with a matching checksum means this
+ // file doesn't need to run again, even though the overall change's
+ // checksum differs because another file needed fixing.
+ const needsRunFileResult = await history.needsRunFile(
+ change.name,
+ direction,
+ relPath,
+ fileChecksum,
+ force,
+ );
+
+ if (!needsRunFileResult.needsRun) {
+
+ results.push({
+ filepath: file.path,
+ checksum: fileChecksum,
+ status: 'skipped',
+ skipReason: needsRunFileResult.skipReason,
+ durationMs: 0,
+ });
+
+ const skipUpdateErr = await history.updateFileExecution(
+ operationId,
+ relPath,
+ 'skipped',
+ 0,
+ undefined,
+ needsRunFileResult.skipReason,
+ );
+
+ if (skipUpdateErr) {
+
+ // Log but continue - the skip decision itself is sound
+ observer.emit('error', {
+ source: 'change',
+ error: new Error(skipUpdateErr),
+ context: { filepath: relPath, operation: 'update-skipped-record' },
+ });
+
+ }
+
+ continue;
+
+ }
// Load and render file
const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile(context, file.path));
@@ -524,14 +703,13 @@ async function executeFiles(
results.push({
filepath: file.path,
- checksum: fileChecksums.get(file.path) ?? '',
+ checksum: fileChecksum,
status: 'failed',
error: loadErr.message,
durationMs,
});
// Update DB record (use relative path to match createFileRecords)
- const relPath = path.relative(context.projectRoot, file.path);
const updateErr = await history.updateFileExecution(
operationId,
relPath,
@@ -556,7 +734,7 @@ async function executeFiles(
}
// Execute SQL
- const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db));
+ const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(executor));
const durationMs = performance.now() - fileStart;
@@ -571,17 +749,16 @@ async function executeFiles(
results.push({
filepath: file.path,
- checksum: fileChecksums.get(file.path) ?? '',
+ checksum: fileChecksum,
status: 'failed',
error: errorMessage,
durationMs,
});
// Update DB record (use relative path to match createFileRecords)
- const execRelPath = path.relative(context.projectRoot, file.path);
const updateErr2 = await history.updateFileExecution(
operationId,
- execRelPath,
+ relPath,
'failed',
durationMs,
errorMessage,
@@ -593,7 +770,7 @@ async function executeFiles(
observer.emit('error', {
source: 'change',
error: new Error(updateErr2),
- context: { filepath: execRelPath, operation: 'update-failed-record' },
+ context: { filepath: relPath, operation: 'update-failed-record' },
});
}
@@ -605,16 +782,15 @@ async function executeFiles(
// Success
results.push({
filepath: file.path,
- checksum: fileChecksums.get(file.path) ?? '',
+ checksum: fileChecksum,
status: 'success',
durationMs,
});
// Update DB record (use relative path to match createFileRecords)
- const successRelPath = path.relative(context.projectRoot, file.path);
const updateErr = await history.updateFileExecution(
operationId,
- successRelPath,
+ relPath,
'success',
durationMs,
);
@@ -625,7 +801,7 @@ async function executeFiles(
observer.emit('error', {
source: 'change',
error: new Error(updateErr),
- context: { filepath: successRelPath, operation: 'update-success-record' },
+ context: { filepath: relPath, operation: 'update-success-record' },
});
}
@@ -923,10 +1099,7 @@ async function validateFilesHaveContent(files: ChangeFile[]): Promise {
}
- const trimmed = content?.trim() ?? '';
-
- // Check if file has actual content (not empty, not just the template)
- if (trimmed && trimmed !== SQL_TEMPLATE) {
+ if (hasExecutableSql(content ?? '')) {
return true;
@@ -938,6 +1111,27 @@ async function validateFilesHaveContent(files: ChangeFile[]): Promise {
}
+/**
+ * True if content has a line that isn't blank or a `--` line comment.
+ *
+ * Scaffolded stubs (and the default template) are comment-only by design;
+ * matching on a single fixed template string missed any other wording,
+ * so a differently-worded stub would silently execute as a no-op.
+ */
+function hasExecutableSql(content: string): boolean {
+
+ return content
+ .split('\n')
+ .some((line) => {
+
+ const trimmed = line.trim();
+
+ return trimmed.length > 0 && !trimmed.startsWith('--');
+
+ });
+
+}
+
/**
* Create a failed result.
*/
diff --git a/src/core/change/history.ts b/src/core/change/history.ts
index ad170bd5..c78f8d9c 100644
--- a/src/core/change/history.ts
+++ b/src/core/change/history.ts
@@ -48,6 +48,43 @@ import type {
} from './types.js';
import type { ChangeType } from '../shared/index.js';
+// ─────────────────────────────────────────────────────────────
+// Date Hydration
+// ─────────────────────────────────────────────────────────────
+
+/**
+ * Normalizes a change-tracking timestamp column to a real `Date`.
+ *
+ * WHY: postgres/mysql/mssql drivers parse `executed_at` into a `Date`
+ * automatically, but SQLite (both `bun:sqlite` and `better-sqlite3`)
+ * hands back the raw `CURRENT_TIMESTAMP` text (`'YYYY-MM-DD HH:MM:SS'`,
+ * always UTC, no offset marker). Parsing that string with `new Date(str)`
+ * directly reads it as local time, silently shifting the result by the
+ * host's UTC offset — so the string must be marked UTC explicitly first.
+ *
+ * @example
+ * hydrateDate('2026-07-12 09:02:59') // -> 2026-07-12T09:02:59.000Z
+ * hydrateDate(new Date('2026-07-12T09:02:59.000Z')) // -> unchanged
+ * hydrateDate(null) // -> null
+ */
+export function hydrateDate(value: Date | string | null | undefined): Date | null {
+
+ if (value === null || value === undefined) {
+
+ return null;
+
+ }
+
+ if (value instanceof Date) {
+
+ return value;
+
+ }
+
+ return new Date(`${value.replace(' ', 'T')}Z`);
+
+}
+
// ─────────────────────────────────────────────────────────────
// History Class
// ─────────────────────────────────────────────────────────────
@@ -117,6 +154,7 @@ export class ChangeHistory {
this.#ndb
.selectFrom(this.#tables.change)
.select([
+ 'id',
'name',
'status',
'executed_at',
@@ -169,10 +207,11 @@ export class ChangeHistory {
return {
name: record.name,
status: record.status,
- appliedAt: record.executed_at,
+ appliedAt: hydrateDate(record.executed_at),
appliedBy: record.executed_by,
- revertedAt: revertRecord?.executed_at ?? null,
+ revertedAt: hydrateDate(revertRecord?.executed_at),
errorMessage: record.error_message || null,
+ appliedHistoryId: record.id,
};
}
@@ -220,10 +259,11 @@ export class ChangeHistory {
statuses.set(record.name, {
name: record.name,
status: record.status,
- appliedAt: record.executed_at,
+ appliedAt: hydrateDate(record.executed_at),
appliedBy: record.executed_by,
revertedAt: null, // Will be filled in below
errorMessage: record.error_message || null,
+ appliedHistoryId: record.id,
});
}
@@ -253,7 +293,7 @@ export class ChangeHistory {
if (!seenReverts.has(revert.name) && statuses.has(revert.name)) {
const status = statuses.get(revert.name)!;
- status.revertedAt = revert.executed_at;
+ status.revertedAt = hydrateDate(revert.executed_at);
seenReverts.add(revert.name);
}
@@ -379,6 +419,132 @@ export class ChangeHistory {
}
+ /**
+ * Check if a single file within a change needs to run.
+ *
+ * Mirrors `Tracker.needsRun` (runner/tracker.ts) but scoped to this
+ * change's name+direction+config instead of a global filepath lookup,
+ * so file A's success under one change never satisfies file A's check
+ * under a different change.
+ *
+ * Excludes `pending` rows from consideration: `createFileRecords`
+ * inserts a fresh pending row for every file before the per-file loop
+ * runs, and that row (always the highest id for this filepath) would
+ * otherwise shadow the prior operation's real success/failure record,
+ * making retries re-run every file instead of just the one that failed.
+ *
+ * Also excludes `skipped` rows: `status: 'skipped'` is written for two
+ * different meanings — `skipRemainingFiles` writes it for files never
+ * reached after an earlier failure (must re-run), while this method's
+ * own per-file-skip path (called from `executor.ts`) writes it for a
+ * file that matched a prior success (must stay skipped). A `skipped`
+ * row is never itself a decision basis: a success-match skip still has
+ * its covering `success` row further back in history (found once the
+ * `skipped` row is excluded), and a never-reached skip has no terminal
+ * row at all, so the lookup falls through to `{ needsRun: true, reason:
+ * 'new' }` below and the file runs. Both resolve correctly without
+ * consulting the ambiguous row — excluding it here is what prevents a
+ * third attempt from re-running a file a prior success already covered.
+ *
+ * @param name - Change name
+ * @param direction - 'change' or 'revert'
+ * @param filepath - Relative filepath as stored in execution records
+ * @param checksum - Current checksum of the file
+ * @param force - Force re-run regardless of status
+ * @returns Whether the file needs to run and why
+ */
+ async needsRunFile(
+ name: string,
+ direction: Direction,
+ filepath: string,
+ checksum: string,
+ force: boolean,
+ ): Promise {
+
+ // Force always runs
+ if (force) {
+
+ return { needsRun: true, reason: 'force' };
+
+ }
+
+ // Get most recent completed execution record for this file, scoped
+ // to this change's name+direction+config
+ const [record, err] = await attempt(() =>
+ (this.#ndb
+ .selectFrom(this.#tables.executions)
+ .innerJoin(
+ this.#tables.change,
+ `${this.#tables.change}.id`,
+ `${this.#tables.executions}.change_id`,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) as any)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .select((eb: any) => [
+ eb.ref(`${this.#tables.executions}.checksum`).as('checksum'),
+ eb.ref(`${this.#tables.executions}.status`).as('exec_status'),
+ ])
+ .where(`${this.#tables.change}.name`, '=', name)
+ .where(`${this.#tables.change}.direction`, '=', direction)
+ .where(`${this.#tables.change}.config_name`, '=', this.#configName)
+ .where(`${this.#tables.executions}.filepath`, '=', filepath)
+ .where(`${this.#tables.executions}.status`, 'not in', ['pending', 'skipped'])
+ .orderBy(`${this.#tables.executions}.id`, 'desc')
+ .limit(1)
+ .executeTakeFirst(),
+ );
+
+ if (err) {
+
+ observer.emit('error', {
+ source: 'change',
+ error: err,
+ context: { name, filepath, operation: 'needs-run-file-check' },
+ });
+
+ // On error, assume needs to run
+ return { needsRun: true, reason: 'new' };
+
+ }
+
+ // No previous completed record - new file
+ if (!record) {
+
+ return { needsRun: true, reason: 'new' };
+
+ }
+
+ // Previous execution failed - retry
+ if (record.exec_status === 'failed') {
+
+ return {
+ needsRun: true,
+ reason: 'failed',
+ previousChecksum: record.checksum,
+ };
+
+ }
+
+ // Checksum changed since the last recorded attempt
+ if (record.checksum !== checksum) {
+
+ return {
+ needsRun: true,
+ reason: 'changed',
+ previousChecksum: record.checksum,
+ };
+
+ }
+
+ // Success and unchanged - skip
+ return {
+ needsRun: false,
+ skipReason: 'already applied',
+ previousChecksum: record.checksum,
+ };
+
+ }
+
// ─────────────────────────────────────────────────────────
// Create Records
// ─────────────────────────────────────────────────────────
@@ -908,7 +1074,9 @@ export class ChangeHistory {
name: r.name,
direction: r.direction,
status: r.status,
- executedAt: r.executed_at,
+ // Non-null: executed_at is NOT NULL with a CURRENT_TIMESTAMP
+ // default, always populated on write (see createOperation).
+ executedAt: hydrateDate(r.executed_at)!,
executedBy: r.executed_by,
durationMs: r.duration_ms,
errorMessage: r.error_message || null,
@@ -981,7 +1149,9 @@ export class ChangeHistory {
changeType: r.change_type,
direction: r.direction,
status: r.status,
- executedAt: r.executed_at,
+ // Non-null: executed_at is NOT NULL with a CURRENT_TIMESTAMP
+ // default, always populated on write (see createOperation).
+ executedAt: hydrateDate(r.executed_at)!,
executedBy: r.executed_by,
durationMs: r.duration_ms,
errorMessage: r.error_message || null,
diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts
index 1e42c175..81520536 100644
--- a/src/core/change/manager.ts
+++ b/src/core/change/manager.ts
@@ -135,6 +135,7 @@ export class ChangeManager {
appliedBy: status?.appliedBy ?? null,
revertedAt: status?.revertedAt ?? null,
errorMessage: status?.errorMessage ?? null,
+ appliedHistoryId: status?.appliedHistoryId ?? null,
isNew: !status,
orphaned: false,
});
@@ -153,6 +154,7 @@ export class ChangeManager {
appliedBy: status.appliedBy,
revertedAt: status.revertedAt,
errorMessage: status.errorMessage,
+ appliedHistoryId: status.appliedHistoryId ?? null,
isNew: false,
orphaned: true,
// Disk fields are not available for orphaned changes
@@ -362,7 +364,12 @@ export class ChangeManager {
const opts = { ...DEFAULT_BATCH, ...options };
const start = performance.now();
- // Get applied changes sorted by appliedAt (most recent first)
+ // Get applied changes sorted by appliedAt (most recent first), tiebreaking
+ // on the history table's autoincrement id -- appliedAt is second-precision,
+ // so changes applied within the same tick (e.g. `change ff`) tie, and the
+ // id is the only reliable true apply-order key. appliedAt stays primary
+ // because not every applied entry is guaranteed to carry an id (older/
+ // partial hydration paths leave it undefined).
const list = await this.list();
const applied = list
.filter((cs) => !cs.orphaned && cs.status === 'success' && cs.appliedAt)
@@ -371,7 +378,16 @@ export class ChangeManager {
const aTime = a.appliedAt?.getTime() ?? 0;
const bTime = b.appliedAt?.getTime() ?? 0;
- return bTime - aTime; // Most recent first
+ if (aTime !== bTime) {
+
+ return bTime - aTime; // Most recent first
+
+ }
+
+ const aId = a.appliedHistoryId ?? 0;
+ const bId = b.appliedHistoryId ?? 0;
+
+ return bId - aId; // Tie: most recently applied (highest id) first
});
diff --git a/src/core/change/scaffold.ts b/src/core/change/scaffold.ts
index 653a8809..45b7110e 100644
--- a/src/core/change/scaffold.ts
+++ b/src/core/change/scaffold.ts
@@ -27,6 +27,8 @@ import path from 'node:path';
import { mkdir, writeFile, rename, unlink, rm, stat } from 'node:fs/promises';
import { attempt } from '@logosdx/utils';
+import dayjs from 'dayjs';
+import v from 'voca';
import { observer } from '../observer.js';
import { parseSequence, parseDescription } from './parser.js';
@@ -46,6 +48,14 @@ import { ChangeValidationError } from './types.js';
const SQL_TEMPLATE = `-- TODO: Add SQL statements here
`;
+/** Stub content for a freshly scaffolded change/_.sql */
+const CHANGE_STUB_TEMPLATE = `-- Add the SQL statements that apply this change.
+`;
+
+/** Stub content for a freshly scaffolded revert/_.sql */
+const REVERT_STUB_TEMPLATE = `-- Add the SQL statements that undo this change.
+`;
+
/** Default template for changelog */
const CHANGELOG_TEMPLATE = `# Changelog
@@ -71,7 +81,7 @@ TODO: Describe any impact on existing data or functionality.
*
* @param changesDir - Parent directory for changes
* @param options - Creation options
- * @returns Parsed change (empty, ready for files)
+ * @returns Parsed change with a runnable change/revert stub already scaffolded
*
* @example
* ```typescript
@@ -90,8 +100,8 @@ export async function createChange(
// Generate name
const date = options.date ?? new Date();
- const dateStr = formatDate(date);
- const slug = slugify(options.description);
+ const dateStr = dayjs(date).format('YYYY-MM-DD');
+ const slug = v.slugify(options.description);
const name = `${dateStr}-${slug}`;
const changeDir = path.join(changesDir, name);
@@ -133,8 +143,11 @@ export async function createChange(
path: changeDir,
});
- // Return parsed change
- return {
+ // An empty change/ + revert/ pair fails parseChange's validation, and the
+ // caller sees that misreported as "change not found" rather than "needs
+ // editing". Scaffold a stub in each folder via addFile so the sequence/
+ // filename convention has one source of truth.
+ let change: Change = {
name,
path: changeDir,
date,
@@ -144,6 +157,19 @@ export async function createChange(
hasChangelog: true,
};
+ change = await addFile(change, 'change', {
+ name: slug,
+ type: 'sql',
+ content: CHANGE_STUB_TEMPLATE,
+ });
+ change = await addFile(change, 'revert', {
+ name: slug,
+ type: 'sql',
+ content: REVERT_STUB_TEMPLATE,
+ });
+
+ return change;
+
}
// ─────────────────────────────────────────────────────────────
@@ -187,7 +213,7 @@ export async function addFile(
const sequence = maxSequence + 1;
// Generate filename
- const slug = slugify(options.name);
+ const slug = v.slugify(options.name);
const extension = options.type === 'txt' ? '.txt' : '.sql';
const filename = `${padSequence(sequence)}_${slug}${extension}`;
@@ -352,7 +378,7 @@ export async function renameFile(
}
// Generate new filename
- const slug = slugify(newDescription);
+ const slug = v.slugify(newDescription);
const newFilename = `${padSequence(sequence)}_${slug}${extension}`;
const newPath = path.join(path.dirname(file.path), newFilename);
@@ -563,32 +589,6 @@ export async function deleteChange(change: Change): Promise {
// Internal Helpers
// ─────────────────────────────────────────────────────────────
-/**
- * Format date as YYYY-MM-DD.
- */
-function formatDate(date: Date): string {
-
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
-
-}
-
-/**
- * Convert text to URL-safe slug.
- */
-function slugify(text: string): string {
-
- return text
- .toLowerCase()
- .trim()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
-
-}
-
/**
* Pad sequence number to 3 digits.
*/
diff --git a/src/core/change/types.ts b/src/core/change/types.ts
index a38f660e..7b88d68e 100644
--- a/src/core/change/types.ts
+++ b/src/core/change/types.ts
@@ -147,6 +147,14 @@ export interface ChangeStatus {
/** Error message if failed */
errorMessage: string | null;
+
+ /**
+ * History-row id of the record that produced `appliedAt` — the true
+ * apply-order key (autoincrement, unlike second-precision `appliedAt`).
+ * Optional/additive: `undefined` where not hydrated, `null` when the
+ * change has never been applied.
+ */
+ appliedHistoryId?: number | null;
}
// ─────────────────────────────────────────────────────────────
@@ -216,6 +224,14 @@ export interface ChangeListItem {
/** Error message if failed */
errorMessage: string | null;
+ /**
+ * History-row id of the record that produced `appliedAt` — the true
+ * apply-order key (autoincrement, unlike second-precision `appliedAt`).
+ * Optional/additive: `undefined` where not hydrated, `null` when the
+ * change has never been applied.
+ */
+ appliedHistoryId?: number | null;
+
// Computed
/** True if exists on disk but no DB record */
isNew: boolean;
diff --git a/src/core/config/index.ts b/src/core/config/index.ts
index 4aebf28f..eb645ac7 100644
--- a/src/core/config/index.ts
+++ b/src/core/config/index.ts
@@ -116,6 +116,8 @@ export {
resolveConfig,
checkConfigCompleteness,
canDeleteConfig,
+ assertCanDeleteConfig,
+ ConfigStageLockedError,
type ResolveOptions,
type StateProvider,
type SettingsProvider,
diff --git a/src/core/config/resolver.ts b/src/core/config/resolver.ts
index 4d020408..9b4c3080 100644
--- a/src/core/config/resolver.ts
+++ b/src/core/config/resolver.ts
@@ -415,6 +415,35 @@ export function checkConfigCompleteness(
}
+/**
+ * Error when a config is linked to a locked stage and cannot be deleted.
+ *
+ * Thrown by `assertCanDeleteConfig` — the throwing counterpart to
+ * `canDeleteConfig`'s bool+reason check.
+ *
+ * @example
+ * ```typescript
+ * const [, err] = await attempt(() => stateManager.deleteConfig(name, settings))
+ * if (err instanceof ConfigStageLockedError) {
+ * console.log(`Blocked by stage "${err.stageName}"`)
+ * }
+ * ```
+ */
+export class ConfigStageLockedError extends Error {
+
+ override readonly name = 'ConfigStageLockedError' as const;
+
+ constructor(
+ public readonly configName: string,
+ public readonly stageName: string,
+ ) {
+
+ super(`Config "${configName}" is linked to locked stage "${stageName}" and cannot be deleted`);
+
+ }
+
+}
+
/**
* Check if a config can be deleted.
*
@@ -440,7 +469,7 @@ export function canDeleteConfig(
return {
allowed: false,
- reason: `Config "${configName}" is linked to a locked stage and cannot be deleted`,
+ reason: `Config "${configName}" is linked to locked stage "${stageName ?? configName}" and cannot be deleted`,
};
}
@@ -448,3 +477,29 @@ export function canDeleteConfig(
return { allowed: true };
}
+
+/**
+ * Runs `canDeleteConfig` and throws when denied — the throwing counterpart
+ * every deletion seam reaches for, mirroring `checkConfigPolicy`/`assertPolicy`
+ * in `core/policy/check.ts`.
+ *
+ * @throws ConfigStageLockedError when the config's stage is locked.
+ *
+ * @example
+ * assertCanDeleteConfig(configName, settingsProvider);
+ */
+export function assertCanDeleteConfig(
+ configName: string,
+ settings?: SettingsProvider,
+ stageName?: string,
+): void {
+
+ const check = canDeleteConfig(configName, settings, stageName);
+
+ if (!check.allowed) {
+
+ throw new ConfigStageLockedError(configName, stageName ?? configName);
+
+ }
+
+}
diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts
index 63cfd62e..7c6740f9 100644
--- a/src/core/config/schema.ts
+++ b/src/core/config/schema.ts
@@ -6,6 +6,7 @@
*/
import { z } from 'zod';
+import { PortSchema } from '../connection/defaults.js';
import { resolveLegacyAccess } from '../policy/index.js';
import type { ConfigAccess } from '../policy/index.js';
@@ -49,7 +50,7 @@ function withResolvedAccess conn.dialect === 'sqlite' || conn.host, {
message: 'Host is required for non-SQLite databases',
path: ['host'],
- });
+ })
+ .refine(
+ (conn) => conn.dialect === 'sqlite' || !containsDangerousDbNameChar(conn.database),
+ {
+ message:
+ 'Database name must not contain quotes, backticks, brackets, semicolons, or control characters',
+ path: ['database'],
+ },
+ );
/**
* Full config object schema, before access/protected resolution.
diff --git a/src/core/config/validate.ts b/src/core/config/validate.ts
new file mode 100644
index 00000000..ecf76b43
--- /dev/null
+++ b/src/core/config/validate.ts
@@ -0,0 +1,89 @@
+/**
+ * Config validate algorithm.
+ *
+ * Single source for the three-check sequence (connection, name/database
+ * presence, host presence for non-sqlite) shared by `cli/config/validate.ts`
+ * (text/JSON output) and `ConfigValidateScreen.tsx` (`StatusList`/toast).
+ * Both call `validateConfigChecks` and keep only their own presentation
+ * layer.
+ */
+import { testConnection } from '../connection/factory.js';
+import type { Config } from './types.js';
+
+/**
+ * Result of a single validate check.
+ */
+export interface ConfigCheckResult {
+ key: string;
+ label: string;
+ status: 'success' | 'error';
+ detail: string;
+}
+
+/**
+ * Runs the config-validate check sequence against a resolved config:
+ * connection test, then name/database presence, then host presence for
+ * non-sqlite dialects. All checks always run (no fail-fast) so callers can
+ * show a full report; `valid` is the AND of every check's status.
+ *
+ * @example
+ * ```typescript
+ * const { checks, valid } = await validateConfigChecks(config);
+ * ```
+ */
+export async function validateConfigChecks(
+ config: Config,
+): Promise<{ checks: ConfigCheckResult[]; valid: boolean }> {
+
+ const checks: ConfigCheckResult[] = [];
+ let valid = true;
+
+ const connResult = await testConnection(config.connection);
+
+ checks.push({
+ key: 'connection',
+ label: 'Connection',
+ status: connResult.ok ? 'success' : 'error',
+ detail: connResult.ok ? 'Connection successful' : (connResult.error ?? 'Connection failed'),
+ });
+
+ if (!connResult.ok) valid = false;
+
+ const requiredChecks = [
+ { key: 'name', label: 'Name', value: config.name },
+ { key: 'database', label: 'Database', value: config.connection.database },
+ ];
+
+ for (const check of requiredChecks) {
+
+ const isSet = Boolean(check.value);
+ checks.push({
+ key: check.key,
+ label: check.label,
+ status: isSet ? 'success' : 'error',
+ detail: isSet ? check.value : 'Not set',
+ });
+
+ if (!isSet) valid = false;
+
+ }
+
+ if (config.connection.dialect !== 'sqlite') {
+
+ const host = config.connection.host;
+ const hasHost = Boolean(host);
+
+ checks.push({
+ key: 'host',
+ label: 'Host',
+ status: hasHost ? 'success' : 'error',
+ detail: host ? host : 'Not set',
+ });
+
+ if (!hasHost) valid = false;
+
+ }
+
+ return { checks, valid };
+
+}
diff --git a/src/core/connection/defaults.ts b/src/core/connection/defaults.ts
new file mode 100644
index 00000000..deb14b10
--- /dev/null
+++ b/src/core/connection/defaults.ts
@@ -0,0 +1,27 @@
+/**
+ * Default connection ports by dialect.
+ *
+ * Single source for the port every dialect factory, `same-server.ts`, and
+ * the TUI's connection-config builder fall back to when a config omits
+ * `port`.
+ */
+import { z } from 'zod';
+
+import type { Dialect } from './types.js';
+
+export const DEFAULT_PORTS: Record = {
+ postgres: 5432,
+ mysql: 3306,
+ sqlite: 0, // Not applicable
+ mssql: 1433,
+};
+
+/**
+ * Port number validation. Shared by `core/config` and `core/settings` so the
+ * bound has one source of truth.
+ */
+export const PortSchema = z
+ .number()
+ .int()
+ .min(1, 'Port must be at least 1')
+ .max(65535, 'Port must be at most 65535');
diff --git a/src/core/connection/dialects/mssql.ts b/src/core/connection/dialects/mssql.ts
index 367f803f..7d518c90 100644
--- a/src/core/connection/dialects/mssql.ts
+++ b/src/core/connection/dialects/mssql.ts
@@ -10,6 +10,7 @@
*/
import { Kysely, MssqlDialect, sql } from 'kysely';
import type { ConnectionConfig, ConnectionResult } from '../types.js';
+import { DEFAULT_PORTS } from '../defaults.js';
import { MssqlLimitPlugin } from './mssql-limit-plugin.js';
/**
@@ -34,7 +35,7 @@ function buildTediousConfig(
},
},
options: {
- port: config.port ?? 1433,
+ port: config.port ?? DEFAULT_PORTS.mssql,
database: database ?? config.database,
trustServerCertificate: !config.ssl,
encrypt: true,
@@ -83,7 +84,7 @@ async function verifyDatabaseExists(
if (rows.length === 0) {
throw new Error(
- `Database '${config.database}' does not exist on ${config.host ?? 'localhost'}:${config.port ?? 1433}`,
+ `Database '${config.database}' does not exist on ${config.host ?? 'localhost'}:${config.port ?? DEFAULT_PORTS.mssql}`,
);
}
@@ -109,7 +110,6 @@ async function verifyDatabaseExists(
* const conn = createMssqlConnection({
* dialect: 'mssql',
* host: 'localhost',
- * port: 1433,
* database: 'myapp',
* user: 'sa',
* password: 'secret',
diff --git a/src/core/connection/dialects/mysql.ts b/src/core/connection/dialects/mysql.ts
index 9372b625..8291facc 100644
--- a/src/core/connection/dialects/mysql.ts
+++ b/src/core/connection/dialects/mysql.ts
@@ -6,6 +6,7 @@
*/
import { Kysely, MysqlDialect } from 'kysely';
import type { ConnectionConfig, ConnectionResult } from '../types.js';
+import { DEFAULT_PORTS } from '../defaults.js';
/**
* Create a MySQL connection.
@@ -15,7 +16,6 @@ import type { ConnectionConfig, ConnectionResult } from '../types.js';
* const conn = createMysqlConnection({
* dialect: 'mysql',
* host: 'localhost',
- * port: 3306,
* database: 'myapp',
* user: 'root',
* password: 'secret',
@@ -30,7 +30,7 @@ export async function createMysqlConnection(config: ConnectionConfig): Promise {
+ const { retries = 3, delay = 1000, backoff = 2 } = retryOptions;
+
const [conn, err] = await attempt(() =>
retry(
async () => {
@@ -121,9 +143,9 @@ export async function createConnection(
},
{
- retries: 3,
- delay: 1000,
- backoff: 2, // 1s, 2s, 4s
+ retries,
+ delay,
+ backoff,
jitterFactor: 0.1,
shouldRetry: (err) => {
@@ -179,7 +201,15 @@ export async function createConnection(
destroy: wrappedDestroy,
};
- observer.emit('connection:open', { configName, dialect: config.dialect });
+ // host/port/database remove the "which database was I actually talking
+ // to" ambiguity (#51) -- never user/password.
+ observer.emit('connection:open', {
+ configName,
+ dialect: config.dialect,
+ host: config.host,
+ port: config.port,
+ database: config.database,
+ });
return trackedConn;
diff --git a/src/core/connection/index.ts b/src/core/connection/index.ts
index cf2667c4..69a07b3f 100644
--- a/src/core/connection/index.ts
+++ b/src/core/connection/index.ts
@@ -4,5 +4,7 @@
* Provides database connection creation and management.
*/
export { createConnection, testConnection } from './factory.js';
+export type { ConnectionRetryOptions } from './factory.js';
export { getConnectionManager, resetConnectionManager } from './manager.js';
+export { DEFAULT_PORTS, PortSchema } from './defaults.js';
export * from './types.js';
diff --git a/src/core/connection/manager.ts b/src/core/connection/manager.ts
index 28e6f515..1c2ae81a 100644
--- a/src/core/connection/manager.ts
+++ b/src/core/connection/manager.ts
@@ -4,12 +4,13 @@
* Tracks ALL active database connections and ensures they're closed on shutdown.
* Connections can be cached by config name for reuse, or tracked ephemerally.
*/
-import { attempt } from '@logosdx/utils';
+import { attempt, runWithTimeout } from '@logosdx/utils';
import type { Config } from '../config/types.js';
import type { ConnectionResult } from './types.js';
import type { WorkerBridge } from '../worker-bridge/bridge.js';
import type { ConnectionEvents } from '../worker-bridge/types.js';
import { observer } from '../observer.js';
+import { isDebug } from '../environment.js';
/**
* Internal connection entry with metadata.
@@ -57,7 +58,7 @@ class ConnectionManager {
// Listen for shutdown event
this.#unsubscribe = observer.on('app:shutdown', async () => {
- if (process.env['NOORM_DEBUG']) {
+ if (isDebug()) {
console.error(
`[ConnectionManager] app:shutdown received, closing ${this.size} connections`,
@@ -68,7 +69,7 @@ class ConnectionManager {
this.#shuttingDown = true;
await this.closeAll();
- if (process.env['NOORM_DEBUG']) {
+ if (isDebug()) {
console.error('[ConnectionManager] all connections closed');
@@ -212,22 +213,9 @@ class ConnectionManager {
const trackedEntries = Array.from(this.#tracked.entries());
for (const [id, entry] of trackedEntries) {
- let timer: ReturnType;
- const destroyWithTimeout = Promise.race([
- entry.conn.destroy().then(() => {
-
- clearTimeout(timer);
-
- }),
- new Promise((resolve) => {
-
- timer = setTimeout(resolve, CLOSE_TIMEOUT);
-
- }),
- ]);
-
- const [, err] = await attempt(() => destroyWithTimeout);
- clearTimeout(timer!);
+ const [, err] = await attempt(() =>
+ runWithTimeout(() => entry.conn.destroy(), { timeout: CLOSE_TIMEOUT, throws: true }),
+ );
this.#tracked.delete(id);
if (err) {
diff --git a/src/core/db/dialects/mssql.ts b/src/core/db/dialects/mssql.ts
index 436a10ef..f0b860cb 100644
--- a/src/core/db/dialects/mssql.ts
+++ b/src/core/db/dialects/mssql.ts
@@ -10,6 +10,57 @@ import type { ConnectionConfig } from '../../connection/types.js';
import type { DialectDbOperations } from '../types.js';
import { createConnection } from '../../connection/factory.js';
+import { createDialectQuoting } from '../../shared/index.js';
+
+const { quote } = createDialectQuoting({ open: '[', close: ']', escape: ']]' });
+
+/**
+ * Builds the CREATE DATABASE statement.
+ *
+ * Quotes dbName as a single identifier so embedded closing brackets can't
+ * break out of the DDL into arbitrary statements.
+ *
+ * @example
+ * buildCreateDatabaseSql('my]app'); // → 'CREATE DATABASE [my]]app]'
+ */
+export function buildCreateDatabaseSql(dbName: string): string {
+
+ return `CREATE DATABASE ${quote(dbName)}`;
+
+}
+
+/**
+ * Builds the conditional-drop batch: single-user + rollback to disconnect
+ * active sessions, then drop, guarded by an existence check.
+ *
+ * T-SQL string literals have no backslash-escape channel, so the embedded
+ * single quote is doubled per the standard SQL literal-escaping rule; the
+ * `ALTER`/`DROP` identifiers are bracket-quoted independently of the
+ * literal, since the two escaping rules don't share a channel to break out
+ * through.
+ *
+ * @example
+ * buildDropDatabaseSql('myapp');
+ * // → "IF EXISTS(SELECT 1 FROM sys.databases WHERE name = 'myapp')\n" +
+ * // 'BEGIN\n' +
+ * // ' ALTER DATABASE [myapp] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;\n' +
+ * // ' DROP DATABASE [myapp];\n' +
+ * // 'END'
+ */
+export function buildDropDatabaseSql(dbName: string): string {
+
+ const identifier = quote(dbName);
+ const literal = dbName.replace(/'/g, '\'\'');
+
+ return [
+ `IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '${literal}')`,
+ 'BEGIN',
+ ` ALTER DATABASE ${identifier} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;`,
+ ` DROP DATABASE ${identifier};`,
+ 'END',
+ ].join('\n');
+
+}
/**
* Execute a query against the master database.
@@ -67,7 +118,7 @@ export const mssqlDbOperations: DialectDbOperations = {
await withMasterDb(config, async (conn) => {
- await sql.raw(`CREATE DATABASE [${dbName}]`).execute(conn.db);
+ await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db);
});
@@ -78,17 +129,7 @@ export const mssqlDbOperations: DialectDbOperations = {
await withMasterDb(config, async (conn) => {
// Set to single user mode to disconnect all users
- await sql
- .raw(
- `
- IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '${dbName}')
- BEGIN
- ALTER DATABASE [${dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
- DROP DATABASE [${dbName}];
- END
- `,
- )
- .execute(conn.db);
+ await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db);
});
diff --git a/src/core/db/dialects/mysql.ts b/src/core/db/dialects/mysql.ts
index d6d9ab43..4bb0b5eb 100644
--- a/src/core/db/dialects/mysql.ts
+++ b/src/core/db/dialects/mysql.ts
@@ -10,6 +10,36 @@ import type { ConnectionConfig } from '../../connection/types.js';
import type { DialectDbOperations } from '../types.js';
import { createConnection } from '../../connection/factory.js';
+import { createDialectQuoting } from '../../shared/index.js';
+
+const { quote } = createDialectQuoting({ open: '`', close: '`', escape: '``' });
+
+/**
+ * Builds the CREATE DATABASE IF NOT EXISTS statement.
+ *
+ * Quotes dbName as a single identifier so embedded backticks can't break
+ * out of the DDL into arbitrary statements.
+ *
+ * @example
+ * buildCreateDatabaseSql('my`app'); // → 'CREATE DATABASE IF NOT EXISTS `my``app`'
+ */
+export function buildCreateDatabaseSql(dbName: string): string {
+
+ return `CREATE DATABASE IF NOT EXISTS ${quote(dbName)}`;
+
+}
+
+/**
+ * Builds the DROP DATABASE IF EXISTS statement.
+ *
+ * @example
+ * buildDropDatabaseSql('myapp'); // → 'DROP DATABASE IF EXISTS `myapp`'
+ */
+export function buildDropDatabaseSql(dbName: string): string {
+
+ return `DROP DATABASE IF EXISTS ${quote(dbName)}`;
+
+}
/**
* Execute a query without a database (MySQL allows this).
@@ -63,7 +93,7 @@ export const mysqlDbOperations: DialectDbOperations = {
await withoutDb(config, async (conn) => {
- await sql.raw(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``).execute(conn.db);
+ await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db);
});
@@ -73,7 +103,7 @@ export const mysqlDbOperations: DialectDbOperations = {
await withoutDb(config, async (conn) => {
- await sql.raw(`DROP DATABASE IF EXISTS \`${dbName}\``).execute(conn.db);
+ await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db);
});
diff --git a/src/core/db/dialects/postgres.ts b/src/core/db/dialects/postgres.ts
index 392abb48..1a141d0d 100644
--- a/src/core/db/dialects/postgres.ts
+++ b/src/core/db/dialects/postgres.ts
@@ -10,6 +10,36 @@ import type { ConnectionConfig } from '../../connection/types.js';
import type { DialectDbOperations } from '../types.js';
import { createConnection } from '../../connection/factory.js';
+import { createDialectQuoting } from '../../shared/index.js';
+
+const { quote } = createDialectQuoting({ open: '"', close: '"', escape: '""' });
+
+/**
+ * Builds the CREATE DATABASE statement.
+ *
+ * Quotes dbName as a single identifier so embedded double quotes can't
+ * break out of the DDL into arbitrary statements.
+ *
+ * @example
+ * buildCreateDatabaseSql('my"app'); // → 'CREATE DATABASE "my""app"'
+ */
+export function buildCreateDatabaseSql(dbName: string): string {
+
+ return `CREATE DATABASE ${quote(dbName)}`;
+
+}
+
+/**
+ * Builds the DROP DATABASE IF EXISTS statement.
+ *
+ * @example
+ * buildDropDatabaseSql('myapp'); // → 'DROP DATABASE IF EXISTS "myapp"'
+ */
+export function buildDropDatabaseSql(dbName: string): string {
+
+ return `DROP DATABASE IF EXISTS ${quote(dbName)}`;
+
+}
/**
* Execute a query against the system database.
@@ -67,7 +97,7 @@ export const postgresDbOperations: DialectDbOperations = {
await withSystemDb(config, async (conn) => {
- await sql.raw(`CREATE DATABASE "${dbName}"`).execute(conn.db);
+ await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db);
});
@@ -85,7 +115,7 @@ export const postgresDbOperations: DialectDbOperations = {
AND pid <> pg_backend_pid()
`.execute(conn.db);
- await sql.raw(`DROP DATABASE IF EXISTS "${dbName}"`).execute(conn.db);
+ await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db);
});
diff --git a/src/core/db/operations.ts b/src/core/db/operations.ts
index 84f9a9e9..2c800a55 100644
--- a/src/core/db/operations.ts
+++ b/src/core/db/operations.ts
@@ -32,6 +32,16 @@ import { attempt } from '@logosdx/utils';
*/
export async function checkDbStatus(config: ConnectionConfig): Promise {
+ const ops = getDialectOperations(config.dialect);
+
+ // SQLite's connectivity probe below connects directly to the target file
+ // (factory.ts has no sqlite system database to swap to), which auto-creates
+ // the file as a side effect. Capture existence before that happens so the
+ // post-probe check below can't report a false positive for a fresh target.
+ const sqlitePreProbeExists = config.dialect === 'sqlite'
+ ? await ops.databaseExists(config, config.database)
+ : undefined;
+
// Test server connectivity first
const serverTest = await testConnection(config, { testServerOnly: true });
@@ -47,8 +57,7 @@ export async function checkDbStatus(config: ConnectionConfig): Promise
}
// Check if database exists
- const ops = getDialectOperations(config.dialect);
- const [exists, existsErr] = await attempt(() => ops.databaseExists(config, config.database));
+ const [existsAfterProbe, existsErr] = await attempt(() => ops.databaseExists(config, config.database));
if (existsErr) {
@@ -61,6 +70,8 @@ export async function checkDbStatus(config: ConnectionConfig): Promise
}
+ const exists = sqlitePreProbeExists ?? existsAfterProbe;
+
if (!exists) {
return {
@@ -122,15 +133,17 @@ export async function createDb(
options: CreateDbOptions = {},
): Promise {
- const { ifNotExists = true, initializeTracking = true } = options;
+ const { ifNotExists = true, initializeTracking = true, precheckedStatus } = options;
const dbName = config.database;
// Get dialect operations
const ops = getDialectOperations(config.dialect);
- // Check current status
- const status = await checkDbStatus(config);
+ // Reuse the caller's status when supplied, instead of re-deriving it —
+ // a second checkDbStatus call for SQLite would see the caller's own
+ // probe having already auto-created the target file.
+ const status = precheckedStatus ?? await checkDbStatus(config);
if (!status.serverOk) {
diff --git a/src/core/db/types.ts b/src/core/db/types.ts
index 014c2f43..643045fd 100644
--- a/src/core/db/types.ts
+++ b/src/core/db/types.ts
@@ -48,6 +48,14 @@ export interface CreateDbOptions {
/** Initialize noorm tracking tables (default: true) */
initializeTracking?: boolean;
+
+ /**
+ * Reuse a status already computed by the caller instead of calling
+ * `checkDbStatus` again internally. For SQLite, a caller's own probe
+ * already touched the target file (opening a connection auto-creates
+ * it), so a second internal check would see a false "already exists".
+ */
+ precheckedStatus?: DbStatus;
}
/**
diff --git a/src/core/dt/crypto.ts b/src/core/dt/crypto.ts
index dcc576e4..8c6fa0fc 100644
--- a/src/core/dt/crypto.ts
+++ b/src/core/dt/crypto.ts
@@ -29,6 +29,15 @@ const KEY_LENGTH = 32;
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_DIGEST = 'sha256';
+/**
+ * Minimum passphrase length enforced on encryption.
+ *
+ * Not enforced on decryption — a floor there would brick .dtzx archives
+ * encrypted by older versions with shorter passphrases; a wrong passphrase
+ * already fails via GCM auth tag verification.
+ */
+export const MIN_PASSPHRASE_LENGTH = 12;
+
/**
* Encrypt data with a passphrase using AES-256-GCM.
*
@@ -36,8 +45,9 @@ const PBKDF2_DIGEST = 'sha256';
* Each call generates a new salt and IV for unique ciphertexts.
*
* @param data - Data to encrypt
- * @param passphrase - User-provided encryption passphrase
+ * @param passphrase - User-provided encryption passphrase, at least MIN_PASSPHRASE_LENGTH characters
* @returns Encrypted payload with salt, IV, authTag, and ciphertext
+ * @throws If passphrase is shorter than MIN_PASSPHRASE_LENGTH
*
* @example
* ```typescript
@@ -47,6 +57,12 @@ const PBKDF2_DIGEST = 'sha256';
*/
export function encryptWithPassphrase(data: Buffer, passphrase: string): DtEncryptedPayload {
+ if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
+
+ throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`);
+
+ }
+
const salt = randomBytes(SALT_LENGTH);
const iv = randomBytes(IV_LENGTH);
diff --git a/src/core/dt/index.ts b/src/core/dt/index.ts
index 34a06d21..cd03bf12 100644
--- a/src/core/dt/index.ts
+++ b/src/core/dt/index.ts
@@ -44,7 +44,7 @@ import type { Kysely } from 'kysely';
import type { NoormDatabase } from '../shared/tables.js';
import type { ExportTableOptions, ImportFileOptions, DtStreamerOptions, DtSchema, DtValue, DatabaseVersion } from './types.js';
import type { Dialect } from '../connection/types.js';
-import type { ConnectionEvents, ComputeEvents } from '../worker-bridge/types.js';
+import type { ComputeEvents } from '../worker-bridge/types.js';
import { observer } from '../observer.js';
import { WorkerBridge } from '../worker-bridge/bridge.js';
@@ -56,7 +56,6 @@ import { DtReader } from './reader.js';
import { DtStreamer } from './streamer.js';
import { buildDtSchema, validateSchema } from './schema.js';
-const CONNECTION_WORKER = resolveWorker('connection');
const COMPUTE_WORKER = resolveWorker('compute');
/**
@@ -72,34 +71,6 @@ function createDefaultComputePool(): WorkerPool {
}
-/**
- * Create and connect a connection worker bridge.
- *
- * Spawns a new worker thread and connects it to the given database.
- */
-async function createDefaultConnectionBridge(
- dialect: Dialect,
- connectionString: string,
-): Promise<[WorkerBridge | null, Error | null]> {
-
- const bridge = new WorkerBridge(CONNECTION_WORKER);
-
- const [, connectErr] = await attempt(() =>
- bridge.request('connect', { dialect, connectionString }),
- );
-
- if (connectErr) {
-
- await attempt(() => bridge.shutdown());
-
- return [null, connectErr];
-
- }
-
- return [bridge, null];
-
-}
-
/**
* Export a database table to a .dt/.dtz/.dtzx file.
*
@@ -160,36 +131,14 @@ export async function exportTable(
}
- // Resolve workers — use provided overrides or create our own
- let ownedConnectionBridge: WorkerBridge | null = null;
- let ownedComputePool: WorkerPool | null = null;
-
- let connectionBridge = options.connectionBridge;
- let computePool = options.computePool;
-
- // Create connection worker when connection string is available but no bridge provided
- if (!connectionBridge && options.connectionString) {
-
- const [bridge, bridgeErr] = await createDefaultConnectionBridge(dialect, options.connectionString);
-
- if (bridgeErr) {
-
- return [null, bridgeErr];
-
- }
-
- connectionBridge = bridge!;
- ownedConnectionBridge = bridge!;
-
- }
-
- // Always create compute pool when not provided
- if (!computePool) {
-
- computePool = createDefaultComputePool();
- ownedComputePool = computePool;
-
- }
+ // This seam once let a caller hand in a running connection worker for
+ // off-main-thread fetch (TUI responsiveness during a big export), a
+ // connectionString to spin a dedicated fetch worker against a different
+ // database, or a shared computePool to amortize worker spinup across a
+ // batch of table exports. No caller ever used any of the three — deleted
+ // per D8. The removed createDefaultConnectionBridge (see git history on
+ // this file) is the rebuild recipe if a real caller shows up.
+ const computePool = createDefaultComputePool();
// Worker pipeline: offload fetching and serialization to worker threads
const [result, workerErr] = await exportTableWithWorkers({
@@ -199,23 +148,11 @@ export async function exportTable(
filepath,
dialect,
batchSize,
- connectionBridge,
computePool,
kyselyDb,
});
- // Shut down owned workers
- if (ownedComputePool) {
-
- await attempt(() => ownedComputePool!.shutdown());
-
- }
-
- if (ownedConnectionBridge) {
-
- await attempt(() => ownedConnectionBridge!.shutdown());
-
- }
+ await attempt(() => computePool.shutdown());
if (workerErr) {
@@ -249,28 +186,6 @@ function getQuoteIdent(dialect: string): (c: string) => string {
}
-/**
- * Build a raw SQL string for a paginated SELECT query.
- */
-function buildBatchSql(
- dialect: string,
- tableName: string,
- columnList: string,
- orderCol: string,
- batchSize: number,
- offset: number,
-): string {
-
- if (dialect === 'mssql') {
-
- return `SELECT ${columnList} FROM [${tableName}] ORDER BY ${orderCol} OFFSET ${offset} ROWS FETCH NEXT ${batchSize} ROWS ONLY`;
-
- }
-
- return `SELECT ${columnList} FROM "${tableName}" LIMIT ${batchSize} OFFSET ${offset}`;
-
-}
-
/**
* Worker-based export pipeline — offload fetching and serialization to worker threads.
*
@@ -288,38 +203,16 @@ async function exportTableWithWorkers(ctx: {
filepath: string;
dialect: string;
batchSize: number;
- connectionBridge?: WorkerBridge;
computePool: WorkerPool;
kyselyDb: Kysely;
}): Promise<[{ rowsWritten: number; bytesWritten: number } | null, Error | null]> {
const { writer, dtSchema, tableName, filepath, dialect, batchSize } = ctx;
- const { connectionBridge, computePool, kyselyDb } = ctx;
+ const { computePool, kyselyDb } = ctx;
const backpressureLimit = batchSize * 3;
// --- Stage 0: Get total row count ---
- let totalRows = 0;
-
- if (connectionBridge) {
-
- const countSql = dialect === 'mssql'
- ? `SELECT COUNT(*) AS cnt FROM [${tableName}]`
- : `SELECT COUNT(*) AS cnt FROM "${tableName}"`;
-
- const [countResult, countErr] = await attempt(() =>
- connectionBridge.request('query', { sql: countSql }),
- );
-
- if (countErr) {
-
- return [null, countErr];
-
- }
-
- const firstRow = countResult!.rows[0] as Record | undefined;
- totalRows = Number(firstRow?.['cnt'] ?? 0);
-
- }
+ const totalRows = 0;
// --- Stage 1-3: Fetch → Serialize → Write ---
const columns = dtSchema.columns.map((c) => c.name);
@@ -358,70 +251,38 @@ async function exportTableWithWorkers(ctx: {
}
// Fetch a batch
- let batchRows: Record[];
-
- if (connectionBridge) {
-
- const batchSql = buildBatchSql(dialect, tableName, columnList, orderCol, batchSize, offset);
-
- const [queryResult, queryErr] = await attempt(() =>
- connectionBridge.request('query', { sql: batchSql }),
- );
-
- if (queryErr) {
+ const [rows, fetchErr] = await attempt(() => {
- pipelineError = queryErr;
- break;
-
- }
-
- if (queryResult!.error) {
-
- pipelineError = new Error(queryResult!.error);
- break;
-
- }
-
- batchRows = queryResult!.rows as Record[];
-
- }
- else {
-
- // Direct Kysely fetch when no connection worker is available
- const [rows, fetchErr] = await attempt(() => {
-
- if (dialect === 'mssql') {
-
- return sql>`
- SELECT ${sql.raw(columnList)}
- FROM ${sql.table(tableName)}
- ORDER BY ${sql.raw(orderCol)}
- OFFSET ${offset} ROWS
- FETCH NEXT ${batchSize} ROWS ONLY
- `.execute(kyselyDb);
-
- }
+ if (dialect === 'mssql') {
return sql>`
SELECT ${sql.raw(columnList)}
FROM ${sql.table(tableName)}
- LIMIT ${batchSize}
- OFFSET ${offset}
+ ORDER BY ${sql.raw(orderCol)}
+ OFFSET ${offset} ROWS
+ FETCH NEXT ${batchSize} ROWS ONLY
`.execute(kyselyDb);
- });
+ }
- if (fetchErr) {
+ return sql>`
+ SELECT ${sql.raw(columnList)}
+ FROM ${sql.table(tableName)}
+ LIMIT ${batchSize}
+ OFFSET ${offset}
+ `.execute(kyselyDb);
- pipelineError = fetchErr;
- break;
+ });
- }
+ if (fetchErr) {
- batchRows = rows.rows;
+ pipelineError = fetchErr;
+ break;
}
+ const batchRows = rows.rows;
+
if (batchRows.length === 0) break;
loaded += batchRows.length;
@@ -615,16 +476,7 @@ export async function importDtFile(
}
- // Resolve compute pool — use provided override or create our own
- let ownedComputePool: WorkerPool | null = null;
- let computePool = options.computePool;
-
- if (!computePool) {
-
- computePool = createDefaultComputePool();
- ownedComputePool = computePool;
-
- }
+ const computePool = createDefaultComputePool();
// Worker pipeline: offload deserialization to compute pool
const [result, workerErr] = await importFileWithWorkers({
@@ -640,12 +492,7 @@ export async function importDtFile(
kyselyDb,
});
- // Shut down owned compute pool
- if (ownedComputePool) {
-
- await attempt(() => ownedComputePool!.shutdown());
-
- }
+ await attempt(() => computePool.shutdown());
if (workerErr) {
diff --git a/src/core/dt/reader.ts b/src/core/dt/reader.ts
index 835928cf..bcd7bf46 100644
--- a/src/core/dt/reader.ts
+++ b/src/core/dt/reader.ts
@@ -175,6 +175,10 @@ export class DtReader {
const fileStream = createReadStream(this.#filepath);
const gunzip = createGunzip();
+ // .pipe() does not forward the source 'error' event, so an
+ // unhandled fileStream error (e.g. ENOENT) would otherwise
+ // crash the process instead of rejecting open().
+ fileStream.on('error', (err) => gunzip.destroy(err));
fileStream.pipe(gunzip);
return gunzip;
diff --git a/src/core/dt/schema.ts b/src/core/dt/schema.ts
index 431c87d4..c88295bf 100644
--- a/src/core/dt/schema.ts
+++ b/src/core/dt/schema.ts
@@ -84,7 +84,6 @@ export async function buildDtSchema(
const mapping = toUniversalType({
dbType: col.dataType,
dialect,
- version,
});
const dtCol: DtColumn = {
diff --git a/src/core/dt/type-map.ts b/src/core/dt/type-map.ts
index e95df1fd..a344bb94 100644
--- a/src/core/dt/type-map.ts
+++ b/src/core/dt/type-map.ts
@@ -34,9 +34,6 @@ export interface ToUniversalOptions {
/** Source database dialect. */
dialect: Dialect;
- /** Source database version (optional, for future version-aware source mapping). */
- version?: DatabaseVersion;
-
}
/**
diff --git a/src/core/dt/types.ts b/src/core/dt/types.ts
index 76ff56ba..825402c2 100644
--- a/src/core/dt/types.ts
+++ b/src/core/dt/types.ts
@@ -22,9 +22,6 @@
* ```
*/
import type { Dialect } from '../connection/types.js';
-import type { WorkerBridge } from '../worker-bridge/bridge.js';
-import type { WorkerPool } from '../worker-bridge/pool.js';
-import type { ConnectionEvents, ComputeEvents } from '../worker-bridge/types.js';
// ---------------------------------------------------------------------------
// Universal type system
@@ -360,15 +357,6 @@ export interface ExportTableOptions {
/** Rows per batch. Default: 1000. */
batchSize?: number;
- /** Connection string for the worker to connect to the database. When provided, enables worker-based fetching. */
- connectionString?: string;
-
- /** Shared connection worker override. When omitted, one is created if connectionString is available. */
- connectionBridge?: WorkerBridge;
-
- /** Shared compute pool override. When omitted, one is created automatically. */
- computePool?: WorkerPool;
-
}
/**
@@ -403,9 +391,6 @@ export interface ImportFileOptions {
/** Rows per batch. Default: 1000. */
batchSize?: number;
- /** Shared compute pool override. When omitted, one is created automatically. */
- computePool?: WorkerPool;
-
}
/**
diff --git a/src/core/environment.ts b/src/core/environment.ts
index d434628d..73c1853d 100644
--- a/src/core/environment.ts
+++ b/src/core/environment.ts
@@ -88,34 +88,44 @@ export function isDev(): boolean {
*/
export function isDebug(): boolean {
- return process.env['NOORM_DEBUG'] === 'true';
+ return isEnvTruthy(process.env['NOORM_DEBUG']);
}
/**
- * Check if confirmations should be skipped.
+ * Parse an environment-variable string value into a boolean using the
+ * shared NOORM_YES-style truthiness rule.
+ *
+ * A value is truthy iff it is a non-empty string that is not `'0'` and not
+ * `'false'` in any letter case — this matches common shell conventions
+ * (`NOORM_YES=1`, `NOORM_YES=true`, `NOORM_YES=yes` all enable; `NOORM_YES=0`
+ * does not). No trimming: the raw string is compared as-is. Centralized so
+ * every NOORM_YES-shaped env var (and future ones, e.g. NOORM_DEBUG) parses
+ * the same way instead of each call site inventing its own truthy set.
*
- * Returns true if NOORM_YES is set, enabling non-interactive mode.
+ * @example
+ * isEnvTruthy(process.env['NOORM_YES']); // '1' -> true, '0' -> false
*/
-export function shouldSkipConfirmations(): boolean {
+export function isEnvTruthy(value: string | undefined): boolean {
- const yes = process.env['NOORM_YES'];
+ if (!value) return false;
- return yes === '1' || yes === 'true';
+ const normalized = value.toLowerCase();
+
+ return normalized !== '0' && normalized !== 'false';
}
/**
- * Check if output should be JSON.
+ * Check if confirmations should be skipped.
*
- * Returns true if NOORM_JSON is set, enabling headless/parseable output.
+ * Returns true if NOORM_YES is set to a truthy value, enabling
+ * non-interactive mode.
*/
-export function shouldOutputJson(): boolean {
-
- const json = process.env['NOORM_JSON'];
+export function shouldSkipConfirmations(): boolean {
- return json === '1' || json === 'true';
+ return isEnvTruthy(process.env['NOORM_YES']);
}
diff --git a/src/core/identity/storage.ts b/src/core/identity/storage.ts
index 3fb2710b..0ed1e40e 100644
--- a/src/core/identity/storage.ts
+++ b/src/core/identity/storage.ts
@@ -249,6 +249,16 @@ export async function loadPrivateKey(): Promise {
}
+ const permissionsOk = await validateKeyPermissions();
+
+ if (!permissionsOk) {
+
+ throw new Error(
+ `Insecure permissions on private key file (${PRIVATE_KEY_PATH}). Fix with: chmod 600 ${PRIVATE_KEY_PATH}`,
+ );
+
+ }
+
return content.trim();
}
@@ -331,13 +341,28 @@ export async function hasKeyFiles(): Promise {
// =============================================================================
/**
- * Validate that private key file has correct permissions.
+ * Validate that a private key file has secure permissions.
+ *
+ * Threat-model check, not strict equality: passes when no group/other
+ * bits are set (mode & 0o077 === 0), so both 0600 and a stricter 0400
+ * pass while 0644/0640/0660/0666 fail.
*
- * @returns True if permissions are 600 (owner read/write only)
+ * Windows emulates POSIX modes and `stat` commonly reports 0666 there
+ * regardless of actual ACLs, so this always returns true on win32 —
+ * otherwise the check would hard-lock every Windows user out.
+ *
+ * @param path - Key file to check (defaults to the private key path)
+ * @returns True if permissions are secure, or the platform is win32
*/
-export async function validateKeyPermissions(): Promise {
+export async function validateKeyPermissions(path: string = PRIVATE_KEY_PATH): Promise {
+
+ if (process.platform === 'win32') {
+
+ return true;
+
+ }
- const [stats, err] = await attempt(() => stat(PRIVATE_KEY_PATH));
+ const [stats, err] = await attempt(() => stat(path));
if (err) {
@@ -348,7 +373,7 @@ export async function validateKeyPermissions(): Promise {
// Check mode (mask off file type bits)
const mode = stats.mode & 0o777;
- return mode === PRIVATE_KEY_MODE;
+ return (mode & 0o077) === 0;
}
diff --git a/src/core/index.ts b/src/core/index.ts
index f3a5b566..80bcae64 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -97,7 +97,7 @@ export type {
} from './lifecycle/index.js';
// Shared (table types, constants, and utilities)
-export { NOORM_TABLES, filterFilesByPaths, matchesPathPrefix } from './shared/index.js';
+export { NOORM_TABLES, filterFilesByPaths } from './shared/index.js';
export type {
NoormTableName,
NoormDatabase,
diff --git a/src/core/lifecycle/manager.ts b/src/core/lifecycle/manager.ts
index 58124646..b347ac71 100644
--- a/src/core/lifecycle/manager.ts
+++ b/src/core/lifecycle/manager.ts
@@ -21,7 +21,7 @@
* await lifecycle.shutdown('user')
* ```
*/
-import { attempt } from '@logosdx/utils';
+import { attempt, runWithTimeout } from '@logosdx/utils';
import { observer } from '../observer.js';
import { getConnectionManager } from '../connection/manager.js';
@@ -340,7 +340,7 @@ export class LifecycleManager {
// Execute all resources with timeout
const [, err] = await attempt(() =>
- this.#executeWithTimeout(() => this.#executePhaseResources(resources), timeout),
+ runWithTimeout(() => this.#executePhaseResources(resources), { timeout, throws: true }),
);
const durationMs = Date.now() - start;
@@ -377,37 +377,6 @@ export class LifecycleManager {
}
- /**
- * Execute a function with a timeout.
- */
- async #executeWithTimeout(fn: () => Promise, timeoutMs: number): Promise {
-
- return new Promise((resolve, reject) => {
-
- const timer = setTimeout(() => {
-
- reject(new Error(`Timeout after ${timeoutMs}ms`));
-
- }, timeoutMs);
-
- fn()
- .then((result) => {
-
- clearTimeout(timer);
- resolve(result);
-
- })
- .catch((error) => {
-
- clearTimeout(timer);
- reject(error);
-
- });
-
- });
-
- }
-
/**
* Get timeout for a phase.
*/
diff --git a/src/core/lock/manager.ts b/src/core/lock/manager.ts
index 6b416f01..73d712cb 100644
--- a/src/core/lock/manager.ts
+++ b/src/core/lock/manager.ts
@@ -23,7 +23,7 @@
* }
* ```
*/
-import { attempt } from '@logosdx/utils';
+import { attempt, wait } from '@logosdx/utils';
import type { Kysely } from 'kysely';
import { observer } from '../observer.js';
@@ -96,15 +96,15 @@ class LockManager {
while (true) {
// Clean up expired locks first
- await this.cleanupExpired(db, configName, opts.dialect);
+ await this.#cleanupExpired(db, configName, opts.dialect);
// Try to get existing lock
- const existing = await this.getLock(db, configName, opts.dialect);
+ const existing = await this.#getLock(db, configName, opts.dialect);
if (!existing) {
// No lock exists, create one
- const lock = await this.createLock(db, configName, identity, opts);
+ const lock = await this.#createLock(db, configName, identity, opts);
observer.emit('lock:acquired', {
configName,
identity,
@@ -120,7 +120,7 @@ class LockManager {
if (existing.lockedBy === identity) {
// We already hold the lock - extend it
- const lock = await this.extendLock(db, configName, identity, opts);
+ const lock = await this.#extendLock(db, configName, identity, opts);
observer.emit('lock:acquired', {
configName,
identity,
@@ -165,7 +165,7 @@ class LockManager {
}
// Wait and retry
- await sleep(opts.pollInterval);
+ await wait(opts.pollInterval);
}
@@ -192,7 +192,7 @@ class LockManager {
const tables = getNoormTables(dialect);
// Check existing lock
- const existing = await this.getLock(db, configName, dialect);
+ const existing = await this.#getLock(db, configName, dialect);
if (!existing) {
@@ -236,7 +236,7 @@ class LockManager {
const ndb = noormDb(db, dialect);
const tables = getNoormTables(dialect);
- const existing = await this.getLock(db, configName, dialect);
+ const existing = await this.#getLock(db, configName, dialect);
if (!existing) {
return false;
@@ -323,7 +323,7 @@ class LockManager {
dialect: Dialect = 'postgres',
): Promise {
- const existing = await this.getLock(db, configName, dialect);
+ const existing = await this.#getLock(db, configName, dialect);
if (!existing) {
@@ -340,7 +340,7 @@ class LockManager {
if (existing.expiresAt < new Date()) {
// Clean it up
- await this.cleanupExpired(db, configName, dialect);
+ await this.#cleanupExpired(db, configName, dialect);
throw new LockExpiredError(configName, identity, existing.expiresAt);
@@ -369,7 +369,7 @@ class LockManager {
// Validate first
await this.validate(db, configName, identity, opts.dialect);
- return this.extendLock(db, configName, identity, options);
+ return this.#extendLock(db, configName, identity, options);
}
@@ -388,9 +388,9 @@ class LockManager {
): Promise {
// Clean up expired first
- await this.cleanupExpired(db, configName, dialect);
+ await this.#cleanupExpired(db, configName, dialect);
- const lock = await this.getLock(db, configName, dialect);
+ const lock = await this.#getLock(db, configName, dialect);
return {
isLocked: lock !== null,
@@ -406,7 +406,7 @@ class LockManager {
/**
* Get lock from database, or null if none exists.
*/
- private async getLock(
+ async #getLock(
db: Kysely,
configName: string,
dialect: Dialect,
@@ -439,7 +439,7 @@ class LockManager {
/**
* Create a new lock in the database.
*/
- private async createLock(
+ async #createLock(
db: Kysely,
configName: string,
identity: string,
@@ -474,7 +474,7 @@ class LockManager {
/**
* Extend an existing lock.
*/
- private async extendLock(
+ async #extendLock(
db: Kysely,
configName: string,
identity: string,
@@ -506,7 +506,7 @@ class LockManager {
.execute();
// Fetch the updated lock
- const lock = await this.getLock(db, configName, dialect);
+ const lock = await this.#getLock(db, configName, dialect);
return lock!;
@@ -515,7 +515,7 @@ class LockManager {
/**
* Clean up expired locks.
*/
- private async cleanupExpired(
+ async #cleanupExpired(
db: Kysely,
configName: string,
dialect: Dialect = 'postgres',
@@ -593,15 +593,5 @@ export function resetLockManager(): void {
}
-// ─────────────────────────────────────────────────────────────
-// Utility
-// ─────────────────────────────────────────────────────────────
-
-function sleep(ms: number): Promise {
-
- return new Promise((resolve) => setTimeout(resolve, ms));
-
-}
-
// Export class for typing
export { LockManager };
diff --git a/src/core/logger/index.ts b/src/core/logger/index.ts
index 93e9f8fb..e30e5fb5 100644
--- a/src/core/logger/index.ts
+++ b/src/core/logger/index.ts
@@ -32,6 +32,9 @@ export { generateMessage, formatEntry, serializeEntry } from './formatter.js';
// Color Formatter
export { formatColorLine, formatDuration, STATUS_ICONS } from './color.js';
+// Timestamp Formatter
+export { formatLogTimestamp, formatLogTimestampIso } from './timestamp.js';
+
// Rotation
export {
parseSize,
diff --git a/src/core/logger/logger.ts b/src/core/logger/logger.ts
index a3df4721..4c662833 100644
--- a/src/core/logger/logger.ts
+++ b/src/core/logger/logger.ts
@@ -23,8 +23,6 @@ import { join, dirname } from 'node:path';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
-import dayjs from 'dayjs';
-
import { observer, type NoormEvents } from '../observer.js';
import { isCi } from '../environment.js';
import { classifyEvent, shouldLog } from './classifier.js';
@@ -32,6 +30,7 @@ import { generateMessage } from './formatter.js';
import { formatColorLine } from './color.js';
import { filterData } from './redact.js';
import { checkAndRotate } from './rotation.js';
+import { formatLogTimestamp, formatLogTimestampIso } from './timestamp.js';
import type { LogLevel, LoggerConfig, LoggerState, EntryLevel } from './types.js';
import { DEFAULT_LOGGER_CONFIG } from './types.js';
import type { Settings } from '../settings/types.js';
@@ -506,15 +505,16 @@ export class Logger {
if (this.#json) {
- // JSON mode for console
+ // Event stream, not a command result — stays on diagnostics even in
+ // JSON mode, so `result()`'s payload is the only thing on stdout.
const entry = this.#buildJsonEntry(level, event, message, data, hasData);
- this.#writers.console.write(JSON.stringify(entry) + '\n');
+ this.#writers.diagnostics.write(JSON.stringify(entry) + '\n');
}
else if (this.#color) {
// Colored format: [timestamp] icon event message key=value key=value
- const timestamp = dayjs().format('YY-MM-DD HH:mm:ss');
+ const timestamp = formatLogTimestamp(new Date());
const colorLine = formatColorLine(
level,
event,
@@ -527,7 +527,7 @@ export class Logger {
else {
// Plain format: [timestamp] [LEVEL] [event] message
- const timestamp = dayjs().format('YY-MM-DD HH:mm:ss');
+ const timestamp = formatLogTimestamp(new Date());
const levelLabel = level.toUpperCase().padEnd(5);
let line = `[${timestamp}] [${levelLabel}] [${event}] ${message}`;
@@ -586,7 +586,7 @@ export class Logger {
): Record {
const entry: Record = {
- time: dayjs().format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
+ time: formatLogTimestampIso(new Date()),
type: event,
level,
message,
diff --git a/src/core/logger/timestamp.ts b/src/core/logger/timestamp.ts
new file mode 100644
index 00000000..9d214bff
--- /dev/null
+++ b/src/core/logger/timestamp.ts
@@ -0,0 +1,58 @@
+/**
+ * Log timestamp formatting.
+ *
+ * Native `Date` formatters for the two fixed, non-locale,
+ * non-timezone-conversion formats the logger writes on every entry —
+ * avoids pulling in dayjs on the per-line hot path.
+ */
+
+/**
+ * Format a Date as `YY-MM-DD HH:mm:ss` (local time) — the console log
+ * line timestamp.
+ *
+ * @example
+ * formatLogTimestamp(new Date(2024, 0, 15, 10, 30, 0)); // '24-01-15 10:30:00'
+ */
+export function formatLogTimestamp(d: Date): string {
+
+ const year = String(d.getFullYear()).slice(-2);
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ const hours = String(d.getHours()).padStart(2, '0');
+ const minutes = String(d.getMinutes()).padStart(2, '0');
+ const seconds = String(d.getSeconds()).padStart(2, '0');
+
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+
+}
+
+/**
+ * Format a Date as `YYYY-MM-DDTHH:mm:ss.SSS±HH:mm` (local time with UTC
+ * offset) — the JSON log entry's `time` field.
+ *
+ * The offset is the local UTC offset, not a literal `Z` suffix.
+ * `Date#getTimezoneOffset()` returns UTC-minus-local in minutes (positive
+ * when local is behind UTC), so the sign is flipped relative to it.
+ *
+ * @example
+ * formatLogTimestampIso(new Date(Date.UTC(2024, 0, 15, 10, 30, 0, 123))); // '2024-01-15T10:30:00.123+00:00' in UTC
+ */
+export function formatLogTimestampIso(d: Date): string {
+
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ const hours = String(d.getHours()).padStart(2, '0');
+ const minutes = String(d.getMinutes()).padStart(2, '0');
+ const seconds = String(d.getSeconds()).padStart(2, '0');
+ const millis = String(d.getMilliseconds()).padStart(3, '0');
+
+ const offset = d.getTimezoneOffset();
+ const sign = offset <= 0 ? '+' : '-';
+ const absOffset = Math.abs(offset);
+ const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0');
+ const offsetMinutes = String(absOffset % 60).padStart(2, '0');
+
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${offsetHours}:${offsetMinutes}`;
+
+}
diff --git a/src/core/observer.ts b/src/core/observer.ts
index f162cf07..a6bda480 100644
--- a/src/core/observer.ts
+++ b/src/core/observer.ts
@@ -27,6 +27,7 @@ import type { TransferEvents } from './transfer/events.js';
import type { DtEvents } from './dt/events.js';
import type { LogLevel } from './logger/types.js';
import type { TruncateResult, TeardownResult } from './teardown/types.js';
+import { isDebug } from './environment.js';
/**
* All events emitted by noorm core modules.
@@ -160,7 +161,16 @@ export interface NoormEvents extends SettingsEvents, UpdateEvents, VaultEvents,
'identity:not-found': void;
// Connection
- 'connection:open': { configName: string; dialect: string };
+ // host/port/database are carried so a log line names the database that was
+ // actually connected to — a config name alone cannot, since env vars can
+ // redirect it (see #51).
+ 'connection:open': {
+ configName: string;
+ dialect: string;
+ host?: string;
+ port?: number;
+ database?: string;
+ };
'connection:close': { configName: string };
'connection:error': { configName: string; error: string };
@@ -244,7 +254,7 @@ export type NoormEventCallback = ObserverEngine.Event
*/
export const observer = new ObserverEngine({
name: 'noorm',
- spy: process.env['NOORM_DEBUG']
+ spy: isDebug()
? (action) => console.error(`[noorm:${action.fn}] ${String(action.event)}`)
: undefined,
});
diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts
index c3a266b6..f8a8f5b8 100644
--- a/src/core/policy/check.ts
+++ b/src/core/policy/check.ts
@@ -154,6 +154,23 @@ export function assertPolicy(
}
+/**
+ * Whether a config is visible on a channel — the mcp-channel invisibility
+ * rule, extracted so `list_configs` and `session.ts`'s `connect()` share one
+ * fail-closed implementation instead of two independently drifting copies.
+ *
+ * Fails closed: missing `access` is treated the same as `access.mcp ===
+ * false`. The `user` channel is always visible; only `mcp` can be hidden.
+ *
+ * @example
+ * isVisibleToChannel(config.access, 'mcp'); // false when access.mcp === false or access is missing
+ */
+export function isVisibleToChannel(access: ConfigAccess | undefined, channel: Channel): boolean {
+
+ return !(channel === 'mcp' && (!access || access.mcp === false));
+
+}
+
/**
* Display-only shorthand for "this config isn't wide open" — used by TUI
* styling, `config list`, and settings rule matching. Never an enforcement
@@ -167,3 +184,22 @@ export function guarded(target: PolicyTarget): boolean {
return target.access.user !== 'admin';
}
+
+/**
+ * Formats access as `user: mcp:` — the shared display
+ * string for `noorm config list` and the TUI config list screen, so the
+ * format can't drift between the two. Omitted entirely (`null`) for fully
+ * open (admin/admin) configs, per `guarded()`.
+ *
+ * @example
+ * formatAccessTag({ name: 'prod', access: { user: 'operator', mcp: false } }); // 'user:operator mcp:off'
+ */
+export function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null {
+
+ if (!guarded(config)) return null;
+
+ const { access } = config;
+
+ return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`;
+
+}
diff --git a/src/core/policy/index.ts b/src/core/policy/index.ts
index a921fca8..217fd9a6 100644
--- a/src/core/policy/index.ts
+++ b/src/core/policy/index.ts
@@ -4,7 +4,7 @@
* One central policy check for every channel (CLI/TUI/SDK, MCP) and every
* config-scoped action.
*/
-export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, guarded } from './check.js';
+export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, formatAccessTag, guarded, isVisibleToChannel } from './check.js';
export { classifyStatements } from './classify.js';
export type { SqlClass } from './classify.js';
export { GUARDED_ACCESS, OPEN_ACCESS, resolveLegacyAccess } from './legacy-access.js';
diff --git a/src/core/policy/matrix.ts b/src/core/policy/matrix.ts
index 4e6d0761..6e615268 100644
--- a/src/core/policy/matrix.ts
+++ b/src/core/policy/matrix.ts
@@ -14,6 +14,7 @@ export const MATRIX: Record> = {
'change:run': { viewer: 'deny', operator: 'confirm', admin: 'allow' },
'change:ff': { viewer: 'deny', operator: 'confirm', admin: 'allow' },
'change:revert': { viewer: 'deny', operator: 'confirm', admin: 'allow' },
+ 'change:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' },
'run:build': { viewer: 'deny', operator: 'confirm', admin: 'allow' },
'run:file': { viewer: 'deny', operator: 'confirm', admin: 'allow' },
diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts
index 331b9618..1f0220c5 100644
--- a/src/core/policy/types.ts
+++ b/src/core/policy/types.ts
@@ -35,7 +35,7 @@ export interface ConfigAccess {
export type Permission =
| 'explore'
| 'sql:read' | 'sql:write' | 'sql:ddl'
- | 'change:run' | 'change:ff' | 'change:revert'
+ | 'change:run' | 'change:ff' | 'change:revert' | 'change:rm'
| 'run:build' | 'run:file' | 'run:dir'
| 'db:create' | 'db:reset' | 'db:destroy'
| 'config:rm';
diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts
index d643d643..699a0bcc 100644
--- a/src/core/runner/runner.ts
+++ b/src/core/runner/runner.ts
@@ -587,6 +587,27 @@ export async function executeFiles(
// Convert FileInput[] to string[] for preview/dryRun modes
const filepaths = files.map((f) => f.path);
+ // A duplicate filepath would make updateFileExecution's
+ // WHERE change_id = ? AND filepath = ? match more than one row later
+ // in this same operation -- silently updating N records instead of
+ // failing. Uniqueness of the discovered batch is an invariant this
+ // function depends on, not something that happens to hold today.
+ const duplicateFilepaths = findDuplicates(filepaths);
+
+ if (duplicateFilepaths.length > 0) {
+
+ const error = `Duplicate files in execution batch: ${duplicateFilepaths.join(', ')}`;
+
+ observer.emit('error', {
+ source: 'runner:duplicate-files',
+ error: new Error(error),
+ context: { duplicates: duplicateFilepaths },
+ });
+
+ return createFailedBatchResult(error, performance.now() - start);
+
+ }
+
// Handle preview mode
if (opts.preview) {
@@ -741,6 +762,19 @@ export async function executeFiles(
}
+ // When abortOnError stops the loop early, fewer results than files is
+ // by design -- the remaining files were never attempted, only marked
+ // skipped above. The invariant "one result per file" only holds for a
+ // full pass; this guards against a future regression re-introducing
+ // double (or dropped) execution within that pass.
+ if (!failed && results.length !== files.length) {
+
+ throw new Error(
+ `Execution accounting mismatch: expected ${files.length} results, got ${results.length}`,
+ );
+
+ }
+
// Compute stats
const filesRun = results.filter((r) => r.status === 'success').length;
const filesSkipped = results.filter((r) => r.status === 'skipped').length;
@@ -901,7 +935,11 @@ async function executeSingleFileWithUpdate(
// For 'change', the change-level check was already done by the caller
if (changeType !== 'change') {
- const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force);
+ // Exclude this operation's own rows -- createFileRecords already
+ // inserted a pending row for every file in this batch before this
+ // loop started, so without the exclusion the newest row is always
+ // this run's own pending record and every file reads as "new".
+ const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force, operationId);
if (!needsRunResult.needsRun) {
@@ -939,11 +977,13 @@ async function executeSingleFileWithUpdate(
if (execErrMsg) {
+ const error = execErrMsg + (await describePriorSuccesses(tracker, relFilepath, operationId));
+
const result: FileResult = {
filepath,
checksum: finalChecksum,
status: 'failed',
- error: execErrMsg,
+ error,
durationMs,
};
@@ -952,14 +992,14 @@ async function executeSingleFileWithUpdate(
relFilepath,
'failed',
Math.round(durationMs),
- execErrMsg,
+ error,
);
observer.emit('file:after', {
filepath,
status: 'failed',
durationMs,
- error: execErrMsg,
+ error,
});
return result;
@@ -1119,11 +1159,13 @@ async function executeSingleFile(
if (execErrMsg) {
+ const error = execErrMsg + (await describePriorSuccesses(tracker, relFilepath, operationId));
+
const result: FileResult = {
filepath,
checksum,
status: 'failed',
- error: execErrMsg,
+ error,
durationMs,
};
@@ -1132,7 +1174,7 @@ async function executeSingleFile(
filepath: relFilepath,
checksum,
status: 'failed',
- errorMessage: execErrMsg,
+ errorMessage: error,
durationMs: Math.round(durationMs),
});
@@ -1140,7 +1182,7 @@ async function executeSingleFile(
filepath,
status: 'failed',
durationMs,
- error: execErrMsg,
+ error,
});
return result;
@@ -1438,3 +1480,51 @@ function formatErrorChain(err: Error): string {
}
+/**
+ * Find values that occur more than once, without depending on discovery
+ * order. Used to reject a duplicate discovered file loudly rather than let
+ * it silently update the same execution record twice.
+ */
+function findDuplicates(values: string[]): string[] {
+
+ const seen = new Set();
+ const duplicates = new Set();
+
+ for (const value of values) {
+
+ if (seen.has(value)) duplicates.add(value);
+ seen.add(value);
+
+ }
+
+ return [...duplicates];
+
+}
+
+/**
+ * Build a suffix describing prior successful executions of a file, for
+ * appending to a failure message.
+ *
+ * @example
+ * ```typescript
+ * const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId)
+ * // "already exists; 1 prior successful execution (build:2026-07-24T…, operation 7)"
+ * ```
+ */
+async function describePriorSuccesses(
+ tracker: Tracker,
+ relFilepath: string,
+ excludeOperationId: number,
+): Promise {
+
+ const prior = await tracker.priorSuccessfulExecutions(relFilepath, excludeOperationId);
+
+ if (prior.length === 0) return '';
+
+ const [latest] = prior;
+ const plural = prior.length === 1 ? '' : 's';
+
+ return `; ${prior.length} prior successful execution${plural} (${latest!.operationName}, operation ${latest!.operationId})`;
+
+}
+
diff --git a/src/core/runner/tracker.ts b/src/core/runner/tracker.ts
index 823e1eda..5eb09a51 100644
--- a/src/core/runner/tracker.ts
+++ b/src/core/runner/tracker.ts
@@ -93,9 +93,21 @@ export class Tracker {
* @param filepath - File path to check
* @param checksum - Current file checksum
* @param force - Force re-run regardless of status
+ * @param excludeOperationId - Operation whose own rows should be ignored.
+ * `executeFiles` inserts a `pending` row for every discovered file
+ * *before* running any of them (for batch visibility), so within that
+ * same operation the newest row for a file is always its own pending
+ * record — reading as "new" forever and making checksum-based skipping
+ * unreachable. Passing the running operation's id here excludes those
+ * rows so the lookup finds the last *completed* operation instead.
* @returns Whether file needs to run and why
*/
- async needsRun(filepath: string, checksum: string, force: boolean): Promise {
+ async needsRun(
+ filepath: string,
+ checksum: string,
+ force: boolean,
+ excludeOperationId?: number,
+ ): Promise {
// Force always runs
if (force) {
@@ -106,23 +118,31 @@ export class Tracker {
// Find most recent execution for this file and config
// Also fetch the parent change status to check for stale
+ let query = (this.#ndb
+ .selectFrom(this.#tables.executions)
+ .innerJoin(
+ this.#tables.change,
+ `${this.#tables.change}.id`,
+ `${this.#tables.executions}.change_id`,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) as any)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .select((eb: any) => [
+ eb.ref(`${this.#tables.executions}.checksum`).as('checksum'),
+ eb.ref(`${this.#tables.executions}.status`).as('exec_status'),
+ eb.ref(`${this.#tables.change}.status`).as('change_status'),
+ ])
+ .where(`${this.#tables.executions}.filepath`, '=', filepath)
+ .where(`${this.#tables.change}.config_name`, '=', this.#configName);
+
+ if (excludeOperationId !== undefined) {
+
+ query = query.where(`${this.#tables.executions}.change_id`, '<>', excludeOperationId);
+
+ }
+
const [record, err] = await attempt(() =>
- (this.#ndb
- .selectFrom(this.#tables.executions)
- .innerJoin(
- this.#tables.change,
- `${this.#tables.change}.id`,
- `${this.#tables.executions}.change_id`,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ) as any)
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- .select((eb: any) => [
- eb.ref(`${this.#tables.executions}.checksum`).as('checksum'),
- eb.ref(`${this.#tables.executions}.status`).as('exec_status'),
- eb.ref(`${this.#tables.change}.status`).as('change_status'),
- ])
- .where(`${this.#tables.executions}.filepath`, '=', filepath)
- .where(`${this.#tables.change}.config_name`, '=', this.#configName)
+ query
.orderBy(`${this.#tables.executions}.id`, 'desc')
.limit(1)
.executeTakeFirst(),
@@ -136,8 +156,11 @@ export class Tracker {
context: { filepath, operation: 'needs-run-check' },
});
- // On error, assume file needs to run
- return { needsRun: true, reason: 'new' };
+ // Distinct from 'new': the SELECT itself failed, so whether a
+ // record exists is genuinely unknown. Reporting this as 'new'
+ // would make a transient read failure indistinguishable from a
+ // first-ever run in logs and audits.
+ return { needsRun: true, reason: 'error' };
}
@@ -531,12 +554,18 @@ export class Tracker {
}
- // Check if any rows were updated
+ // Exactly one row must match. Zero means the pending record is
+ // missing; more than one means (change_id, filepath) isn't unique --
+ // exactly the shape a duplicate discovered file would take, and
+ // tolerating it here would silently update N rows and mask the
+ // duplicate from ever surfacing.
const numUpdated = Number(result?.numUpdatedRows ?? 0);
- if (numUpdated === 0) {
+ if (numUpdated !== 1) {
- const errMsg = `No execution record found for ${filepath} (operationId: ${operationId})`;
+ const errMsg = numUpdated === 0
+ ? `No execution record found for ${filepath} (operationId: ${operationId})`
+ : `Expected exactly 1 execution record for ${filepath} (operationId: ${operationId}), matched ${numUpdated}`;
observer.emit('error', {
source: 'runner',
@@ -552,6 +581,67 @@ export class Tracker {
}
+ /**
+ * Find prior successful executions of a file, most recent first.
+ *
+ * Called on the failure path only. A file that fails after a history of
+ * clean runs at this config rules out a broken file and points instead
+ * at drift between what the tracker expects and what the target
+ * database actually has — the detail that would have answered #54
+ * immediately instead of reading as intermittent double execution.
+ *
+ * @param filepath - File path to check (relative, as stored)
+ * @param excludeOperationId - Omit rows belonging to the operation
+ * currently running, so a failure doesn't cite itself as history.
+ */
+ async priorSuccessfulExecutions(
+ filepath: string,
+ excludeOperationId?: number,
+ ): Promise> {
+
+ let query = (this.#ndb
+ .selectFrom(this.#tables.executions)
+ .innerJoin(
+ this.#tables.change,
+ `${this.#tables.change}.id`,
+ `${this.#tables.executions}.change_id`,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) as any)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .select((eb: any) => [
+ eb.ref(`${this.#tables.change}.name`).as('operationName'),
+ eb.ref(`${this.#tables.executions}.change_id`).as('operationId'),
+ ])
+ .where(`${this.#tables.executions}.filepath`, '=', filepath)
+ .where(`${this.#tables.executions}.status`, '=', 'success')
+ .where(`${this.#tables.change}.config_name`, '=', this.#configName);
+
+ if (excludeOperationId !== undefined) {
+
+ query = query.where(`${this.#tables.executions}.change_id`, '<>', excludeOperationId);
+
+ }
+
+ const [rows, err] = await attempt(() =>
+ query.orderBy(`${this.#tables.executions}.id`, 'desc').execute(),
+ );
+
+ if (err) {
+
+ observer.emit('error', {
+ source: 'runner',
+ error: err,
+ context: { filepath, operation: 'prior-successful-executions' },
+ });
+
+ return [];
+
+ }
+
+ return rows ?? [];
+
+ }
+
/**
* Mark remaining pending files as skipped.
*
diff --git a/src/core/runner/types.ts b/src/core/runner/types.ts
index f47711fd..fc8cc6f1 100644
--- a/src/core/runner/types.ts
+++ b/src/core/runner/types.ts
@@ -220,8 +220,12 @@ export interface BatchResult {
/**
* Why a file needs to run.
+ *
+ * 'error' is distinct from 'new': it means the lookup that would have
+ * decided this failed to read at all, so treat it as needing a run without
+ * claiming it's a first-ever execution.
*/
-export type RunReason = 'new' | 'changed' | 'failed' | 'stale' | 'force';
+export type RunReason = 'new' | 'changed' | 'failed' | 'stale' | 'force' | 'error';
/**
* Result of checking if a file needs to run.
diff --git a/src/core/settings/schema.ts b/src/core/settings/schema.ts
index 91f55e49..6282229a 100644
--- a/src/core/settings/schema.ts
+++ b/src/core/settings/schema.ts
@@ -6,6 +6,8 @@
*/
import { z } from 'zod';
+import { PortSchema } from '../connection/defaults.js';
+
// ─────────────────────────────────────────────────────────────
// Base Schemas
// ─────────────────────────────────────────────────────────────
@@ -30,14 +32,7 @@ const ConnectionTypeSchema = z.enum(['local', 'remote']);
*/
const LogLevelSchema = z.enum(['silent', 'error', 'warn', 'info', 'verbose']);
-/**
- * Port number validation.
- */
-const PortSchema = z
- .number()
- .int()
- .min(1, 'Port must be at least 1')
- .max(65535, 'Port must be at most 65535');
+export { PortSchema };
/**
* File size pattern (e.g., '10mb', '100kb').
diff --git a/src/core/shared/files.ts b/src/core/shared/files.ts
index 56a70e68..91a3f4f1 100644
--- a/src/core/shared/files.ts
+++ b/src/core/shared/files.ts
@@ -19,26 +19,26 @@ import { relative, sep } from 'path';
* 4. Exclude wins if both match (consistent with rule evaluation)
*
* @param files - Absolute file paths (e.g., from discoverFiles())
- * @param baseDir - Base directory for relative path matching
- * @param include - Relative paths to include (e.g., ['sql/tables', 'sql/views'])
- * @param exclude - Relative paths to exclude (e.g., ['sql/archive'])
+ * @param baseDir - Base directory for relative path matching (the resolved sql dir)
+ * @param include - Relative paths to include (e.g., ['01_tables', '02_views'])
+ * @param exclude - Relative paths to exclude (e.g., ['archive'])
* @returns Filtered array of absolute file paths
*
* @example
* ```typescript
* const files = [
- * '/project/sql/tables/users.sql',
- * '/project/sql/views/active.sql',
+ * '/project/sql/01_tables/users.sql',
+ * '/project/sql/02_views/active.sql',
* '/project/sql/archive/old.sql',
* ]
*
* const filtered = filterFilesByPaths(
* files,
- * '/project',
- * ['sql/tables', 'sql/views'],
- * ['sql/archive']
+ * '/project/sql',
+ * ['01_tables', '02_views'],
+ * ['archive']
* )
- * // ['/project/sql/tables/users.sql', '/project/sql/views/active.sql']
+ * // ['/project/sql/01_tables/users.sql', '/project/sql/02_views/active.sql']
* ```
*/
export function filterFilesByPaths(
@@ -81,18 +81,3 @@ export function filterFilesByPaths(
});
}
-
-/**
- * Check if a file path matches a pattern prefix.
- *
- * @param filePath - Relative file path to check
- * @param pattern - Pattern prefix to match against
- * @returns True if the file path starts with the pattern
- */
-export function matchesPathPrefix(filePath: string, pattern: string): boolean {
-
- const normalizedPattern = pattern.split(/[\\/]/).join(sep);
-
- return filePath === normalizedPattern || filePath.startsWith(normalizedPattern + sep);
-
-}
diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts
index f876846e..9bd64c41 100644
--- a/src/core/shared/index.ts
+++ b/src/core/shared/index.ts
@@ -9,7 +9,7 @@
export { getSqlErrorMessage } from './errors.js';
// Files
-export { filterFilesByPaths, matchesPathPrefix } from './files.js';
+export { filterFilesByPaths } from './files.js';
// Dialect quoting
export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js';
diff --git a/src/core/state/index.ts b/src/core/state/index.ts
index 9ab38669..367c940d 100644
--- a/src/core/state/index.ts
+++ b/src/core/state/index.ts
@@ -6,6 +6,7 @@
import { StateManager } from './manager.js';
export { StateManager };
+export { InvalidSecretKeyError, isValidSecretKey } from './manager.js';
export type { StateManagerOptions } from './manager.js';
export * from './types.js';
export { migrateState, needsMigration } from './migrations.js';
diff --git a/src/core/state/manager.ts b/src/core/state/manager.ts
index 4ed1ee65..be75244f 100644
--- a/src/core/state/manager.ts
+++ b/src/core/state/manager.ts
@@ -6,10 +6,11 @@
*
* Encryption uses the user's private key from ~/.noorm/identity.key
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { attemptSync, attempt } from '@logosdx/utils';
import type { Config } from '../config/types.js';
+import { assertCanDeleteConfig, type SettingsProvider } from '../config/resolver.js';
import type { KnownUser } from '../identity/types.js';
import { loadPrivateKey } from '../identity/storage.js';
import { resolveLegacyAccess } from '../policy/index.js';
@@ -77,20 +78,23 @@ export interface StateManagerOptions {
*/
export class StateManager {
- private state: State | null = null;
- private privateKey: string | undefined;
- private statePath: string;
- private loaded = false;
+ #state: State | null = null;
+ #privateKey: string | undefined;
+ #statePath: string;
+ #loaded = false;
+ // eslint-disable-next-line no-unused-private-class-members
+ #projectRoot: string;
constructor(
- private readonly projectRoot: string,
+ projectRoot: string,
options: StateManagerOptions = {},
) {
- this.privateKey = options.privateKey;
+ this.#projectRoot = projectRoot;
+ this.#privateKey = options.privateKey;
const stateDir = options.stateDir ?? DEFAULT_STATE_DIR;
const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
- this.statePath = join(projectRoot, stateDir, stateFile);
+ this.#statePath = join(projectRoot, stateDir, stateFile);
}
@@ -107,15 +111,15 @@ export class StateManager {
*/
async load(): Promise {
- if (this.loaded) return;
+ if (this.#loaded) return;
// Try to load private key if not provided
- if (!this.privateKey) {
+ if (!this.#privateKey) {
const [key] = await attempt(() => loadPrivateKey());
if (key) {
- this.privateKey = key;
+ this.#privateKey = key;
}
@@ -124,10 +128,10 @@ export class StateManager {
const currentVersion = getPackageVersion();
// New project - no state file yet
- if (!existsSync(this.statePath)) {
+ if (!existsSync(this.#statePath)) {
- this.state = createEmptyState(currentVersion);
- this.loaded = true;
+ this.#state = createEmptyState(currentVersion);
+ this.#loaded = true;
observer.emit('state:loaded', {
configCount: 0,
activeConfig: null,
@@ -139,7 +143,7 @@ export class StateManager {
}
// Existing state file - require private key
- if (!this.privateKey) {
+ if (!this.#privateKey) {
throw new Error(
'Private key required to decrypt state. ' + 'Set up identity with: noorm init',
@@ -147,7 +151,7 @@ export class StateManager {
}
- const [raw, readErr] = attemptSync(() => readFileSync(this.statePath, 'utf8'));
+ const [raw, readErr] = attemptSync(() => readFileSync(this.#statePath, 'utf8'));
if (readErr) {
observer.emit('error', { source: 'state', error: readErr });
@@ -163,7 +167,7 @@ export class StateManager {
}
- const [decrypted, decryptErr] = attemptSync(() => decrypt(payload!, this.privateKey!));
+ const [decrypted, decryptErr] = attemptSync(() => decrypt(payload!, this.#privateKey!));
if (decryptErr) {
observer.emit('error', { source: 'state', error: decryptErr });
@@ -194,7 +198,7 @@ export class StateManager {
const needsVersionMigration =
needsMigration(schemaMigratedState, currentVersion) ||
needsStateMigration(stateRecord);
- this.state = migrateState(schemaMigratedState, currentVersion);
+ this.#state = migrateState(schemaMigratedState, currentVersion);
// Raw-data-boundary invariant: migrations above guarantee every
// config carries `access`, but a hand-edited or corrupted state
@@ -215,7 +219,7 @@ export class StateManager {
// to operator/viewer, never the admin/admin fallback.
let backfilledAccess = false;
- for (const config of Object.values(this.state.configs)) {
+ for (const config of Object.values(this.#state.configs)) {
if (!config.access) {
@@ -232,19 +236,19 @@ export class StateManager {
}
- this.loaded = true;
+ this.#loaded = true;
// Persist if migrations were applied or the backfill above mutated a config
if (needsVersionMigration || backfilledAccess) {
- this.persist();
+ this.#persist();
}
observer.emit('state:loaded', {
- configCount: Object.keys(this.state.configs).length,
- activeConfig: this.state.activeConfig,
- version: this.state.version,
+ configCount: Object.keys(this.#state.configs).length,
+ activeConfig: this.#state.activeConfig,
+ version: this.#state.version,
});
}
@@ -263,7 +267,7 @@ export class StateManager {
const [key] = await attempt(() => loadPrivateKey());
if (key) {
- this.privateKey = key;
+ this.#privateKey = key;
return true;
@@ -278,9 +282,9 @@ export class StateManager {
*
* Requires private key to be set.
*/
- private persist(): void {
+ #persist(): void {
- if (!this.privateKey) {
+ if (!this.#privateKey) {
throw new Error(
'Private key required to save state. ' + 'Set up identity with: noorm init',
@@ -288,9 +292,9 @@ export class StateManager {
}
- const state = this.getState();
+ const state = this.#getState();
- const dir = dirname(this.statePath);
+ const dir = dirname(this.#statePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
@@ -298,10 +302,10 @@ export class StateManager {
}
const json = JSON.stringify(state);
- const payload = encrypt(json, this.privateKey);
+ const payload = encrypt(json, this.#privateKey);
const [, writeErr] = attemptSync(() =>
- writeFileSync(this.statePath, JSON.stringify(payload, null, 2)),
+ writeFileSync(this.#statePath, JSON.stringify(payload, null, 2), { mode: 0o600 }),
);
if (writeErr) {
@@ -311,6 +315,9 @@ export class StateManager {
}
+ // Ensure permissions are correct (writeFile mode may not work on all platforms)
+ attemptSync(() => chmodSync(this.#statePath, 0o600));
+
observer.emit('state:persisted', {
configCount: Object.keys(state.configs).length,
});
@@ -320,15 +327,15 @@ export class StateManager {
/**
* Get the loaded state, throwing if not loaded.
*/
- private getState(): State {
+ #getState(): State {
- if (!this.loaded || !this.state) {
+ if (!this.#loaded || !this.#state) {
throw new Error('StateManager not loaded. Call load() first.');
}
- return this.state;
+ return this.#state;
}
@@ -341,7 +348,7 @@ export class StateManager {
*/
getConfig(name: string): Config | null {
- const state = this.getState();
+ const state = this.#getState();
return state.configs[name] ?? null;
@@ -352,11 +359,11 @@ export class StateManager {
*/
async setConfig(name: string, config: Config): Promise {
- const state = this.getState();
+ const state = this.#getState();
const isNew = !state.configs[name];
state.configs[name] = { ...config };
- this.persist();
+ this.#persist();
observer.emit(isNew ? 'config:created' : 'config:updated', {
name,
@@ -367,10 +374,14 @@ export class StateManager {
/**
* Delete a config and its secrets.
+ *
+ * @throws ConfigStageLockedError if the config is linked to a locked stage.
*/
- async deleteConfig(name: string): Promise {
+ async deleteConfig(name: string, settings?: SettingsProvider): Promise {
- const state = this.getState();
+ assertCanDeleteConfig(name, settings);
+
+ const state = this.#getState();
delete state.configs[name];
delete state.secrets[name];
@@ -380,7 +391,7 @@ export class StateManager {
}
- this.persist();
+ this.#persist();
observer.emit('config:deleted', { name });
}
@@ -390,7 +401,7 @@ export class StateManager {
*/
listConfigs(): ConfigSummary[] {
- const state = this.getState();
+ const state = this.#getState();
return Object.entries(state.configs).map(([name, config]) => ({
name,
@@ -409,7 +420,7 @@ export class StateManager {
*/
getActiveConfig(): Config | null {
- const state = this.getState();
+ const state = this.#getState();
if (!state.activeConfig) return null;
return state.configs[state.activeConfig] ?? null;
@@ -421,7 +432,7 @@ export class StateManager {
*/
getActiveConfigName(): string | null {
- const state = this.getState();
+ const state = this.#getState();
return state.activeConfig;
@@ -432,7 +443,7 @@ export class StateManager {
*/
async setActiveConfig(name: string): Promise {
- const state = this.getState();
+ const state = this.#getState();
if (!state.configs[name]) {
throw new Error(`Config "${name}" does not exist.`);
@@ -441,7 +452,7 @@ export class StateManager {
const previous = state.activeConfig;
state.activeConfig = name;
- this.persist();
+ this.#persist();
observer.emit('config:activated', { name, previous });
@@ -456,7 +467,7 @@ export class StateManager {
*/
getSecret(configName: string, key: string): string | null {
- const state = this.getState();
+ const state = this.#getState();
return state.secrets[configName]?.[key] ?? null;
@@ -467,7 +478,7 @@ export class StateManager {
*/
getAllSecrets(configName: string): Record {
- const state = this.getState();
+ const state = this.#getState();
return state.secrets[configName] ? { ...state.secrets[configName] } : {};
@@ -478,7 +489,13 @@ export class StateManager {
*/
async setSecret(configName: string, key: string, value: string): Promise {
- const state = this.getState();
+ if (!isValidSecretKey(key)) {
+
+ throw new InvalidSecretKeyError(key);
+
+ }
+
+ const state = this.#getState();
if (!state.configs[configName]) {
@@ -493,7 +510,7 @@ export class StateManager {
}
state.secrets[configName][key] = value;
- this.persist();
+ this.#persist();
observer.emit('secret:set', { configName, key });
@@ -504,12 +521,12 @@ export class StateManager {
*/
async deleteSecret(configName: string, key: string): Promise {
- const state = this.getState();
+ const state = this.#getState();
if (state.secrets[configName]) {
delete state.secrets[configName][key];
- this.persist();
+ this.#persist();
observer.emit('secret:deleted', { configName, key });
@@ -522,7 +539,7 @@ export class StateManager {
*/
listSecrets(configName: string): string[] {
- const state = this.getState();
+ const state = this.#getState();
return Object.keys(state.secrets[configName] ?? {});
@@ -542,7 +559,7 @@ export class StateManager {
*/
getGlobalSecret(key: string): string | null {
- const state = this.getState();
+ const state = this.#getState();
return state.globalSecrets[key] ?? null;
@@ -553,7 +570,7 @@ export class StateManager {
*/
getAllGlobalSecrets(): Record {
- const state = this.getState();
+ const state = this.#getState();
return { ...state.globalSecrets };
@@ -569,9 +586,9 @@ export class StateManager {
*/
async setGlobalSecret(key: string, value: string): Promise {
- const state = this.getState();
+ const state = this.#getState();
state.globalSecrets[key] = value;
- this.persist();
+ this.#persist();
observer.emit('global-secret:set', { key });
@@ -582,12 +599,12 @@ export class StateManager {
*/
async deleteGlobalSecret(key: string): Promise {
- const state = this.getState();
+ const state = this.#getState();
if (key in state.globalSecrets) {
delete state.globalSecrets[key];
- this.persist();
+ this.#persist();
observer.emit('global-secret:deleted', { key });
@@ -600,7 +617,7 @@ export class StateManager {
*/
listGlobalSecrets(): string[] {
- const state = this.getState();
+ const state = this.#getState();
return Object.keys(state.globalSecrets);
@@ -615,7 +632,7 @@ export class StateManager {
*/
getKnownUsers(): Record {
- const state = this.getState();
+ const state = this.#getState();
return { ...state.knownUsers };
@@ -626,7 +643,7 @@ export class StateManager {
*/
getKnownUser(identityHash: string): KnownUser | null {
- const state = this.getState();
+ const state = this.#getState();
return state.knownUsers[identityHash] ?? null;
@@ -639,7 +656,7 @@ export class StateManager {
*/
findKnownUsersByEmail(email: string): KnownUser[] {
- const state = this.getState();
+ const state = this.#getState();
return Object.values(state.knownUsers).filter((u) => u.email === email);
@@ -652,9 +669,9 @@ export class StateManager {
*/
async addKnownUser(user: KnownUser): Promise {
- const state = this.getState();
+ const state = this.#getState();
state.knownUsers[user.identityHash] = user;
- this.persist();
+ this.#persist();
observer.emit('known-user:added', {
email: user.email,
@@ -668,7 +685,7 @@ export class StateManager {
*/
async addKnownUsers(users: KnownUser[]): Promise {
- const state = this.getState();
+ const state = this.#getState();
for (const user of users) {
@@ -676,7 +693,7 @@ export class StateManager {
}
- this.persist();
+ this.#persist();
}
@@ -689,7 +706,7 @@ export class StateManager {
*/
getVersion(): string {
- const state = this.getState();
+ const state = this.#getState();
return state.version;
@@ -700,7 +717,7 @@ export class StateManager {
*/
exists(): boolean {
- return existsSync(this.statePath);
+ return existsSync(this.#statePath);
}
@@ -709,7 +726,7 @@ export class StateManager {
*/
getStatePath(): string {
- return this.statePath;
+ return this.#statePath;
}
@@ -718,9 +735,9 @@ export class StateManager {
*/
exportEncrypted(): string | null {
- if (!existsSync(this.statePath)) return null;
+ if (!existsSync(this.#statePath)) return null;
- return readFileSync(this.statePath, 'utf8');
+ return readFileSync(this.#statePath, 'utf8');
}
@@ -729,7 +746,7 @@ export class StateManager {
*/
async importEncrypted(encrypted: string): Promise {
- if (!this.privateKey) {
+ if (!this.#privateKey) {
throw new Error('Private key required to import state.');
@@ -739,18 +756,22 @@ export class StateManager {
const [payload, parseErr] = attemptSync(() => JSON.parse(encrypted) as EncryptedPayload);
if (parseErr) throw parseErr;
- const [, decryptErr] = attemptSync(() => decrypt(payload!, this.privateKey!));
+ const [, decryptErr] = attemptSync(() => decrypt(payload!, this.#privateKey!));
if (decryptErr) throw decryptErr;
- const dir = dirname(this.statePath);
+ const dir = dirname(this.#statePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
- writeFileSync(this.statePath, encrypted);
- this.loaded = false;
+ writeFileSync(this.#statePath, encrypted, { mode: 0o600 });
+
+ // Ensure permissions are correct (writeFile mode may not work on all platforms)
+ attemptSync(() => chmodSync(this.#statePath, 0o600));
+
+ this.#loaded = false;
await this.load();
}
@@ -762,7 +783,7 @@ export class StateManager {
*/
setPrivateKey(privateKey: string): void {
- this.privateKey = privateKey;
+ this.#privateKey = privateKey;
}
@@ -771,7 +792,46 @@ export class StateManager {
*/
hasPrivateKey(): boolean {
- return !!this.privateKey;
+ return !!this.#privateKey;
+
+ }
+
+}
+
+/**
+ * Identifier rule for secret keys: the one place the format regex is
+ * declared. `setSecret` enforces it as the actual seam every CLI/SDK/MCP
+ * caller funnels through; the TUI's live-typing validators call this same
+ * predicate instead of hand-copying the pattern.
+ */
+export function isValidSecretKey(key: string): boolean {
+
+ return /^[A-Za-z][A-Za-z0-9_]*$/.test(key);
+
+}
+
+/**
+ * Error when a secret key fails the identifier format rule.
+ *
+ * Thrown by `setSecret` before any state mutation.
+ *
+ * @example
+ * ```typescript
+ * const [, err] = await attempt(() => stateManager.setSecret('dev', 'bad key', 'v'))
+ * if (err instanceof InvalidSecretKeyError) {
+ * console.log(`Rejected key: ${err.key}`)
+ * }
+ * ```
+ */
+export class InvalidSecretKeyError extends Error {
+
+ override readonly name = 'InvalidSecretKeyError' as const;
+
+ constructor(
+ public readonly key: string,
+ ) {
+
+ super(`Secret key "${key}" is invalid: must start with a letter and contain only letters, numbers, and underscores`);
}
diff --git a/src/core/teardown/operations.ts b/src/core/teardown/operations.ts
index 970017c6..f80f09f8 100644
--- a/src/core/teardown/operations.ts
+++ b/src/core/teardown/operations.ts
@@ -62,6 +62,68 @@ function pushFlat(out: string[], value: string | string[]): void {
}
+/**
+ * Execute a group of teardown SQL statements against `db`.
+ *
+ * Shared by every phase (disable/truncate/enable): skips comment-only
+ * entries, splits `'; '`-joined compound statements (e.g. MSSQL's
+ * DELETE + DBCC CHECKIDENT reseed), and emits `teardown:progress` /
+ * `teardown:error` exactly as the old inline loop did.
+ *
+ * `continueOnError` encodes the two failure policies the FK re-enable
+ * guarantee needs: the disable/truncate phase stops at the first
+ * failure (pre-fix throw-on-first-failure semantics, preserved here as
+ * "stop and let the caller decide what to do next"), while the
+ * enable-FK phase must attempt every statement even after a failure —
+ * one MSSQL table's re-enable failing must not skip the other tables'
+ * re-enable. Either way the first error encountered is returned rather
+ * than thrown, so the caller can run both phases before deciding what
+ * to surface.
+ */
+async function executeStatements(
+ db: Kysely,
+ statements: string[],
+ continueOnError: boolean,
+): Promise {
+
+ let firstError: Error | null = null;
+
+ for (const stmt of statements) {
+
+ if (stmt.startsWith('--')) continue;
+
+ const subStatements = stmt.includes('; ')
+ ? stmt.split('; ').map(s => s.trim()).filter(s => s.length > 0)
+ : [stmt];
+
+ for (const subStmt of subStatements) {
+
+ observer.emit('teardown:progress', {
+ category: 'tables',
+ object: subStmt.includes('DELETE') || subStmt.includes('TRUNCATE') ? subStmt : null,
+ action: 'truncating',
+ });
+
+ const [, execErr] = await attempt(() => sql.raw(subStmt).execute(db));
+
+ if (execErr) {
+
+ observer.emit('teardown:error', { error: execErr, object: subStmt });
+
+ firstError = firstError ?? execErr;
+
+ if (!continueOnError) return firstError;
+
+ }
+
+ }
+
+ }
+
+ return firstError;
+
+}
+
/**
* Truncate data from tables.
*
@@ -147,56 +209,45 @@ export async function truncateData(
}
- // Build SQL statements. Skip the FK disable/enable bookends entirely
- // when nothing will be truncated — keeps the dry-run output honest
- // and avoids emitting a no-op `ALTER TABLE NOCHECK` against an empty list.
+ // Build SQL statements in three groups (disable/truncate/enable) so the
+ // enable-FK phase can execute independently of a disable/truncate
+ // failure below. `statements` stays the same flat concatenation for
+ // dry-run output — skip the FK disable/enable bookends entirely when
+ // nothing will be truncated, keeping the dry-run output honest and
+ // avoiding a no-op `ALTER TABLE NOCHECK` against an empty list.
+ const disableStatements: string[] = [];
+ const truncateStatements: string[] = [];
+ const enableStatements: string[] = [];
+
if (truncated.length > 0) {
- pushFlat(statements, ops.disableForeignKeyChecks(truncated));
+ pushFlat(disableStatements, ops.disableForeignKeyChecks(truncated));
for (const tableName of truncated) {
- statements.push(ops.truncateTable(tableName, undefined, options.restartIdentity ?? true));
+ truncateStatements.push(ops.truncateTable(tableName, undefined, options.restartIdentity ?? true));
}
- pushFlat(statements, ops.enableForeignKeyChecks(truncated));
+ pushFlat(enableStatements, ops.enableForeignKeyChecks(truncated));
+
+ statements.push(...disableStatements, ...truncateStatements, ...enableStatements);
}
- // Execute unless dry run
+ // Execute unless dry run. The enable-FK phase always runs, even when the
+ // disable/truncate phase fails — a mid-truncate error must never leave
+ // FK enforcement off (e.g. MSSQL's per-table NOCHECK survives reconnects
+ // until manually repaired). The disable/truncate error takes priority
+ // when both phases fail — the caller needs to know why the truncate
+ // itself broke, not just that FK re-enable also failed.
if (!options.dryRun) {
- for (const stmt of statements) {
+ const truncateError = await executeStatements(db, [...disableStatements, ...truncateStatements], false);
+ const enableError = await executeStatements(db, enableStatements, true);
- // Skip comments
- if (stmt.startsWith('--')) continue;
-
- // Handle multi-statement strings (e.g., SQLite truncate returns two statements)
- const subStatements = stmt.includes('; ')
- ? stmt.split('; ').map(s => s.trim()).filter(s => s.length > 0)
- : [stmt];
-
- for (const subStmt of subStatements) {
-
- observer.emit('teardown:progress', {
- category: 'tables',
- object: subStmt.includes('DELETE') || subStmt.includes('TRUNCATE') ? subStmt : null,
- action: 'truncating',
- });
-
- const [, execErr] = await attempt(() => sql.raw(subStmt).execute(db));
-
- if (execErr) {
-
- observer.emit('teardown:error', { error: execErr, object: subStmt });
- throw execErr;
-
- }
-
- }
-
- }
+ if (truncateError) throw truncateError;
+ if (enableError) throw enableError;
}
diff --git a/src/core/template/context.ts b/src/core/template/context.ts
index b398a53c..e9eeb207 100644
--- a/src/core/template/context.ts
+++ b/src/core/template/context.ts
@@ -31,6 +31,106 @@ import { loadHelpers } from './helpers.js';
import { loadDataFile, hasLoader } from './loaders/index.js';
import { toContextKey, sqlEscape, sqlQuote, generateUuid, isoNow } from './utils.js';
+/**
+ * Tiers `$.secrets` actually resolves from, in priority order.
+ *
+ * Named here (not derived) because `buildContext` only ever sees the
+ * already-built `options.secrets` record — it has no visibility into which
+ * tiers produced it. Every render-path caller (`RunNamespace`,
+ * `ChangesNamespace`, `TemplatesNamespace`, `run preview`, `run inspect`,
+ * the TUI run context) sources `secrets` from `buildSecretsContext`, which
+ * merges all three. Keep this list in step with that function: a
+ * `MissingSecretError` that names a tier nobody searched sends the reader
+ * looking in the wrong place, and one that omits a tier hides where the
+ * value should have come from.
+ *
+ * A vault tier that cannot be reached (no vault, no key, unreachable DB)
+ * degrades to the local tiers rather than failing, so it is still listed —
+ * it was searched, it just had nothing to give.
+ */
+const SECRET_TIERS_SEARCHED = ['config-local', 'global-local', 'vault'] as const;
+
+/**
+ * Error when a template reads a secret that could not be resolved.
+ *
+ * A missing secret has no correct SQL rendering. Before this, a miss on the
+ * plain `secrets` object read as `undefined`, and `sqlQuote` stringified
+ * that into the literal text `undefined` — a real credential shipped with
+ * the six-character password `undefined` (noorm#50). Thrown by the
+ * `$.secrets` proxy's `get` trap; the `has` trap does not throw, so
+ * templates can still probe for an optional secret via `'KEY' in $.secrets`.
+ *
+ * @example
+ * ```typescript
+ * const [, err] = await attempt(() => processFile(templatePath, { secrets }));
+ * if (err instanceof MissingSecretError) {
+ * console.log(`missing ${err.key}, searched: ${err.tiersSearched.join(', ')}`);
+ * }
+ * ```
+ */
+export class MissingSecretError extends Error {
+
+ override readonly name = 'MissingSecretError' as const;
+
+ constructor(
+ public readonly key: string,
+ public readonly tiersSearched: readonly string[] = SECRET_TIERS_SEARCHED,
+ ) {
+
+ super(`Secret "${key}" not found (searched: ${tiersSearched.join(', ')})`);
+
+ }
+
+}
+
+/**
+ * Wrap a resolved secrets record so an unknown key fails loudly instead of
+ * reading as `undefined`.
+ *
+ * `Object.keys`, spreading, and `JSON.stringify` never trigger `get` for a
+ * key that isn't actually present — they resolve keys through `ownKeys`/
+ * `getOwnPropertyDescriptor`, which this proxy leaves at the default
+ * (forwarded to `target`) — so none of them throw. `toJSON` is special-cased
+ * because `JSON.stringify` probes it as a `get` on the value itself before
+ * falling back to default object serialization.
+ *
+ * @param secrets - The merged secrets record to guard
+ * @returns A proxy over `secrets` whose `get` throws on an unresolved key
+ *
+ * @example
+ * ```typescript
+ * const secrets = createSecretsProxy({ API_KEY: 'abc' });
+ * secrets.API_KEY; // 'abc'
+ * 'MISSING' in secrets; // false, does not throw
+ * secrets.MISSING; // throws MissingSecretError
+ * ```
+ */
+function createSecretsProxy(secrets: Record): Record {
+
+ return new Proxy(secrets, {
+
+ get(target, prop, receiver) {
+
+ if (typeof prop === 'symbol' || prop === 'toJSON' || prop in target) {
+
+ return Reflect.get(target, prop, receiver);
+
+ }
+
+ throw new MissingSecretError(prop);
+
+ },
+
+ has(target, prop) {
+
+ return Reflect.has(target, prop);
+
+ },
+
+ });
+
+}
+
/**
* Build the template context ($) for a template file.
*
@@ -67,7 +167,7 @@ export async function buildContext(
...(hasLocalConfig ? {} : { config: options.config }),
// Secrets
- secrets: options.secrets ?? {},
+ secrets: createSecretsProxy(options.secrets ?? {}),
globalSecrets: options.globalSecrets ?? {},
// Environment
diff --git a/src/core/template/utils.ts b/src/core/template/utils.ts
index aed17dd9..0e302438 100644
--- a/src/core/template/utils.ts
+++ b/src/core/template/utils.ts
@@ -15,31 +15,7 @@
*/
import path from 'node:path';
-/**
- * Convert a string to camelCase.
- *
- * Splits on hyphens, underscores, and case boundaries,
- * then joins with first word lowercased and rest capitalized.
- *
- * @param str - Input string in any casing
- * @returns camelCase string
- */
-function camelCase(str: string): string {
-
- const words = str
- .replace(/([a-z])([A-Z])/g, '$1 $2')
- .split(/[-_\s]+/)
- .filter(Boolean);
-
- if (words.length === 0) return '';
-
- return words
- .map((w, i) => i === 0
- ? w.toLowerCase()
- : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
- .join('');
-
-}
+import v from 'voca';
/**
* Convert a filename to a camelCase context key.
@@ -65,7 +41,7 @@ export function toContextKey(filename: string): string {
const base = path.basename(filename, ext);
// Convert to camelCase
- return camelCase(base);
+ return v.camelCase(base);
}
@@ -90,13 +66,42 @@ export function sqlEscape(value: string): string {
}
+/**
+ * Error when a value that must be rendered into SQL is `undefined`.
+ *
+ * `undefined` reaching `sqlQuote` is always a bug upstream — a missing
+ * secret, an unresolved config key, a typo'd lookup — never a value a
+ * template author meant to write. `null` remains a legitimate SQL value
+ * and still quotes to `NULL`; stringifying `undefined` is how a missing
+ * secret shipped as the literal password `undefined` (noorm#50).
+ *
+ * @example
+ * ```typescript
+ * sqlQuote(null) // → 'NULL'
+ * sqlQuote(undefined) // throws UndefinedSqlValueError
+ * ```
+ */
+export class UndefinedSqlValueError extends Error {
+
+ override readonly name = 'UndefinedSqlValueError' as const;
+
+ constructor() {
+
+ super('sqlQuote() received undefined — use null for an explicit SQL NULL, or resolve the missing value before rendering');
+
+ }
+
+}
+
/**
* SQL-escape and wrap in single quotes.
*
- * Handles null values and various types appropriately.
+ * Handles null values and various types appropriately. Throws on
+ * `undefined` rather than stringifying it — see `UndefinedSqlValueError`.
*
* @param value - The value to quote
* @returns The quoted SQL literal
+ * @throws UndefinedSqlValueError if value is undefined
*
* @example
* ```typescript
@@ -106,7 +111,13 @@ export function sqlEscape(value: string): string {
* sqlQuote(true) // → "'true'"
* ```
*/
-export function sqlQuote(value: string | number | boolean | null): string {
+export function sqlQuote(value: string | number | boolean | null | undefined): string {
+
+ if (value === undefined) {
+
+ throw new UndefinedSqlValueError();
+
+ }
if (value === null) {
diff --git a/src/core/transfer/events.ts b/src/core/transfer/events.ts
index 819039f3..3e9d64d1 100644
--- a/src/core/transfer/events.ts
+++ b/src/core/transfer/events.ts
@@ -63,6 +63,7 @@ export interface TransferEvents {
totalRows: number;
tableCount: number;
durationMs: number;
+ fkChecksRestored: boolean;
};
}
diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts
index 5b6b6fa5..f866455d 100644
--- a/src/core/transfer/executor.ts
+++ b/src/core/transfer/executor.ts
@@ -170,7 +170,13 @@ export async function executeTransfer(
}
- // Re-enable FK checks on destination
+ // Re-enable FK checks on destination. A failed re-enable never fails
+ // the transfer itself (data already moved), but it must stay visible
+ // to the caller — a swallowed failure here left referential integrity
+ // off on the destination with no signal beyond an observer event
+ // (QL-safe-05).
+ let fkChecksRestored = true;
+
if (options.disableForeignKeys !== false) {
const [, enableErr] = await attempt(() =>
@@ -182,6 +188,8 @@ export async function executeTransfer(
if (enableErr) {
+ fkChecksRestored = false;
+
// Log warning but don't fail the transfer
observer.emit('error', {
source: 'transfer',
@@ -201,6 +209,7 @@ export async function executeTransfer(
tables: tableResults,
totalRows,
durationMs,
+ fkChecksRestored,
};
observer.emit('transfer:complete', {
@@ -208,6 +217,7 @@ export async function executeTransfer(
totalRows,
tableCount: plan.tables.length,
durationMs,
+ fkChecksRestored,
});
return [result, null];
diff --git a/src/core/transfer/index.ts b/src/core/transfer/index.ts
index 27d049d8..b80e5857 100644
--- a/src/core/transfer/index.ts
+++ b/src/core/transfer/index.ts
@@ -123,6 +123,7 @@ export async function transferData(
tables: [],
totalRows: 0,
durationMs: 0,
+ fkChecksRestored: true,
};
}
@@ -141,6 +142,7 @@ export async function transferData(
})),
totalRows: 0,
durationMs: 0,
+ fkChecksRestored: true,
};
}
diff --git a/src/core/transfer/same-server.ts b/src/core/transfer/same-server.ts
index 2be1ae2e..161753ff 100644
--- a/src/core/transfer/same-server.ts
+++ b/src/core/transfer/same-server.ts
@@ -5,16 +5,7 @@
* enabling direct INSERT...SELECT transfers without data marshalling.
*/
import type { ConnectionConfig, Dialect } from '../connection/types.js';
-
-/**
- * Default ports by dialect.
- */
-const DEFAULT_PORTS: Record = {
- postgres: 5432,
- mysql: 3306,
- mssql: 1433,
- sqlite: 0, // Not applicable
-};
+import { DEFAULT_PORTS } from '../connection/defaults.js';
/**
* Normalize a hostname for comparison.
diff --git a/src/core/transfer/types.ts b/src/core/transfer/types.ts
index 96924910..ffc4adc1 100644
--- a/src/core/transfer/types.ts
+++ b/src/core/transfer/types.ts
@@ -174,4 +174,15 @@ export interface TransferResult {
/** Total duration in milliseconds */
durationMs: number;
+ /**
+ * Whether FK checks were successfully re-enabled on the destination.
+ * `false` only when checks were disabled for this transfer and the
+ * re-enable attempt failed; `true` otherwise, including when
+ * `disableForeignKeys: false` meant checks were never touched. This
+ * is the only signal a caller has that referential integrity may
+ * still be off — `status` does not flip on a failed FK restore
+ * (QL-safe-05).
+ */
+ fkChecksRestored: boolean;
+
}
diff --git a/src/core/update/checksum.ts b/src/core/update/checksum.ts
new file mode 100644
index 00000000..8cd50eab
--- /dev/null
+++ b/src/core/update/checksum.ts
@@ -0,0 +1,159 @@
+/**
+ * Checksum verification for binary release assets.
+ *
+ * Ports the pattern from the sibling `ignatius` repo but hardens its one
+ * weakness: an unreachable/missing checksums.txt is a hard failure by
+ * default (opt out via `insecure`), and a confirmed hash mismatch is ALWAYS
+ * a hard failure — `insecure` can only ever downgrade "we couldn't prove
+ * anything either way" to a warning, never "we proved this binary is bad".
+ */
+import { attempt } from '@logosdx/utils';
+
+/**
+ * Parse a `shasum -a 256`-style checksums file into an asset → sha256 map.
+ *
+ * Mirrors the exact output format the release workflow produces: a 64-char
+ * hex hash, whitespace, an optional `*` binary-mode marker, then the
+ * filename. checksums.txt is generated by a trusted CI step, not user
+ * input — blank/malformed lines are skipped rather than thrown on, since a
+ * single stray line shouldn't take down verification for every other entry.
+ *
+ * @example
+ * const map = parseChecksums('deadbeef... noorm-darwin-arm64\n');
+ * map['noorm-darwin-arm64']; // 'deadbeef...'
+ */
+export function parseChecksums(text: string): Record {
+
+ const out: Record = {};
+
+ for (const line of text.split('\n')) {
+
+ const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
+
+ if (match && match[1] && match[2]) {
+
+ out[match[2]] = match[1].toLowerCase();
+
+ }
+
+ }
+
+ return out;
+
+}
+
+/**
+ * Compute the sha256 hex digest of a file on disk.
+ *
+ * Streams the file rather than buffering it whole — release binaries run
+ * tens of MB, and buffering the full file would spike memory on every
+ * update check for no benefit.
+ *
+ * @example
+ * const hash = await sha256File('/tmp/noorm.download');
+ */
+export async function sha256File(path: string): Promise {
+
+ const hasher = new Bun.CryptoHasher('sha256');
+
+ for await (const chunk of Bun.file(path).stream()) {
+
+ hasher.update(chunk);
+
+ }
+
+ return hasher.digest('hex');
+
+}
+
+/**
+ * A checksum verification failure.
+ *
+ * `reason` distinguishes two categorically different failures so callers
+ * (and the `insecure` escape hatch) branch on structure instead of matching
+ * error message text:
+ * - `'unreachable'`: checksums.txt couldn't be fetched, or was fetched but
+ * didn't mention this asset. Either way we have no trustworthy answer —
+ * bypassable via `insecure`, the documented escape hatch for offline
+ * installs and mirrors without a checksums.txt.
+ * - `'mismatch'`: checksums.txt was fetched and DID have an entry for this
+ * asset, and the computed hash disagrees with it. We have proof the bytes
+ * are wrong — never bypassable, by design (see `verifyChecksum`).
+ */
+export class ChecksumError extends Error {
+
+ override readonly name = 'ChecksumError' as const;
+
+ constructor(message: string, readonly reason: 'unreachable' | 'mismatch') {
+
+ super(message);
+
+ }
+
+}
+
+/**
+ * Verify a downloaded file's sha256 against a release's checksums.txt.
+ *
+ * Security-critical asymmetry, deliberate: "we couldn't verify" (checksums.txt
+ * unreachable, or missing an entry for this asset) is bypassable via
+ * `insecure`. "We verified and it's wrong" is NEVER bypassable — `insecure`
+ * only ever waves through the absence of proof, not a proven-bad binary.
+ * Get this branch order wrong and the entire feature becomes a no-op for the
+ * one case it exists to catch.
+ *
+ * @throws ChecksumError('mismatch') when the computed hash disagrees with a
+ * found checksums.txt entry — unconditional, `insecure` has no effect here.
+ * @throws ChecksumError('unreachable') when checksums.txt can't be fetched,
+ * or has no entry for `assetName`, and `insecure` is false.
+ *
+ * @example
+ * await verifyChecksum({
+ * checksumsUrl: getChecksumsUrl(version),
+ * assetName: getBinaryAssetName(),
+ * filePath: tmpPath,
+ * insecure: false,
+ * });
+ */
+export async function verifyChecksum(opts: {
+ checksumsUrl: string;
+ assetName: string;
+ filePath: string;
+ insecure: boolean;
+}): Promise {
+
+ const { checksumsUrl, assetName, filePath, insecure } = opts;
+
+ const [response, fetchErr] = await attempt(() => fetch(checksumsUrl));
+
+ if (fetchErr || !response || !response.ok) {
+
+ if (insecure) return;
+
+ throw new ChecksumError(`checksums.txt unreachable at ${checksumsUrl}`, 'unreachable');
+
+ }
+
+ const text = await response.text();
+ const expected = parseChecksums(text)[assetName];
+
+ if (!expected) {
+
+ if (insecure) return;
+
+ throw new ChecksumError(`no checksum entry for ${assetName} in ${checksumsUrl}`, 'unreachable');
+
+ }
+
+ const actual = await sha256File(filePath);
+
+ if (actual !== expected) {
+
+ throw new ChecksumError(
+ `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})`,
+ 'mismatch',
+ );
+
+ }
+
+}
diff --git a/src/core/update/install-mode.ts b/src/core/update/install-mode.ts
index 48229463..fffabc83 100644
--- a/src/core/update/install-mode.ts
+++ b/src/core/update/install-mode.ts
@@ -62,18 +62,32 @@ export function detectInstallMode(): InstallMode {
const GITHUB_REPO = 'noormdev/noorm';
/**
- * Get the download URL for a binary release.
+ * Release-tag URL base shared by every asset published for a version — the
+ * binary itself and its checksums.txt. Factored out so both stay in
+ * lockstep instead of two copies of the same URL-building logic drifting
+ * apart.
+ */
+function releaseBaseUrl(version: string): string {
+
+ return `https://github.com/${GITHUB_REPO}/releases/download/%40noormdev%2Fcli%40${version}`;
+
+}
+
+/**
+ * Get the platform-appropriate binary asset filename (no version, no URL).
*
- * @param version - Semver version to download
- * @returns URL to the platform-appropriate binary asset
+ * Split out from `getBinaryDownloadUrl` so the download URL and the
+ * checksums.txt lookup key (the entry name checksum verification looks up)
+ * are always derived from the exact same platform/arch table — one place to
+ * update, not two that could silently drift apart.
*
* @example
* ```typescript
- * const url = getBinaryDownloadUrl('1.2.0');
- * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/noorm-darwin-arm64'
+ * const asset = getBinaryAssetName();
+ * // → 'noorm-darwin-arm64'
* ```
*/
-export function getBinaryDownloadUrl(version: string): string {
+export function getBinaryAssetName(): string {
const platform = process.platform;
const arch = process.arch;
@@ -111,6 +125,43 @@ export function getBinaryDownloadUrl(version: string): string {
}
- return `https://github.com/${GITHUB_REPO}/releases/download/%40noormdev%2Fcli%40${version}/noorm-${suffix}`;
+ return `noorm-${suffix}`;
+
+}
+
+/**
+ * Get the download URL for a binary release.
+ *
+ * @param version - Semver version to download
+ * @returns URL to the platform-appropriate binary asset
+ *
+ * @example
+ * ```typescript
+ * const url = getBinaryDownloadUrl('1.2.0');
+ * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/noorm-darwin-arm64'
+ * ```
+ */
+export function getBinaryDownloadUrl(version: string): string {
+
+ return `${releaseBaseUrl(version)}/${getBinaryAssetName()}`;
+
+}
+
+/**
+ * Get the URL to the checksums.txt published alongside the platform
+ * binaries at the same release tag.
+ *
+ * @param version - Semver version to download
+ * @returns URL to the release's checksums.txt
+ *
+ * @example
+ * ```typescript
+ * const url = getChecksumsUrl('1.2.0');
+ * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/checksums.txt'
+ * ```
+ */
+export function getChecksumsUrl(version: string): string {
+
+ return `${releaseBaseUrl(version)}/checksums.txt`;
}
diff --git a/src/core/update/registry.ts b/src/core/update/registry.ts
index 1ad8f6d1..b237fea0 100644
--- a/src/core/update/registry.ts
+++ b/src/core/update/registry.ts
@@ -67,18 +67,13 @@ export interface RegistryPackageInfo {
*/
export async function fetchPackageInfo(): Promise {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
-
const [response, fetchErr] = await attempt(() =>
fetch(REGISTRY_URL, {
- signal: controller.signal,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'Accept': 'application/json' },
}),
);
- clearTimeout(timeoutId);
-
if (fetchErr) {
// Network error or timeout - return null for graceful handling
diff --git a/src/core/update/updater.ts b/src/core/update/updater.ts
index 4018c88f..f3ef1a0b 100644
--- a/src/core/update/updater.ts
+++ b/src/core/update/updater.ts
@@ -16,11 +16,12 @@
import { spawn } from 'child_process';
import { open, rename, unlink, chmod, stat } from 'fs/promises';
-import { attempt } from '@logosdx/utils';
+import { attempt, retry, wait } from '@logosdx/utils';
import { observer } from '../observer.js';
import { getCurrentVersion } from './checker.js';
-import { detectInstallMode, getBinaryDownloadUrl } from './install-mode.js';
+import { verifyChecksum } from './checksum.js';
+import { detectInstallMode, getBinaryDownloadUrl, getChecksumsUrl, getBinaryAssetName } from './install-mode.js';
import type { UpdateResult } from './types.js';
// =============================================================================
@@ -180,8 +181,6 @@ interface DownloadState {
total: number;
}
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
/** Current size of a file, or 0 if it doesn't exist — the resume offset. */
async function fileSizeOrZero(path: string): Promise {
@@ -225,26 +224,31 @@ export async function downloadToFile(
const state: DownloadState = { total: 0 };
- for (let attemptNo = 1; attemptNo <= maxAttempts; attemptNo++) {
-
- const offset = await fileSizeOrZero(destPath);
-
- // A prior attempt already pulled the whole asset — nothing left to do.
- if (state.total > 0 && offset >= state.total) break;
+ await retry(
+ async () => {
- const [, err] = await attempt(() => downloadAttempt(url, destPath, version, offset, state, stallMs));
+ const offset = await fileSizeOrZero(destPath);
- if (!err) break;
+ // A prior attempt already pulled the whole asset — nothing left to do.
+ if (state.total > 0 && offset >= state.total) return;
- const retriable = !(err instanceof DownloadError) || err.retriable;
+ await downloadAttempt(url, destPath, version, offset, state, stallMs);
- if (!retriable || attemptNo >= maxAttempts) throw err;
+ },
+ {
+ retries: maxAttempts,
+ delay: 0,
+ throwLastError: true,
+ shouldRetry: (err) => !(err instanceof DownloadError) || err.retriable,
+ onRetry: async (err, attempt) => {
- observer.emit('update:retry', { version, attempt: attemptNo, maxAttempts, error: err.message });
+ observer.emit('update:retry', { version, attempt, maxAttempts, error: err.message });
- await sleep(backoffMs * attemptNo);
+ await wait(backoffMs * attempt);
- }
+ },
+ },
+ );
await chmod(destPath, 0o755);
@@ -388,9 +392,11 @@ async function downloadAttempt(
* Streams the platform-appropriate binary to a temp file **in the target's own
* directory** — a cross-filesystem `rename` (e.g. `os.tmpdir()` on a different
* volume than `~/.local/bin`) throws `EXDEV`, so the swap must stage next to the
- * destination — then atomically replaces the current executable.
+ * destination — then verifies its sha256 against checksums.txt before atomically
+ * replacing the current executable. A tampered or corrupted binary must never
+ * reach the swap, so verification runs after download and before rename.
*/
-async function installViaBinary(version: string, previousVersion: string): Promise {
+async function installViaBinary(version: string, previousVersion: string, insecure = false): Promise {
const url = getBinaryDownloadUrl(version);
const currentExe = process.execPath;
@@ -414,6 +420,21 @@ async function installViaBinary(version: string, previousVersion: string): Promi
}
+ const [, verifyErr] = await attempt(() => verifyChecksum({
+ checksumsUrl: getChecksumsUrl(version),
+ assetName: getBinaryAssetName(),
+ filePath: tmpPath,
+ insecure,
+ }));
+
+ if (verifyErr) {
+
+ await attempt(() => unlink(tmpPath));
+
+ return fail(verifyErr.message);
+
+ }
+
// Atomic replace: rename old → backup, rename new → current, remove backup.
// All three paths share `currentExe`'s directory, so every rename is
// same-filesystem and atomic.
@@ -457,10 +478,15 @@ async function installViaBinary(version: string, previousVersion: string): Promi
* Install update via the appropriate channel.
*
* Automatically detects install mode and routes to the correct updater:
- * - npm mode: runs `npm install -g @noormdev/cli@{version}`
- * - binary mode: downloads replacement binary from GitHub releases
+ * - npm mode: runs `npm install -g @noormdev/cli@{version}` (npm's own
+ * registry integrity hashes already cover this channel — `options` is
+ * ignored here)
+ * - binary mode: downloads replacement binary from GitHub releases and
+ * verifies its checksum before the atomic swap
*
* @param version - Version to install
+ * @param options.insecure - Skip checksum verification when checksums.txt is
+ * unreachable (binary mode only). Never bypasses a confirmed mismatch.
* @returns Promise that resolves when install completes
*
* @example
@@ -479,7 +505,7 @@ async function installViaBinary(version: string, previousVersion: string): Promi
* }
* ```
*/
-export function installUpdate(version: string): Promise {
+export function installUpdate(version: string, options: { insecure?: boolean } = {}): Promise {
const previousVersion = getCurrentVersion();
const mode = detectInstallMode();
@@ -488,7 +514,7 @@ export function installUpdate(version: string): Promise {
if (mode === 'binary') {
- return installViaBinary(version, previousVersion);
+ return installViaBinary(version, previousVersion, options.insecure ?? false);
}
diff --git a/src/core/vault/resolve.ts b/src/core/vault/resolve.ts
index 66db98de..5185ba97 100644
--- a/src/core/vault/resolve.ts
+++ b/src/core/vault/resolve.ts
@@ -8,13 +8,53 @@
*
* Local always wins over remote.
*/
+import { attempt } from '@logosdx/utils';
+
import type { Kysely } from 'kysely';
import type { NoormDatabase } from '../shared/tables.js';
import type { Dialect } from '../connection/types.js';
import type { StateManager } from '../state/manager.js';
+import { loadIdentityMetadata, loadPrivateKey } from '../identity/storage.js';
+
+import { getVaultKey, getVaultSecret } from './storage.js';
+
+/**
+ * Resolve the vault key for the current identity, if any.
+ *
+ * Degrades to `null` on any failure — no identity on disk, no vault
+ * access yet, or a decrypt failure all mean the vault tier is
+ * unavailable, not that the caller should fail. A project with no vault
+ * at all must keep working exactly as it does today.
+ *
+ * Single source of truth for the five render-context builders that need
+ * it (`run`/`changes`/`templates` SDK namespaces, `run preview`/`run
+ * inspect`) — a future change to the degrade rules is one edit here.
+ *
+ * @example
+ * ```typescript
+ * const vaultKey = await resolveVaultKey(db, 'postgres');
+ * ```
+ */
+export async function resolveVaultKey(
+ db: Kysely,
+ dialect: Dialect,
+): Promise {
+
+ const [vaultKey] = await attempt(async () => {
+
+ const cryptoIdentity = await loadIdentityMetadata();
+ const privateKey = cryptoIdentity ? await loadPrivateKey() : null;
+
+ if (!cryptoIdentity || !privateKey) return null;
-import { getVaultSecret } from './storage.js';
+ return getVaultKey(db, cryptoIdentity.identityHash, privateKey, dialect);
+
+ });
+
+ return vaultKey ?? null;
+
+}
/**
* Resolve a secret value.
diff --git a/src/core/vault/storage.ts b/src/core/vault/storage.ts
index bed9335b..f3cc2784 100644
--- a/src/core/vault/storage.ts
+++ b/src/core/vault/storage.ts
@@ -160,7 +160,9 @@ export async function getVaultKey(
});
- if (err || !row?.encrypted_vault_key) return null;
+ if (err) throw err;
+
+ if (!row?.encrypted_vault_key) return null;
const encryptedValue = row.encrypted_vault_key;
@@ -312,7 +314,9 @@ export async function getVaultSecret(
});
- if (err || !row) return null;
+ if (err) throw err;
+
+ if (!row) return null;
const [parsed, parseErr] = attemptSync(() =>
JSON.parse(row.encrypted_value) as { iv: string; authTag: string; ciphertext: string },
@@ -361,7 +365,9 @@ export async function getAllVaultSecrets(
});
- if (err || !rows) return {};
+ if (err) throw err;
+
+ if (!rows) return {};
const secrets: Record = {};
@@ -424,7 +430,9 @@ export async function listVaultSecretKeys(
});
- if (err || !rows) return [];
+ if (err) throw err;
+
+ if (!rows) return [];
return rows.map((r) => r.secret_key);
@@ -508,7 +516,7 @@ export async function vaultSecretExists(
});
- if (err) return false;
+ if (err) throw err;
return !!row;
diff --git a/src/core/worker-bridge/bridge.ts b/src/core/worker-bridge/bridge.ts
index f2d5bf7a..55331d0d 100644
--- a/src/core/worker-bridge/bridge.ts
+++ b/src/core/worker-bridge/bridge.ts
@@ -1,9 +1,7 @@
-import { Worker, parentPort, workerData } from 'worker_threads';
-import type { Transferable as NodeTransferable } from 'worker_threads';
+import { Worker, parentPort } from 'worker_threads';
import { ObserverRelay } from '@logosdx/observer';
import { randomUUID } from 'crypto';
import { WorkerPool } from './pool.js';
-import { isTransferable } from './types.js';
import type { WireMessage, ResKey, PoolOptions } from './types.js';
type Port = Worker | import('worker_threads').MessagePort
@@ -13,14 +11,14 @@ export class WorkerBridge extends Obser
#port: Port;
#ownsWorker: boolean;
- constructor(script?: string | URL, data?: unknown) {
+ constructor(script?: string | URL) {
const isParent = !!script;
super({ name: isParent ? 'bridge:parent' : 'bridge:worker' });
if (script) {
- this.#port = new Worker(script, { workerData: data });
+ this.#port = new Worker(script);
this.#ownsWorker = true
// Detect worker crash — emit error event so orchestrators can abort
@@ -59,16 +57,7 @@ export class WorkerBridge extends Obser
protected override send(event: string, data: unknown): void {
- if (isTransferable(data)) {
-
- this.#port.postMessage({ event, data }, data.__transfer as NodeTransferable[]);
-
- }
- else {
-
- this.#port.postMessage({ event, data }, []);
-
- }
+ this.#port.postMessage({ event, data }, []);
}
@@ -87,16 +76,6 @@ export class WorkerBridge extends Obser
}
- transfer(
- event: K,
- data: TEvents[K],
- transferables: NodeTransferable[],
- ): void {
-
- this.#port.postMessage({ event, data }, transferables);
-
- }
-
static pool(
script: string | URL,
options: PoolOptions,
@@ -106,12 +85,6 @@ export class WorkerBridge extends Obser
}
- static get workerData() {
-
- return workerData;
-
- }
-
override async shutdown(): Promise {
super.shutdown();
diff --git a/src/core/worker-bridge/index.ts b/src/core/worker-bridge/index.ts
index 361667bd..3c08f1ec 100644
--- a/src/core/worker-bridge/index.ts
+++ b/src/core/worker-bridge/index.ts
@@ -9,5 +9,4 @@ export type {
ComputeEvents,
PoolOptions,
} from './types.js';
-export { isTransferable } from './types.js';
export { resolveWorker } from './paths.js';
diff --git a/src/core/worker-bridge/pool.ts b/src/core/worker-bridge/pool.ts
index 08ae5a25..9bc175fb 100644
--- a/src/core/worker-bridge/pool.ts
+++ b/src/core/worker-bridge/pool.ts
@@ -67,28 +67,6 @@ export class WorkerPool {
}
- /**
- * Registers a listener on all workers for the given event.
- *
- * Useful for broadcast-style events that every worker may emit.
- *
- * @example
- * pool.on('serialize:res', ({ data }) => console.log(data.values))
- */
- on(
- event: K,
- callback: (payload: { data: TEvents[K] }) => void,
- options?: { signal?: AbortSignal },
- ) {
-
- for (const worker of this.#workers) {
-
- worker.on(event, callback, options);
-
- }
-
- }
-
/**
* Terminates all workers in the pool.
*
diff --git a/src/core/worker-bridge/types.ts b/src/core/worker-bridge/types.ts
index 6b04f9c2..7257e3c7 100644
--- a/src/core/worker-bridge/types.ts
+++ b/src/core/worker-bridge/types.ts
@@ -15,13 +15,6 @@ export type ResKey = `${K}:res`
// See Correlation Protocol in the spec.
export type Correlated = T & { __cid: string }
-// Type guard for zero-copy ArrayBuffer transfer
-export function isTransferable(data: unknown): data is { __transfer: Transferable[] } {
-
- return !!data && typeof data === 'object' && '__transfer' in data;
-
-}
-
// --- Connection Worker Events ---
export type ConnectionEvents = {
diff --git a/src/hooks/observer.ts b/src/hooks/observer.ts
deleted file mode 100644
index bf985aee..00000000
--- a/src/hooks/observer.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
-
-import {
- type NoormEvents,
- type NoormEventNames,
- type NoormEventCallback,
- observer as obsrv,
-} from '../core/observer.js';
-
-// re-export for convenience
-export const observer = obsrv;
-
-/**
- * Attaches a listener to the observer with an automatic cleanup function
- *
- * @param event App event
- * @param cb Callback to listen to
- * @param deps Dependencies to bind to
- */
-export const useOnNoormEvent = (
- event: E,
- cb: NoormEventCallback,
- deps: React.DependencyList = [],
-) => {
-
- useEffect(() => obsrv.on(event, cb), [event, cb, ...deps]);
-
-};
-
-/**
- * Creates an event generator for an event
- *
- * @param event App event
- * @param deps Dependencies to bind to
- * @returns
- */
-export const useNoormEventGenerator = (
- event: E,
- deps: React.DependencyList = [],
-) => {
-
- return useMemo(() => obsrv.on(event), [event, ...deps]);
-
-};
-
-/**
- * Attaches a listener to the observer once with an automatic cleanup function
- *
- * @param event App event
- * @param cb Callback to listen to
- * @param deps Dependencies to bind to
- */
-export const useOnceNoormEvent = (
- event: E,
- cb: NoormEventCallback,
- deps: React.DependencyList = [],
-) => {
-
- useEffect(() => obsrv.once(event, cb), [event, cb, ...deps]);
-
-};
-
-/**
- * Creates a function that emits events, scoped to a single function
- *
- * @param event App event
- * @param deps Dependencies to bind to
- */
-export const useEmitNoormEvent = (
- event: E,
- deps: React.DependencyList = [],
-) => {
-
- return useCallback((data: D) => obsrv.emit(event, data as never), [event, ...deps]);
-
-};
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index a93095b9..59bfec65 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -129,10 +129,13 @@ export function createMcpServer(registry: RpcRegistry, session: SessionManager):
if (err) {
+ // Stack traces stay server-side; the MCP client only sees the message.
+ console.error(err.stack ?? err.message);
+
return {
content: [{
type: 'text' as const,
- text: JSON.stringify({ error: err.message, stack: err.stack }),
+ text: JSON.stringify({ error: err.message }),
}],
isError: true,
};
diff --git a/src/rpc/commands/config.ts b/src/rpc/commands/config.ts
index 40dfa2c2..98ba8464 100644
--- a/src/rpc/commands/config.ts
+++ b/src/rpc/commands/config.ts
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { attempt } from '@logosdx/utils';
import { initState } from '../../core/state/index.js';
+import { isVisibleToChannel } from '../../core/policy/index.js';
import type { ConfigSummary } from '../../core/config/types.js';
import type { RpcCommand } from '../types.js';
import { RpcError } from '../types.js';
@@ -26,15 +27,9 @@ const listConfigsCommand: RpcCommand, ConfigSummary[]> = {
const summaries = manager.listConfigs();
- // Invisibility: a config with access.mcp === false does not exist
- // as far as the mcp channel is concerned.
- if (session.channel === 'mcp') {
-
- return summaries.filter((summary) => summary.access.mcp !== false);
-
- }
-
- return summaries;
+ // Invisibility: a config with access.mcp === false (or missing
+ // access) does not exist as far as the mcp channel is concerned.
+ return summaries.filter((summary) => isVisibleToChannel(summary.access, session.channel));
},
};
diff --git a/src/rpc/commands/session.ts b/src/rpc/commands/session.ts
index 2103abf9..31b91cb3 100644
--- a/src/rpc/commands/session.ts
+++ b/src/rpc/commands/session.ts
@@ -1,6 +1,10 @@
import { z } from 'zod';
+import { attempt } from '@logosdx/utils';
+import { initState } from '../../core/state/index.js';
+import { getEnvConfigName } from '../../core/environment.js';
import type { RpcCommand } from '../types.js';
+import { RpcError } from '../types.js';
const connectSchema = z.object({
config: z.string().optional().describe('Config name to connect to. Omit to use active config.'),
@@ -47,7 +51,76 @@ const disconnectCommand: RpcCommand = {
},
};
+/**
+ * Snapshot of session connection state returned by the `status` command.
+ */
+export interface SessionStatus {
+ connections: string[];
+ activeConfig: string | null;
+ activeConnected: boolean;
+}
+
+/**
+ * Reports what an agent is connected to and what a bare `connect` would
+ * target, giving `SessionManager.hasConnection`/`listConnections`
+ * (`src/rpc/session.ts:166,175`) their production callers — closing
+ * AP-yagni-06 productively (decision D9, tickets/v1/00-DECISIONS.md).
+ * Read-only: no session mutation.
+ *
+ * Active-config resolution mirrors `resolveConfig`'s no-name path
+ * (`src/core/config/resolver.ts:214`) so `status` reports exactly what a
+ * bare `connect` would target. On the mcp channel, a config hidden via
+ * `access.mcp === false` (or unknown to state) is reported as no active
+ * config, same invisibility `list_configs` applies — `status` must not leak
+ * a hidden config's name through the active-config field.
+ *
+ * @example
+ * const { connections, activeConfig, activeConnected } = await cmd.handler({}, session);
+ */
+const statusCommand: RpcCommand, SessionStatus> = {
+ name: 'status',
+ description: 'Show connected configs and the active config. Does not require a database connection.',
+ examples: [
+ { description: 'check session status', input: {} },
+ ],
+ inputSchema: z.object({}),
+ permission: 'open',
+ handler: async (_input, session): Promise => {
+
+ const [manager, err] = await attempt(() => initState());
+
+ if (err) {
+
+ throw new RpcError('Failed to load state', err.message);
+
+ }
+
+ const connections = session.listConnections();
+ let activeConfig = getEnvConfigName() ?? manager.getActiveConfigName() ?? null;
+
+ if (activeConfig && session.channel === 'mcp') {
+
+ const config = manager.getConfig(activeConfig);
+
+ if (!config || config.access.mcp === false) {
+
+ activeConfig = null;
+
+ }
+
+ }
+
+ return {
+ connections,
+ activeConfig,
+ activeConnected: activeConfig !== null && session.hasConnection(activeConfig),
+ };
+
+ },
+};
+
export const sessionCommands: RpcCommand[] = [
connectCommand as RpcCommand,
disconnectCommand as RpcCommand,
+ statusCommand as RpcCommand,
];
diff --git a/src/rpc/session.ts b/src/rpc/session.ts
index a922ff63..360b3e97 100644
--- a/src/rpc/session.ts
+++ b/src/rpc/session.ts
@@ -2,7 +2,7 @@ import { attempt } from '@logosdx/utils';
import { createContext, type Context } from '../sdk/index.js';
import { configNotFoundMessage } from '../core/config/resolver.js';
-import type { Channel, Role } from '../core/policy/index.js';
+import { isVisibleToChannel, type Channel, type Role } from '../core/policy/index.js';
import { RpcError, type RpcSession } from './types.js';
/**
@@ -65,7 +65,7 @@ export class SessionManager implements RpcSession {
const resolvedName = ctx.noorm.config.name;
const rawAccess = ctx.noorm.config.access;
- if (this.channel === 'mcp' && (!rawAccess || rawAccess.mcp === false)) {
+ if (!isVisibleToChannel(rawAccess, this.channel)) {
throw new RpcError('Failed to create context', configNotFoundMessage(resolvedName));
diff --git a/src/sdk/context.ts b/src/sdk/context.ts
index e1dc4715..3e6e1543 100644
--- a/src/sdk/context.ts
+++ b/src/sdk/context.ts
@@ -14,10 +14,12 @@ import type { Config } from '../core/config/types.js';
import type { Settings } from '../core/settings/index.js';
import type { Identity } from '../core/identity/index.js';
import { createConnection } from '../core/connection/index.js';
+import type { ConnectionRetryOptions } from '../core/connection/index.js';
import { buildProcCall, buildFuncCall, buildTvfCall } from './sql.js';
import { NoormOps } from './noorm-ops.js';
import type { ContextState } from './state.js';
+import { requireConnection } from './state.js';
import type { CreateContextOptions, ExtractArgs, ExtractReturn } from './types.js';
import { dialectStrategy, validateUsername } from './impersonate/dialect-strategy.js';
import { buildScope } from './impersonate/scope.js';
@@ -95,13 +97,7 @@ export class Context {
- if (!this.#state.connection) {
-
- throw new Error('Not connected. Call connect() first.');
-
- }
-
- return this.#state.connection.db as Kysely;
+ return requireConnection(this.#state).db as Kysely;
}
@@ -138,13 +134,21 @@ export class Context {
+ /**
+ * Connect to the configured database.
+ *
+ * `retryOptions` defaults to the standard 3-attempt backoff. Pass
+ * `{ retries: 1, delay: 0 }` for a best-effort probe that must fail
+ * fast rather than hang on an unreachable database.
+ */
+ async connect(retryOptions?: ConnectionRetryOptions): Promise {
if (this.#state.connection) return;
this.#state.connection = await createConnection(
this.#state.config.connection,
this.#state.config.name,
+ retryOptions,
);
}
@@ -225,12 +229,6 @@ export class Context extends void ? [] : [params: ExtractArgs]
): Promise {
- if (this.dialect === 'sqlite') {
-
- throw new Error('SQLite does not support stored procedures.');
-
- }
-
const params = args[0] as Record | unknown[] | undefined;
if (this.dialect === 'postgres') {
@@ -314,12 +312,6 @@ export class Context extends void ? [column: string] : [params: ExtractArgs