Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [1.1.0] - 2026-09-08

### Added

- **The schema advisor, and `vacuum:lint`.** `vacuum:check` reads what a database has been *doing* — dead tuples, bloat, freeze age, `pg_stat_statements` — and every one of those needs a database with a history behind it. A pipeline has the opposite thing: a container ninety seconds old with the migrations freshly applied, where fifteen of the twenty rules find nothing and the command reports a perfect score on a database nobody has ever used. That is the same failure the command's own guard was written to prevent, arrived at from the other direction. `vacuum:lint` asks the questions that *are* answerable there — the ones the catalog can answer the moment `migrate` returns, without a single row existing.

- **Six rules that need no statistics.** `unindexed-foreign-key`, the one that matters most: PostgreSQL indexes a primary key and a unique constraint and creates **nothing** for a foreign key, so `foreignId()->constrained()` writes half of what it looks like it wrote, and the cost lands on the *parent's* deletes as a sequential scan of the child under a lock. `foreign-key-type-mismatch`, where an `integer` key referencing a `bigint` is accepted, enforced correctly, and quietly un-indexable because the comparison crosses types — nothing in the catalog is marked wrong and the delete is simply slow forever. `int4-primary-key`, which is the wraparound story on a different clock: `increments('id')` counts to 2,147,483,647 and the next insert fails, with no warning and no slowdown first. `missing-primary-key`, which breaks logical replication before it inconveniences anybody. `unindexed-morphs`, and `json-not-jsonb`. `duplicate-index` is carried across from the dashboard's tier, because two migrations creating one index is visible on an empty database and is exactly what this is for.

- **Schema findings are scored separately, by their own advisor.** `Health` computes the score from the findings and from nothing else — deliberately, so the grade can never disagree with the list beneath it — and the consequence of that property is that adding rules to the shared advisor would silently re-grade every installation that upgraded. Somebody's A becomes a C on a `composer update`, with no breaking change to point at. So `SchemaAdvisor` merges its own tier and scores it alone; the two numbers are never added and never averaged. The dashboard is untouched.

- **A CI-shaped command.** `vacuum:lint` defaults to `--fail-on=warning` where `vacuum:check` defaults to `critical`, because the two mean different things by the word: a warning from `check` is a database drifting and should not redden a build overnight, while a warning from `lint` is a schema that was wrong the moment somebody typed it. It keeps `check`'s guard that a disabled Vacuum **fails rather than passes**, and carries its own `--format=json` document, which adds `evidence` and `table` — a schema finding is always about a table, and whatever reads the document will want to say which.

- **New public API, covered from 1.1.** The `SchemaRule` contract, the `TableSchema` and `IndexDefinition` value objects, the `SCHEMA_RULES` tag a custom schema rule is registered under, the `vacuum:lint --format=json` document, and configuration under `vacuum.lint.*`. `SchemaRule` returns a *list* of findings where every other rule contract returns one or null: a table with four unindexed foreign keys has four problems on four columns, each fixed by a different statement, and collapsing them would read acceptably in a terminal and be useless anywhere else.

- **`IndexColumns`, a catalog read describing what an index could serve** rather than whether anything has used it. `pg_stat_user_indexes` cannot say anything at all about a database created ninety seconds ago; `pg_index` is true as soon as `CREATE INDEX` returns. Expression indexes are excluded, because their `indkey` carries a placeholder that joins to no attribute and a column list with a silent gap in it is worse than no column list.

### Fixed

- **A partial index no longer counts as covering a foreign key.** `constraints.sql` tested `indisvalid` and not `indpred`, so an index with a `WHERE` clause satisfied the coverage check — while `TableSchema::hasIndexLeadingWith()`, written later, correctly refused it. The two disagreed, and the permissive one fed the headline rule: a foreign key covered only by a partial index was silently passed. A partial index holds only the rows its predicate admits and the referential-integrity check looks for arbitrary parent rows, so it cannot serve one. The `unindexed-foreign-keys` lesson reads the same column and is corrected with it, and the query that lesson hands the reader to run themselves is now pinned to the shipped one by a test, since it had already drifted once.

