@syntopica/db-quality 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,8 @@ everything as one gate with honest exit codes. Brings to databases what
8
8
 
9
9
  - **Design spec:**
10
10
  [docs/superpowers/specs/2026-09-25-db-quality-design.md](https://github.com/syntopica/codeality/blob/main/docs/superpowers/specs/2026-09-25-db-quality-design.md)
11
+ - **Strict mode and performance design spec:**
12
+ [docs/superpowers/specs/2026-09-25-db-quality-perf-design.md](https://github.com/syntopica/codeality/blob/main/docs/superpowers/specs/2026-09-25-db-quality-perf-design.md)
11
13
 
12
14
  ## Install
13
15
 
@@ -16,8 +18,11 @@ pnpm add -D @syntopica/db-quality squawk-cli prisma-lint eslint eslint-plugin-dr
16
18
  ```
17
19
 
18
20
  Install only the peers your stacks need: `squawk-cli` for Supabase migrations,
19
- `prisma-lint` for Prisma, the three ESLint packages for Drizzle. The Supabase
20
- CLI, `sqlite3` and `uvx` are external executables.
21
+ `prisma-lint` for Prisma, the three ESLint packages for Drizzle, `typescript`
22
+ for the PostgREST rules (`postgrest.roots`). The Supabase CLI, `sqlite3`, `uvx`
23
+ and `psql` are external executables; `psql` is required by the `perf` commands
24
+ and the gate's `perf` stage, and a missing one fails with exit 3 rather than
25
+ skipping silently.
21
26
 
22
27
  ## Usage
23
28
 
@@ -25,51 +30,266 @@ CLI, `sqlite3` and `uvx` are external executables.
25
30
  codeality-db init [--check|--apply|--force] # codeality-db.json, db:gate script, CI workflow
26
31
  codeality-db check [--json] # static findings, never writes
27
32
  codeality-db audit --linked|--db-url <url> # live Supabase advisors, inspect, Soda
28
- codeality-db gate # check or baseline check, then the linked audit
33
+ codeality-db gate # check (or baseline check), the linked audit, then perf
29
34
  codeality-db baseline create|update|check # record and enforce the debt you carry
35
+ codeality-db perf snapshot|diff|bench [--db-url <url>] [--json] [--record] # record, compare and benchmark the live database
30
36
  ```
31
37
 
32
38
  `--project <dir>` before the command runs against another directory.
33
39
 
34
40
  ## Configuration
35
41
 
36
- `codeality-db.json`, written by `init` from the stacks it detects:
42
+ `codeality-db.json`, written by `init` from the stacks it detects. This example
43
+ is the **phase 4** configuration — see [Adoption in phases](#adoption-in-phases)
44
+ — with every section adopted and `perf.inGate: true`; `init` itself writes
45
+ `perf.inGate: false` and no `postgrest` section until `@supabase/supabase-js` is
46
+ a dependency:
37
47
 
38
48
  ```json
39
49
  {
40
- "schemaVersion": 1,
50
+ "schemaVersion": 2,
41
51
  "supabase": { "migrations": "supabase/migrations" },
42
52
  "prisma": { "schema": "prisma/schema.prisma" },
43
53
  "drizzle": { "roots": ["src"], "objectNames": ["db", "tx"] },
44
54
  "sqlite": { "files": ["data/app.db"] },
55
+ "postgrest": { "roots": ["src", "app"] },
45
56
  "audit": { "inGate": true, "bloatThreshold": 5, "soda": "db-quality/soda" },
46
- "disable": ["BDB100/prefer-bigint-over-int"]
57
+ "perf": {
58
+ "inGate": true,
59
+ "slowMs": 100,
60
+ "regressionPercent": 20,
61
+ "minCalls": 20,
62
+ "seqScanRows": 10000,
63
+ "benchDir": "db-quality/bench",
64
+ "benchRuns": 5,
65
+ "benchTimeoutMs": 60000,
66
+ "roles": ["authenticator", "service_role", "postgres"],
67
+ "ignore": []
68
+ },
69
+ "disable": [
70
+ {
71
+ "code": "BDB100/prefer-bigint-over-int",
72
+ "reason": "int keys are a deliberate choice, see ADR-0007"
73
+ }
74
+ ]
47
75
  }
48
76
  ```
49
77
 
50
78
  Every section is optional. `audit.soda` names a directory holding a Soda Core
51
79
  `checks.yml`; it runs only with `--db-url`, because a linked project carries no
52
- database password.
80
+ database password. `postgrest.roots` names the directories scanned for `.ts` and
81
+ `.tsx` files. The `perf` values above are the built-in defaults except `inGate`,
82
+ which `init` always writes as `false` — the gate measures nothing until a
83
+ project reaches phase 4.
53
84
 
54
85
  ## Findings
55
86
 
56
- | Code | Source | Severity | What it means |
57
- | ------------------ | --------------------------- | ----------- | ------------------------------------------------------------------ |
58
- | `BDB001` | permissive-policy | warn | a policy uses `using (true)` or `with check (true)` |
59
- | `BDB002` | rls-enabled-no-policy | info | RLS on, no policy in any migration: service role only |
60
- | `BDB003` | table-without-rls | warn | a `public` table never enables row level security |
61
- | `BDB004` | auth-uid-not-wrapped | warn | `auth.uid()` in a policy without `(select ...)`: evaluated per row |
62
- | `BDB005` | definer-without-search-path | warn | `SECURITY DEFINER` function without `set search_path` |
63
- | `BDB100/<rule>` | squawk | as squawk | migration lock and schema hazards, Supabase profile |
64
- | `BDB200/<rule>` | prisma-lint | warn | relation field without an index |
65
- | `BDB300/<rule>` | eslint-plugin-drizzle | error | `delete` or `update` without `.where()` |
66
- | `BDB401`-`BDB403` | sqlite3 | error/warn | integrity, dangling foreign keys, table without primary key |
67
- | `BDB500/<name>` | Supabase advisors | as Supabase | splinter security and performance lints on the live project |
68
- | `BDB601`, `BDB602` | Supabase inspect | info/warn | never-scanned index, table bloat over `audit.bloatThreshold` |
69
- | `BDB700/<check>` | Soda Core | error/warn | a failed or warned data check from `<audit.soda>/checks.yml` |
70
-
71
- Disable a code for a project with `"disable": ["BDB100/prefer-bigint-over-int"]`
72
- in `codeality-db.json`.
87
+ | Code | Source | Severity | What it means |
88
+ | ------------------ | --------------------------- | ----------- | --------------------------------------------------------------------------------------------------- |
89
+ | `BDB001` | permissive-policy | warn | a policy uses `using (true)` or `with check (true)` |
90
+ | `BDB002` | rls-enabled-no-policy | warn | RLS on, no policy in any migration: service role only |
91
+ | `BDB003` | table-without-rls | warn | a `public` table never enables row level security |
92
+ | `BDB004` | auth-uid-not-wrapped | warn | `auth.uid()` in a policy without `(select ...)`: evaluated per row |
93
+ | `BDB005` | definer-without-search-path | warn | `SECURITY DEFINER` function without `set search_path` |
94
+ | `BDB100/<rule>` | squawk | as squawk | migration lock and schema hazards, Supabase profile |
95
+ | `BDB200/<rule>` | prisma-lint | warn | relation field without an index |
96
+ | `BDB300/<rule>` | eslint-plugin-drizzle | error | `delete` or `update` without `.where()` |
97
+ | `BDB401`-`BDB403` | sqlite3 | error/warn | integrity, dangling foreign keys, table without primary key |
98
+ | `BDB500/<name>` | Supabase advisors | as Supabase | splinter security and performance lints on the live project |
99
+ | `BDB601`, `BDB602` | Supabase inspect | warn | never-scanned index, table bloat over `audit.bloatThreshold` |
100
+ | `BDB700/<check>` | Soda Core | error/warn | a failed or warned data check from `<audit.soda>/checks.yml` |
101
+ | `BDB801`-`BDB805` | PostgREST rules | warn | query-chain rules on `.ts`/`.tsx` under `postgrest.roots`; see [PostgREST rules](#postgrest-rules) |
102
+ | `BDB901`-`BDB904` | perf diff | error/warn | live regressions, slow queries, sequential scans, temp spill; see [Performance](#performance) |
103
+ | `BDB911`-`BDB913` | perf bench | error/warn | bench query regressions, plan degradation, stale planner estimates; see [Performance](#performance) |
104
+
105
+ Disable a code for a project with an object naming the reason:
106
+
107
+ ```json
108
+ "disable": [
109
+ {
110
+ "code": "BDB100/prefer-bigint-over-int",
111
+ "reason": "int keys are a deliberate choice, see ADR-0007"
112
+ }
113
+ ]
114
+ ```
115
+
116
+ That is the `schemaVersion: 2` shape. A `schemaVersion: 1` file still accepts
117
+ the old bare string form (`"disable": ["BDB100/prefer-bigint-over-int"]`); see
118
+ [Strict mode](#strict-mode).
119
+
120
+ ## Strict mode
121
+
122
+ `schemaVersion: 2` removes the `info` severity: every finding is now `error` or
123
+ `warn`, and every finding fails the gate unless the baseline already records it
124
+ or a `disable` entry names it with a written reason. Two codes changed severity
125
+ in 0.2.0, independent of `schemaVersion`: `BDB002` (RLS enabled, no policy) and
126
+ `BDB601` (an index that has never been scanned) moved from `info` to `warn`.
127
+ Their fingerprints did not change, so a `BDB002` already carried in a baseline
128
+ stays known.
129
+
130
+ A `schemaVersion: 1` file keeps working exactly as before: its `disable` entries
131
+ stay bare strings, and every command prints one line to stderr saying that
132
+ `codeality-db init --apply` will upgrade the file to `schemaVersion: 2` and give
133
+ each disabled rule a written reason. Under `schemaVersion: 2` a bare string
134
+ `disable` entry is a configuration error whose message shows the object form
135
+ instead. The strictness is opted into by bumping the schema version yourself;
136
+ installing 0.2.0 alone changes nothing for a `schemaVersion: 1` project.
137
+
138
+ ## PostgREST rules
139
+
140
+ With a `postgrest.roots` section, `check` and `gate` walk every `.ts` and `.tsx`
141
+ file under those directories with the TypeScript compiler API and collect the
142
+ call chains rooted at `.from('<table>')` or `.rpc('<fn>')`:
143
+
144
+ | Code | Rule | What it proves |
145
+ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
146
+ | `BDB801` | select-star | `.select('*')`, `.select()`, or a column list that contains `*` |
147
+ | `BDB802` | unbounded-list | a read with no `limit`, `range`, `single`, `maybeSingle`, `csv`, `head: true`, and no equality on a primary or unique key |
148
+ | `BDB803` | filter-without-index | a filter on a literal column that no index, primary key or unique constraint in the migrations covers as its leading column |
149
+ | `BDB804` | query-in-loop | a `.from(` or `.rpc(` chain awaited inside `for`, `for of`, `while`, or a `map`/`forEach`/`reduce`/`filter`/`flatMap` callback |
150
+ | `BDB805` | exact-count-unbounded | `{ count: 'exact' }` with no `limit`, `range` or `head: true` |
151
+
152
+ Table knowledge for `BDB802` and `BDB803` comes from the same migration set the
153
+ `BDB00x` rules already read: the leading column of every
154
+ `create [unique] index`, `primary key` and `unique` constraint. A chain on a
155
+ table the migrations never created — a view, a table in another schema — is
156
+ exempt from `BDB803` rather than reported as a false positive, and a chain whose
157
+ table or column is not a string literal is exempt from every rule: each rule
158
+ reports only what it can prove. `.select('*')` inside `.rpc()` is not `BDB801`:
159
+ a function returns what it returns. A chain rooted at `<expr>.storage.from(...)`
160
+ (e.g. `supabase.storage.from('bucket')`) is a Supabase Storage call, not
161
+ PostgREST, and is skipped by every rule; a bucket reached through an aliased
162
+ variable is not recognized.
163
+
164
+ What these rules cannot see: a table or column name built from a variable
165
+ instead of a string literal, a filter reached through `.match()` rather than a
166
+ named method like `.eq()`, and anything about a view or a table the migrations
167
+ do not define — the index knowledge `BDB802` and `BDB803` use comes only from
168
+ `create table`, `create index` and constraint statements.
169
+
170
+ ## Performance
171
+
172
+ Three commands measure a live Supabase project, and the gate's `perf` stage runs
173
+ the last two of them when `perf.inGate` is `true`:
174
+
175
+ ```bash
176
+ codeality-db perf snapshot # record pg_stat_statements and table stats
177
+ codeality-db perf diff # compare a fresh reading against the snapshot
178
+ codeality-db perf bench [--record] # EXPLAIN ANALYZE the project's own bench queries
179
+ ```
180
+
181
+ `perf snapshot` writes `.codeality-db-perf.json`: per-statement counters from
182
+ `pg_stat_statements`, for the roles named in `perf.roles` (default
183
+ `authenticator`, `service_role`, `postgres`), and per-table
184
+ `pg_stat_user_tables` counters, alongside the target's host name and the tool's
185
+ own version — never a password or a connection string. `pg_stat_statements`
186
+ counters are cumulative, and the `postgres` role cannot reset them on Supabase,
187
+ so `perf diff` reads that file, takes a fresh reading, and for every matched
188
+ statement computes the window since the snapshot: the delta in calls, total time
189
+ and temp blocks written, turned back into a window mean. A statement whose
190
+ counters fell below the snapshot, or whose stats-reset timestamp moved, had its
191
+ server-side counters reset and is windowed from its current absolute values
192
+ instead, the same as a statement that is new since the snapshot.
193
+
194
+ Before the findings, `perf diff` prints an **improvement report**: every
195
+ statement whose window mean fell by `perf.regressionPercent` or more against its
196
+ snapshot mean, with both means, the call count, and the total time saved — the
197
+ answer to "did the change help", and never itself a finding. `--json` returns
198
+ `{ improvements, findings }`. Statements that are platform noise, not the
199
+ application, are excluded before any of this: `pg_sleep`, `pg_timezone_names`,
200
+ `pg_stat_statements` itself, PostgREST's schema-cache CTEs, the WAL replication
201
+ poll, `COPY` statements, and any statement containing one of the plain
202
+ substrings (not patterns) listed in `perf.ignore`, which extends that built-in
203
+ list rather than replacing it.
204
+
205
+ `perf bench` runs each `.sql` file in `perf.benchDir` (default
206
+ `db-quality/bench`) as `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`: one warm-up
207
+ run, then `perf.benchRuns` runs — or the count from a leading `-- runs: N`
208
+ comment in the file — taking the median execution time. `perf bench --record`
209
+ writes `.codeality-db-bench.json`; `perf bench` without the flag compares the
210
+ current run against that record. A `.sql` file with no recorded entry yet is
211
+ still judged for `BDB913` (the estimate check needs only the current run);
212
+ `BDB911` and `BDB912` need a recorded entry to compare against, so they never
213
+ fire for a file that has none. A statement that fails to run is an
214
+ infrastructure error (exit 3) naming the file.
215
+
216
+ A bench file is one statement; a file holding more than one is refused as a
217
+ configuration error. The server, not that check, is the boundary: the statement
218
+ reaches Postgres as a dollar-quoted literal opened as a PL/pgSQL cursor inside a
219
+ `DO` block, in a session that is read-only before it starts, and the plan comes
220
+ back through a session setting. A cursor over more than one statement is
221
+ refused, so nothing after the `EXPLAIN` ever runs; a write fails in the
222
+ read-only transaction; and the cursor runs in an inner block that is always
223
+ rolled back, which undoes what a read-only transaction does not stop:
224
+ `EXPLAIN ANALYZE` of `CREATE TABLE AS`, `SELECT INTO` or
225
+ `CREATE MATERIALIZED VIEW` writes even there.
226
+
227
+ Some side effects are outside any transaction and no read-only session stops
228
+ them: calls that leave the database through `dblink` or `pg_net`; for a role
229
+ with `REPLICATION`, a replication slot, which persists and retains WAL until it
230
+ is dropped; and, for a superuser, `COPY ... TO PROGRAM`, `lo_export`,
231
+ `pg_terminate_backend` and `pg_stat_statements_reset`. The strongest boundary is
232
+ a dedicated role that is not a superuser, has no `REPLICATION`, and can only
233
+ read; point `--db-url` at it for `perf`.
234
+
235
+ | Code | Layer | Rule | Severity |
236
+ | -------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
237
+ | `BDB901` | live | query-regressed: mean time grew by `perf.regressionPercent` or more and by at least 5 ms, at least `perf.minCalls` calls, at least 5 ms mean | error |
238
+ | `BDB902` | live | slow-query: mean time at or above `perf.slowMs`, at least `perf.minCalls` calls | warn |
239
+ | `BDB903` | live | seq-scan-table: a table with at least `perf.seqScanRows` live rows and more sequential than index scans in the window | warn |
240
+ | `BDB904` | live | temp-spill: a statement wrote temp blocks in the window, at least `perf.minCalls` calls: a sort or hash spilled to disk | warn |
241
+ | `BDB911` | bench | bench-regressed: median grew by `perf.regressionPercent` or more and by at least 5 ms | error |
242
+ | `BDB912` | bench | bench-plan-degraded: a new sequential scan on a table with at least `perf.seqScanRows` rows, or an index scan that became one | error |
243
+ | `BDB913` | bench | bench-estimate-off: the planner's row estimate is off by a factor of 100 or more on a plan node with at least 1000 estimated or actual rows | warn |
244
+
245
+ Live and bench findings are never carried in `.codeality-db-baseline.json`: they
246
+ are measurements, and a measurement nobody likes is fixed at the source, not
247
+ recorded away. `disable` with a written reason is the escape hatch for a live or
248
+ bench rule that genuinely does not apply to a project.
249
+
250
+ ### Connection
251
+
252
+ The live commands connect the same way the Supabase CLI itself does:
253
+ `--db-url <url>` wins; otherwise the linked project's pooler url in
254
+ `supabase/.temp/pooler-url` (written by `supabase link`) plus the password in
255
+ `SUPABASE_DB_PASSWORD` — the same variable the Supabase CLI reads. `gate` never
256
+ takes `--db-url`; its `perf` stage resolves only from the linked project, and
257
+ reports why it skipped when no password is available. Neither
258
+ `perf snapshot`/`diff`/`bench` nor the gate's `perf` stage runs without `psql`
259
+ on `PATH`: it is a required tool, and a missing one is an infrastructure failure
260
+ (exit 3), the same as a missing Supabase CLI.
261
+
262
+ Every session opens with `set default_transaction_read_only = on` and
263
+ `set statement_timeout = <ms>` as separate arguments before the query, sent
264
+ again before every call: a Supabase session pooler was measured, on 2026-09-25,
265
+ to ignore `PGOPTIONS` for this, so `PGOPTIONS` is never used. A transaction
266
+ pooler (port 6543, or `pgbouncer=true` in the url) is refused as a configuration
267
+ error: it can run each statement on a different backend, so the read-only
268
+ setting would not hold, and it would stay behind on a connection the application
269
+ shares. Use the session pooler (port 5432) or a direct connection.
270
+
271
+ The password reaches `psql` through `PGPASSWORD` only: a password in `--db-url`
272
+ is taken out of the url before `psql` sees its arguments, and `PGPASSWORD` is
273
+ left as it is when no password is given. A password `psql` echoes back in a
274
+ connection error is redacted to `***` before it reaches a finding or the
275
+ terminal.
276
+
277
+ ## Adoption in phases
278
+
279
+ No step below turns a green gate red by itself; a family of findings exists only
280
+ once its section is in the configuration file:
281
+
282
+ | phase | what the adopter does | what changes in the gate |
283
+ | ----- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
284
+ | 0 | `pnpm add -D @syntopica/db-quality@0.2` | Nothing. `schemaVersion: 1` is read as before, `postgrest` and `perf` are absent, so no new finding exists. |
285
+ | 1 | `init --apply` (rewrites `disable`, adds `postgrest.roots` and `perf` with `inGate: false`), then `baseline update` | `check` gains `BDB8xx`; the baseline absorbs the existing ones, so the gate stays green and only new debt fails. |
286
+ | 2 | `perf snapshot` after a deploy, `perf diff` after the next one | Nothing yet: `perf.inGate` is false. The improvement report and the `BDB9xx` findings are read by a person. |
287
+ | 3 | Write bench queries, `perf bench --record` | Nothing yet. The bench file is the reference. |
288
+ | 4 | Set `perf.inGate: true` | The `perf` stage runs `diff` and `bench` in the gate; regressions fail it. Locally and in any CI that holds `SUPABASE_DB_PASSWORD`; elsewhere the stage is `skipped-not-applicable`. |
289
+
290
+ `codeality-db init` prints this table with the phase it detects and the next
291
+ step every time it runs, so the next move is never a guess; `--apply` prints the
292
+ same report after writing the plan.
73
293
 
74
294
  ## Baseline
75
295
 
@@ -79,7 +299,9 @@ fails only on findings the baseline does not carry. Fingerprints hash the code,
79
299
  the path and the normalised statement, never the line number, so inserting a
80
300
  migration above a known finding does not renew it.
81
301
  `baseline check --check-stale` also fails on entries nothing reports any more,
82
- so dead debt is not carried forever.
302
+ so dead debt is not carried forever. The baseline covers `check` findings only:
303
+ `perf`'s live and bench findings are never recorded there — see
304
+ [Performance](#performance).
83
305
 
84
306
  ## Squawk under Supabase
85
307
 
@@ -24,3 +24,5 @@ jobs:
24
24
  - run: pnpm exec codeality-db gate
25
25
  env:
26
26
  SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
27
+ # The perf stage skips itself without a database password.
28
+ SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}