From e2850b07d86a983f1c55f58fe061a9ccec0be49e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 12 Jun 2026 13:00:49 +0100 Subject: [PATCH 1/4] feat(cli): port supabase inspect report to native TypeScript Promotes the last `inspect` leaf from a Phase 0 Go proxy to a native TS port (CLI-1317). The command runs every inspect query via server-side `COPY (...) TO STDOUT WITH CSV HEADER`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary table validating those CSVs. - Extend the shared `LegacyDbConnection` driver with `copyToCsv`, which reuses a single raw `pg` connection (via `pg-copy-streams`) for all copies against the winning dial target, matching Go's single `pgconn`. - Add a bounded csvq-subset evaluator (`report.csvq.ts`) replicating csvq value/type-comparison semantics for the inspect rule grammar. - Support custom `[experimental.inspect.rules]` from config.toml with `env(VAR)` expansion; default to the 7 embedded rules otherwise. - Hoist the shared inspect base layer into `inspect/inspect.layers.ts`. - Add `legacy/output/legacy-bold.ts` (lipgloss Bold parity). - json/stream-json output modes are a TS-only addition (Go is text-only). --- apps/cli/docs/go-cli-porting-status.md | 2 +- apps/cli/package.json | 4 + .../legacy/commands/inspect/db/db.layers.ts | 37 +- ...acy-inspect-deprecated.integration.test.ts | 1 + .../legacy-inspect-query.integration.test.ts | 1 + .../legacy-inspect-specs.integration.test.ts | 1 + .../legacy/commands/inspect/inspect.layers.ts | 42 ++ .../commands/inspect/report/SIDE_EFFECTS.md | 106 ++- .../commands/inspect/report/report.command.ts | 21 +- .../commands/inspect/report/report.config.ts | 91 +++ .../inspect/report/report.config.unit.test.ts | 90 +++ .../commands/inspect/report/report.csvq.ts | 699 ++++++++++++++++++ .../inspect/report/report.csvq.unit.test.ts | 296 ++++++++ .../inspect/report/report.e2e.test.ts | 42 ++ .../commands/inspect/report/report.errors.ts | 21 + .../commands/inspect/report/report.handler.ts | 178 ++++- .../inspect/report/report.integration.test.ts | 491 ++++++++++++ .../commands/inspect/report/report.layers.ts | 19 + .../commands/inspect/report/report.queries.ts | 99 +++ .../report/report.queries.unit.test.ts | 76 ++ .../commands/inspect/report/report.rules.ts | 134 ++++ .../inspect/report/report.rules.unit.test.ts | 68 ++ .../commands/test/db/db.integration.test.ts | 1 + apps/cli/src/legacy/output/legacy-bold.ts | 17 + .../shared/legacy-db-config.toml-read.ts | 9 +- .../shared/legacy-db-connection.errors.ts | 13 + .../shared/legacy-db-connection.service.ts | 22 +- .../legacy-db-connection.sql-pg.layer.ts | 109 ++- pnpm-lock.yaml | 34 + 29 files changed, 2629 insertions(+), 95 deletions(-) create mode 100644 apps/cli/src/legacy/commands/inspect/inspect.layers.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.config.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.csvq.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.csvq.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.errors.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.layers.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.queries.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.queries.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.rules.ts create mode 100644 apps/cli/src/legacy/commands/inspect/report/report.rules.unit.test.ts create mode 100644 apps/cli/src/legacy/output/legacy-bold.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 8bee396119..b61b62b9cf 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -89,7 +89,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `wrapped` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Phase 0 proxy. Wrapped in legacy shell. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | | `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | | `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | | `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | diff --git a/apps/cli/package.json b/apps/cli/package.json index 9632571da8..d625b57e3c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,8 @@ "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/pg": "^8.15.0", + "@types/pg-copy-streams": "^1.2.5", "@types/react": "^19.2.16", "@typescript/native-preview": "catalog:", "@vercel/detect-agent": "^1.2.3", @@ -66,6 +68,8 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "pg": "^8.21.0", + "pg-copy-streams": "^7.0.0", "posthog-node": "^5.36.1", "react": "^19.2.7", "react-devtools-core": "^7.0.1", diff --git a/apps/cli/src/legacy/commands/inspect/db/db.layers.ts b/apps/cli/src/legacy/commands/inspect/db/db.layers.ts index b895cb5f1d..2cb8baead1 100644 --- a/apps/cli/src/legacy/commands/inspect/db/db.layers.ts +++ b/apps/cli/src/legacy/commands/inspect/db/db.layers.ts @@ -1,40 +1,7 @@ import { Layer } from "effect"; -import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; -import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; -import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; -import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; -import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; - -/** - * `legacyCliConfigLayer` is provided to the resolver AND exposed at the top level - * because `Layer.provide` does not share to merge siblings (legacy CLAUDE.md item - * 5); the resolver requires it internally and so it is provided to `dbConfig`, - * while the merge keeps it available alongside. - */ -const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); - -const dbConfig = legacyDbConfigLayer.pipe( - Layer.provide(cliConfig), - Layer.provide(legacyDbConnectionLayer), - Layer.provide(legacyDebugLoggerLayer), -); - -/** - * The services every `inspect db` subcommand shares, minus the command-runtime - * identity. Mirrors `test/test.layers.ts` minus the docker layer: the DB-config - * resolver, the Postgres connection, the CLI config, and telemetry state. The - * Management API stack is NOT merged here — it resolves an access token eagerly, - * which would break the auth-free `--local` / `--db-url` paths. The `--linked` - * path provides it lazily inside the resolver (`legacy-db-config.layer.ts`). - */ -const baseLayer = Layer.mergeAll( - dbConfig, - legacyDbConnectionLayer, - cliConfig, - legacyTelemetryStateLayer, -); +import { legacyInspectBaseLayer } from "../inspect.layers.ts"; /** * The command-runtime path for a single `inspect db ` subcommand. @@ -57,4 +24,4 @@ export const legacyInspectDbCommandPath = (leaf: string): ReadonlyArray /** Runtime layer for a single `supabase inspect db ` subcommand. */ export const legacyInspectDbRuntimeLayer = (leaf: string) => - Layer.merge(baseLayer, commandRuntimeLayer(legacyInspectDbCommandPath(leaf))); + Layer.merge(legacyInspectBaseLayer, commandRuntimeLayer(legacyInspectDbCommandPath(leaf))); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts index 39e88394b2..9f85b85925 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts @@ -59,6 +59,7 @@ function setup() { Effect.succeed({ exec: () => Effect.void, extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), query: (sql: string) => { querySql = sql; return Effect.succeed([]); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts index f46b8ec901..fd08e018ae 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts @@ -109,6 +109,7 @@ function mockDbConnection(opts: { return Effect.succeed({ exec: () => Effect.void, extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), query: (sql: string, params?: ReadonlyArray) => { querySql = sql; queryParams = params; diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts index 2b1dbc1874..c994c2674e 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts @@ -50,6 +50,7 @@ function setup(rows: ReadonlyArray>) { Effect.succeed({ exec: () => Effect.void, extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), query: (sql: string, params?: ReadonlyArray) => { querySql = sql; queryParams = params; diff --git a/apps/cli/src/legacy/commands/inspect/inspect.layers.ts b/apps/cli/src/legacy/commands/inspect/inspect.layers.ts new file mode 100644 index 0000000000..bee8a331e1 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/inspect.layers.ts @@ -0,0 +1,42 @@ +import { Layer } from "effect"; + +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDbConfigLayer } from "../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * `legacyCliConfigLayer` is provided to the resolver AND exposed at the top level + * because `Layer.provide` does not share to merge siblings (legacy CLAUDE.md item + * 5); the resolver requires it internally and so it is provided to `dbConfig`, + * while the merge keeps it available alongside. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), +); + +/** + * The services every `inspect` leaf shares, minus the command-runtime identity: + * the DB-config resolver, the Postgres connection, the CLI config (for the + * `--workdir` config rules `inspect report` reads), and telemetry state. Mirrors + * `test/test.layers.ts` minus the docker layer. + * + * The Management API stack is NOT merged here — it resolves an access token + * eagerly, which would break the auth-free `--local` / `--db-url` paths. The + * `--linked` path provides it lazily inside the resolver (`legacy-db-config.layer.ts`). + * + * Hoisted out of `db/db.layers.ts` so both the `inspect db ` subcommands and + * `inspect report` (a sibling of `db`, not a child) share one definition rather + * than each carrying a parallel copy. + */ +export const legacyInspectBaseLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + legacyTelemetryStateLayer, +); diff --git a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md index 41c443cf13..7aa35d483f 100644 --- a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md @@ -1,17 +1,46 @@ # `supabase inspect report` +Runs every inspect query against the target Postgres database, writes one CSV per +query into `//`, then prints a Glamour "rules" summary table +validating those CSVs. Native TypeScript port of `apps/cli-go/internal/inspect/report.go`. + ## Files Read -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — `[experimental.inspect.rules]` (custom rules) + `[db]` subtree | +| `/supabase/.env*` (nested) | dotenv | always — `env(VAR)` expansion for `[db]` and rule string fields | +| `/supabase/.temp/pooler-url` | plain text | `--linked` path (pooler connection string) | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` path (resolve linked project ref) | +| `~/.supabase/access-token` | plain text | `--linked` path, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `//.csv` ×14 | CSV | read back in-memory for rule evaluation | + +`config.toml` policy mirrors Go: a **missing** file is fine (defaults apply); a +**malformed** file aborts the command. ## Files Written -| Path | Format | When | -| --------------------------------- | ------ | -------------------------------------- | -| `//.csv` | CSV | always, one CSV file per inspect query | +| Path | Mode | When | +| ------------------------------------------------ | ---- | --------------------------------------------------------------- | +| `//` (directory) | 0755 | always — created recursively | +| `//.csv` ×14 | 0644 | always — one CSV per inspect query (server-side `COPY ... CSV`) | +| `~/.supabase/telemetry.json` | — | always (telemetry flush) | +| `~/.supabase//linked-project.json` | — | `--linked` path (linked-project cache) | + +The 14 CSV basenames (underscored, matching Go's SQL filenames — **not** the +`inspect db` command names): `bloat`, `blocking`, `calls`, `db_stats`, +`index_stats`, `locks`, `long_running_queries`, `outliers`, `replication_slots`, +`role_stats`, `table_stats`, `traffic_profile`, `unused_indexes`, `vacuum_stats`. + +The date folder is **local-time** `YYYY-MM-DD`. A relative `--output-dir` resolves +against the process CWD (`utils.CurrentDirAbs`), not `--workdir`; an absolute path +is used as-is. + +Re-running on the same day reuses the existing dated folder (mkdir is recursive / +idempotent) and **overwrites** the previous run's CSVs silently — no `--force`, +matching Go. If a `COPY` fails partway through, the CSVs written before the failure +remain on disk (Go writes each file before running the next query), the command +aborts with exit code 1, and the rules summary is not printed. ## API Routes @@ -19,41 +48,62 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | -Queries are run directly against the Postgres database (not via Management API). +Queries run directly against Postgres (server-side `COPY () TO STDOUT WITH +CSV HEADER`). The Management API is used lazily only on the `--linked` path, to +resolve the connection (via `LegacyDbConfigResolver`). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_API_URL` | override Management API base URL | no | +| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | +| `SUPABASE_ENV` | selects which project `.env` files load | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — CSV files written to output-dir | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | -| `1` | output directory not writable | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | mkdir failure (`failed to mkdir`) | +| `1` | DB connection / resolution failure (not linked, invalid ref, dial failure) | +| `1` | COPY failure (`failed to copy output`) / file-write failure (`failed to create output file`) | +| `1` | malformed `config.toml` | +| `1` | more than one of `--db-url` / `--linked` / `--local` | + +A **per-rule** csvq evaluation error does **not** fail the command — it becomes the +rule's STATUS cell, matching Go. ## Output ### `--output-format text` (Go CLI compatible) -Runs all inspect queries against the linked (or local) Postgres database and writes -one CSV file per query into `//`. No output to stdout on success. +stderr progress, in order: -### `--output-format json` +``` +Connecting to database... +Running queries... +Reports saved to / (path bolded when stdout is a TTY) +Loading default rules... (only when no custom config.toml rules) +``` -Not applicable — this command writes CSV files, not JSON. +stdout: the Glamour `RULE | STATUS | MATCHES` summary table (byte-exact with Go's +`utils.RenderTable`, `AsciiStyle`, `WordWrap(-1)`). -### `--output-format stream-json` +When a rule's csvq query cannot be evaluated (unsupported grammar, unknown table, +or unknown column — e.g. a typo in a custom `config.toml` rule), the **error +message is shown verbatim as that rule's STATUS cell** and the command continues; +it does not fail. This matches Go, where csvq's own error string becomes the cell. +Note: the default rule "No large tables waiting on autovacuum" references a `tbl` +column that `vacuum_stats` does not emit, so its STATUS shows an unknown-column +error on real data — a pre-existing Go quirk preserved verbatim for parity. -Not applicable — this command writes CSV files, not streamed JSON events. +### `--output-format json` / `stream-json` (TS-extra; Go has no machine output) -## Notes +The CSVs are still written. Progress lines are suppressed and no table is printed; +instead a structured result is emitted: -- The `--output-dir` flag (default `.`) specifies where CSV files are written. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +```json +{ "outputDir": "", "files": [{ "name": "locks", "path": "..." }, ...], "rules": [{ "name": "...", "status": "...", "matches": "..." }, ...] } +``` diff --git a/apps/cli/src/legacy/commands/inspect/report/report.command.ts b/apps/cli/src/legacy/commands/inspect/report/report.command.ts index 3ee62fb983..e9d2f0abde 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.command.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyInspectReport } from "./report.handler.ts"; +import { legacyInspectReportRuntimeLayer } from "./report.layers.ts"; const config = { dbUrl: Flag.string("db-url").pipe( @@ -21,6 +25,19 @@ export type LegacyInspectReportFlags = CliCommand.Command.Config.Infer legacyInspectReport(flags)), + Command.withShortDescription("Generate a CSV output for all inspect commands"), + Command.withHandler((flags) => + legacyInspectReport(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "output-dir": flags.outputDir, + }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyInspectReportRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.ts new file mode 100644 index 0000000000..b1f76c9f0f --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.ts @@ -0,0 +1,91 @@ +import { Effect, type FileSystem, type Path } from "effect"; +import * as SmolToml from "smol-toml"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { + legacyExpandEnv, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyInspectRule } from "./report.rules.ts"; + +type RawDoc = { readonly [key: string]: unknown }; + +function asRecord(value: unknown): RawDoc | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as RawDoc) + : undefined; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * Read `[experimental.inspect.rules]` from `/supabase/config.toml`, + * mirroring Go's `config.Load` (`apps/cli-go/pkg/config/config.go:236-256`): when + * present and non-empty, these custom rules replace the embedded defaults. + * + * Follows the `legacyReadDbToml` policy exactly — a **missing** config file yields + * `[]` (defaults apply), but a **malformed** file is a hard error + * (`LegacyDbConfigLoadError`). Each rule's string fields are run through Go's + * `LoadEnvHook` `env(VAR)` expansion (`legacyExpandEnv`), resolving against the + * shell environment first and then the project `.env` files (Go populates the + * process env via `loadNestedEnv` before the decode hook runs). + * + * `fs`/`path` are passed in so the caller controls the platform layer; the read is + * colocated here for now and hoisted to `legacy/shared/` if a second command reads + * `[experimental.inspect.*]`. + */ +export const legacyReadInspectRules = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +) { + const configPath = path.join(workdir, "supabase", "config.toml"); + + const content = yield* fs.readFileString(configPath).pipe( + Effect.map((text): string | undefined => text), + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to read file config: ${error.message}`, + }), + ), + ), + ); + + if (content === undefined) return [] as ReadonlyArray; + + let doc: RawDoc | undefined; + try { + doc = asRecord(SmolToml.parse(content)); + } catch (cause) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to load config: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ); + } + + const inspect = asRecord(asRecord(doc?.["experimental"])?.["inspect"]); + const rawRules = inspect?.["rules"]; + if (!Array.isArray(rawRules)) return [] as ReadonlyArray; + + // Resolve `env(VAR)` against the shell env first, then the project `.env` files. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const lookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; + + const rules: Array = []; + for (const entry of rawRules) { + const record = asRecord(entry); + if (record === undefined) continue; + rules.push({ + query: legacyExpandEnv(asString(record["query"]), lookup), + name: legacyExpandEnv(asString(record["name"]), lookup), + pass: legacyExpandEnv(asString(record["pass"]), lookup), + fail: legacyExpandEnv(asString(record["fail"]), lookup), + }); + } + return rules as ReadonlyArray; +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts new file mode 100644 index 0000000000..7bf7347780 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts @@ -0,0 +1,90 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { legacyReadInspectRules } from "./report.config.ts"; + +function makeWorkdir(configToml?: string): string { + const workdir = mkdtempSync(join(tmpdir(), "supabase-report-config-")); + if (configToml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), configToml); + } + return workdir; +} + +const readRules = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadInspectRules(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +describe("legacyReadInspectRules", () => { + it.effect("returns [] when config.toml is absent", () => + Effect.gen(function* () { + const rules = yield* readRules(makeWorkdir()); + expect(rules).toEqual([]); + }), + ); + + it.effect("returns [] when there are no inspect rules", () => + Effect.gen(function* () { + const rules = yield* readRules(makeWorkdir('project_id = "demo"\n')); + expect(rules).toEqual([]); + }), + ); + + it.effect("parses [experimental.inspect.rules]", () => + Effect.gen(function* () { + const rules = yield* readRules( + makeWorkdir( + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT COUNT(*) FROM `locks.csv`"', + 'name = "No locks"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), + ), + ); + expect(rules).toEqual([ + { query: "SELECT COUNT(*) FROM `locks.csv`", name: "No locks", pass: "ok", fail: "bad" }, + ]); + }), + ); + + it.effect("expands env(VAR) in rule string fields", () => + Effect.gen(function* () { + process.env["LEGACY_REPORT_TEST_FAIL"] = "from-env"; + const rules = yield* readRules( + makeWorkdir( + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT COUNT(*) FROM `locks.csv`"', + 'name = "r"', + 'pass = "ok"', + 'fail = "env(LEGACY_REPORT_TEST_FAIL)"', + "", + ].join("\n"), + ), + ); + delete process.env["LEGACY_REPORT_TEST_FAIL"]; + expect(rules[0]?.fail).toBe("from-env"); + }), + ); + + it.effect("fails with LegacyDbConfigLoadError on a malformed config.toml", () => + Effect.gen(function* () { + const exit = yield* Effect.exit(readRules(makeWorkdir("this is = = not valid toml [[["))); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + }), + ); +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts new file mode 100644 index 0000000000..f71236b66d --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts @@ -0,0 +1,699 @@ +import { Option } from "effect"; + +/** + * A bounded, hand-written evaluator for the subset of the csvq SQL dialect that + * the `inspect report` validation rules use. There is no JS port of csvq + * (`github.com/mithrandie/csvq`), and neither DuckDB (native addon) nor alasql + * accepts csvq's dialect, so the report's rule queries are evaluated here. + * + * Supported grammar (anything outside it throws `LegacyInspectCsvqError`, which + * the rule evaluator turns into the rule's STATUS cell, matching Go — a per-rule + * csvq error becomes the cell rather than failing the command): + * + * SELECT [AS ] + * FROM `.csv` [] + * [WHERE ] + * [;] + * + * agg := LISTAGG '(' colRef ',' string ')' + * | COUNT '(' ('*' | colRef) ')' + * | (SUM|MIN|MAX|AVG) '(' colRef ')' + * condition := or + * or := and (OR and)* + * and := not (AND not)* + * not := NOT not | predicate + * predicate := '(' condition ')' | comparison + * comparison:= arith ( (op arith) | (IS [NOT] NULL) )? + * arith := term (('+'|'-') term)* + * term := factor (('*'|'/') factor)* + * factor := number | string | colRef | '(' arith ')' + * colRef := ident ('.' ident)? (the alias prefix is ignored — single table) + * + * csvq value semantics replicated for parity: + * - Every CSV cell is a string (csvq reads an empty field as `""`, not NULL); the + * only NULL values are *computed* (an aggregate over zero rows, or arithmetic on + * a non-numeric operand). + * - A comparison numerically compares its operands only when **both** convert to a + * number under Go's `strconv` rules (no surrounding whitespace, no digit + * grouping); otherwise it compares them as strings. This mirrors csvq's type + * promotion, including the quirk that a thousands-grouped `to_char` value such as + * `" 2,000"` falls back to a string comparison. + * - WHERE keeps a row only when the condition evaluates to TRUE (three-valued + * logic: NULL/false exclude the row). + */ + +/** Thrown for grammar or evaluation outside the supported csvq subset. */ +export class LegacyInspectCsvqError extends Error { + override readonly name = "LegacyInspectCsvqError"; +} + +// --------------------------------------------------------------------------- +// CSV parsing (RFC 4180, the shape Postgres `COPY ... WITH CSV HEADER` emits). +// Every field is a string; quoting only affects escaping, not value identity. +// --------------------------------------------------------------------------- + +/** A parsed CSV table: header → column index, plus the data rows as strings. */ +export interface LegacyCsvTable { + readonly columns: ReadonlyMap; + readonly rows: ReadonlyArray>; +} + +function parseCsvRecords(text: string): Array> { + const records: Array> = []; + let field = ""; + let row: Array = []; + let inQuotes = false; + let started = false; + const pushField = () => { + row.push(field); + field = ""; + }; + const pushRow = () => { + pushField(); + records.push(row); + row = []; + started = false; + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inQuotes) { + started = true; + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += ch; + } + continue; + } + if (ch === '"') { + inQuotes = true; + started = true; + } else if (ch === ",") { + pushField(); + started = true; + } else if (ch === "\n") { + // `pushRow` resets `started`, so a trailing `\n` leaves no phantom row. + pushRow(); + } else if (ch === "\r") { + // Swallow a bare/`\r\n` CR outside quotes WITHOUT marking the record started, + // so a stray trailing CR cannot synthesise an empty record at EOF. + } else { + field += ch; + started = true; + } + } + // Flush a trailing record unless the input ended exactly on a row boundary. + if (started || field.length > 0 || row.length > 0) { + pushRow(); + } + return records; +} + +/** Parse CSV bytes/text into a header-indexed table. */ +export function legacyParseReportCsv(input: Uint8Array | string): LegacyCsvTable { + const text = typeof input === "string" ? input : new TextDecoder().decode(input); + const records = parseCsvRecords(text); + if (records.length === 0) { + return { columns: new Map(), rows: [] }; + } + const header = records[0]!; + const columns = new Map(); + header.forEach((name, index) => { + columns.set(name.toLowerCase(), index); + }); + return { columns, rows: records.slice(1) }; +} + +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- + +type Token = + | { readonly t: "ident"; readonly v: string } + | { readonly t: "btick"; readonly v: string } + | { readonly t: "str"; readonly v: string } + | { readonly t: "num"; readonly v: number } + | { readonly t: "op"; readonly v: string } + | { readonly t: "punct"; readonly v: string } + | { readonly t: "eof" }; + +const IDENT_START = /[A-Za-z_]/; +const IDENT_PART = /[A-Za-z0-9_]/; + +function tokenize(sql: string): Array { + const tokens: Array = []; + let i = 0; + while (i < sql.length) { + const ch = sql[i]!; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + i++; + continue; + } + if (ch === "'") { + let value = ""; + i++; + while (i < sql.length) { + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + value += "'"; + i += 2; + } else { + i++; + break; + } + } else { + value += sql[i]; + i++; + } + } + tokens.push({ t: "str", v: value }); + continue; + } + if (ch === "`") { + let value = ""; + i++; + while (i < sql.length && sql[i] !== "`") { + value += sql[i]; + i++; + } + i++; // closing backtick + tokens.push({ t: "btick", v: value }); + continue; + } + if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(sql[i + 1] ?? ""))) { + let raw = ""; + while (i < sql.length && /[0-9.eE+-]/.test(sql[i]!)) { + // Stop a sign that is not part of an exponent (e.g. `1-2`). + if ((sql[i] === "+" || sql[i] === "-") && !/[eE]/.test(sql[i - 1] ?? "")) break; + raw += sql[i]; + i++; + } + const n = Number(raw); + if (!Number.isFinite(n)) throw new LegacyInspectCsvqError(`invalid number literal: ${raw}`); + tokens.push({ t: "num", v: n }); + continue; + } + if (IDENT_START.test(ch)) { + let value = ""; + while (i < sql.length && IDENT_PART.test(sql[i]!)) { + value += sql[i]; + i++; + } + tokens.push({ t: "ident", v: value }); + continue; + } + // Multi-char operators first. + const two = sql.slice(i, i + 2); + if (two === "<=" || two === ">=" || two === "<>" || two === "!=") { + tokens.push({ t: "op", v: two }); + i += 2; + continue; + } + if ( + ch === "=" || + ch === "<" || + ch === ">" || + ch === "*" || + ch === "/" || + ch === "+" || + ch === "-" + ) { + tokens.push({ t: "op", v: ch }); + i++; + continue; + } + if (ch === "(" || ch === ")" || ch === "," || ch === "." || ch === ";") { + tokens.push({ t: "punct", v: ch }); + i++; + continue; + } + throw new LegacyInspectCsvqError(`unexpected character: ${ch}`); + } + tokens.push({ t: "eof" }); + return tokens; +} + +// --------------------------------------------------------------------------- +// AST +// --------------------------------------------------------------------------- + +type ValNode = + | { readonly k: "num"; readonly n: number } + | { readonly k: "str"; readonly s: string } + | { readonly k: "col"; readonly name: string } + | { readonly k: "binop"; readonly op: string; readonly l: ValNode; readonly r: ValNode }; + +type CondNode = + | { readonly k: "or"; readonly l: CondNode; readonly r: CondNode } + | { readonly k: "and"; readonly l: CondNode; readonly r: CondNode } + | { readonly k: "not"; readonly e: CondNode } + | { readonly k: "cmp"; readonly op: string; readonly l: ValNode; readonly r: ValNode } + | { readonly k: "isnull"; readonly e: ValNode; readonly negated: boolean }; + +interface AggNode { + readonly fn: "LISTAGG" | "COUNT" | "SUM" | "MIN" | "MAX" | "AVG"; + readonly col?: string; // undefined for COUNT(*) + readonly star?: boolean; + readonly sep?: string; // LISTAGG separator +} + +interface SelectStmt { + readonly agg?: AggNode; + readonly column?: string; // plain (non-aggregate) column select + readonly table: string; + readonly where?: CondNode; +} + +const AGG_FNS = new Set(["LISTAGG", "COUNT", "SUM", "MIN", "MAX", "AVG"]); + +// --------------------------------------------------------------------------- +// Parser (recursive descent) +// --------------------------------------------------------------------------- + +class Parser { + private pos = 0; + constructor(private readonly tokens: ReadonlyArray) {} + + private peek(): Token { + return this.tokens[this.pos]!; + } + private next(): Token { + return this.tokens[this.pos++]!; + } + private isKeyword(word: string): boolean { + const tok = this.peek(); + return tok.t === "ident" && tok.v.toUpperCase() === word; + } + private eatKeyword(word: string): boolean { + if (this.isKeyword(word)) { + this.pos++; + return true; + } + return false; + } + private expectKeyword(word: string): void { + if (!this.eatKeyword(word)) { + throw new LegacyInspectCsvqError(`expected ${word}`); + } + } + private expectPunct(sym: string): void { + const tok = this.next(); + if (tok.t !== "punct" || tok.v !== sym) { + throw new LegacyInspectCsvqError(`expected '${sym}'`); + } + } + private isPunct(sym: string): boolean { + const tok = this.peek(); + return tok.t === "punct" && tok.v === sym; + } + + parse(): SelectStmt { + this.expectKeyword("SELECT"); + const { agg, column } = this.parseSelectExpr(); + // optional `AS ` + if (this.eatKeyword("AS")) { + const tok = this.next(); + if (tok.t !== "ident") throw new LegacyInspectCsvqError("expected alias after AS"); + } + this.expectKeyword("FROM"); + const tableTok = this.next(); + if (tableTok.t !== "btick") { + throw new LegacyInspectCsvqError("expected a backtick-quoted CSV table name"); + } + // optional table alias (a bare ident that is not a clause keyword) + if (this.peek().t === "ident" && !this.isKeyword("WHERE")) { + this.pos++; + } + let where: CondNode | undefined; + if (this.eatKeyword("WHERE")) { + where = this.parseCondition(); + } + if (this.isPunct(";")) this.pos++; + if (this.peek().t !== "eof") { + throw new LegacyInspectCsvqError("unexpected trailing tokens"); + } + return { agg, column, table: tableTok.v, where }; + } + + private parseSelectExpr(): { agg?: AggNode; column?: string } { + const tok = this.peek(); + if ( + tok.t === "ident" && + AGG_FNS.has(tok.v.toUpperCase()) && + this.tokens[this.pos + 1]?.t === "punct" && + (this.tokens[this.pos + 1] as { v: string }).v === "(" + ) { + return { agg: this.parseAgg() }; + } + // plain column reference + const col = this.parseColRef(); + return { column: col }; + } + + private parseAgg(): AggNode { + const fnTok = this.next(); + const fn = (fnTok as { v: string }).v.toUpperCase() as AggNode["fn"]; + this.expectPunct("("); + if (fn === "COUNT" && this.peek().t === "op" && (this.peek() as { v: string }).v === "*") { + this.pos++; + this.expectPunct(")"); + return { fn, star: true }; + } + const col = this.parseColRef(); + if (fn === "LISTAGG") { + this.expectPunct(","); + const sepTok = this.next(); + if (sepTok.t !== "str") + throw new LegacyInspectCsvqError("LISTAGG separator must be a string"); + this.expectPunct(")"); + return { fn, col, sep: sepTok.v }; + } + this.expectPunct(")"); + return { fn, col }; + } + + private parseColRef(): string { + const tok = this.next(); + if (tok.t !== "ident") throw new LegacyInspectCsvqError("expected a column reference"); + if (this.isPunct(".")) { + this.pos++; + const col = this.next(); + if (col.t !== "ident") throw new LegacyInspectCsvqError("expected column after '.'"); + return col.v; // alias prefix ignored (single table) + } + return tok.v; + } + + private parseCondition(): CondNode { + return this.parseOr(); + } + private parseOr(): CondNode { + let left = this.parseAnd(); + while (this.eatKeyword("OR")) { + left = { k: "or", l: left, r: this.parseAnd() }; + } + return left; + } + private parseAnd(): CondNode { + let left = this.parseNot(); + while (this.eatKeyword("AND")) { + left = { k: "and", l: left, r: this.parseNot() }; + } + return left; + } + private parseNot(): CondNode { + if (this.eatKeyword("NOT")) { + return { k: "not", e: this.parseNot() }; + } + return this.parsePredicate(); + } + private parsePredicate(): CondNode { + if (this.isPunct("(")) { + this.pos++; + const cond = this.parseCondition(); + this.expectPunct(")"); + return cond; + } + const left = this.parseArith(); + if (this.eatKeyword("IS")) { + const negated = this.eatKeyword("NOT"); + this.expectKeyword("NULL"); + return { k: "isnull", e: left, negated }; + } + const opTok = this.peek(); + if (opTok.t === "op" && ["=", "<>", "!=", "<", ">", "<=", ">="].includes(opTok.v)) { + this.pos++; + const right = this.parseArith(); + return { k: "cmp", op: opTok.v, l: left, r: right }; + } + throw new LegacyInspectCsvqError("expected a comparison operator"); + } + + private parseArith(): ValNode { + let left = this.parseTerm(); + while ( + this.peek().t === "op" && + ((this.peek() as { v: string }).v === "+" || (this.peek() as { v: string }).v === "-") + ) { + const op = (this.next() as { v: string }).v; + left = { k: "binop", op, l: left, r: this.parseTerm() }; + } + return left; + } + private parseTerm(): ValNode { + let left = this.parseFactor(); + while ( + this.peek().t === "op" && + ((this.peek() as { v: string }).v === "*" || (this.peek() as { v: string }).v === "/") + ) { + const op = (this.next() as { v: string }).v; + left = { k: "binop", op, l: left, r: this.parseFactor() }; + } + return left; + } + private parseFactor(): ValNode { + const tok = this.peek(); + if (tok.t === "num") { + this.pos++; + return { k: "num", n: tok.v }; + } + if (tok.t === "str") { + this.pos++; + return { k: "str", s: tok.v }; + } + if (tok.t === "punct" && tok.v === "(") { + this.pos++; + const inner = this.parseArith(); + this.expectPunct(")"); + return inner; + } + if (tok.t === "ident") { + return { k: "col", name: this.parseColRef() }; + } + throw new LegacyInspectCsvqError("expected a value"); + } +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +type EvalValue = + | { readonly kind: "null" } + | { readonly kind: "num"; readonly n: number } + | { readonly kind: "str"; readonly s: string }; + +const NULL_VALUE: EvalValue = { kind: "null" }; + +// Go's strconv accepts no surrounding whitespace and no digit grouping. Mirror +// that strictly so a `to_char`-formatted value (e.g. `" 2,000"`) does NOT convert +// to a number and falls back to a string comparison, exactly as csvq does. +const STRICT_NUMERIC = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +function toNumber(value: EvalValue): number | undefined { + if (value.kind === "num") return value.n; + if (value.kind === "str" && STRICT_NUMERIC.test(value.s)) { + const n = Number(value.s); + return Number.isFinite(n) ? n : undefined; + } + return undefined; +} + +function toStringValue(value: EvalValue): string { + if (value.kind === "str") return value.s; + if (value.kind === "num") return String(value.n); + return ""; +} + +function evalVal(node: ValNode, table: LegacyCsvTable, row: ReadonlyArray): EvalValue { + switch (node.k) { + case "num": + return { kind: "num", n: node.n }; + case "str": + return { kind: "str", s: node.s }; + case "col": { + const index = table.columns.get(node.name.toLowerCase()); + if (index === undefined) { + throw new LegacyInspectCsvqError(`unknown column: ${node.name}`); + } + return { kind: "str", s: row[index] ?? "" }; + } + case "binop": { + const l = toNumber(evalVal(node.l, table, row)); + const r = toNumber(evalVal(node.r, table, row)); + if (l === undefined || r === undefined) return NULL_VALUE; + switch (node.op) { + case "+": + return { kind: "num", n: l + r }; + case "-": + return { kind: "num", n: l - r }; + case "*": + return { kind: "num", n: l * r }; + case "/": + return r === 0 ? NULL_VALUE : { kind: "num", n: l / r }; + default: + throw new LegacyInspectCsvqError(`unsupported operator: ${node.op}`); + } + } + } +} + +type Tri = true | false | null; + +function compareValues(op: string, left: EvalValue, right: EvalValue): Tri { + if (left.kind === "null" || right.kind === "null") return null; + const ln = toNumber(left); + const rn = toNumber(right); + let cmp: number; + if (ln !== undefined && rn !== undefined) { + cmp = ln < rn ? -1 : ln > rn ? 1 : 0; + } else { + const ls = toStringValue(left); + const rs = toStringValue(right); + cmp = ls < rs ? -1 : ls > rs ? 1 : 0; + } + switch (op) { + case "=": + return cmp === 0; + case "<>": + case "!=": + return cmp !== 0; + case "<": + return cmp < 0; + case ">": + return cmp > 0; + case "<=": + return cmp <= 0; + case ">=": + return cmp >= 0; + default: + throw new LegacyInspectCsvqError(`unsupported comparison: ${op}`); + } +} + +function evalCond(node: CondNode, table: LegacyCsvTable, row: ReadonlyArray): Tri { + switch (node.k) { + case "or": { + const l = evalCond(node.l, table, row); + const r = evalCond(node.r, table, row); + if (l === true || r === true) return true; + if (l === false && r === false) return false; + return null; + } + case "and": { + const l = evalCond(node.l, table, row); + const r = evalCond(node.r, table, row); + if (l === false || r === false) return false; + if (l === true && r === true) return true; + return null; + } + case "not": { + const e = evalCond(node.e, table, row); + return e === null ? null : !e; + } + case "cmp": + return compareValues(node.op, evalVal(node.l, table, row), evalVal(node.r, table, row)); + case "isnull": { + // CSV cells are never NULL, so `IS NULL` is always false here (and the + // negated form always true). Computed expressions can be NULL. + const value = evalVal(node.e, table, row); + const isNull = value.kind === "null"; + return node.negated ? !isNull : isNull; + } + } +} + +function matchedRows(stmt: SelectStmt, table: LegacyCsvTable): Array> { + if (stmt.where === undefined) return [...table.rows]; + const where = stmt.where; + return table.rows.filter((row) => evalCond(where, table, row) === true); +} + +function columnIndex(table: LegacyCsvTable, name: string): number { + const index = table.columns.get(name.toLowerCase()); + if (index === undefined) throw new LegacyInspectCsvqError(`unknown column: ${name}`); + return index; +} + +function evalAggregate( + agg: AggNode, + table: LegacyCsvTable, + rows: Array>, +): Option.Option { + if (agg.fn === "COUNT" && agg.star === true) { + return Option.some(String(rows.length)); + } + // Resolve the aggregate's column up front, so an unknown column errors at + // "bind time" regardless of how many rows match — matching csvq, which validates + // referenced columns against the table schema before evaluating rows. (This is + // what surfaces default rule 6's `s.tbl` typo as a STATUS cell even when the + // matched set is empty.) + const index = columnIndex(table, agg.col!); + if (agg.fn === "COUNT") { + // CSV cells are never NULL, so COUNT(col) == COUNT(*) == the matched-row count. + return Option.some(String(rows.length)); + } + if (agg.fn === "LISTAGG") { + if (rows.length === 0) return Option.none(); + return Option.some(rows.map((row) => row[index] ?? "").join(agg.sep ?? "")); + } + // SUM / MIN / MAX / AVG over the strictly-numeric matched cells. + const nums = rows + .map((row) => toNumber({ kind: "str", s: row[index] ?? "" })) + .filter((n): n is number => n !== undefined); + if (nums.length === 0) return Option.none(); + switch (agg.fn) { + case "SUM": + return Option.some(String(nums.reduce((a, b) => a + b, 0))); + case "AVG": + return Option.some(String(nums.reduce((a, b) => a + b, 0) / nums.length)); + case "MIN": + return Option.some(String(Math.min(...nums))); + case "MAX": + return Option.some(String(Math.max(...nums))); + } +} + +/** + * Provides the parsed CSV table for a backtick-quoted table name (e.g. + * `locks.csv`). Returns `undefined` when the table does not exist, which the + * evaluator surfaces as an error (→ the rule's STATUS cell). + */ +export type LegacyCsvTableProvider = (name: string) => LegacyCsvTable | undefined; + +/** + * Evaluate a csvq rule query to its scalar first-column result. + * + * Returns `Option.none()` for the two cases Go maps to a passing rule with a `-` + * matches cell: an aggregate over zero matched rows (csvq NULL) and a + * non-aggregate select that matches no rows (`sql.ErrNoRows`). Returns + * `Option.some(value)` otherwise — including `Option.some("")` for a valid empty + * string, which Go also treats as a pass but renders as an empty matches cell. + * + * Throws `LegacyInspectCsvqError` for unsupported grammar, an unknown table, or an + * unknown column; the rule evaluator catches it and uses the message as the STATUS + * cell (Go does the same with csvq's own error text). + */ +export function legacyEvalCsvqScalar( + query: string, + provider: LegacyCsvTableProvider, +): Option.Option { + const stmt = new Parser(tokenize(query)).parse(); + const table = provider(stmt.table); + if (table === undefined) { + throw new LegacyInspectCsvqError(`table not found: ${stmt.table}`); + } + const rows = matchedRows(stmt, table); + if (stmt.agg !== undefined) { + return evalAggregate(stmt.agg, table, rows); + } + // Plain column select: first matched row's cell, or none (ErrNoRows). + const index = columnIndex(table, stmt.column!); + const first = rows[0]; + return first === undefined ? Option.none() : Option.some(first[index] ?? ""); +} diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.unit.test.ts new file mode 100644 index 0000000000..60c981299c --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.unit.test.ts @@ -0,0 +1,296 @@ +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + type LegacyCsvTableProvider, + LegacyInspectCsvqError, + legacyEvalCsvqScalar, + legacyParseReportCsv, +} from "./report.csvq.ts"; +import { LEGACY_DEFAULT_INSPECT_RULES } from "./report.rules.ts"; + +function provider(tables: Record): LegacyCsvTableProvider { + return (name) => (name in tables ? legacyParseReportCsv(tables[name]!) : undefined); +} + +const rule = (name: string): string => { + const found = LEGACY_DEFAULT_INSPECT_RULES.find((r) => r.name === name); + if (found === undefined) throw new Error(`no rule named ${name}`); + return found.query; +}; + +function evalScalar(query: string, tables: Record): Option.Option { + return legacyEvalCsvqScalar(query, provider(tables)); +} + +describe("legacyParseReportCsv", () => { + it("indexes headers case-insensitively and parses RFC4180 quoted fields", () => { + const table = legacyParseReportCsv('name,stmt\npublic.t,"SELECT a, b\nFROM t"\n'); + expect(table.columns.get("name")).toBe(0); + expect(table.columns.get("stmt")).toBe(1); + expect(table.rows).toEqual([["public.t", "SELECT a, b\nFROM t"]]); + }); + + it("reads a quoted empty field and an unquoted empty field both as empty strings", () => { + const table = legacyParseReportCsv('a,b,c\n"",,x\n'); + expect(table.rows).toEqual([["", "", "x"]]); + }); + + it("returns an empty table for header-only input", () => { + const table = legacyParseReportCsv("a,b\n"); + expect(table.rows).toEqual([]); + }); +}); + +describe("default rules — pass and fail fixtures", () => { + it("No old locks: fails for an old lock, passes otherwise", () => { + const q = rule("No old locks"); + expect(evalScalar(q, { "locks.csv": "stmt,age\nSELECT 1,00:05:00\n" })).toEqual( + Option.some("SELECT 1"), + ); + expect(evalScalar(q, { "locks.csv": "stmt,age\nSELECT 1,00:01:00\n" })).toEqual(Option.none()); + }); + + it("No ungranted locks: fails on granted = 'f'", () => { + const q = rule("No ungranted locks"); + expect(evalScalar(q, { "locks.csv": "stmt,granted\nLOCK A,f\n" })).toEqual( + Option.some("LOCK A"), + ); + expect(evalScalar(q, { "locks.csv": "stmt,granted\nLOCK A,t\n" })).toEqual(Option.none()); + }); + + it("No unused indexes: LISTAGGs all rows with no WHERE, joined in order", () => { + const q = rule("No unused indexes"); + expect(evalScalar(q, { "unused_indexes.csv": "index\nidx_a\nidx_b\n" })).toEqual( + Option.some("idx_a,idx_b"), + ); + expect(evalScalar(q, { "unused_indexes.csv": "index\n" })).toEqual(Option.none()); + }); + + it("Check cache hit: numeric compare with OR", () => { + const q = rule("Check cache hit is within acceptable bounds"); + expect( + evalScalar(q, { + "db_stats.csv": "name,index_hit_rate,table_hit_rate\npostgres,0.90,0.99\n", + }), + ).toEqual(Option.some("postgres")); + expect( + evalScalar(q, { + "db_stats.csv": "name,index_hit_rate,table_hit_rate\npostgres,0.99,0.99\n", + }), + ).toEqual(Option.none()); + }); + + it("Check cache hit: a non-numeric ratio (N/A) string-compares and is excluded", () => { + const q = rule("Check cache hit is within acceptable bounds"); + expect( + evalScalar(q, { + "db_stats.csv": "name,index_hit_rate,table_hit_rate\npostgres,N/A,N/A\n", + }), + ).toEqual(Option.none()); + }); + + it("Sequential scans: arithmetic + AND with alias refs", () => { + const q = rule("No large tables with sequential scans more than 10% of rows"); + expect( + evalScalar(q, { + "table_stats.csv": "name,seq_scans,estimated_row_count\npublic.t,500,2000\n", + }), + ).toEqual(Option.some("public.t")); + // estimated_row_count <= 1000 → excluded by the second predicate. + expect( + evalScalar(q, { + "table_stats.csv": "name,seq_scans,estimated_row_count\npublic.t,500,500\n", + }), + ).toEqual(Option.none()); + }); + + it("Waiting on autovacuum (rule 6) references s.tbl, which vacuum_stats lacks → errors (Go-verbatim)", () => { + // rules.toml uses `s.tbl` but vacuum_stats.sql emits the column as `name`; csvq + // raises unknown-column and the report surfaces it as the rule's STATUS cell. + // Preserved verbatim for strict Go parity (see report.rules.ts). + const q = rule("No large tables waiting on autovacuum"); + expect(() => + evalScalar(q, { + "vacuum_stats.csv": "name,expect_autovacuum,rowcount\npublic.t,yes,2000\n", + }), + ).toThrow(LegacyInspectCsvqError); + }); + + it("evaluator mechanics: alias-qualified string + numeric AND predicate", () => { + // Generic query (not a default rule) covering `s.col` alias refs, string `=`, + // numeric `>`, and AND against the real vacuum_stats columns. + const q = + "SELECT LISTAGG(s.name, ',') FROM `vacuum_stats.csv` s WHERE s.expect_autovacuum = 'yes' AND s.rowcount > 1000"; + expect( + evalScalar(q, { + "vacuum_stats.csv": "name,expect_autovacuum,rowcount\npublic.t,yes,2000\n", + }), + ).toEqual(Option.some("public.t")); + expect( + evalScalar(q, { + "vacuum_stats.csv": "name,expect_autovacuum,rowcount\npublic.t,no,2000\n", + }), + ).toEqual(Option.none()); + }); + + it("Yet to be vacuumed: empty-string compare inside parenthesised OR", () => { + const q = rule("No tables yet to be vacuumed"); + expect( + evalScalar(q, { + "vacuum_stats.csv": + "name,rowcount,last_autovacuum,last_vacuum\npublic.t,2000,,2024-01-01 00:00\n", + }), + ).toEqual(Option.some("public.t")); + expect( + evalScalar(q, { + "vacuum_stats.csv": + "name,rowcount,last_autovacuum,last_vacuum\npublic.t,2000,2024-01-01 00:00,2024-01-01 00:00\n", + }), + ).toEqual(Option.none()); + }); +}); + +describe("csvq value semantics", () => { + it("NOT negates a comparison", () => { + expect( + evalScalar("SELECT LISTAGG(stmt, ',') FROM `locks.csv` WHERE NOT granted = 't'", { + "locks.csv": "stmt,granted\nA,f\nB,t\n", + }), + ).toEqual(Option.some("A")); + }); + + it("joins multiple matched rows in scan order with the separator", () => { + expect( + evalScalar("SELECT LISTAGG(stmt, ';') FROM `locks.csv` WHERE granted = 'f'", { + "locks.csv": "stmt,granted\nA,f\nB,f\nC,t\n", + }), + ).toEqual(Option.some("A;B")); + }); + + it("COUNT(*) returns the matched row count", () => { + expect( + evalScalar("SELECT COUNT(*) FROM `locks.csv` WHERE granted = 'f'", { + "locks.csv": "stmt,granted\nA,f\nB,f\nC,t\n", + }), + ).toEqual(Option.some("2")); + }); + + it("string-compares a thousands-grouped to_char value (csvq parity quirk)", () => { + // `" 2,000"` is not strictly numeric (leading space, comma), so `rowcount > 1000` + // falls back to a string comparison: `" 2,000"` < `"1000"` → the row is excluded, + // exactly as csvq behaves on a `to_char`-formatted column. + expect( + evalScalar("SELECT LISTAGG(tbl, ',') FROM `vacuum_stats.csv` WHERE rowcount > 1000", { + "vacuum_stats.csv": 'tbl,rowcount\npublic.t," 2,000"\n', + }), + ).toEqual(Option.none()); + }); +}); + +describe("comparison operators", () => { + const data = { "t.csv": "n\n5\n" }; + it.each([ + ["n = 5", true], + ["n = 6", false], + ["n <> 6", true], + ["n != 5", false], + ["n < 6", true], + ["n <= 5", true], + ["n > 4", true], + ["n >= 6", false], + ])("%s → matched=%s", (cond, matched) => { + const result = evalScalar(`SELECT COUNT(*) FROM \`t.csv\` WHERE ${cond}`, data); + expect(result).toEqual(Option.some(matched ? "1" : "0")); + }); + + it("string-compares when one side is a non-numeric string", () => { + // `name = 'postgres'` is a pure string comparison. + expect( + evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE name = 'postgres'", { + "t.csv": "name\npostgres\nother\n", + }), + ).toEqual(Option.some("1")); + }); +}); + +describe("arithmetic", () => { + it("supports + - * / and excludes rows when an operand is non-numeric (NULL result)", () => { + const data = { "t.csv": "a,b\n10,4\n" }; + expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a + b > 13", data)).toEqual( + Option.some("1"), + ); + expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a - b > 5", data)).toEqual( + Option.some("1"), + ); + expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a / b > 2", data)).toEqual( + Option.some("1"), + ); + // Division by zero → NULL → row excluded. + expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a / 0 > 0", data)).toEqual( + Option.some("0"), + ); + // Arithmetic on a non-numeric column → NULL → row excluded. + expect( + evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE c * 1 > 0", { "t.csv": "c\nabc\n" }), + ).toEqual(Option.some("0")); + }); +}); + +describe("IS NULL", () => { + it("treats a computed NULL as IS NULL and a CSV cell as never NULL", () => { + expect( + evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE c * 1 IS NULL", { "t.csv": "c\nabc\n" }), + ).toEqual(Option.some("1")); + expect( + evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE c IS NOT NULL", { "t.csv": "c\nabc\n" }), + ).toEqual(Option.some("1")); + expect( + evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE c IS NULL", { "t.csv": "c\nabc\n" }), + ).toEqual(Option.some("0")); + }); +}); + +describe("aggregates", () => { + const data = { "t.csv": "v\n10\n20\n30\n" }; + it("COUNT(col) counts the matched rows", () => { + expect(evalScalar("SELECT COUNT(v) FROM `t.csv`", data)).toEqual(Option.some("3")); + }); + it("SUM / AVG / MIN / MAX over numeric cells", () => { + expect(evalScalar("SELECT SUM(v) FROM `t.csv`", data)).toEqual(Option.some("60")); + expect(evalScalar("SELECT AVG(v) FROM `t.csv`", data)).toEqual(Option.some("20")); + expect(evalScalar("SELECT MIN(v) FROM `t.csv`", data)).toEqual(Option.some("10")); + expect(evalScalar("SELECT MAX(v) FROM `t.csv`", data)).toEqual(Option.some("30")); + }); + it("a numeric aggregate over zero matched rows is none", () => { + expect(evalScalar("SELECT SUM(v) FROM `t.csv` WHERE v > 100", data)).toEqual(Option.none()); + }); +}); + +describe("plain column select", () => { + it("returns the first matched cell, or none when nothing matches", () => { + const data = { "t.csv": "name,flag\na,x\nb,y\n" }; + expect(evalScalar("SELECT name FROM `t.csv` WHERE flag = 'y'", data)).toEqual(Option.some("b")); + expect(evalScalar("SELECT name FROM `t.csv` WHERE flag = 'z'", data)).toEqual(Option.none()); + }); +}); + +describe("errors", () => { + it("throws for an unknown table", () => { + expect(() => evalScalar("SELECT COUNT(*) FROM `missing.csv`", {})).toThrow( + LegacyInspectCsvqError, + ); + }); + + it("throws for an unknown column", () => { + expect(() => + evalScalar("SELECT LISTAGG(nope, ',') FROM `locks.csv`", { "locks.csv": "stmt\nA\n" }), + ).toThrow(LegacyInspectCsvqError); + }); + + it("throws for unsupported grammar", () => { + expect(() => + evalScalar("UPDATE `locks.csv` SET stmt = 'x'", { "locks.csv": "stmt\nA\n" }), + ).toThrow(LegacyInspectCsvqError); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts new file mode 100644 index 0000000000..8257db59e3 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdtempSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +// A definitely-closed local port: the `--db-url` is parsed directly (no config.toml +// / running stack needed), so the native handler creates the dated output directory, +// prints the connect diagnostic, then fails fast dialing. This exercises the real +// subprocess path — flag parse → resolution → mkdir → native connect — without the +// Go binary and without depending on a live database in CI. +const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; + +// `--agent no` forces text-mode output deterministically (the CLI otherwise +// auto-selects JSON on stdout in a detected agent environment). +const TEXT_MODE = ["--agent", "no"]; + +describe("supabase inspect report (legacy)", () => { + test( + "creates the dated output directory and prints the connect diagnostic before failing on an unreachable database", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const outputDir = mkdtempSync(join(tmpdir(), "supabase-report-e2e-")); + const { exitCode, stderr } = await runSupabase( + ["inspect", "report", ...TEXT_MODE, "--db-url", DEAD_DB_URL, "--output-dir", outputDir], + { entrypoint: "legacy", home: home.dir, env: { HOME: home.dir } }, + ); + expect(exitCode).toBe(1); + // The native handler writes the connect diagnostic to stderr (Go parity). + expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); + // mkdir runs before the connection, so the dated folder exists even on failure. + const dated = readdirSync(outputDir).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name)); + expect(dated.length).toBe(1); + expect(existsSync(join(outputDir, dated[0]!))).toBe(true); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts new file mode 100644 index 0000000000..57ef7a82e4 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts @@ -0,0 +1,21 @@ +import { Data } from "effect"; + +/** + * Creating the dated `//` directory failed. Mirrors Go's + * `utils.MkdirIfNotExistFS` error (`apps/cli-go/internal/utils/misc.go:265-271`), + * which wraps the failure as `failed to mkdir: %w`. + */ +export class LegacyInspectReportMkdirError extends Data.TaggedError( + "LegacyInspectReportMkdirError", +)<{ readonly message: string }> {} + +/** + * Writing one of the report CSV files failed. Mirrors Go's `copyToCSV` + * (`apps/cli-go/internal/inspect/report.go:66-69`), which wraps an `OpenFile` + * failure as `failed to create output file: %w`. The TS port collects the COPY + * bytes first and writes them afterwards, so a file-write failure surfaces here + * with Go's matching text. + */ +export class LegacyInspectReportWriteError extends Data.TaggedError( + "LegacyInspectReportWriteError", +)<{ readonly message: string }> {} diff --git a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts index 6e669b72bb..7dd4c1340a 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts @@ -1,15 +1,175 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; + +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyBold } from "../../../output/legacy-bold.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyInspectMutuallyExclusiveFlagsError } from "../db/legacy-inspect-query.ts"; import type { LegacyInspectReportFlags } from "./report.command.ts"; +import { + type LegacyCsvTable, + type LegacyCsvTableProvider, + legacyParseReportCsv, +} from "./report.csvq.ts"; +import { legacyReadInspectRules } from "./report.config.ts"; +import { LegacyInspectReportMkdirError, LegacyInspectReportWriteError } from "./report.errors.ts"; +import { + LEGACY_REPORT_QUERIES, + legacyReportIgnoreSchemas, + legacyWrapReportQuery, +} from "./report.queries.ts"; +import { + LEGACY_DEFAULT_INSPECT_RULES, + legacyBuildRuleSummaryRows, + legacyEvaluateInspectRule, +} from "./report.rules.ts"; + +/** Local-time `YYYY-MM-DD`, matching Go's `time.Now().Format("2006-01-02")`. */ +function legacyReportDateFolder(epochMillis: number): string { + const date = new Date(epochMillis); + 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}`; +} +/** + * `supabase inspect report` — runs every inspect query and writes one CSV per + * query into `//`, then prints a Glamour "rules" summary + * table validating those CSVs. + * + * 1:1 port of Go's `inspect.Report` (`apps/cli-go/internal/inspect/report.go`). + * Telemetry is flushed on success and failure (Go's `PersistentPostRun`); the + * command-level wrapper adds the `cli_command_executed` instrumentation and the + * machine-format JSON error envelope. + */ export const legacyInspectReport = Effect.fn("legacy.inspect.report")(function* ( flags: LegacyInspectReportFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "report"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (flags.outputDir !== ".") args.push("--output-dir", flags.outputDir); - yield* proxy.exec(args); + const dnsResolver = yield* LegacyDnsResolverFlag; + const telemetryState = yield* LegacyTelemetryState; + yield* legacyRunInspectReport(flags, dnsResolver).pipe(Effect.ensuring(telemetryState.flush)); +}); + +const legacyRunInspectReport = Effect.fnUntraced(function* ( + flags: LegacyInspectReportFlags, + dnsResolver: "native" | "https", +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const runtimeInfo = yield* RuntimeInfo; + const tty = yield* Tty; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isText = output.format === "text"; + + // Reproduce cobra's MarkFlagsMutuallyExclusive("db-url","linked","local"), + // keyed off explicitly-set flags (cobra's `Changed`), not the default value. + const setFlags: Array = []; + if (Option.isSome(flags.dbUrl)) setFlags.push("db-url"); + if (flags.linked) setFlags.push("linked"); + if (flags.local) setFlags.push("local"); + if (setFlags.length > 1) { + return yield* Effect.fail( + new LegacyInspectMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }), + ); + } + + // Go's `--linked` defaults to true, so absence of the others resolves to linked. + const linked = flags.linked || (Option.isNone(flags.dbUrl) && !flags.local); + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + linked, + local: flags.local, + dnsResolver, + }); + + // `outDir = /`, resolved against the process CWD when relative + // (Go's `utils.CurrentDirAbs`, set from `os.Getwd()` — NOT `--workdir`). + const epochMillis = yield* Clock.currentTimeMillis; + let outDir = path.join(flags.outputDir, legacyReportDateFolder(epochMillis)); + if (!path.isAbsolute(outDir)) { + outDir = path.join(runtimeInfo.cwd, outDir); + } + yield* fs + .makeDirectory(outDir, { recursive: true }) + .pipe( + Effect.mapError( + (error) => new LegacyInspectReportMkdirError({ message: `failed to mkdir: ${error}` }), + ), + ); + + // Go's `ConnectByConfig` writes the connect diagnostic to stderr before dialing. + if (isText) { + yield* output.raw(`Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr"); + } + + const ignoreSchemas = legacyReportIgnoreSchemas(); + const dbLiteral = `'${cfg.conn.database}'`; + const csvByFile = new Map(); + const files: Array<{ readonly name: string; readonly path: string }> = []; + + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + if (isText) yield* output.raw("Running queries...\n", "stderr"); + for (const { fileName, sql } of LEGACY_REPORT_QUERIES) { + const bytes = yield* session.copyToCsv( + legacyWrapReportQuery(sql, ignoreSchemas, dbLiteral), + ); + const filePath = path.join(outDir, `${fileName}.csv`); + yield* fs.writeFile(filePath, bytes).pipe( + Effect.mapError( + (error) => + new LegacyInspectReportWriteError({ + message: `failed to create output file: ${error}`, + }), + ), + ); + csvByFile.set(`${fileName}.csv`, bytes); + files.push({ name: fileName, path: filePath }); + } + }), + ); + + if (isText) { + yield* output.raw(`Reports saved to ${legacyBold(outDir, tty.stdoutIsTty)}\n`, "stderr"); + } + + // Custom `[experimental.inspect.rules]` replace the 7 defaults when present. + const configRules = yield* legacyReadInspectRules(fs, path, cliConfig.workdir); + const rules = configRules.length > 0 ? configRules : LEGACY_DEFAULT_INSPECT_RULES; + if (configRules.length === 0 && isText) { + yield* output.raw("Loading default rules...\n", "stderr"); + } + + const tableCache = new Map(); + const provider: LegacyCsvTableProvider = (name) => { + if (!tableCache.has(name)) { + const bytes = csvByFile.get(name); + tableCache.set(name, bytes === undefined ? undefined : legacyParseReportCsv(bytes)); + } + return tableCache.get(name); + }; + const results = rules.map((rule) => legacyEvaluateInspectRule(rule, provider)); + + if (isText) { + yield* output.raw( + renderGlamourTable(["RULE", "STATUS", "MATCHES"], legacyBuildRuleSummaryRows(results)), + ); + return; + } + + // json / stream-json (TS-extra; Go has only text). CSVs are still written. + yield* output.success("inspect report", { outputDir: outDir, files, rules: results }); }); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts new file mode 100644 index 0000000000..091c9e0d58 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -0,0 +1,491 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnectError, + LegacyDbCopyError, +} from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyInspectReportFlags } from "./report.command.ts"; +import { legacyInspectReport } from "./report.handler.ts"; +import { + LEGACY_REPORT_QUERIES, + legacyReportIgnoreSchemas, + legacyWrapReportQuery, +} from "./report.queries.ts"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +// Map each query's wrapped COPY statement back to its file name so the mocked +// `copyToCsv` can return the right canned CSV. +const WRAPPED_TO_FILE = new Map(); +for (const { fileName, sql } of LEGACY_REPORT_QUERIES) { + WRAPPED_TO_FILE.set( + legacyWrapReportQuery(sql, legacyReportIgnoreSchemas(), "'postgres'"), + fileName, + ); +} + +function tempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function mockResolver(opts: { conn?: LegacyPgConnInput; isLocal?: boolean; fails?: boolean } = {}) { + let resolveInput: unknown; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags) => { + resolveInput = flags; + if (opts.fails === true) { + return Effect.fail(new LegacyDbConfigLoadError({ message: "cannot load config" })); + } + return Effect.succeed({ + conn: opts.conn ?? LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig); + }, + }); + return { + layer, + get resolveInput() { + return resolveInput; + }, + }; +} + +function mockReportConnection(opts: { + csvs?: Record; + connectFails?: boolean; + copyFails?: boolean; +}) { + const copiedSql: Array = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => { + if (opts.connectFails === true) { + return Effect.fail( + new LegacyDbConnectError({ message: "failed to connect to postgres: refused" }), + ); + } + return Effect.succeed({ + exec: () => Effect.void, + extensionExists: () => Effect.succeed(false), + query: () => Effect.succeed([]), + copyToCsv: (sql: string) => { + copiedSql.push(sql); + if (opts.copyFails === true) { + return Effect.fail(new LegacyDbCopyError({ message: "failed to copy output: boom" })); + } + const fileName = WRAPPED_TO_FILE.get(sql) ?? "unknown"; + const text = opts.csvs?.[`${fileName}.csv`] ?? ""; + return Effect.succeed(new TextEncoder().encode(text)); + }, + }); + }, + }); + return { + layer, + get copiedSql() { + return copiedSql; + }, + }; +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + conn?: LegacyPgConnInput; + isLocal?: boolean; + csvs?: Record; + resolveFails?: boolean; + connectFails?: boolean; + copyFails?: boolean; + stdoutIsTty?: boolean; + cwd?: string; + workdir?: string; +} + +function setupLegacyReport(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const resolver = mockResolver({ + conn: opts.conn, + isLocal: opts.isLocal, + fails: opts.resolveFails, + }); + const connection = mockReportConnection({ + csvs: opts.csvs, + connectFails: opts.connectFails, + copyFails: opts.copyFails, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const workdir = opts.workdir ?? tempDir("supabase-report-workdir-"); + const layer = Layer.mergeAll( + out.layer, + resolver.layer, + connection.layer, + telemetry.layer, + Layer.succeed(LegacyDnsResolverFlag, "native"), + mockLegacyCliConfig({ workdir }), + mockRuntimeInfo({ cwd: opts.cwd ?? tempDir("supabase-report-cwd-") }), + mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), + BunServices.layer, + ); + return { layer, out, resolver, connection, telemetry, workdir }; +} + +const flags = (over: Partial = {}): LegacyInspectReportFlags => ({ + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, + outputDir: over.outputDir ?? ".", +}); + +// One CSV per referenced file with the REAL column headers each query emits, so +// column lookups resolve exactly as they would against Postgres. `locks.csv` +// carries an old (rule 1 fail) but granted (rule 2 pass) row. Note `vacuum_stats` +// has no `tbl` column (it never did) — default rule 6 references `s.tbl` verbatim +// from Go, so it surfaces an unknown-column error as its STATUS cell. +const DEFAULT_RULE_CSVS: Record = { + "locks.csv": "stmt,age,granted\nLOCK_A,00:05:00,t\n", + "unused_indexes.csv": "index\n", + "db_stats.csv": "name,index_hit_rate,table_hit_rate\npostgres,0.99,0.99\n", + "table_stats.csv": "name,seq_scans,estimated_row_count\n", + "vacuum_stats.csv": "name,rowcount,expect_autovacuum,last_autovacuum,last_vacuum\n", +}; + +function localDateFolder(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function dateFolderContents(base: string): { dir: string; files: Array } { + const entries = readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory()); + expect(entries.length).toBe(1); + const dir = join(base, entries[0]!.name); + return { dir, files: readdirSync(dir) }; +} + +describe("legacy inspect report", () => { + it.live("writes one CSV per inspect query for the linked project", () => { + const base = tempDir("supabase-report-out-"); + const { layer, connection } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + const { files } = dateFolderContents(base); + expect(files.length).toBe(14); + expect(files).toContain("db_stats.csv"); + expect(files).toContain("unused_indexes.csv"); + expect(files).not.toContain("db-stats.csv"); + // Every query was copied with both placeholders substituted. + expect(connection.copiedSql.length).toBe(14); + expect( + connection.copiedSql.every( + (s) => s.startsWith("COPY (") && s.endsWith("TO STDOUT WITH CSV HEADER"), + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects the local database with --local", () => { + const base = tempDir("supabase-report-out-"); + const { layer, resolver } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base, local: true })); + expect((resolver.resolveInput as { local: boolean }).local).toBe(true); + expect((resolver.resolveInput as { linked: boolean }).linked).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects a custom database with --db-url and labels the diagnostic 'remote'", () => { + const base = tempDir("supabase-report-out-"); + const { layer, resolver, out } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, isLocal: false }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base, dbUrl: Option.some("postgres://x") })); + expect(Option.isSome((resolver.resolveInput as { dbUrl: Option.Option }).dbUrl)).toBe( + true, + ); + // The connect diagnostic reflects a non-local target (Go parity). + expect(out.stderrText).toContain("Connecting to remote database..."); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects the linked project by default when no connection flag is set", () => { + const base = tempDir("supabase-report-out-"); + const { layer, resolver } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect((resolver.resolveInput as { linked: boolean }).linked).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects more than one of --db-url/--linked/--local", () => { + const { layer } = setupLegacyReport(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ linked: true, local: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("are set none of the others can be"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "prints connect + running + saved progress to stderr and the rules table to stdout", + () => { + const base = tempDir("supabase-report-out-"); + const { layer, out } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, isLocal: true }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(out.stderrText).toContain("Connecting to local database..."); + expect(out.stderrText).toContain("Running queries..."); + expect(out.stderrText).toContain("Reports saved to "); + expect(out.stderrText).toContain("Loading default rules..."); + // stdout carries the Glamour rules table. + expect(out.stdoutText).toContain("RULE"); + expect(out.stdoutText).toContain("STATUS"); + expect(out.stdoutText).toContain("MATCHES"); + expect(out.stdoutText).toContain("No old locks"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("shows a passing rule as ✔/- and a failing rule with its message and matches", () => { + const base = tempDir("supabase-report-out-"); + const { layer, out } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + // Rule 1 fails (old lock): message + matched statement. + expect(out.stdoutText).toContain("There is at least one lock older than 2 minutes"); + expect(out.stdoutText).toContain("LOCK_A"); + // Rule 2 passes (lock is granted): ✔. + expect(out.stdoutText).toContain("✔"); + // Rule 6 references `s.tbl` (Go-verbatim) which vacuum_stats lacks → the + // unknown-column error is shown as its STATUS cell, command still succeeds. + expect(out.stdoutText).toContain("unknown column: tbl"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "custom config.toml rules replace the defaults and suppress 'Loading default rules...'", + () => { + const base = tempDir("supabase-report-out-"); + const workdir = tempDir("supabase-report-workdir-"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + "query = \"SELECT COUNT(*) FROM `locks.csv` WHERE granted = 'f'\"", + 'name = "Custom rule"', + 'pass = "good"', + 'fail = "bad"', + "", + ].join("\n"), + ); + const { layer, out } = setupLegacyReport({ + workdir, + csvs: { "locks.csv": "stmt,granted\nA,t\n" }, + }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(out.stderrText).not.toContain("Loading default rules..."); + expect(out.stdoutText).toContain("Custom rule"); + // No-match COUNT(*) returns 0, a non-empty value → fail status for this rule. + expect(out.stdoutText).toContain("bad"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("surfaces a malformed rule query as the STATUS cell without failing", () => { + const base = tempDir("supabase-report-out-"); + const workdir = tempDir("supabase-report-workdir-"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + // References a CSV that was never produced — the provider returns no table + // and the evaluator surfaces the error as the STATUS cell (not a failure). + 'query = "SELECT COUNT(*) FROM `nope.csv`"', + 'name = "Broken rule"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), + ); + const { layer, out } = setupLegacyReport({ workdir, csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toContain("Broken rule"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured result and writes CSVs but no table in json mode", () => { + const base = tempDir("supabase-report-out-"); + const { layer, out } = setupLegacyReport({ format: "json", csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(out.stdoutText).toBe(""); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "inspect report" }), + ); + const success = out.messages.find((m) => m.type === "success"); + const data = ( + success as { data?: { files?: Array; outputDir?: string; rules?: Array } } + ).data; + expect(data?.files?.length).toBe(14); + expect(typeof data?.outputDir).toBe("string"); + expect(data?.rules?.length).toBe(7); + // CSVs are still written. + expect(dateFolderContents(base).files.length).toBe(14); + // No progress lines in machine mode. + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("streams the structured result in stream-json mode", () => { + const base = tempDir("supabase-report-out-"); + const { layer, out } = setupLegacyReport({ format: "stream-json", csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "inspect report" }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts with a failed-to-mkdir error when the output directory cannot be created", () => { + // Point --output-dir at a regular file so mkdir of `/` fails. + const fileAsDir = join(tempDir("supabase-report-out-"), "afile"); + writeFileSync(fileAsDir, "x"); + const { layer } = setupLegacyReport(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: fileAsDir }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to mkdir"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts with a copy error when COPY fails", () => { + const base = tempDir("supabase-report-out-"); + const { layer } = setupLegacyReport({ copyFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to copy output"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts with a failed-to-create-output-file error when a CSV cannot be written", () => { + const base = tempDir("supabase-report-out-"); + // Pre-create the first CSV target (`bloat.csv`) as a DIRECTORY so the file + // write fails (EISDIR) while mkdir (recursive, idempotent) still succeeds. + mkdirSync(join(base, localDateFolder(), "bloat.csv"), { recursive: true }); + const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to create output file"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts when the connection fails", () => { + const base = tempDir("supabase-report-out-"); + const { layer } = setupLegacyReport({ connectFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts when resolution fails", () => { + const base = tempDir("supabase-report-out-"); + const { layer } = setupLegacyReport({ resolveFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("cannot load config"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves a relative --output-dir under the process CWD", () => { + const cwd = tempDir("supabase-report-cwd-"); + const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, cwd }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: "reports" })); + const { files } = dateFolderContents(join(cwd, "reports")); + expect(files.length).toBe(14); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses an absolute --output-dir as-is", () => { + const base = tempDir("supabase-report-out-"); + const cwd = tempDir("supabase-report-cwd-"); + const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, cwd }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + // Written under the absolute base, not under the CWD. + expect(dateFolderContents(base).files.length).toBe(14); + expect(readdirSync(cwd).length).toBe(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders the path in bold when stdout is a TTY", () => { + const base = tempDir("supabase-report-out-"); + const { layer, out } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, stdoutIsTty: true }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(out.stderrText).toContain("\x1b[1m"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry on success", () => { + const base = tempDir("supabase-report-out-"); + const { layer, telemetry } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base })); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even when the command fails", () => { + const { layer, telemetry } = setupLegacyReport({ resolveFails: true }); + return Effect.gen(function* () { + yield* Effect.exit( + legacyInspectReport(flags({ outputDir: tempDir("supabase-report-out-") })), + ); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.layers.ts b/apps/cli/src/legacy/commands/inspect/report/report.layers.ts new file mode 100644 index 0000000000..8ae132d04f --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.layers.ts @@ -0,0 +1,19 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyInspectBaseLayer } from "../inspect.layers.ts"; + +/** + * Runtime layer for `supabase inspect report`. + * + * `inspect report` is a sibling of `inspect db` (a direct child of `inspect`, not + * under `db`), so its command-runtime path is `["inspect", "report"]` — two levels, + * matching Go's `cmd.CommandPath()` (`apps/cli-go/cmd/inspect.go:292`). It shares + * the same `legacyInspectBaseLayer` (resolver + connection + CLI config + telemetry) + * as the `db` leaves. `FileSystem` / `Path` / `Tty` / `RuntimeInfo` / `Clock` are + * provided by the global run harness (`shared/cli/run.ts`), not here. + */ +export const legacyInspectReportRuntimeLayer = Layer.merge( + legacyInspectBaseLayer, + commandRuntimeLayer(["inspect", "report"]), +); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.queries.ts b/apps/cli/src/legacy/commands/inspect/report/report.queries.ts new file mode 100644 index 0000000000..90dc5d2ec8 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.queries.ts @@ -0,0 +1,99 @@ +import { legacyBloatSpec } from "../db/bloat/bloat.query.ts"; +import { legacyBlockingSpec } from "../db/blocking/blocking.query.ts"; +import { legacyCallsSpec } from "../db/calls/calls.query.ts"; +import { legacyDbStatsSpec } from "../db/db-stats/db-stats.query.ts"; +import { legacyIndexStatsSpec } from "../db/index-stats/index-stats.query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../db/legacy-inspect-schemas.ts"; +import { legacyLocksSpec } from "../db/locks/locks.query.ts"; +import { legacyLongRunningQueriesSpec } from "../db/long-running-queries/long-running-queries.query.ts"; +import { legacyOutliersSpec } from "../db/outliers/outliers.query.ts"; +import { legacyReplicationSlotsSpec } from "../db/replication-slots/replication-slots.query.ts"; +import { legacyRoleStatsSpec } from "../db/role-stats/role-stats.query.ts"; +import { legacyTableStatsSpec } from "../db/table-stats/table-stats.query.ts"; +import { legacyTrafficProfileSpec } from "../db/traffic-profile/traffic-profile.query.ts"; +import { legacyVacuumStatsSpec } from "../db/vacuum-stats/vacuum-stats.query.ts"; + +/** + * The `unused_indexes` query, verbatim from + * `apps/cli-go/internal/inspect/unused_indexes/unused_indexes.sql`. The `inspect db` + * tree folds `unused-indexes` into a deprecated alias of `index-stats`, so this + * distinct query (columns: `name`, `index`, `index_size`, `index_scans`) has no + * existing `LegacyInspectQuerySpec`; the report still emits its own `unused_indexes.csv` + * (report.go embeds every nested `.sql`, so it walks all 14 files). + */ +const LEGACY_UNUSED_INDEXES_REPORT_SQL = `SELECT + FORMAT('%I.%I', schemaname, relname) AS name, + indexrelname AS index, + pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size, + idx_scan as index_scans +FROM pg_stat_user_indexes ui +JOIN pg_index i ON ui.indexrelid = i.indexrelid +WHERE + NOT indisunique AND idx_scan < 50 AND pg_relation_size(relid) > 5 * 8192 + AND NOT schemaname LIKE ANY($1) +ORDER BY + pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST, + pg_relation_size(i.indexrelid) DESC`; + +/** + * One report query: the basename Go derives from the embedded SQL filename + * (`strings.Split(d.Name(), ".")[0]`, `report.go:52`) and the SQL it runs. + * + * The `fileName` is the **SQL basename with underscores** (`db_stats`), which is + * also the CSV name (`.csv`) — NOT the `inspect db` spec `name` + * (`db-stats`). The report does not bind parameters: `COPY` cannot, so the + * placeholders are substituted textually by `legacyWrapReportQuery`, not via + * `spec.params()`. + */ +export interface LegacyReportQuery { + readonly fileName: string; + readonly sql: string; +} + +/** + * The 14 report queries, 1:1 with the SQL files Go embeds under + * `internal/inspect`. Reuses the 13 `inspect db` specs' `.sql` verbatim + * (byte-identical COPY input → byte-identical CSVs) plus the standalone + * `unused_indexes` query. + */ +export const LEGACY_REPORT_QUERIES: ReadonlyArray = [ + { fileName: "bloat", sql: legacyBloatSpec.sql }, + { fileName: "blocking", sql: legacyBlockingSpec.sql }, + { fileName: "calls", sql: legacyCallsSpec.sql }, + { fileName: "db_stats", sql: legacyDbStatsSpec.sql }, + { fileName: "index_stats", sql: legacyIndexStatsSpec.sql }, + { fileName: "locks", sql: legacyLocksSpec.sql }, + { fileName: "long_running_queries", sql: legacyLongRunningQueriesSpec.sql }, + { fileName: "outliers", sql: legacyOutliersSpec.sql }, + { fileName: "replication_slots", sql: legacyReplicationSlotsSpec.sql }, + { fileName: "role_stats", sql: legacyRoleStatsSpec.sql }, + { fileName: "table_stats", sql: legacyTableStatsSpec.sql }, + { fileName: "traffic_profile", sql: legacyTrafficProfileSpec.sql }, + { fileName: "unused_indexes", sql: LEGACY_UNUSED_INDEXES_REPORT_SQL }, + { fileName: "vacuum_stats", sql: legacyVacuumStatsSpec.sql }, +]; + +/** + * The `$1` substitution value: the internal schemas escaped into `LIKE` patterns + * and rendered as a Postgres `text[]` literal. 1:1 with Go's package-level + * `ignoreSchemas` (`report.go:62`): + * `fmt.Sprintf("'{%s}'::text[]", strings.Join(reset.LikeEscapeSchema(utils.InternalSchemas), ","))`. + */ +export function legacyReportIgnoreSchemas(): string { + return `'{${legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS).join(",")}}'::text[]`; +} + +/** + * Port of Go's `wrapQuery` (`report.go:79-84`): substitute each positional + * placeholder (`$1`, `$2`, …) with the corresponding `arg` via `ReplaceAll` + * (every occurrence — `$1` appears 3× in `index_stats.sql`), in order, then wrap + * the result in `COPY (...) TO STDOUT WITH CSV HEADER`. With no args it is a pure + * wrap. + */ +export function legacyWrapReportQuery(sql: string, ...args: ReadonlyArray): string { + let query = sql; + for (let index = 0; index < args.length; index++) { + query = query.replaceAll(`$${index + 1}`, args[index]!); + } + return `COPY (${query}) TO STDOUT WITH CSV HEADER`; +} diff --git a/apps/cli/src/legacy/commands/inspect/report/report.queries.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.queries.unit.test.ts new file mode 100644 index 0000000000..6939fb200e --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.queries.unit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../db/legacy-inspect-schemas.ts"; +import { + LEGACY_REPORT_QUERIES, + legacyReportIgnoreSchemas, + legacyWrapReportQuery, +} from "./report.queries.ts"; + +describe("legacyWrapReportQuery", () => { + // Ports apps/cli-go/internal/inspect/report_test.go::TestWrapQuery. + it("wraps a query in CSV COPY with no placeholders", () => { + expect(legacyWrapReportQuery("SELECT 1")).toBe("COPY (SELECT 1) TO STDOUT WITH CSV HEADER"); + }); + + it("replaces the $1 placeholder value", () => { + const ignoreSchemas = legacyReportIgnoreSchemas(); + expect(legacyWrapReportQuery("SELECT 'a' LIKE ANY($1)", ignoreSchemas)).toBe( + `COPY (SELECT 'a' LIKE ANY(${ignoreSchemas})) TO STDOUT WITH CSV HEADER`, + ); + }); + + it("replaces $1 and $2 in order", () => { + expect(legacyWrapReportQuery("SELECT $1, $2", "'schemas'", "'postgres'")).toBe( + "COPY (SELECT 'schemas', 'postgres') TO STDOUT WITH CSV HEADER", + ); + }); + + it("replaces every occurrence of $1 (ReplaceAll, not first-only)", () => { + expect(legacyWrapReportQuery("WHERE a LIKE ANY($1) AND b LIKE ANY($1)", "X")).toBe( + "COPY (WHERE a LIKE ANY(X) AND b LIKE ANY(X)) TO STDOUT WITH CSV HEADER", + ); + }); +}); + +describe("legacyReportIgnoreSchemas", () => { + it("renders the internal schemas as an escaped text[] literal", () => { + const expected = `'{${legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS).join(",")}}'::text[]`; + expect(legacyReportIgnoreSchemas()).toBe(expected); + // The wildcard schema patterns are LIKE-escaped (underscore → \_, * → %). + expect(legacyReportIgnoreSchemas()).toContain("pg\\_%"); + }); +}); + +describe("LEGACY_REPORT_QUERIES", () => { + it("has the 14 underscore CSV basenames Go embeds", () => { + expect(LEGACY_REPORT_QUERIES.map((q) => q.fileName)).toEqual([ + "bloat", + "blocking", + "calls", + "db_stats", + "index_stats", + "locks", + "long_running_queries", + "outliers", + "replication_slots", + "role_stats", + "table_stats", + "traffic_profile", + "unused_indexes", + "vacuum_stats", + ]); + }); + + it("carries non-empty SQL for every query", () => { + for (const query of LEGACY_REPORT_QUERIES) { + expect(query.sql.length).toBeGreaterThan(0); + } + }); + + it("keeps the standalone unused_indexes query (its own columns, not index-stats)", () => { + const unused = LEGACY_REPORT_QUERIES.find((q) => q.fileName === "unused_indexes"); + expect(unused?.sql).toContain("idx_scan as index_scans"); + expect(unused?.sql).toContain("$1"); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.rules.ts b/apps/cli/src/legacy/commands/inspect/report/report.rules.ts new file mode 100644 index 0000000000..7cfe9049cc --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.rules.ts @@ -0,0 +1,134 @@ +import { Option } from "effect"; +import { legacyInspectText } from "../db/legacy-inspect-query.ts"; +import { + type LegacyCsvTableProvider, + LegacyInspectCsvqError, + legacyEvalCsvqScalar, +} from "./report.csvq.ts"; + +/** + * One report validation rule, 1:1 with Go's `config.rule` + * (`apps/cli-go/pkg/config/config.go:236-241`): the csvq `query` over the written + * CSVs, the `name` shown in the summary, and the `pass`/`fail` STATUS strings. + */ +export interface LegacyInspectRule { + readonly query: string; + readonly name: string; + readonly pass: string; + readonly fail: string; +} + +/** + * The 7 default rules, ported verbatim from + * `apps/cli-go/internal/inspect/templates/rules.toml`. Used when + * `[experimental.inspect.rules]` is absent or empty in `config.toml`. + */ +export const LEGACY_DEFAULT_INSPECT_RULES: ReadonlyArray = [ + { + query: "SELECT LISTAGG(stmt, ',') AS match FROM `locks.csv` WHERE age > '00:02:00'", + name: "No old locks", + pass: "✔", + fail: "There is at least one lock older than 2 minutes", + }, + { + query: "SELECT LISTAGG(stmt, ',') AS match FROM `locks.csv` WHERE granted = 'f'", + name: "No ungranted locks", + pass: "✔", + fail: "There is at least one ungranted lock", + }, + { + query: "SELECT LISTAGG(index, ',') AS match FROM `unused_indexes.csv`", + name: "No unused indexes", + pass: "✔", + fail: "There is at least one unused index", + }, + { + query: + "SELECT LISTAGG(name, ',') AS match FROM `db_stats.csv` WHERE index_hit_rate < 0.94 OR table_hit_rate < 0.94", + name: "Check cache hit is within acceptable bounds", + pass: "✔", + fail: "There is a cache hit ratio (table or index) below 94%", + }, + { + query: + "SELECT LISTAGG(t.name, ',') AS match FROM `table_stats.csv` t WHERE t.seq_scans > t.estimated_row_count * 0.1 AND t.estimated_row_count > 1000;", + name: "No large tables with sequential scans more than 10% of rows", + pass: "✔", + fail: "At least one table is showing sequential scans more than 10% of total row count", + }, + { + // NOTE: this query references `s.tbl`, but `vacuum_stats.sql` emits the column + // as `name` (there is no `tbl` column). csvq — and this evaluator — therefore + // raise an unknown-column error that surfaces as the rule's STATUS cell on real + // data. This is a pre-existing quirk in Go's `templates/rules.toml` and is kept + // VERBATIM for strict parity; do not "fix" it to `s.name` without changing Go. + query: + "SELECT LISTAGG(s.tbl, ',') AS match FROM `vacuum_stats.csv` s WHERE s.expect_autovacuum = 'yes' and s.rowcount > 1000;", + name: "No large tables waiting on autovacuum", + pass: "✔", + fail: "At least one table is waiting on autovacuum", + }, + { + query: + "SELECT LISTAGG(s.name, ',') AS match FROM `vacuum_stats.csv` s WHERE s.rowcount > 0 AND (s.last_autovacuum = '' OR s.last_vacuum = '');", + name: "No tables yet to be vacuumed", + pass: "✔", + fail: "At least one table has never had autovacuum or vacuum run on it", + }, +]; + +/** The outcome of evaluating one rule: the STATUS and MATCHES summary cells. */ +export interface LegacyInspectRuleResult { + readonly name: string; + readonly status: string; + readonly matches: string; +} + +/** + * Evaluate one rule against the written CSVs and map it to its summary cells, + * reproducing Go's status logic (`report.go:107-120`): + * + * - aggregate over zero rows / non-aggregate with no rows (csvq NULL / ErrNoRows) + * → STATUS = `pass`, MATCHES = `-`; + * - a valid empty string → STATUS = `pass`, MATCHES = `` (empty); + * - a non-empty value → STATUS = `fail`, MATCHES = the value; + * - a csvq error → STATUS = the error message, MATCHES = `-` (the command does not + * fail; the error becomes the cell). + */ +export function legacyEvaluateInspectRule( + rule: LegacyInspectRule, + provider: LegacyCsvTableProvider, +): LegacyInspectRuleResult { + try { + const match = legacyEvalCsvqScalar(rule.query, provider); + if (Option.isNone(match)) { + return { name: rule.name, status: rule.pass, matches: "-" }; + } + if (match.value === "") { + return { name: rule.name, status: rule.pass, matches: "" }; + } + return { name: rule.name, status: rule.fail, matches: match.value }; + } catch (error) { + const message = error instanceof LegacyInspectCsvqError ? error.message : String(error); + return { name: rule.name, status: message, matches: "-" }; + } +} + +/** + * Build the `[RULE, STATUS, MATCHES]` summary rows in rule order, for + * `renderGlamourTable`. Go wraps each cell in backticks inside its markdown + * (`report.go:121`): Glamour strips a non-empty inline code span (so a populated + * cell renders bare), but an EMPTY code span (`` `` ``) is passed through as the + * two literal backtick characters — the same rule `legacyInspectText` encodes for + * the `inspect db` tables. A valid empty `matches` cell therefore renders as `` `` + * (width 2), byte-matching Go; `name`/`status` are never empty. + */ +export function legacyBuildRuleSummaryRows( + results: ReadonlyArray, +): ReadonlyArray> { + return results.map((result) => [ + legacyInspectText(result.name), + legacyInspectText(result.status), + legacyInspectText(result.matches), + ]); +} diff --git a/apps/cli/src/legacy/commands/inspect/report/report.rules.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.rules.unit.test.ts new file mode 100644 index 0000000000..f3b137e7fa --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/report/report.rules.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { type LegacyCsvTableProvider, legacyParseReportCsv } from "./report.csvq.ts"; +import { + type LegacyInspectRule, + legacyBuildRuleSummaryRows, + legacyEvaluateInspectRule, +} from "./report.rules.ts"; + +function provider(tables: Record): LegacyCsvTableProvider { + return (name) => (name in tables ? legacyParseReportCsv(tables[name]!) : undefined); +} + +const RULE: LegacyInspectRule = { + query: "SELECT LISTAGG(stmt, ',') AS match FROM `locks.csv` WHERE granted = 'f'", + name: "No ungranted locks", + pass: "✔", + fail: "There is at least one ungranted lock", +}; + +describe("legacyEvaluateInspectRule", () => { + it("passes with a '-' matches cell when no rows match (csvq NULL)", () => { + const result = legacyEvaluateInspectRule( + RULE, + provider({ "locks.csv": "stmt,granted\nA,t\n" }), + ); + expect(result).toEqual({ name: RULE.name, status: "✔", matches: "-" }); + }); + + it("fails with the matched list when rows match", () => { + const result = legacyEvaluateInspectRule( + RULE, + provider({ "locks.csv": "stmt,granted\nA,f\nB,f\n" }), + ); + expect(result).toEqual({ name: RULE.name, status: RULE.fail, matches: "A,B" }); + }); + + it("treats a valid empty string match as a pass with an empty matches cell", () => { + // The single matched row's `stmt` is empty, so LISTAGG yields "" (valid, not NULL). + const result = legacyEvaluateInspectRule( + RULE, + provider({ "locks.csv": 'stmt,granted\n"",f\n' }), + ); + expect(result).toEqual({ name: RULE.name, status: "✔", matches: "" }); + }); + + it("surfaces a csvq error as the STATUS cell without throwing", () => { + const broken: LegacyInspectRule = { ...RULE, query: "SELECT COUNT(*) FROM `missing.csv`" }; + const result = legacyEvaluateInspectRule(broken, provider({})); + expect(result.matches).toBe("-"); + expect(result.status).toContain("missing.csv"); + expect(result.status).not.toBe(RULE.pass); + expect(result.status).not.toBe(RULE.fail); + }); +}); + +describe("legacyBuildRuleSummaryRows", () => { + it("preserves rule order and renders an empty matches cell as two backticks", () => { + const rows = legacyBuildRuleSummaryRows([ + { name: "First", status: "✔", matches: "-" }, + { name: "Second", status: "fail msg", matches: "" }, + ]); + expect(rows).toEqual([ + ["First", "✔", "-"], + ["Second", "fail msg", "``"], + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts index 06e7316c90..fc8f990eae 100644 --- a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts +++ b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts @@ -73,6 +73,7 @@ function mockDbConnection(opts: { } }), extensionExists: () => Effect.succeed(opts.existed ?? false), + copyToCsv: () => Effect.succeed(new Uint8Array()), query: () => Effect.succeed([]), }; const connectCalls: Array<{ diff --git a/apps/cli/src/legacy/output/legacy-bold.ts b/apps/cli/src/legacy/output/legacy-bold.ts new file mode 100644 index 0000000000..3c25feaafe --- /dev/null +++ b/apps/cli/src/legacy/output/legacy-bold.ts @@ -0,0 +1,17 @@ +/** + * Reproduces Go's `utils.Bold` (`apps/cli-go/internal/utils/colors.go:26`), which + * renders a string with lipgloss `Bold(true)`. + * + * lipgloss emits ANSI only when its output stream is detected as a TTY (termenv's + * color profile is `Ascii` — no escapes — when stdout is not a terminal, e.g. when + * piped or captured in e2e). lipgloss's default renderer keys this off **stdout**, + * regardless of which stream the bolded text is ultimately written to, so callers + * pass `Tty.stdoutIsTty` here even when the text goes to stderr (as `inspect report` + * does for "Reports saved to "). + * + * - TTY → wrap in SGR bold (`\x1b[1m … \x1b[0m`). + * - non-TTY → return the string unchanged (matching termenv's `Ascii` profile). + */ +export function legacyBold(text: string, isTty: boolean): string { + return isTty ? `\x1b[1m${text}\x1b[0m` : text; +} diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index ea3687bdbf..1a41075e32 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -57,7 +57,10 @@ const ENV_PATTERN = /^env\((.*)\)$/; * against the shell environment first and then the project `.env` files, matching * Go's `loadNestedEnv` (which populates the process env before `LoadEnvHook`). */ -function expandEnv(value: string, lookup: EnvLookup): string { +export function legacyExpandEnv( + value: string, + lookup: (name: string) => string | undefined, +): string { const matches = ENV_PATTERN.exec(value); if (matches !== null) { const env = lookup(matches[1] ?? ""); @@ -89,7 +92,7 @@ function resolvePort(value: unknown, fallback: number, lookup: EnvLookup): numbe return Number.isInteger(value) && value >= 0 && value <= MAX_PORT ? value : undefined; } if (typeof value === "string") { - const expanded = expandEnv(value, lookup); + const expanded = legacyExpandEnv(value, lookup); if (/^\d+$/.test(expanded)) { const parsed = Number(expanded); if (parsed <= MAX_PORT) return parsed; @@ -264,7 +267,7 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( const values: LegacyDbTomlValues = { port, shadowPort, - password: passwordRaw !== undefined ? expandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, + password: passwordRaw !== undefined ? legacyExpandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, poolerConnectionString, projectId, }; diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 6c84741458..c5a4e6e33c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -18,3 +18,16 @@ export class LegacyDbConnectError extends Data.TaggedError("LegacyDbConnectError export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string; }> {} + +/** + * A server-side `COPY (...) TO STDOUT` stream failed. Mirrors Go's + * `copyToCSV` (`apps/cli-go/internal/inspect/report.go:64-77`), where + * `conn.CopyTo` returns `failed to copy output: %w`. Raised by the driver's + * `copyToCsv`; the report handler maps a subsequent file-write failure to its + * own `failed to create output file` error (Go raises that one first, when it + * opens the file before copying — the TS port collects the bytes first, so the + * two messages still match Go's text on the matching failure). + */ +export class LegacyDbCopyError extends Data.TaggedError("LegacyDbCopyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index 02d727786f..494276a741 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -1,5 +1,9 @@ import { Context, type Effect, type Scope } from "effect"; -import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +import type { + LegacyDbConnectError, + LegacyDbCopyError, + LegacyDbExecError, +} from "./legacy-db-connection.errors.ts"; /** * Plain Postgres connection parameters, mirroring Go's `pgconn.Config` @@ -86,6 +90,22 @@ export interface LegacyDbSession { * See `apps/cli-go/internal/db/test/test.go:57-78`. */ readonly extensionExists: (name: string) => Effect.Effect; + /** + * Run a server-side `COPY (...) TO STDOUT` and return its raw bytes. Mirrors + * Go's `copyToCSV` (`apps/cli-go/internal/inspect/report.go:64-77`), which + * streams `pgconn.CopyTo` into a file. `sql` is the already-wrapped COPY + * statement (e.g. `COPY () TO STDOUT WITH CSV HEADER`); the driver does + * not wrap it. Used by `inspect report` to produce byte-identical CSVs by + * construction (the server serializes the values, never the TS side). + * + * The driver opens ONE dedicated raw connection (node-postgres' COPY protocol + * needs the raw client, which `@effect/sql-pg` does not expose) against the same + * resolved dial target the primary connection won — so TLS / fallback / DoH + * parity is preserved — and reuses it for every copy, matching Go's single + * `pgconn` for all report queries. The connection is opened lazily on the first + * copy and closed when the owning session's scope closes. + */ + readonly copyToCsv: (sql: string) => Effect.Effect; } /** Per-connection options the driver layer cannot infer from `cfg` alone. */ diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index c31713933f..bf85a3aa5a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -4,7 +4,17 @@ import type { ConnectionOptions } from "node:tls"; import { PgClient } from "@effect/sql-pg"; import { Duration, Effect, Layer, Redacted, type Scope } from "effect"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; -import { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +// `pg` is also `@effect/sql-pg`'s transitive driver; we depend on it directly only +// for the COPY protocol (which `@effect/sql-pg` does not expose). Keep the direct +// `pg` version constraint in package.json aligned with the one `@effect/sql-pg` +// resolves, so the COPY path and the pooled path use the same driver. +import * as Pg from "pg"; +import { to as pgCopyTo } from "pg-copy-streams"; +import { + LegacyDbConnectError, + LegacyDbCopyError, + LegacyDbExecError, +} from "./legacy-db-connection.errors.ts"; import { type LegacyDbConnectOptions, LegacyDbConnection, @@ -258,6 +268,11 @@ const connect = ( } } const hasOptions = cfg.options !== undefined && cfg.options.length > 0; + // Connect timeout parity: Go's `ToPostgresURL` always sets `connect_timeout`, + // defaulting to 10s (`connect.go:24-28`); `ConnectLocalPostgres` uses 2s for + // local (`connect.go:143-145`). A DSN/`PGCONNECT_TIMEOUT` value (>0) overrides + // both. Without this a black-holed host would hang to the OS/driver default. + const connectTimeoutSeconds = cfg.connectTimeoutSeconds ?? (isLocal ? 2 : 10); const makeClient = ( dialHost: string, port: number, @@ -278,14 +293,26 @@ const connect = ( }), // TLS parity with Go (`internal/utils/connect.go`): see `legacySslOptionFor`. ...(sslOption === undefined ? {} : { ssl: sslOption }), - // Connect timeout parity: Go's `ToPostgresURL` always sets `connect_timeout`, - // defaulting to 10s (`connect.go:24-28`); `ConnectLocalPostgres` uses 2s for - // local (`connect.go:143-145`). A DSN/`PGCONNECT_TIMEOUT` value (>0) overrides - // both. Without this a black-holed host would hang to the OS/driver default. - connectTimeout: Duration.seconds(cfg.connectTimeoutSeconds ?? (isLocal ? 2 : 10)), + connectTimeout: Duration.seconds(connectTimeoutSeconds), maxConnections: 1, }).pipe(Effect.provide(Reactivity.layer)); + // The raw `pg.ClientConfig` for the same dial target, mirroring `makeClient`'s + // discrete-vs-url choice. `copyToCsv` uses it to open a dedicated node-postgres + // connection for the COPY protocol (which `@effect/sql-pg` does not expose), + // against whichever target the primary connection won. + const buildRawPgConfig = ( + dialHost: string, + port: number, + sslOption: boolean | ConnectionOptions | undefined, + ): Pg.ClientConfig => ({ + ...(hasOptions + ? { connectionString: legacyBuildConnectionUrl(cfg, dialHost, port) } + : { host: dialHost, port, user: cfg.user, password: cfg.password, database: cfg.database }), + ...(sslOption === undefined ? {} : { ssl: sslOption }), + connectionTimeoutMillis: connectTimeoutSeconds * 1000, + }); + const toConnectError = (error: unknown) => new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }); @@ -320,6 +347,7 @@ const connect = ( // failed attempt used TLS (`pgconn.go:182`, gated on `fc.TLSConfig != nil`); // a TLS config is any non-plaintext `ssl` value. usedTls: ssl !== undefined && ssl !== false, + rawConfig: buildRawPgConfig(dialHost, port, ssl), })), ); @@ -332,22 +360,24 @@ const connect = ( // left lazy): Go always dials eagerly — the main path via `pgx.ConnectConfig` and // the temp-role wait via `pgconn.ConnectConfig` (`db_url.go:192`) — so `connect` // must return a live session for callers like `waitForTempRole` that don't run a - // follow-up query. + // follow-up query. The winning attempt's `rawConfig` is carried out so `copyToCsv` + // can reuse the exact dial target the primary connection succeeded against. + const probe = (attempt: (typeof attempts)[number]) => + attempt.client.pipe( + Effect.tap((candidate) => candidate`select 1`), + Effect.map((candidate) => ({ candidate, rawConfig: attempt.rawConfig })), + ); const lastIndex = attempts.length - 1; - const lastProbed = attempts[lastIndex]!.client.pipe( - Effect.tap((candidate) => candidate`select 1`), - ); - const client = yield* attempts + const { candidate: client, rawConfig: winningRawConfig } = yield* attempts .slice(0, lastIndex) .reduceRight( - (next, { client: attempt, usedTls }) => - attempt.pipe( - Effect.tap((candidate) => candidate`select 1`), + (next, attempt) => + probe(attempt).pipe( Effect.catch((error) => - legacyIsTerminalConnectError(error, usedTls) ? Effect.fail(error) : next, + legacyIsTerminalConnectError(error, attempt.usedTls) ? Effect.fail(error) : next, ), ), - lastProbed, + probe(attempts[lastIndex]!), ) .pipe(Effect.mapError(toConnectError)); @@ -365,6 +395,38 @@ const connect = ( ); } + // `inspect report` runs ~14 `COPY (...) TO STDOUT` statements. node-postgres' + // COPY protocol needs the raw client (which `@effect/sql-pg` does not surface), + // so the session opens ONE dedicated raw connection against the winning dial + // target and reuses it for every copy — matching Go, which runs all copies on a + // single `pgconn` (`report.go:35-59`). It is created lazily on first copy (so + // `test db` / `inspect db`, which never copy, never open it) and closed by a + // scope finalizer when the session's scope closes. The step-down runs once, here, + // so every COPY executes with the same privileges as the primary session. + let copyClient: Pg.Client | undefined; + yield* Effect.addFinalizer(() => + copyClient === undefined + ? Effect.void + : Effect.promise(() => copyClient!.end().catch(() => {})), + ); + const acquireCopyClient = Effect.gen(function* () { + if (copyClient !== undefined) return copyClient; + const fresh = new Pg.Client(winningRawConfig); + yield* Effect.tryPromise({ + try: () => fresh.connect(), + catch: (error) => new LegacyDbCopyError({ message: `failed to copy output: ${error}` }), + }); + if (!isLocal && needsRoleStepDown(cfg.user)) { + yield* Effect.tryPromise({ + try: () => fresh.query(SET_SESSION_ROLE), + catch: (error) => + new LegacyDbCopyError({ message: `failed to set session role: ${error}` }), + }); + } + copyClient = fresh; + return fresh; + }); + const session: LegacyDbSession = { exec: (sql) => client.unsafe(sql).pipe( @@ -380,6 +442,21 @@ const connect = ( Effect.map((rows) => rows.length > 0), Effect.mapError((error) => new LegacyDbExecError({ message: String(error) })), ), + copyToCsv: (sql) => + Effect.gen(function* () { + const activeClient = yield* acquireCopyClient; + return yield* Effect.callback((resume) => { + const stream = activeClient.query(pgCopyTo(sql)); + const chunks: Array = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("error", (error: Error) => + resume( + Effect.fail(new LegacyDbCopyError({ message: `failed to copy output: ${error}` })), + ), + ); + stream.on("end", () => resume(Effect.succeed(new Uint8Array(Buffer.concat(chunks))))); + }); + }), }; return session; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf57e921f2..5e1ef4c475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,12 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 + '@types/pg': + specifier: ^8.15.0 + version: 8.20.0 + '@types/pg-copy-streams': + specifier: ^1.2.5 + version: 1.2.5 '@types/react': specifier: ^19.2.16 version: 19.2.16 @@ -173,6 +179,12 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 0.23.0 + pg: + specifier: ^8.21.0 + version: 8.21.0 + pg-copy-streams: + specifier: ^7.0.0 + version: 7.0.0 posthog-node: specifier: ^5.36.1 version: 5.36.1 @@ -2779,6 +2791,12 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pg-copy-streams@1.2.5': + resolution: {integrity: sha512-7D6/GYW2uHIaVU6S/5omI+6RZnwlZBpLQDZAH83xX1rjxAOK0f6/deKyyUTewxqts145VIGn6XWYz1YGf50G5g==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5443,6 +5461,9 @@ packages: pg-connection-string@2.13.0: resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + pg-copy-streams@7.0.0: + resolution: {integrity: sha512-zBvnY6wtaBRE2ae2xXWOOGMaNVPkXh1vhypAkNSKgMdciJeTyIQAHZaEeRAxUjs/p1El5jgzYmwG5u871Zj3dQ==} + pg-cursor@2.20.0: resolution: {integrity: sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA==} peerDependencies: @@ -8557,6 +8578,17 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/pg-copy-streams@1.2.5': + dependencies: + '@types/node': 25.9.1 + '@types/pg': 8.20.0 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.9.1 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -11711,6 +11743,8 @@ snapshots: pg-connection-string@2.13.0: {} + pg-copy-streams@7.0.0: {} + pg-cursor@2.20.0(pg@8.21.0): dependencies: pg: 8.21.0 From dbd6d4a599b243ccbb41c6aa5beefc61c9161d51 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 12 Jun 2026 13:22:49 +0100 Subject: [PATCH 2/4] test(cli): make inspect report e2e parity faithful to the native port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promoting `inspect report` from a Go proxy to a native TS handler exposed two e2e mock gaps (the parity tests previously compared Go-vs-Go and passed trivially): - The native driver eagerly forces its lazy `pg` connection with a simple `SELECT 1` probe; the pg-mock rejected it in the empty state, so the command failed to connect. A real Postgres always answers `SELECT 1` — make the mock do so too, regardless of fixture state. - The rules summary is computed by csvq over COPY CSV content the mock cannot emit (it returns an empty, header-less COPY for every query). On those empty CSVs Go's csvq panics with an "index out of range" fatal error printed into each STATUS cell, while the native TS evaluator reports a clean "unknown column" — so stdout is not faithfully comparable. Add a documented `compareStdout` opt-out to runParity and use it for the report parity test; exit code, stderr, request log, and the 14 written CSVs are still compared, and the rules-table rendering is covered by the apps/cli unit + integration tests against real fixtures. --- apps/cli-e2e/src/server/pg-mock.ts | 28 +++++++++++++++++++++- apps/cli-e2e/src/tests/inspect.e2e.test.ts | 9 +++++++ packages/cli-test-helpers/src/parity.ts | 21 +++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index 90a4c76afa..a5a734f28a 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -17,6 +17,8 @@ * - Error injection: returns ErrorResponse for all queries (extended: on Execute) * - Empty state: returns CommandComplete "SELECT 0" for extended Execute; * returns error for simple SELECT; returns empty COPY for COPY + * - Liveness probe: a simple `SELECT 1` always succeeds (one row) regardless of + * state — the native driver uses it to force its lazy connection * - Terminate ('X') → closes connection gracefully */ @@ -244,6 +246,23 @@ function buildStartupResponse(): Buffer { ]); } +/** + * Connection liveness probe (`SELECT 1`). The native TS driver eagerly forces its + * lazily-connected `pg` client by running `SELECT 1` over the simple-query + * protocol (Go's pgx connects eagerly at the protocol level and issues no such + * query). A real Postgres always answers `SELECT 1` regardless of any fixtures, so + * the mock must too — otherwise the empty-state "no fixture" guard rejects the + * probe and the native command fails to connect before doing any real work. + */ +function buildProbeResponse(): Buffer { + return Buffer.concat([ + buildRowDescription(["?column?"], [TEXT_OID]), + buildDataRow(["1"]), + buildCommandComplete("SELECT 1"), + buildReadyForQuery(), + ]); +} + function buildSelectResponse(state: PgMockState): Buffer { if (state.type === "error") { const { code, message, severity = "ERROR" } = state.error; @@ -372,8 +391,15 @@ function processMessages(socket: Bun.Socket, getState: () => PgMockS if (msgType === 0x51) { // 'Q' — simple query (used by pgconn.CopyTo and simple-protocol callers) const query = payload.slice(0, payload.length - 1).toString("utf8"); + const isProbe = /^\s*select\s+1\s*;?\s*$/i.test(query); const isCopy = /^\s*COPY\s+/i.test(query); - socket.write(isCopy ? buildCopyResponse(getState()) : buildSelectResponse(getState())); + socket.write( + isProbe + ? buildProbeResponse() + : isCopy + ? buildCopyResponse(getState()) + : buildSelectResponse(getState()), + ); } else if (msgType === 0x50) { // 'P' Parse → extract param count from SQL, then ParseComplete const sql = parseSqlFromPayload(payload); diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index d8036c8a83..eede4b6f72 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -171,6 +171,15 @@ describe("inspect:report", () => { apiUrl: serverUrl, accessToken: ACCESS_TOKEN, workspaceSetup: (dir) => setupInspectWorkspace(dir, pgMockPort), + // The rules summary table is computed by csvq over the COPY CSV content, + // which the pg-mock cannot emit (it returns an empty, header-less COPY for + // every query). On those empty CSVs Go's csvq panics with an "index out of + // range" fatal error that it prints into each STATUS cell, while the native + // TS evaluator reports a clean "unknown column" — so stdout is not faithfully + // comparable here. Exit code, stderr (progress lines), request log, and the + // 14 written CSV files ARE still compared; the rules-table rendering is + // covered by the apps/cli unit + integration tests against real fixtures. + compareStdout: false, }, ["inspect", "report", "--local"], ); diff --git a/packages/cli-test-helpers/src/parity.ts b/packages/cli-test-helpers/src/parity.ts index 38f0e4ecec..9b0aae97c1 100644 --- a/packages/cli-test-helpers/src/parity.ts +++ b/packages/cli-test-helpers/src/parity.ts @@ -264,7 +264,12 @@ function formatSection(label: string, go: string, ts: string): string { ].join("\n"); } -function compareRunResults(cmdStr: string, go: RunResult, ts: RunResult): void { +function compareRunResults( + cmdStr: string, + go: RunResult, + ts: RunResult, + opts: { compareStdout: boolean } = { compareStdout: true }, +): void { const summary: string[] = []; const diffs: string[] = []; @@ -276,7 +281,9 @@ function compareRunResults(cmdStr: string, go: RunResult, ts: RunResult): void { summary.push(` ✓ exit code: ${go.exitCode.toString()}`); } - if (go.stdout !== ts.stdout) { + if (!opts.compareStdout) { + summary.push(" – stdout: comparison skipped (compareStdout: false)"); + } else if (go.stdout !== ts.stdout) { summary.push(" ✗ stdout differs"); diffs.push(formatSection("stdout", go.stdout, ts.stdout)); } else { @@ -331,6 +338,12 @@ export interface ParityOptions { /** Sort table data rows before comparing stdout. Use when the Go CLI produces * non-deterministic row order (e.g. from map iteration). */ sortStdoutRows?: boolean; + /** Compare stdout (default true). Set false only when stdout is not faithfully + * reproducible through the test mocks AND is covered by lower-level tests — e.g. + * `inspect report`, whose rules summary is computed from COPY CSV content the + * pg-mock cannot emit, and where Go's csvq panics on the empty mock CSVs. The + * other dimensions (exit code, stderr, request log, filesystem) are still compared. */ + compareStdout?: boolean; /** Additional environment variables injected into both CLI subprocesses. */ extraEnv?: Record; /** Fine-grained normalization controls for stdout/stderr parity comparison. */ @@ -395,7 +408,9 @@ export async function runParity(opts: ParityOptions, cmd: string[]): Promise Date: Fri, 12 Jun 2026 13:29:27 +0100 Subject: [PATCH 3/4] fix(cli): match Go's weakly-typed decoder for inspect report config rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go loads `[experimental.inspect].rules` via `viper.UnmarshalExact` without disabling `WeaklyTypedInput` (config.go:579-584), so mapstructure coerces scalar rule fields to strings (123 → "123", true → "1") and aborts only when an array entry is not a table or a field is a non-coercible type. The TS reader was coercing non-strings to "" and silently skipping non-table entries, so a broken custom rule could replace the defaults and let the command succeed with empty rules. Coerce scalar fields the same way Go does, and fail with LegacyDbConfigLoadError on a non-table entry or a non-coercible field — matching Go's "expected a map or struct" / "expected type string" decode errors. (review: report.config rule validation) --- .../commands/inspect/report/report.config.ts | 55 ++++++++++++++++--- .../inspect/report/report.config.unit.test.ts | 54 ++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.ts index b1f76c9f0f..bcc6c47db6 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.ts @@ -15,8 +15,23 @@ function asRecord(value: unknown): RawDoc | undefined { : undefined; } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +/** + * Coerce a rule field value to a string, mirroring Go's mapstructure decoder under + * viper's default `WeaklyTypedInput: true` (Go's `config.Load` calls + * `v.UnmarshalExact` without disabling it — `apps/cli-go/pkg/config/config.go:579-584`): + * a string passes through; a number/bigint becomes its decimal string; a boolean + * becomes `"1"`/`"0"`; a missing field is the zero value `""`. Any other type (a + * nested table/array/datetime as a scalar field) is NOT coercible — mapstructure's + * `decodeString` falls through to "expected type 'string'" and Go aborts — so this + * returns `undefined` to signal the caller to fail with `LegacyDbConfigLoadError`. + */ +function coerceRuleField(value: unknown): string | undefined { + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + if (typeof value === "bigint") return value.toString(); + if (typeof value === "boolean") return value ? "1" : "0"; + return undefined; } /** @@ -77,14 +92,36 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( const lookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; const rules: Array = []; - for (const entry of rawRules) { - const record = asRecord(entry); - if (record === undefined) continue; + for (let index = 0; index < rawRules.length; index++) { + const record = asRecord(rawRules[index]); + // A non-table array entry (e.g. `rules = ["foo"]`) is rejected by Go: mapstructure + // routes the element into `decodeStruct`, whose default branch returns "expected a + // map or struct", aborting `config.Load`. Match that rather than silently skipping. + if (record === undefined) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}] expected a map or struct`, + }), + ); + } + const fields: Record = {}; + for (const field of ["query", "name", "pass", "fail"] as const) { + const coerced = coerceRuleField(record[field]); + // A non-coercible field type (nested table/array/datetime) aborts in Go too. + if (coerced === undefined) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, + }), + ); + } + fields[field] = legacyExpandEnv(coerced, lookup); + } rules.push({ - query: legacyExpandEnv(asString(record["query"]), lookup), - name: legacyExpandEnv(asString(record["name"]), lookup), - pass: legacyExpandEnv(asString(record["pass"]), lookup), - fail: legacyExpandEnv(asString(record["fail"]), lookup), + query: fields["query"]!, + name: fields["name"]!, + pass: fields["pass"]!, + fail: fields["fail"]!, }); } return rules as ReadonlyArray; diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts index 7bf7347780..ba56322400 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts @@ -87,4 +87,58 @@ describe("legacyReadInspectRules", () => { } }), ); + + it.effect("weakly coerces scalar rule fields to strings, matching Go's decoder", () => + Effect.gen(function* () { + // Go's viper UnmarshalExact keeps WeaklyTypedInput:true, so an int/bool field + // coerces to its string form (123 → "123", true → "1") rather than erroring. + const rules = yield* readRules( + makeWorkdir( + [ + "[[experimental.inspect.rules]]", + "query = 123", + 'name = "r"', + "pass = true", + 'fail = "bad"', + "", + ].join("\n"), + ), + ); + expect(rules[0]?.query).toBe("123"); + expect(rules[0]?.pass).toBe("1"); + }), + ); + + it.effect("fails when an inspect.rules entry is not a table (Go aborts)", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + readRules(makeWorkdir('[experimental.inspect]\nrules = ["not-a-table"]\n')), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("expected a map or struct"); + } + }), + ); + + it.effect("fails when a rule field is a non-coercible type (nested table)", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + readRules( + makeWorkdir( + [ + "[[experimental.inspect.rules]]", + "[experimental.inspect.rules.query]", + 'a = "b"', + "", + ].join("\n"), + ), + ), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("expected a string"); + } + }), + ); }); From 23a598ec3c09532bed1b56b33ac97e8ed9ec0800 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 12 Jun 2026 13:52:52 +0100 Subject: [PATCH 4/4] fix(cli): match Go's config validation for inspect report rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parity gaps in how custom `[experimental.inspect.rules]` are loaded, all grounded in Go's `UnmarshalExact` (config.go:579) under viper's default `WeaklyTypedInput: true` and its `PersistentPreRunE` config load: - Validate the rule config BEFORE connecting or writing CSVs. Go loads/validates the whole config in PersistentPreRunE (via ParseDatabaseConfig → LoadConfig), so a malformed config aborts with zero side effects. The reader now runs at the top of the handler, before mkdir/connect/COPY (rules are still applied in the summary step). (review: validate-before-COPY) - Reject a non-array `rules` value the way Go's decodeSlice does under weak typing: a scalar (`rules = "foo"`) aborts ("expected a map or struct"); a single inline table is wrapped into one rule; an empty table yields no custom rules. Previously any non-array silently fell through to the defaults. (review: non-array rules) - Reject unknown/misspelled keys in a rule table. UnmarshalExact sets mapstructure ErrorUnused per-struct, so `fails = "bad"` aborts the load; the reader now fails with LegacyDbConfigLoadError instead of silently ignoring extra keys. (review: unknown keys) Adds unit coverage (unknown key, single inline table, scalar rules) and an integration test asserting a malformed config aborts before any connection or CSV. --- .../commands/inspect/report/report.config.ts | 52 +++++++++++++++--- .../inspect/report/report.config.unit.test.ts | 54 +++++++++++++++++++ .../commands/inspect/report/report.handler.ts | 11 +++- .../inspect/report/report.integration.test.ts | 32 +++++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.ts index bcc6c47db6..c07fd3d7ef 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.ts @@ -85,18 +85,42 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( const inspect = asRecord(asRecord(doc?.["experimental"])?.["inspect"]); const rawRules = inspect?.["rules"]; - if (!Array.isArray(rawRules)) return [] as ReadonlyArray; + + // Normalize `rules` into the list of entries to decode, mirroring Go's + // `decodeSlice` under viper's `WeaklyTypedInput: true` (which is NOT disabled in + // `config.Load`, `apps/cli-go/pkg/config/config.go:579-584`): + // - absent → no custom rules (defaults apply) + // - array-of-tables → decode each element as a rule + // - a single table → weak-typing wraps it into a 1-element slice → one rule + // - an EMPTY table → wraps into an empty slice → no custom rules (defaults) + // - a scalar (string/number/…) → wrapped into `[scalar]`, then decoding a scalar + // into a rule struct aborts ("expected a map or struct") — surfaced below. + let entries: ReadonlyArray; + if (rawRules === undefined) { + return [] as ReadonlyArray; + } else if (Array.isArray(rawRules)) { + entries = rawRules; + } else { + const asMap = asRecord(rawRules); + if (asMap !== undefined && Object.keys(asMap).length === 0) { + return [] as ReadonlyArray; + } + entries = [rawRules]; + } + if (entries.length === 0) return [] as ReadonlyArray; + + const RULE_FIELDS = ["query", "name", "pass", "fail"] as const; // Resolve `env(VAR)` against the shell env first, then the project `.env` files. const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); const lookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; const rules: Array = []; - for (let index = 0; index < rawRules.length; index++) { - const record = asRecord(rawRules[index]); - // A non-table array entry (e.g. `rules = ["foo"]`) is rejected by Go: mapstructure - // routes the element into `decodeStruct`, whose default branch returns "expected a - // map or struct", aborting `config.Load`. Match that rather than silently skipping. + for (let index = 0; index < entries.length; index++) { + const record = asRecord(entries[index]); + // A non-table entry (e.g. `rules = ["foo"]` or `rules = "foo"`) is rejected by Go: + // mapstructure routes it into `decodeStruct`, whose default branch returns + // "expected a map or struct", aborting `config.Load`. Match that, not silent skip. if (record === undefined) { return yield* Effect.fail( new LegacyDbConfigLoadError({ @@ -104,8 +128,22 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( }), ); } + // Go decodes with `UnmarshalExact` (`config.go:579`), which sets mapstructure's + // `ErrorUnused` per-struct: an unknown/misspelled key in a rule table (e.g. + // `fails = "bad"`) aborts the whole config load. The `rule` struct has no + // `,remain` field, so there is no escape hatch. + const unknownKeys = Object.keys(record).filter( + (key) => !(RULE_FIELDS as ReadonlyArray).includes(key), + ); + if (unknownKeys.length > 0) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}] has invalid keys: ${unknownKeys.join(", ")}`, + }), + ); + } const fields: Record = {}; - for (const field of ["query", "name", "pass", "fail"] as const) { + for (const field of RULE_FIELDS) { const coerced = coerceRuleField(record[field]); // A non-coercible field type (nested table/array/datetime) aborts in Go too. if (coerced === undefined) { diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts index ba56322400..6eb83da0c9 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts @@ -121,6 +121,60 @@ describe("legacyReadInspectRules", () => { }), ); + it.effect("rejects unknown keys in a rule table (Go's UnmarshalExact ErrorUnused)", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + readRules( + makeWorkdir( + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT 1"', + 'name = "r"', + 'pass = "ok"', + 'fail = "bad"', + 'fails = "typo"', + "", + ].join("\n"), + ), + ), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("invalid keys: fails"); + } + }), + ); + + it.effect("accepts a single inline rules table as one rule (Go weak-typing wrap)", () => + Effect.gen(function* () { + const rules = yield* readRules( + makeWorkdir( + [ + "[experimental.inspect.rules]", + 'query = "SELECT 1"', + 'name = "solo"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), + ), + ); + expect(rules).toEqual([{ query: "SELECT 1", name: "solo", pass: "ok", fail: "bad" }]); + }), + ); + + it.effect("fails when rules is a scalar string (Go aborts)", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + readRules(makeWorkdir('[experimental.inspect]\nrules = "oops"\n')), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("expected a map or struct"); + } + }), + ); + it.effect("fails when a rule field is a non-coercible type (nested table)", () => Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts index 7dd4c1340a..ddc2777d12 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts @@ -85,6 +85,13 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( ); } + // Read + validate the custom `[experimental.inspect.rules]` BEFORE any DB work. + // Go loads and validates the whole config in `PersistentPreRunE` (via + // `flags.ParseDatabaseConfig` → `LoadConfig`, `cmd/root.go:118`), so a malformed + // `inspect.rules` config aborts before connecting or writing any CSV files. They + // are applied later (in `printSummary`), but validated here for parity. + const configRules = yield* legacyReadInspectRules(fs, path, cliConfig.workdir); + // Go's `--linked` defaults to true, so absence of the others resolves to linked. const linked = flags.linked || (Option.isNone(flags.dbUrl) && !flags.local); const cfg = yield* resolver.resolve({ @@ -146,8 +153,8 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( yield* output.raw(`Reports saved to ${legacyBold(outDir, tty.stdoutIsTty)}\n`, "stderr"); } - // Custom `[experimental.inspect.rules]` replace the 7 defaults when present. - const configRules = yield* legacyReadInspectRules(fs, path, cliConfig.workdir); + // Custom `[experimental.inspect.rules]` (read + validated up front) replace the 7 + // defaults when present. const rules = configRules.length > 0 ? configRules : LEGACY_DEFAULT_INSPECT_RULES; if (configRules.length === 0 && isText) { yield* output.raw("Loading default rules...\n", "stderr"); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts index 091c9e0d58..5ce456bb8a 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -340,6 +340,38 @@ describe("legacy inspect report", () => { }).pipe(Effect.provide(layer)); }); + it.live("aborts on a malformed config.toml before connecting or writing any CSV", () => { + const base = tempDir("supabase-report-out-"); + const workdir = tempDir("supabase-report-workdir-"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + // An invalid rule config (unknown key) — Go loads config in PersistentPreRun, so + // it must abort before the DB connection and before any CSV files are written. + writeFileSync( + join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT 1"', + 'name = "r"', + 'pass = "ok"', + 'fail = "bad"', + 'typo = "x"', + "", + ].join("\n"), + ); + const { layer, connection } = setupLegacyReport({ workdir }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("invalid keys: typo"); + } + // No connection and no dated output folder — config validation ran first, + // before mkdir / connect / COPY (base itself is the pre-created temp dir). + expect(connection.copiedSql.length).toBe(0); + expect(readdirSync(base).length).toBe(0); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a structured result and writes CSVs but no table in json mode", () => { const base = tempDir("supabase-report-out-"); const { layer, out } = setupLegacyReport({ format: "json", csvs: DEFAULT_RULE_CSVS });