- **Constraint column types are no longer split on a delimiter they can contain.** The type lists were aggregated with a comma and split on one, and `format_type` renders `numeric(10,2)` — so a foreign key onto a `decimal` column arrived as two fragments, failed its own equality test, and produced the unparseable remediation `ALTER TABLE … TYPE numeric(10;`. All three lists are aggregated on a newline now, which neither a rendered type nor an identifier can contain.

### Changed

- `Constraint` gains `$columnTypes`, `$referencedColumnTypes` and `typesMatch()`. Both parameters are trailing and optional, so existing construction is unaffected; `Constraint` is not among the value objects frozen at 1.0.
- `CheckCommand`'s severity handling and finding rendering are extracted to `SeverityBar` and `FindingReporter` and shared with `vacuum:lint`, so the rule that `Severity::Unknown` never fails a build has one implementation rather than two. `CheckCommand`'s behaviour is unchanged and its tests were not touched.

## [1.0.0] - 2026-07-21

### Fixed — the advice

- **The configuration audit read a timeout Vacuum had set on itself.** Every query in the package runs through `ReadOnlyExecutor`, which issues `SET LOCAL statement_timeout` before the statement; the audit then selected `pg_settings.setting` from inside that same transaction and reported the number back as a fact about the server. Two rules were wrong because of it, in opposite directions. `lock-timeout-ineffective` compared a real `lock_timeout` against the 5000 Vacuum had just injected, so a textbook-correct server — `statement_timeout` 30s, `lock_timeout` 10s, the lock timeout firing first exactly as intended — was told its lock timeout could never fire, and acting on that advice would have degraded a correct configuration. `timeouts-unset` required `statement_timeout` to read `0` and therefore could not fire at all, on any server, ever: it had never detected anything. Settings are now read from `reset_val`, which is what the role, the database and `postgresql.conf` say and is provably immune to `SET LOCAL`. The session's own view stays available as `Settings::runtimeValue()` for the caller that genuinely wants it, but it is no longer the default, because a configuration rule asking "what is this server configured to" must not be answerable by the observer.
Expand Down Expand Up @@ -109,6 +138,8 @@ First release.
- **A Filament v4 panel** (optional peer — nothing changes for a Blade-only install): a **Vacuum** navigation group with an **Overview** dashboard (health score and grade, database vitals, charts, the findings with copyable remediation, and live running vacuums) and read-only resources for **Tables**, **Indexes**, **Sessions** and **Statements**. Every surface shares the one `Vacuum::auth()` gate and opts out of tenant scoping, so it is at home in a multi-tenant panel.
- **Extensibility.** Application rules can be tagged onto the advisor per subject (`TABLE_RULES`, `INDEX_RULES`, and the rest), and both the config and the dashboard views are publishable.

