diff --git a/apps/cli/docs/inspect-collation-drift-test-evidence.md b/apps/cli/docs/inspect-collation-drift-test-evidence.md new file mode 100644 index 0000000000..a019842410 --- /dev/null +++ b/apps/cli/docs/inspect-collation-drift-test-evidence.md @@ -0,0 +1,157 @@ +# `inspect db collation-drift` — test evidence + +All runs below were performed against a local Supabase stack (PostgreSQL 17, +ICU collator version 153.121) and a remote Supabase project, using the +locally built CLI (`pnpm dev:legacy`). The command is read-only in every mode. + +## 1. Drift detected (fixture) + +Fixture: a named ICU collation created with a deliberately stale recorded +version, one plain and one unique index on a column using it. + +```sql +CREATE COLLATION test_stale_icu (provider = icu, locale = 'en-US', version = '73.2'); +CREATE TABLE collation_drift_demo (id serial PRIMARY KEY, title text COLLATE test_stale_icu); +CREATE INDEX demo_title_idx ON collation_drift_demo (title); +CREATE UNIQUE INDEX demo_title_uniq ON collation_drift_demo (title); +``` + +``` +$ supabase inspect db collation-drift --local +Connecting to local database... + + Database : postgres + Affected indexes : 2 + Keys / unique : 1 (a wrong sort order here can admit duplicate rows) + Drifted ICU collations : public.test_stale_icu + + ✗ These indexes were built under different sorting rules than the system + now provides. Postgres reports no error for this: queries may quietly + return missing rows, sort incorrectly, or let duplicates past a unique + constraint. Rows below are candidates and running amcheck would confirm + which ones are actually mis-ordered. + + | Name | Table | Columns | Collation | Stored version | Current version | Key | Size + ---|------------------------|-----------------------------|---------|-----------------------|----------------|-----------------|--------|------------ + ✗ | public.demo_title_uniq | public.collation_drift_demo | title | public.test_stale_icu | 73.2 | 153.121 | UNIQUE | 8192 bytes + ⚠ | public.demo_title_idx | public.collation_drift_demo | title | public.test_stale_icu | 73.2 | 153.121 | | 8192 bytes + + 1. Confirm which indexes are actually mis-ordered + amcheck raises an error for a mis-ordered index and returns silently for + a healthy one. Check keys and unique indexes first. + + CREATE EXTENSION IF NOT EXISTS amcheck; + SELECT bt_index_check('public.demo_title_uniq'::regclass, heapallindexed => true); -- unique + SELECT bt_index_check('public.demo_title_idx'::regclass, heapallindexed => true); + + 2. Rebuild the affected indexes + Rebuild anything that failed step 1 — or all of them, which is safe if + you would rather not check individually. CONCURRENTLY keeps the + application online during the rebuild. + + REINDEX INDEX CONCURRENTLY public.demo_title_uniq; + REINDEX INDEX CONCURRENTLY public.demo_title_idx; + + 3. Record the new collation version — only after every rebuild has finished + Refreshing first hides the problem without fixing it: it updates a label + and silences the warning while leaving the indexes mis-ordered. + + ALTER COLLATION public.test_stale_icu REFRESH VERSION; +``` + +Constraint-backing indexes sort first and carry the ✗ marker; plain indexes +carry ⚠. Every generated statement uses the object names from the user's own +database, schema-qualified. + +## 2. Full lifecycle: verify → rebuild → refresh, with output + +Each stage of the prescribed workflow was executed against the fixture, +re-running the command between stages. + +**Step 1 — amcheck** returned silently for both indexes — correct, because +the fixture lies about the _recorded_ version while the indexes were built +under the current library. This demonstrates the "rows are candidates" +caveat: detection is a catalog comparison; corruption confirmation is +amcheck's job. + +**Step 2 — reindex.** Postgres itself confirms the mismatch is still live at +rebuild time (warning emitted once per collation per session): + +``` +postgres=> reindex index public.demo_title_uniq; +WARNING: collation "test_stale_icu" has version mismatch +DETAIL: The collation in the database was created using version 73.2, but the operating system provides version 153.121. +HINT: Rebuild all objects affected by this collation and run ALTER COLLATION public.test_stale_icu REFRESH VERSION, or build PostgreSQL with the right library version. +REINDEX +postgres=> reindex index public.demo_title_idx; +REINDEX +``` + +Note the HINT: Postgres prescribes exactly the statement this command +generates, schema qualification included. Re-running the command after +reindex **still reported drift** — correct: Postgres only clears the version +mismatch on REFRESH, never as a side effect of REINDEX. This is precisely why +the workflow's ordering warning exists. (Plain `REINDEX` was used here +against the idle fixture; the command emits `REINDEX INDEX CONCURRENTLY` for +production use, where the table serves live traffic.) + +**Step 3 — refresh, and the report goes green:** + +``` +postgres=> ALTER COLLATION public.test_stale_icu REFRESH VERSION; +NOTICE: changing version from 73.2 to 153.121 +ALTER COLLATION +``` + +``` +$ supabase inspect db collation-drift --local +Connecting to local database... + + ✓ No collation version drift detected. Indexes match the current system + sorting rules. Re-run this check after a PostgreSQL upgrade or instance + migration. +``` + +The healthy output above was captured immediately after the reindex and the +collation version refresh — detect → verify → rebuild → refresh → clean, +end to end, using the SQL the command emitted. + +## 3. Healthy database (no drift) + +A remote Supabase project with no drift reports the healthy state directly: + +``` +$ supabase inspect db collation-drift --db-url "$STAGING_DB_URL" +Connecting to remote database... + + ✓ No collation version drift detected. Indexes match the current system + sorting rules. Re-run this check after a PostgreSQL upgrade or instance + migration. +``` + +## 4. JSON mode + +``` +$ supabase inspect db collation-drift --local --output json +``` + +Emits `{ rows, report }`: `rows` is the raw driver payload (the same shape +the existing inspect commands emit), `report` is the structured document the +text renderer consumes. Verified in both the healthy and drift states. + +## 5. Suite results + +`tsc --noEmit` clean; unit suite green (323 files / 5903 tests, including +this command's report-builder and statement-generator tests); `fmt:check`, +`lint:check`, and the remaining `check:all` tasks pass. + +## Known limitations + +Expression indexes (`CREATE INDEX ON t (lower(name))`) are not detected: the +collation lives in the index expression tree rather than `pg_attribute`, so +`indkey` holds `0` and the row is skipped. Database-level (libc) drift +detection requires PostgreSQL 15+ (`pg_database.datcollversion`); named ICU +drift requires 13+. Rows are candidates until amcheck confirms them. Unlike +the sibling `inspect db` commands, internal Supabase schemas are deliberately +included — a mis-ordered index on `auth.users` is exactly as damaging as one +in `public`. diff --git a/apps/cli/docs/inspect-report-output.md b/apps/cli/docs/inspect-report-output.md new file mode 100644 index 0000000000..fd9a7561f6 --- /dev/null +++ b/apps/cli/docs/inspect-report-output.md @@ -0,0 +1,350 @@ +# `inspect db` output architecture — current design and improvement options + +Status: draft for discussion +Scope: `apps/cli/src/commands/inspect/db/*` (25 subcommands), with +`collation-drift` as the first consumer of any new output model. + +Everything in Part 1 is taken from the current source. Two internals are +inferred rather than read and are marked as such: the body of +`renderGlamourTable` and the internals of the `Output` service. + +--- + +## Part 1 — How the current stack works + +### 1.1 The data flow + +Every `inspect db` subcommand is the same pipeline with a different SQL string +plugged in: + +``` +argv + → Command.make(name, LEGACY_INSPECT_DB_FLAGS) command file + → legacyInspectDbCommandHandler(handler) telemetry + JSON error envelope + → legacyMakeInspectDbHandler(spec, traceName) trace span + telemetry flush + → legacyRunInspectQuery(spec, flags, dnsResolver) the shared engine + → resolveLegacyDbTargetFlags(argv) --db-url / --linked / --local exclusivity + → LegacyDbConfigResolver.resolve(...) connection config + → LegacyDbConnection.connect(...).query(sql, params) + → text mode: spec.project(row) per row → renderGlamourTable(headers, cells) → stdout + json mode: output.success(name, { rows }) raw snake_case driver rows +``` + +Connection diagnostics ("Connecting to local database...") go to stderr so +stdout stays machine-clean in every mode. + +### 1.2 The command anatomy + +Each subcommand is three small files in its own directory. Using `index-stats` +as the reference: + +`index-stats.query.ts` owns everything specific to the command — the SQL, its +parameters, the table headers, and a `project` function mapping one driver row +to ordered cell strings. `index-stats.handler.ts` is two lines: it binds the +spec to `legacyMakeInspectDbHandler` with a trace name. `index-stats.command.ts` +declares the CLI surface: name, descriptions, the shared flag set, the shared +handler wrapper, and the runtime layer keyed by the leaf name for telemetry. + +The contract between a command and the engine is a single interface: + +```ts +export interface LegacyInspectQuerySpec { + readonly name: string; + readonly sql: string; + readonly params: (cfg: LegacyResolvedDbConfig) => ReadonlyArray; + readonly headers: ReadonlyArray; + readonly project: ( + row: Record, + cfg: LegacyResolvedDbConfig, + ) => ReadonlyArray; +} +``` + +This is the load-bearing abstraction. Its strength is that 25 commands share +one engine, one flag set, one connection path, one telemetry shape, and one +error envelope; a new diagnostic is ~150 lines, almost all of it SQL. Its +constraint is equally clear: **the output vocabulary is exactly one table of +strings.** + +### 1.3 The cell formatters + +`legacy-inspect-query.ts` exports pure formatters (`legacyInspectText`, +`legacyInspectInt`, `legacyInspectBool`, `legacyInspectFloat1`, +`legacyInspectStmt`, `legacyInspectBacktickStmt`, `legacyInspectPlainText`) +that encode two kinds of knowledge: driver type quirks (`int8` arrives as a +string; unexpected types degrade to `String(value)` rather than throwing) and +glamour rendering quirks (an empty inline code span is not a valid markdown +token, so empty cells render as two literal backticks to preserve column +width). + +That second category matters for any redesign: **formatting rules currently +live in two places** — the SQL (`pg_size_pretty`, `::text || '%'`) and the +projection — and some of them exist only to satisfy the renderer. + +### 1.4 Output modes + +The `Output` service exposes `format` (`"text" | "json" | "stream-json"`) plus +`raw`, `success`, `warn`, `info` channels. The engine branches once: text mode +renders the glamour table; JSON modes emit raw driver rows under +`{ rows }`. Notable asymmetry: `project` is applied only in text mode, so the +JSON payload has different values (raw, unformatted, snake_case) than the +table. Consumers scripting against `--output json` get sizes in bytes where +the table shows `pg_size_pretty` strings. + +### 1.5 What the current model cannot express + +Observed limitations, all consequences of "one table of strings": + +The empty state is a blank table. For `locks` that reads fine (no locks, no +rows). For a diagnostic like `collation-drift` it is ambiguous — "healthy", +"drift but no affected indexes", and "this PG version cannot detect drift" all +render identically. + +There is no severity. A row for an unused 16 kB index and a row for a +corrupted primary key look the same. Ordering is the only signal available, +and it is invisible unless the user knows to look for it. + +There is no guidance channel. Remediation for `collation-drift` currently +lives in `--help` text, which nobody reads after the first run. A command +whose whole purpose is "now go do these three things in this order" cannot +say so in its output. + +There is no non-tabular content: no key/value header (database, provider, +versions), no multi-section output, no generated-SQL block a user can copy. + +Column formatting is stringly typed. `project` returns strings, so alignment, +truncation, and units are each command's private problem, solved slightly +differently across 25 files. + +--- + +## Part 2 — Improvement options + +Three options, in increasing order of ambition. All keep the outer pipeline +(flags, connection, telemetry, error envelope) untouched — the redesign is +strictly about what sits between "rows came back" and "bytes hit stdout". + +### Option 1 — Extend the spec in place + +Add optional fields to `LegacyInspectQuerySpec`: an `emptyMessage`, a +`preamble(cfg, rows)` for a key/value header, a `severity(row)` used for row +styling, a `footer(rows)` for guidance. The engine renders them around the +existing table. + +This is cheap and every existing command keeps working unmodified. It is also +where the design pressure shows: each new need becomes another optional field, +the spec stops being a data description and becomes a grab-bag of render +hooks, and JSON mode has to decide field-by-field what to do with each hook. +Reasonable as a stopgap; poor as the stated direction. + +### Option 2 — A report document model (recommended) + +Invert the relationship: a command no longer describes a table, it produces a +**structured report document**, and per-format renderers turn that document +into text or JSON. The table becomes one block type among several. + +```ts +// report.types.ts — the vocabulary of everything an inspect command can say. + +export type ReportSeverity = "info" | "ok" | "warn" | "critical"; + +export interface ReportKeyValueBlock { + readonly kind: "keyValue"; + readonly entries: ReadonlyArray<{ readonly key: string; readonly value: string }>; +} + +export interface ReportTableBlock { + readonly kind: "table"; + readonly columns: ReadonlyArray<{ + readonly title: string; + readonly align?: "left" | "right"; + }>; + readonly rows: ReadonlyArray<{ + readonly cells: ReadonlyArray; + readonly severity?: ReportSeverity; + }>; +} + +export interface ReportCalloutBlock { + readonly kind: "callout"; + readonly severity: ReportSeverity; + readonly text: string; +} + +export interface ReportSqlBlock { + readonly kind: "sql"; + readonly title: string; + readonly statements: ReadonlyArray; +} + +export interface ReportStepsBlock { + readonly kind: "steps"; + readonly steps: ReadonlyArray<{ + readonly title: string; + readonly body?: string; + readonly sql?: ReadonlyArray; + }>; +} + +export type ReportBlock = + ReportKeyValueBlock | ReportTableBlock | ReportCalloutBlock | ReportSqlBlock | ReportStepsBlock; + +export interface Report { + readonly command: string; + /** Overall status; drives the empty-state line and the process exit hint. */ + readonly severity: ReportSeverity; + readonly blocks: ReadonlyArray; +} +``` + +The spec interface gains one function and loses none: + +```ts +export interface LegacyInspectReportSpec { + readonly name: string; + readonly sql: string; + readonly params: (cfg: LegacyResolvedDbConfig) => ReadonlyArray; + /** Rows in, document out. Pure — trivially unit-testable without a DB. */ + readonly report: ( + rows: ReadonlyArray>, + cfg: LegacyResolvedDbConfig, + ) => Report; +} +``` + +Rendering is centralized once per format: + +```ts +// renderReportText(report): string — glamour table for table blocks, +// styled callouts, indented SQL blocks. +// json mode: emit the Report object itself. The document IS the payload, so +// text and JSON finally describe the same thing, severities included. +``` + +Migration is mechanical because the old shape embeds in the new one. A single +adapter converts every existing spec without touching its file: + +```ts +export function reportSpecFromTableSpec(spec: LegacyInspectQuerySpec): LegacyInspectReportSpec { + return { + name: spec.name, + sql: spec.sql, + params: spec.params, + report: (rows, cfg) => ({ + command: spec.name, + severity: "info", + blocks: [ + { + kind: "table", + columns: spec.headers.map((title) => ({ title })), + rows: rows.map((row) => ({ cells: spec.project(row, cfg) })), + }, + ], + }), + }; +} +``` + +One decision the adapter forces into the open: today JSON mode emits raw +driver rows, and under the report model it would emit the document. That is a +**breaking change for anyone scripting against `--output json`**. The options +are to version the envelope (`{ rows }` → `{ rows, report }` during a +deprecation window) or to keep raw rows as an additional block field. This is +the main question to put to the maintainers. + +What `collation-drift` looks like as a report — the Option B output, expressed +in the new vocabulary: + +```ts +report: (rows, cfg) => { + if (rows.length === 0) { + return { + command: "collation-drift", + severity: "ok", + blocks: [ + { + kind: "callout", + severity: "ok", + text: "No collation version drift detected. Indexes match the current system sorting rules.", + }, + ], + }; + } + return { + command: "collation-drift", + severity: rows.some(isKeyIndex) ? "critical" : "warn", + blocks: [ + { kind: "keyValue", entries: environmentEntries(rows, cfg) }, + { + kind: "callout", + severity: "warn", + text: "These indexes were built under different sorting rules. Postgres reports no error for this; queries may return missing rows or admit duplicates.", + }, + { kind: "table", columns: DRIFT_COLUMNS, rows: rows.map(toDriftRow) }, + { + kind: "steps", + steps: [ + { title: "Confirm actual corruption with amcheck", sql: amcheckStatements(rows) }, + { + title: "Rebuild affected indexes (CONCURRENTLY keeps the app online)", + sql: reindexStatements(rows), + }, + { + title: "Only after every rebuild: record the new version", + sql: refreshStatements(rows), + body: "Refreshing first hides the problem without fixing it.", + }, + ], + }, + ], + }; +}; +``` + +The generated SQL is now data, not help text: it appears in the terminal with +the user's real index names, and arrives structured in JSON where a tool (or +the dashboard) could offer one-click execution. + +### Option 3 — Interactive TUI + +A full-screen interface (Ink or similar): sortable columns, row drill-down, +"press r to copy the REINDEX statement". Rejected for this iteration. It +multiplies the testing surface, breaks piping and CI usage that the current +commands support well, and everything user-facing it offers is reachable later +by adding a renderer on top of the Option 2 document — which is the strongest +argument for Option 2: it makes the output model a data structure that future +frontends consume, rather than a side effect of each command. + +--- + +## Part 3 — Recommendation and rollout + +Adopt Option 2. Concretely: + +Phase 1 lands the model: `report.types.ts`, `renderReportText`, the JSON +emission, and `reportSpecFromTableSpec`. The engine gains a second entry point +(`legacyRunInspectReport`) alongside the existing one; nothing else changes. +Ship `collation-drift` on it as the proving consumer — it exercises every +block type. + +Phase 2 flips the 24 existing commands through the adapter (one-line change +per handler), keeping their output byte-identical in text mode. This is the +low-risk bulk of the migration and can be a single PR. + +Phase 3, opportunistically and per-command, upgrades specs that benefit from +the richer vocabulary: `bloat` and `unused-indexes` gain severities and a +"consider dropping / rebuilding" steps block; `long-running-queries` gains a +callout when a query exceeds a threshold; `db-stats` becomes a keyValue block +instead of a one-row table. Each is a small, reviewable diff. + +The JSON envelope question (raw rows vs report document, and the deprecation +path) should be settled with the maintainers in the issue before Phase 1 +lands, since it is the only externally visible contract change. + +Open questions to include in that issue: whether severity should influence the +process exit code (useful for CI: `collation-drift` returning non-zero on +`critical` makes it a deployment gate); whether internal Supabase schemas stay +included for `collation-drift` (they are excluded by every sibling command, +but a mis-ordered index on `auth.users` is precisely what a user needs to +see); and whether the steps block's generated SQL should be gated behind a +`--show-fix` flag for users who want the terse table only. diff --git a/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.command.ts b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.command.ts new file mode 100644 index 0000000000..40c87369a4 --- /dev/null +++ b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.command.ts @@ -0,0 +1,26 @@ +import { Command } from "effect/unstable/cli"; +import { inspectDbCollationDrift } from "./collation-drift.handler.ts"; +import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; +import { inspectDbRuntimeLayer } from "../db.layers.ts"; + +export const inspectDbCollationDriftCommand = Command.make( + "collation-drift", + INSPECT_DB_FLAGS, +).pipe( + Command.withDescription( + `Show indexes affected by collation version drift, with the fix workflow. + +Postgres stores btree indexes on text columns in sorted order, using rules from +the system collation library (glibc or ICU). When that library is upgraded the +rules can change, and indexes built under the old rules are no longer correctly +ordered. Postgres reports no error: queries may quietly return missing rows, +sort incorrectly, or let duplicates past a unique constraint. + +The output lists the affected indexes and the exact statements to verify +(amcheck), rebuild (REINDEX CONCURRENTLY), and record the new version — in the +order they must be run. This command itself is read-only.`, + ), + Command.withShortDescription("Show indexes affected by collation version drift"), + Command.withHandler(inspectDbCommandHandler(inspectDbCollationDrift)), + Command.provide(inspectDbRuntimeLayer("collation-drift")), +); diff --git a/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.handler.ts b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.handler.ts new file mode 100644 index 0000000000..24136809f2 --- /dev/null +++ b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.handler.ts @@ -0,0 +1,7 @@ +import { makeInspectDbReportHandler } from "../inspect-report.ts"; +import { collationDriftSpec } from "./collation-drift.query.ts"; + +export const inspectDbCollationDrift = makeInspectDbReportHandler( + collationDriftSpec, + "inspect.db.collation-drift", +); diff --git a/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.ts b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.ts new file mode 100644 index 0000000000..9b161ada8a --- /dev/null +++ b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.ts @@ -0,0 +1,317 @@ +import type { InspectReportSpec } from "../inspect-report.ts"; +import type { Report, ReportSeverity } from "../../../../output/report.types.ts"; + +const SQL = `-- Indexes whose sort order may no longer match the current collation library. +-- +-- Two independent sources of drift, unioned: +-- +-- libc — the database default collation. Postgres records the glibc version +-- the database was created with (pg_database.datcollversion) and can +-- report the version the OS provides now. +-- +-- ICU — an explicitly named ICU collation, e.g. +-- CREATE INDEX ON t (title COLLATE "en-US-x-icu"); +-- Each named collation carries its own recorded version +-- (pg_collation.collversion), compared against the live ICU library. +-- +-- A column's attcollation points at exactly one pg_collation row, so an index +-- appears in at most one branch. When nothing has drifted both branches are +-- empty and the report renders its healthy state. +-- +-- Only btree indexes are considered: sort order is what a btree encodes, so +-- hash/GIN/GiST/BRIN indexes are unaffected by a collation change. +WITH db_row AS MATERIALIZED ( + -- MATERIALIZED pins evaluation order: pg_database_collation_actual_version() + -- is only called for a database that actually records a version (a C/POSIX + -- database records none). + SELECT oid, datcollversion + FROM pg_database + WHERE datname = current_database() + AND datcollversion IS NOT NULL +), +db_drift AS MATERIALIZED ( + SELECT + datcollversion AS stored_version, + pg_database_collation_actual_version(oid) AS current_version + FROM db_row +), +default_collation AS ( + SELECT oid + FROM pg_collation + WHERE collname = 'default' + AND collnamespace = 'pg_catalog'::regnamespace +), +libc_affected AS ( + SELECT + FORMAT('%I.%I', n.nspname, i.relname) AS name, + FORMAT('%I.%I', n.nspname, t.relname) AS table_name, + STRING_AGG(a.attname, ', ' ORDER BY k.ord) AS columns, + 'default'::text AS collation_name, + d.stored_version, + d.current_version, + ix.indisprimary AS is_primary, + ix.indisunique AS is_unique, + pg_relation_size(i.oid) AS size_bytes + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_am am ON am.oid = i.relam + -- indkey holds 0 for expression columns, which have no pg_attribute row. + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) + ON k.attnum <> 0 + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + CROSS JOIN db_drift d + WHERE am.amname = 'btree' + AND a.attcollation = (SELECT oid FROM default_collation) + AND d.stored_version IS DISTINCT FROM d.current_version + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND t.relkind IN ('r', 'p', 'm') + GROUP BY n.nspname, t.relname, i.relname, i.oid, + d.stored_version, d.current_version, + ix.indisprimary, ix.indisunique +), +icu_affected AS ( + SELECT + FORMAT('%I.%I', n.nspname, i.relname) AS name, + FORMAT('%I.%I', n.nspname, t.relname) AS table_name, + STRING_AGG(a.attname, ', ' ORDER BY k.ord) AS columns, + -- Schema-qualified: the generated ALTER COLLATION must target the right + -- object regardless of search_path. + FORMAT('%I.%I', cn.nspname, c.collname) AS collation_name, + c.collversion AS stored_version, + pg_collation_actual_version(c.oid) AS current_version, + ix.indisprimary AS is_primary, + ix.indisunique AS is_unique, + pg_relation_size(i.oid) AS size_bytes + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_am am ON am.oid = i.relam + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) + ON k.attnum <> 0 + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + JOIN pg_collation c ON c.oid = a.attcollation + JOIN pg_namespace cn ON cn.oid = c.collnamespace + WHERE am.amname = 'btree' + AND c.collprovider = 'i' + AND c.collversion IS NOT NULL + AND c.collversion IS DISTINCT FROM pg_collation_actual_version(c.oid) + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND t.relkind IN ('r', 'p', 'm') + GROUP BY n.nspname, t.relname, i.relname, i.oid, + cn.nspname, c.collname, c.collversion, c.oid, + ix.indisprimary, ix.indisunique +), +all_affected AS ( + SELECT * FROM libc_affected + UNION ALL + SELECT * FROM icu_affected +) +SELECT + current_database() AS database, + name, + table_name AS "table", + columns, + collation_name AS collation, + stored_version, + current_version, + CASE + WHEN is_primary THEN 'PRIMARY KEY' + WHEN is_unique THEN 'UNIQUE' + ELSE '' + END AS key_type, + pg_size_pretty(size_bytes) AS size +FROM all_affected +-- Constraint-backing indexes first: a wrong sort order there can let +-- duplicate rows past a unique check, not merely return wrong results. +ORDER BY is_primary DESC, is_unique DESC, size_bytes DESC`; + +// --------------------------------------------------------------------------- +// Pure report construction — exported for unit tests. +// --------------------------------------------------------------------------- + +interface DriftRow { + readonly database: string; + readonly name: string; + readonly table: string; + readonly columns: string; + readonly collation: string; + readonly stored_version: string; + readonly current_version: string; + readonly key_type: string; + readonly size: string; +} + +function toDriftRow(row: Record): DriftRow { + const text = (v: unknown) => (v === null || v === undefined ? "" : String(v)); + return { + database: text(row["database"]), + name: text(row["name"]), + table: text(row["table"]), + columns: text(row["columns"]), + collation: text(row["collation"]), + stored_version: text(row["stored_version"]), + current_version: text(row["current_version"]), + key_type: text(row["key_type"]), + size: text(row["size"]), + }; +} + +/** Double-quotes an identifier, escaping embedded quotes. */ +export function quoteIdent(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +/** Single-quotes a value as a SQL string literal, escaping embedded quotes. */ +export function quoteLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function amcheckStatements(rows: ReadonlyArray): string[] { + return rows.map( + (r) => + `SELECT bt_index_check(${quoteLiteral(r.name)}::regclass, heapallindexed => true);` + + (r.key_type === "" ? "" : ` -- ${r.key_type.toLowerCase()}`), + ); +} + +export function reindexStatements(rows: ReadonlyArray): string[] { + return rows.map((r) => `REINDEX INDEX CONCURRENTLY ${r.name};`); +} + +export function refreshStatements(rows: ReadonlyArray): string[] { + const out: string[] = []; + if (rows.some((r) => r.collation === "default")) { + const database = rows[0]?.database ?? "postgres"; + out.push(`ALTER DATABASE ${quoteIdent(database)} REFRESH COLLATION VERSION;`); + } + // Distinct named collations, first-seen order. Already schema-qualified and + // quoted where needed by the SQL's FORMAT('%I.%I', ...). + const seen = new Set(); + for (const r of rows) { + if (r.collation === "default" || seen.has(r.collation)) continue; + seen.add(r.collation); + out.push(`ALTER COLLATION ${r.collation} REFRESH VERSION;`); + } + return out; +} + +export function buildCollationDriftReport(rawRows: ReadonlyArray>): Report { + if (rawRows.length === 0) { + return { + command: "collation-drift", + severity: "ok", + blocks: [ + { + kind: "callout", + severity: "ok", + text: "No collation version drift detected. Indexes match the current system sorting rules. Re-run this check after a PostgreSQL upgrade or instance migration.", + }, + ], + }; + } + + const rows = rawRows.map(toDriftRow); + const keyRows = rows.filter((r) => r.key_type !== ""); + const severity: ReportSeverity = keyRows.length > 0 ? "critical" : "warn"; + + const libcDrift = rows.find((r) => r.collation === "default"); + const namedCollations = [...new Set(rows.map((r) => r.collation))].filter((c) => c !== "default"); + + const environment: Array<{ key: string; value: string }> = [ + { key: "Database", value: rows[0]?.database ?? "" }, + { key: "Affected indexes", value: String(rows.length) }, + ]; + if (keyRows.length > 0) { + environment.push({ + key: "Keys / unique", + value: `${keyRows.length} (a wrong sort order here can admit duplicate rows)`, + }); + } + if (libcDrift !== undefined) { + environment.push({ + key: "Default collation", + value: `${libcDrift.stored_version} (recorded) → ${libcDrift.current_version} (current)`, + }); + } + if (namedCollations.length > 0) { + environment.push({ key: "Drifted ICU collations", value: namedCollations.join(", ") }); + } + + return { + command: "collation-drift", + severity, + blocks: [ + { kind: "keyValue", entries: environment }, + { + kind: "callout", + severity, + text: "These indexes were built under different sorting rules than the system now provides. Postgres reports no error for this: queries may quietly return missing rows, sort incorrectly, or let duplicates past a unique constraint. Rows below are candidates and running amcheck would confirm which ones are actually mis-ordered.", + }, + { + kind: "table", + columns: [ + { title: "Name" }, + { title: "Table" }, + { title: "Columns" }, + { title: "Collation" }, + { title: "Stored version" }, + { title: "Current version" }, + { title: "Key" }, + { title: "Size" }, + ], + rows: rows.map((r) => ({ + cells: [ + r.name, + r.table, + r.columns, + r.collation, + r.stored_version, + r.current_version, + r.key_type, + r.size, + ], + severity: r.key_type === "" ? ("warn" as const) : ("critical" as const), + })), + }, + { + kind: "steps", + steps: [ + { + title: "Confirm which indexes are actually mis-ordered", + body: "amcheck raises an error for a mis-ordered index and returns silently for a healthy one. Check keys and unique indexes first.", + sql: ["CREATE EXTENSION IF NOT EXISTS amcheck;", ...amcheckStatements(rows)], + }, + { + title: "Rebuild the affected indexes", + body: "Rebuild anything that failed step 1 — or all of them, which is safe if you would rather not check individually. CONCURRENTLY keeps the application online during the rebuild.", + sql: reindexStatements(rows), + }, + { + title: "Record the new collation version — only after every rebuild has finished", + body: "Refreshing first hides the problem without fixing it: it updates a label and silences the warning while leaving the indexes mis-ordered.", + sql: refreshStatements(rows), + }, + ], + }, + ], + }; +} + +/** + * `inspect db collation-drift` — btree indexes on text columns whose collation + * version no longer matches the version the operating system provides, with + * the verify → rebuild → refresh workflow rendered as part of the output. + * + * Unlike the sibling commands this does NOT filter the internal Supabase + * schemas (`auth`, `storage`, …): those hold user data, and a mis-ordered + * index on `auth.users` is exactly as damaging as one in `public`. + */ +export const collationDriftSpec: InspectReportSpec = { + name: "collation-drift", + sql: SQL, + params: () => [], + report: (rows) => buildCollationDriftReport(rows), +}; diff --git a/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.unit.test.ts b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.unit.test.ts new file mode 100644 index 0000000000..7f52c9931f --- /dev/null +++ b/apps/cli/src/commands/inspect/db/collation-drift/collation-drift.query.unit.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { + amcheckStatements, + buildCollationDriftReport, + quoteIdent, + quoteLiteral, + refreshStatements, + reindexStatements, +} from "./collation-drift.query.ts"; +import type { ReportStepsBlock, ReportTableBlock } from "../../../../output/report.types.ts"; + +const libcRow = { + database: "postgres", + name: "public.t_key", + table: "public.events", + columns: "title", + collation: "default", + stored_version: "2.39", + current_version: "2.40", + key_type: "UNIQUE", + size: "148 MB", +}; + +const icuRow = { + database: "postgres", + name: "public.demo_title_idx", + table: "public.collation_drift_demo", + columns: "title", + collation: "public.test_stale_icu", + stored_version: "153.14", + current_version: "153.121", + key_type: "", + size: "8192 bytes", +}; + +function stepsBlock(report: ReturnType): ReportStepsBlock { + const block = report.blocks.find((b) => b.kind === "steps"); + if (block === undefined || block.kind !== "steps") throw new Error("no steps block"); + return block; +} + +describe("buildCollationDriftReport", () => { + it("renders the healthy state as an ok callout with no remediation", () => { + const report = buildCollationDriftReport([]); + + expect(report.severity).toBe("ok"); + expect(report.blocks).toHaveLength(1); + expect(report.blocks[0]).toMatchObject({ + kind: "callout", + severity: "ok", + }); + }); + + it("is critical when a key or unique index is affected, warn otherwise", () => { + expect(buildCollationDriftReport([libcRow]).severity).toBe("critical"); + expect(buildCollationDriftReport([icuRow]).severity).toBe("warn"); + }); + + it("includes environment, callout, table, and steps blocks when drift exists", () => { + const kinds = buildCollationDriftReport([libcRow, icuRow]).blocks.map((b) => b.kind); + expect(kinds).toEqual(["keyValue", "callout", "table", "steps"]); + }); + + it("keeps the row order the SQL produced (keys first)", () => { + const report = buildCollationDriftReport([libcRow, icuRow]); + const table = report.blocks.find((b) => b.kind === "table") as ReportTableBlock; + expect(table.rows[0]?.cells[0]).toBe("public.t_key"); + expect(table.rows[0]?.severity).toBe("critical"); + expect(table.rows[1]?.severity).toBe("warn"); + }); + + it("orders the workflow verify → rebuild → refresh", () => { + const steps = stepsBlock(buildCollationDriftReport([libcRow])).steps; + expect(steps.map((s) => s.title)).toEqual([ + "Confirm which indexes are actually mis-ordered", + "Rebuild the affected indexes", + "Record the new collation version — only after every rebuild has finished", + ]); + }); + + it("degrades null cells instead of throwing", () => { + const report = buildCollationDriftReport([{ ...libcRow, current_version: null }]); + const table = report.blocks.find((b) => b.kind === "table") as ReportTableBlock; + expect(table.rows[0]?.cells[5]).toBe(""); + }); +}); + +describe("generated statements", () => { + it("uses amcheck's real parameter name, heapallindexed", () => { + const [statement] = amcheckStatements([{ ...libcRow }] as never); + expect(statement).toContain("heapallindexed => true"); + expect(statement).not.toContain("heapalloc"); + }); + + it("keeps the amcheck literal intact for quote-bearing index names", () => { + const evil = { ...icuRow, name: `public."evil'; DROP TABLE users; --"` }; + const statements = amcheckStatements([evil] as never); + const statement = statements[0] ?? ""; + expect(statement).toContain(`'public."evil''; DROP TABLE users; --"'::regclass`); + expect(statement.split("'").length % 2).toBe(1); // even quote count = literal broke + }); + + it("marks key-backing indexes in the amcheck list", () => { + const [keyed, plain] = amcheckStatements([libcRow, icuRow] as never); + expect(keyed).toContain("-- unique"); + expect(plain).not.toContain("--"); + }); + + it("reindexes by the already-qualified index name", () => { + expect(reindexStatements([icuRow] as never)).toEqual([ + "REINDEX INDEX CONCURRENTLY public.demo_title_idx;", + ]); + }); + + it("emits ALTER DATABASE only for default-collation drift", () => { + expect(refreshStatements([icuRow] as never)).toEqual([ + "ALTER COLLATION public.test_stale_icu REFRESH VERSION;", + ]); + expect(refreshStatements([libcRow] as never)).toEqual([ + 'ALTER DATABASE "postgres" REFRESH COLLATION VERSION;', + ]); + }); + + it("deduplicates a collation shared by several indexes", () => { + const second = { ...icuRow, name: "public.demo_title_uniq" }; + const statements = refreshStatements([icuRow, second] as never); + expect(statements).toHaveLength(1); + }); +}); + +describe("quoting", () => { + it("quoteIdent escapes embedded double quotes", () => { + expect(quoteIdent("postgres")).toBe('"postgres"'); + expect(quoteIdent('we"ird')).toBe('"we""ird"'); + }); + + it("quoteLiteral escapes embedded single quotes", () => { + expect(quoteLiteral("plain")).toBe("'plain'"); + expect(quoteLiteral("o'brien")).toBe("'o''brien'"); + }); +}); diff --git a/apps/cli/src/commands/inspect/db/db.command.ts b/apps/cli/src/commands/inspect/db/db.command.ts index d68d765621..e31bd844bb 100644 --- a/apps/cli/src/commands/inspect/db/db.command.ts +++ b/apps/cli/src/commands/inspect/db/db.command.ts @@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"; import { inspectDbBloatCommand } from "./bloat/bloat.command.ts"; import { inspectDbBlockingCommand } from "./blocking/blocking.command.ts"; import { inspectDbCallsCommand } from "./calls/calls.command.ts"; +import { inspectDbCollationDriftCommand } from "./collation-drift/collation-drift.command.ts"; import { inspectDbCacheHitCommand } from "./cache-hit/cache-hit.command.ts"; import { inspectDbDbStatsCommand } from "./db-stats/db-stats.command.ts"; import { inspectDbIndexSizesCommand } from "./index-sizes/index-sizes.command.ts"; @@ -36,6 +37,7 @@ export const inspectDbCommand = Command.make("db").pipe( inspectDbOutliersCommand, inspectDbCallsCommand, inspectDbIndexStatsCommand, + inspectDbCollationDriftCommand, inspectDbLongRunningQueriesCommand, inspectDbBloatCommand, inspectDbRoleStatsCommand, diff --git a/apps/cli/src/commands/inspect/db/inspect-report.ts b/apps/cli/src/commands/inspect/db/inspect-report.ts new file mode 100644 index 0000000000..e5653d17e8 --- /dev/null +++ b/apps/cli/src/commands/inspect/db/inspect-report.ts @@ -0,0 +1,115 @@ +import { Effect, Option } from "effect"; + +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { renderReportText } from "../../../output/report-render-text.ts"; +import type { Report } from "../../../output/report.types.ts"; +import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; +import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { + InspectMutuallyExclusiveFlagsError, + type InspectConnectionFlags, +} from "./inspect-query.ts"; + +/** + * A report-producing `inspect db` subcommand: the SQL it runs, the query + * parameters, and how the result rows become a structured `Report` document. + * + * `report` is pure — rows in, document out — so a command's entire output + * logic is unit-testable without a database. + */ +export interface InspectReportSpec { + readonly name: string; + readonly sql: string; + readonly params: (cfg: ResolvedDbConfig) => ReadonlyArray; + readonly report: (rows: ReadonlyArray>, cfg: ResolvedDbConfig) => Report; +} + +/** + * Runs a report-producing subcommand. + * + * The connection-selection half (flag exclusivity keyed off raw argv, the + * `--project-ref` guard, the stderr connect line) intentionally mirrors + * `runInspectQuery` line for line. The two are kept separate for now so + * this change cannot affect the 25 shipped table commands; unifying them by + * extracting the shared prologue is the follow-up once the report path has + * proven itself (design doc, Phase 2). + */ +const runInspectReport = Effect.fnUntraced(function* ( + spec: InspectReportSpec, + flags: InspectConnectionFlags, + dnsResolver: "native" | "https", +) { + const output = yield* Output; + const resolver = yield* DbConfigResolver; + const dbConn = yield* DbConnection; + const cliArgs = yield* CliArgs; + + const target = resolveDbTargetFlags(cliArgs.args); + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new InspectMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + + const connType = target.connType ?? "linked"; + + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new InspectMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + linkedProjectRef: flags.projectRef, + }); + + const rows = yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + return yield* session.query(spec.sql, spec.params(cfg)); + }), + ); + + const report = spec.report(rows, cfg); + + if (output.format === "text") { + yield* output.raw(renderReportText(report)); + return; + } + + // json / stream-json — the raw driver rows keep the pre-report payload shape + // for scripts, and the document rides alongside them. This is the + // compatibility answer from the design doc: no existing `{ rows }` consumer + // breaks when a command moves to the report path. + yield* output.success(`inspect db ${spec.name}`, { rows, report }); +}); + +/** + * Builds an `inspect db ` handler from a report spec. Trace-span and + * telemetry-flush behavior matches `makeInspectDbHandler` — callers must + * NOT add a second `Effect.ensuring(flush)` at the command level. + */ +export function makeInspectDbReportHandler(spec: InspectReportSpec, traceName: string) { + return Effect.fn(traceName)(function* (flags: InspectConnectionFlags) { + const dnsResolver = yield* DnsResolverFlag; + const telemetryState = yield* TelemetryState; + yield* runInspectReport(spec, flags, dnsResolver).pipe(Effect.ensuring(telemetryState.flush)); + }); +} diff --git a/apps/cli/src/output/report-render-text.ts b/apps/cli/src/output/report-render-text.ts new file mode 100644 index 0000000000..87a18b3a68 --- /dev/null +++ b/apps/cli/src/output/report-render-text.ts @@ -0,0 +1,99 @@ +import { renderGlamourTable } from "./glamour-table.ts"; +import type { Report, ReportBlock, ReportSeverity, ReportStepsBlock } from "./report.types.ts"; + +/** + * Renders a `Report` for the terminal. + * + * Table blocks reuse `renderGlamourTable`, so a report containing only a table + * renders byte-identically to the pre-report output — which is what makes the + * adapter migration of the existing commands a no-op in text mode. + * + * No ANSI color: severity is carried by a symbol prefix, matching the plain + * glamour AsciiStyle output the inspect commands already produce and keeping + * piped/CI output clean. + */ +export function renderReportText(report: Report): string { + const parts = report.blocks.map(renderBlock); + return "\n" + parts.join("\n") + "\n"; +} + +const SEVERITY_SYMBOL: Record = { + info: "ℹ", + ok: "✓", + warn: "⚠", + critical: "✗", +}; + +const WRAP_WIDTH = 72; + +function renderBlock(block: ReportBlock): string { + switch (block.kind) { + case "keyValue": { + const width = Math.max(...block.entries.map((e) => e.key.length)); + return block.entries.map((e) => ` ${e.key.padEnd(width)} : ${e.value}`).join("\n") + "\n"; + } + + case "table": { + const hasSeverity = block.rows.some((r) => r.severity !== undefined); + const headers = hasSeverity + ? ["", ...block.columns.map((c) => c.title)] + : block.columns.map((c) => c.title); + const cells = block.rows.map((r) => + hasSeverity + ? [r.severity === undefined ? "" : SEVERITY_SYMBOL[r.severity], ...r.cells] + : [...r.cells], + ); + return renderGlamourTable(headers, cells); + } + + case "callout": + return ( + wrap(block.text, WRAP_WIDTH) + .map((line, i) => + i === 0 ? ` ${SEVERITY_SYMBOL[block.severity]} ${line}` : ` ${line}`, + ) + .join("\n") + "\n" + ); + + case "sql": + return [` ${block.title}`, ...block.statements.map((s) => ` ${s}`)].join("\n") + "\n"; + + case "steps": + return renderSteps(block); + } +} + +function renderSteps(block: ReportStepsBlock): string { + const lines: string[] = []; + block.steps.forEach((step, i) => { + lines.push(` ${i + 1}. ${step.title}`); + if (step.body !== undefined) { + for (const line of wrap(step.body, WRAP_WIDTH)) lines.push(` ${line}`); + } + if (step.sql !== undefined) { + lines.push(""); + for (const statement of step.sql) lines.push(` ${statement}`); + } + lines.push(""); + }); + return lines.join("\n"); +} + +/** Greedy word wrap; preserves words longer than the width intact. */ +function wrap(text: string, width: number): string[] { + const out: string[] = []; + for (const paragraph of text.split("\n")) { + let line = ""; + for (const word of paragraph.split(/ +/)) { + if (word === "") continue; + if (line === "") line = word; + else if (line.length + 1 + word.length <= width) line += " " + word; + else { + out.push(line); + line = word; + } + } + out.push(line); + } + return out; +} diff --git a/apps/cli/src/output/report.types.ts b/apps/cli/src/output/report.types.ts new file mode 100644 index 0000000000..0b9178b4db --- /dev/null +++ b/apps/cli/src/output/report.types.ts @@ -0,0 +1,67 @@ +/** + * The report document model for `inspect` command output. + * + * A command produces a `Report` — a structured document — and per-format + * renderers turn it into terminal text or a JSON payload. The document is the + * single source of truth, so text and JSON mode finally describe the same + * thing, severities included. + * + * Design doc: apps/cli/docs/inspect-report-output.md (Part 2, Option 2). + */ + +export type ReportSeverity = "info" | "ok" | "warn" | "critical"; + +/** Aligned label/value pairs, e.g. the environment header of a diagnostic. */ +interface ReportKeyValueBlock { + readonly kind: "keyValue"; + readonly entries: ReadonlyArray<{ readonly key: string; readonly value: string }>; +} + +/** A table; the one block type the pre-report commands could express. */ +export interface ReportTableBlock { + readonly kind: "table"; + readonly columns: ReadonlyArray<{ readonly title: string }>; + readonly rows: ReadonlyArray<{ + readonly cells: ReadonlyArray; + readonly severity?: ReportSeverity; + }>; +} + +/** A short highlighted message: the empty state, a warning, a success line. */ +interface ReportCalloutBlock { + readonly kind: "callout"; + readonly severity: ReportSeverity; + readonly text: string; +} + +/** Copy-pasteable SQL, rendered indented under a title. */ +interface ReportSqlBlock { + readonly kind: "sql"; + readonly title: string; + readonly statements: ReadonlyArray; +} + +/** An ordered remediation workflow; each step may carry its own SQL. */ +export interface ReportStepsBlock { + readonly kind: "steps"; + readonly steps: ReadonlyArray<{ + readonly title: string; + readonly body?: string; + readonly sql?: ReadonlyArray; + }>; +} + +export type ReportBlock = + | ReportKeyValueBlock + | ReportTableBlock + | ReportCalloutBlock + | ReportSqlBlock + | ReportStepsBlock; + +export interface Report { + /** The producing subcommand, e.g. "collation-drift". */ + readonly command: string; + /** Overall status of what the report found. */ + readonly severity: ReportSeverity; + readonly blocks: ReadonlyArray; +}