[Unreleased]: https://github.com/heyosseus/vacuum/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/heyosseus/vacuum/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/heyosseus/vacuum/compare/v1.0.1...v1.1.0
[1.0.0]: https://github.com/heyosseus/vacuum/compare/v0.3.0...v1.0.0
[0.3.0]: https://github.com/heyosseus/vacuum/compare/v0.1.0...v0.3.0
[0.1.0]: https://github.com/heyosseus/vacuum/releases/tag/v0.1.0
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Vacuum reads what PostgreSQL already knows about itself — `pg_stat_user_tables

It shows you that statement. It never runs it.

> **Status: 1.0.0.** The public API is frozen — see [What semver covers](#what-semver-covers). A breaking change to the rule contracts, `Finding`, the value objects, the configuration keys or the `--format=json` document now requires a major version.
> **Status: 1.1.0.** The public API is frozen — see [What semver covers](#what-semver-covers). A breaking change to the rule contracts, `Finding`, the value objects, the configuration keys or the `--format=json` documents now requires a major version.

## Quick start

Expand All @@ -41,6 +41,7 @@ Already running a Filament panel? `php artisan vacuum:install --filament` puts t
- [The standalone dashboard](#the-standalone-dashboard) · [Inside Filament](#inside-filament)
- [Who may look](#who-may-look) · [Which database](#which-database)
- [In your pipeline](#in-your-pipeline) — `vacuum:check`, and failing a build
- [Linting the schema](#linting-the-schema) — `vacuum:lint`, and what is wrong before a row exists
- [History over time](#history-over-time) — direction, forecasts, and what changed
- [The SQL console](#the-sql-console) — and what actually makes it safe
- [Tuning the thresholds](#tuning-the-thresholds) · [Writing your own rule](#writing-your-own-rule) · [Restyling the dashboard](#restyling-the-dashboard)
Expand Down Expand Up @@ -230,6 +231,47 @@ php artisan vacuum:check --format=json # score, grade, deductions, finding

Two things worth knowing. It **never writes** — the remediation is printed for you to read and decide on, exactly as it is on the page. And if Vacuum is disabled it **fails rather than passing**: a check that goes green because it never looked is worse than no check at all.

### Linting the schema

`vacuum:check` reads what the database has been *doing*, and a pipeline's database
has not done anything. A Postgres container ninety seconds old with the migrations
freshly applied has no dead tuples, no bloat, no freeze age and no statements —
so most of the rules find nothing, and a perfect score on a database nobody has
ever used is exactly the kind of green number this package exists to argue against.

`vacuum:lint` asks the questions that *are* answerable there:

```bash
php artisan vacuum:lint
```

| Rule | Finds |
| --- | --- |
| `unindexed-foreign-key` | Foreign keys PostgreSQL created no index for, which `->constrained()` never does |
| `foreign-key-type-mismatch` | A key referencing a different type, so the index exists and cannot be used |
| `int4-primary-key` | A primary key that stops accepting rows at 2,147,483,647 |
| `missing-primary-key` | Tables nothing can address a single row of |
| `unindexed-morphs` | A polymorphic pair with no composite index leading on the type |
| `json-not-jsonb` | `json` where `jsonb` was almost certainly meant |

Every one of them is true the moment `php artisan migrate` finishes, so this belongs
in `require-dev` and in the job that already runs your tests.

It **defaults to failing on a warning**, where `vacuum:check` defaults to critical.
The two commands mean different things by the word: a warning from `check` is a
database drifting, and a build should not go red because bloat grew overnight. A
warning from `lint` is a schema that was wrong the moment somebody typed it.

```bash
php artisan vacuum:lint --fail-on=critical # critical, warning, info, or never
php artisan vacuum:lint --format=json # score, grade, deductions, findings
```

Schema findings are scored on their own and are **not** part of the dashboard's
health score. Adding rules to that score would silently re-grade every existing
installation on a `composer update`, and a grade that moves for a reason nobody
asked for is worse than one rule fewer.

## History over time

Vacuum is point-in-time by default: every page and every `vacuum:check` reads the database as it is this instant. Switch history on and it records a snapshot on a schedule, so it can tell you which way a number is *moving* — bloat that is growing, a freeze age climbing since the last time anything froze it, a cache-hit ratio measured over the last hour rather than over the life of the server.
Expand Down Expand Up @@ -385,14 +427,15 @@ The stylesheet is inlined rather than fetched from a CDN, on the grounds that th

## What semver covers

From 1.0, these are public API and a breaking change to any of them requires a major version:
From 1.0 — and from 1.1 where a line says so — these are public API and a breaking change to any of them requires a major version:

- **The rule contracts** — `TableRule`, `IndexRule`, `SessionRule`, `StatementRule`, `BloatRule`, `CacheRule`, `DuplicateRule`, `ConfigurationRule`, `SettingRule` — and the `Inspection` contract behind them.
- **The rule contracts** — `TableRule`, `IndexRule`, `SessionRule`, `StatementRule`, `BloatRule`, `CacheRule`, `DuplicateRule`, `ConfigurationRule`, `SettingRule` (1.0) and `SchemaRule` (1.1) — and the `Inspection` contract behind them.
- **`Finding`, `Severity` and `Grade`**, including `Finding`'s constructor signature. A custom rule constructs one, so its parameters are as public as the interface that returns it.
- **The value objects the contracts hand a rule**: `TableStatistic`, `IndexStatistic`, `Session`, `Statement`, `CacheStatistic`, `Settings` and `Capabilities`.
- **The value objects the contracts hand a rule**: `TableStatistic`, `IndexStatistic`, `Session`, `Statement`, `CacheStatistic`, `Settings` and `Capabilities` (1.0), and `TableSchema` and `IndexDefinition` (1.1).
- **Configuration keys** under `vacuum.*`, and the `VACUUM_*` environment variables that feed them. Keys may be added; existing ones will not change meaning.
- **The `vacuum:check --format=json` document**, which is what a pipeline parses.
- **The `vacuum:check --format=json` and `vacuum:lint --format=json` documents**, which are what a pipeline parses.
- **Route names** (`vacuum.dashboard` and the rest) and the `Vacuum::auth()` gate.
- **The `SCHEMA_RULES` tag** (1.1), alongside the others a custom rule is registered under.

Explicitly **not** covered, and free to change in a minor release:

Expand Down
44 changes: 43 additions & 1 deletion resources/sql/constraints.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,66 @@
-- Array equality in PostgreSQL compares contents and element counts and not
-- subscript bounds, so slicing indkey from 0 to n-1 and comparing it to conkey
-- is correct, and the off-by-one it looks like it has, it does not.
--
-- indpred IS NULL is part of "covered" too: a partial index holds only the rows
-- its predicate admits, and the referential-integrity check this answers for --
-- does some row in the parent exist -- has to be able to see an arbitrary row,
-- not just the ones a WHERE clause let in. Values\TableSchema::hasIndexLeadingWith()
-- excludes a partial index for the same reason; this column has to agree with it,
-- or the same table can be told both that it is covered and that it is not.
--
-- The types on both sides of a foreign key are carried because a mismatch between
-- them is invisible everywhere else. PostgreSQL accepts a foreign key from an
-- integer to a bigint without complaint, creates the constraint, enforces it
-- correctly -- and the planner then cannot use the parent's index for the check,
-- because the comparison is across types. Nothing in the catalog is marked wrong.
-- The only symptom is that a delete on the parent is slow forever.
--
-- confkey is null for a primary key or a unique constraint, so it is coalesced to
-- an empty array: a constraint that references nothing has no referenced types,
-- which is different from having failed to look them up.
--
-- Every list below is joined with a newline rather than a comma. format_type
-- renders a parameterised type with a comma already inside it -- numeric(10,2) --
-- so a comma-joined list of column types splits that single type into two
-- pieces, which is a real shape in this package's own migrations. Neither
-- format_type's output nor a PostgreSQL identifier can contain a newline, so it
-- is a safe delimiter where a comma is not. All three lists change together
-- because Constraints::toConstraint() zips them by position, and the delimiter
-- never leaves that class.
SELECT
namespaces.nspname AS schemaname,
tables.relname AS tablename,
constraints.conname AS constraintname,
constraints.contype::text AS kind,
(
SELECT coalesce(string_agg(attributes.attname, ',' ORDER BY keys.ordinality), '')
SELECT coalesce(string_agg(attributes.attname, E'\n' ORDER BY keys.ordinality), '')
FROM unnest(constraints.conkey) WITH ORDINALITY AS keys (attnum, ordinality)
JOIN pg_attribute AS attributes
ON attributes.attrelid = constraints.conrelid
AND attributes.attnum = keys.attnum
) AS columns,
(
SELECT coalesce(string_agg(format_type(attributes.atttypid, attributes.atttypmod), E'\n' ORDER BY keys.ordinality), '')
FROM unnest(constraints.conkey) WITH ORDINALITY AS keys (attnum, ordinality)
JOIN pg_attribute AS attributes
ON attributes.attrelid = constraints.conrelid
AND attributes.attnum = keys.attnum
) AS columntypes,
(
SELECT coalesce(string_agg(format_type(attributes.atttypid, attributes.atttypmod), E'\n' ORDER BY keys.ordinality), '')
FROM unnest(coalesce(constraints.confkey, '{}'::int2[])) WITH ORDINALITY AS keys (attnum, ordinality)
JOIN pg_attribute AS attributes
ON attributes.attrelid = constraints.confrelid
AND attributes.attnum = keys.attnum
) AS referencedcolumntypes,
coalesce(referenced.relname, '') AS referencedtable,
EXISTS (
SELECT 1
FROM pg_index AS indexes
WHERE indexes.indrelid = constraints.conrelid
AND indexes.indisvalid
AND indexes.indpred IS NULL
AND (indexes.indkey::int2[])[0:cardinality(constraints.conkey) - 1] = constraints.conkey
) AS indexed
FROM pg_constraint AS constraints
Expand Down
Loading