@tamsensedev/runner 0.10.0 → 0.18.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/LICENSE +5 -5
- package/README.md +61 -50
- package/dist/cli.js +1188 -324
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +42 -6
- package/dist/recorder.cjs.map +1 -1
- package/library/.claude-plugin/plugin.json +5 -5
- package/library/THIRD_PARTY_NOTICES.md +123 -123
- package/library/agents/code-architect.md +67 -67
- package/library/agents/code-explorer.md +61 -61
- package/library/agents/code-reviewer.md +73 -73
- package/library/agents/code-simplifier.md +62 -62
- package/library/agents/design-critic.md +52 -52
- package/library/agents/second-opinion.md +64 -64
- package/library/agents/security-auditor.md +86 -86
- package/library/agents/silent-failure-hunter.md +70 -70
- package/library/agents/test-coverage-analyzer.md +78 -78
- package/library/agents/test-runner.md +48 -48
- package/library/agents/ui-tester.md +50 -50
- package/library/licenses/Apache-2.0.txt +202 -202
- package/library/skills/accessibility/SKILL.md +144 -144
- package/library/skills/accessibility/references/wcag-checks.md +168 -168
- package/library/skills/addressing-review-feedback/SKILL.md +109 -109
- package/library/skills/app-screens/SKILL.md +96 -96
- package/library/skills/app-screens/references/shell-kpis-charts.md +108 -108
- package/library/skills/app-screens/references/states-density-phone.md +54 -54
- package/library/skills/app-screens/references/tables-forms-settings.md +104 -104
- package/library/skills/avoiding-generic-ai-design/SKILL.md +92 -92
- package/library/skills/avoiding-generic-ai-design/references/tells.md +112 -112
- package/library/skills/clean-commits/SKILL.md +85 -85
- package/library/skills/design-review/SKILL.md +107 -107
- package/library/skills/design-review/references/critique-passes.md +101 -101
- package/library/skills/design-review/references/screenshot-procedure.md +121 -121
- package/library/skills/differential-security-review/LICENSE +427 -427
- package/library/skills/differential-security-review/SKILL.md +105 -105
- package/library/skills/differential-security-review/references/adversarial.md +115 -115
- package/library/skills/differential-security-review/references/methodology.md +145 -145
- package/library/skills/differential-security-review/references/patterns.md +108 -108
- package/library/skills/false-positive-check/LICENSE +427 -427
- package/library/skills/false-positive-check/SKILL.md +95 -95
- package/library/skills/false-positive-check/references/bug-class-verification.md +128 -128
- package/library/skills/false-positive-check/references/deep-verification.md +113 -113
- package/library/skills/false-positive-check/references/evidence-templates.md +93 -93
- package/library/skills/false-positive-check/references/false-positive-patterns.md +122 -122
- package/library/skills/false-positive-check/references/gate-reviews.md +36 -36
- package/library/skills/false-positive-check/references/standard-verification.md +80 -80
- package/library/skills/focused-implementation/SKILL.md +104 -104
- package/library/skills/implementation-plan/SKILL.md +66 -66
- package/library/skills/interface-design/SKILL.md +144 -144
- package/library/skills/interface-design/references/craft-defaults.md +130 -130
- package/library/skills/interface-design/references/new-direction.md +62 -62
- package/library/skills/interface-design/references/writing.md +52 -52
- package/library/skills/interface-polish/SKILL.md +103 -103
- package/library/skills/interface-polish/references/detail-rules.md +154 -154
- package/library/skills/interface-polish/references/worst-case-data.md +158 -158
- package/library/skills/motion-design/SKILL.md +106 -106
- package/library/skills/motion-design/references/recipes.md +243 -243
- package/library/skills/postgres-performance/SKILL.md +97 -97
- package/library/skills/postgres-performance/references/rules.md +265 -265
- package/library/skills/project-orientation/SKILL.md +77 -77
- package/library/skills/reviewing-changes/SKILL.md +183 -183
- package/library/skills/safe-database-changes/SKILL.md +111 -111
- package/library/skills/safe-database-changes/references/backfills.md +75 -75
- package/library/skills/safe-database-changes/references/migration-tools.md +69 -69
- package/library/skills/safe-database-changes/references/postgres-ddl.md +199 -199
- package/library/skills/security-review/SKILL.md +132 -132
- package/library/skills/security-review/references/checks-by-area.md +107 -107
- package/library/skills/security-review/references/do-not-report.md +86 -86
- package/library/skills/spec-writing/SKILL.md +144 -144
- package/library/skills/systematic-debugging/SKILL.md +130 -130
- package/library/skills/systematic-debugging/references/flaky-tests.md +51 -51
- package/library/skills/systematic-debugging/references/root-cause-tracing.md +55 -55
- package/library/skills/task-interview/SKILL.md +84 -84
- package/library/skills/test-driven-development/SKILL.md +116 -116
- package/library/skills/test-driven-development/references/writing-good-tests.md +85 -85
- package/library/skills/ui-testing/SKILL.md +102 -102
- package/library/skills/ui-testing/references/headless-check.md +295 -280
- package/library/skills/ui-testing/references/local-stand.md +51 -51
- package/library/skills/ui-testing/references/playwright-patterns.md +134 -134
- package/library/skills/ui-testing/references/recording.md +61 -58
- package/library/skills/verification-before-completion/SKILL.md +113 -113
- package/library/skills/web-interface-guidelines/SKILL.md +202 -202
- package/package.json +46 -46
|
@@ -1,265 +1,265 @@
|
|
|
1
|
-
# PostgreSQL rules: wrong and right
|
|
2
|
-
|
|
3
|
-
Read the section that matches what you found in step 2 of the skill. Each rule shows the slow form and the fix. Table and column names are examples.
|
|
4
|
-
|
|
5
|
-
## Indexes
|
|
6
|
-
|
|
7
|
-
**Index the columns you filter and join on.** Without one, every lookup scans the whole table.
|
|
8
|
-
|
|
9
|
-
```sql
|
|
10
|
-
-- wrong: no index on customer_id, so Seq Scan on a large table
|
|
11
|
-
SELECT * FROM orders WHERE customer_id = 123;
|
|
12
|
-
-- right
|
|
13
|
-
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
**Composite indexes: equality columns first, range or sort columns last.** An index on `(a, b)` serves `a = ?` and `a = ? AND b > ?`, but not `b > ?` alone.
|
|
17
|
-
|
|
18
|
-
```sql
|
|
19
|
-
-- wrong: two single-column indexes, combined with a slower bitmap scan
|
|
20
|
-
CREATE INDEX ON orders (status);
|
|
21
|
-
CREATE INDEX ON orders (created_at);
|
|
22
|
-
-- right, for WHERE status = 'pending' AND created_at > ... ORDER BY created_at
|
|
23
|
-
CREATE INDEX orders_status_created_idx ON orders (status, created_at);
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
**Index foreign key columns.** Postgres indexes the referenced primary key, not the referencing column. Without it, joins and `ON DELETE CASCADE` scan the child table.
|
|
27
|
-
|
|
28
|
-
```sql
|
|
29
|
-
-- find foreign keys whose columns lead no index
|
|
30
|
-
SELECT c.conrelid::regclass AS table_name, a.attname AS column_name
|
|
31
|
-
FROM pg_constraint c
|
|
32
|
-
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
|
|
33
|
-
WHERE c.contype = 'f'
|
|
34
|
-
AND NOT EXISTS (
|
|
35
|
-
SELECT 1 FROM pg_index i
|
|
36
|
-
WHERE i.indrelid = c.conrelid AND i.indkey[0] = a.attnum
|
|
37
|
-
);
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**Partial indexes for queries that always filter the same way.** Smaller and faster than a full index.
|
|
41
|
-
|
|
42
|
-
```sql
|
|
43
|
-
-- queries always add deleted_at IS NULL
|
|
44
|
-
CREATE INDEX users_active_email_idx ON users (email) WHERE deleted_at IS NULL;
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
**Covering indexes to skip the table.** `INCLUDE` columns you select but do not filter on, so the plan can use an Index Only Scan.
|
|
48
|
-
|
|
49
|
-
```sql
|
|
50
|
-
CREATE INDEX orders_status_idx ON orders (status) INCLUDE (customer_id, total);
|
|
51
|
-
SELECT status, customer_id, total FROM orders WHERE status = 'shipped';
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**Match the index to the expression.** A function or cast on the column hides it from a plain index.
|
|
55
|
-
|
|
56
|
-
```sql
|
|
57
|
-
-- wrong: index on email is not used
|
|
58
|
-
SELECT * FROM users WHERE lower(email) = lower($1);
|
|
59
|
-
-- right: expression index (or store a normalized column)
|
|
60
|
-
CREATE INDEX users_email_lower_idx ON users (lower(email));
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Also watch implicit casts: comparing a `bigint` column with a `text` parameter, or a `timestamptz` column with a date string in another type, can prevent index use.
|
|
64
|
-
|
|
65
|
-
**Pick the index type for the operator.**
|
|
66
|
-
|
|
67
|
-
| Data and operator | Index |
|
|
68
|
-
|---|---|
|
|
69
|
-
| `=`, `<`, `>`, `BETWEEN`, `IN`, `ORDER BY` | B-tree (default) |
|
|
70
|
-
| JSONB containment `@>`, `?`, arrays, full-text `@@` | GIN |
|
|
71
|
-
| Ranges, geometry, nearest neighbour | GiST |
|
|
72
|
-
| Huge append-only tables queried by time ranges | BRIN |
|
|
73
|
-
|
|
74
|
-
**Do not over-index.** Each index costs write time, disk and vacuum work. Before adding one, list existing indexes (`\d table` or `pg_indexes`) and check whether one already has the needed leading columns. In reviews, flag new indexes that duplicate an existing prefix.
|
|
75
|
-
|
|
76
|
-
## N+1 queries
|
|
77
|
-
|
|
78
|
-
One query for the list, then one per item:
|
|
79
|
-
|
|
80
|
-
```sql
|
|
81
|
-
-- wrong: 1 + N round trips
|
|
82
|
-
SELECT id FROM users WHERE active;
|
|
83
|
-
SELECT * FROM orders WHERE user_id = 1;
|
|
84
|
-
SELECT * FROM orders WHERE user_id = 2;
|
|
85
|
-
-- ...
|
|
86
|
-
-- right: one query with an array parameter, or a join
|
|
87
|
-
SELECT * FROM orders WHERE user_id = ANY ($1::bigint[]);
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
In ORMs this hides behind lazy relations in a loop or a template. Use eager loading (`include`, `with`, `select_related` / `prefetch_related`, `includes` / `preload`, `joinedload` / `selectinload`) or collect ids and fetch once. GraphQL resolvers need a batching loader. Count queries per request to confirm.
|
|
91
|
-
|
|
92
|
-
## Pagination
|
|
93
|
-
|
|
94
|
-
OFFSET reads and discards every skipped row, so deep pages get slower and rows shift between pages when data changes.
|
|
95
|
-
|
|
96
|
-
```sql
|
|
97
|
-
-- wrong
|
|
98
|
-
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 19980;
|
|
99
|
-
-- right: keyset (cursor) pagination
|
|
100
|
-
SELECT * FROM products WHERE id > $last_id ORDER BY id LIMIT 20;
|
|
101
|
-
-- sorting by several columns: the cursor holds all of them, the index matches
|
|
102
|
-
SELECT * FROM products
|
|
103
|
-
WHERE (created_at, id) > ($last_created_at, $last_id)
|
|
104
|
-
ORDER BY created_at, id LIMIT 20;
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
OFFSET is acceptable for small, bounded lists (admin tables with a few pages). Exact total counts on large tables are expensive too; consider "has more" instead of a full `COUNT(*)`.
|
|
108
|
-
|
|
109
|
-
## Query shape
|
|
110
|
-
|
|
111
|
-
**Bound every list query.** A `SELECT` without `LIMIT` on a table that grows will eventually return too much. Select only the columns you need, especially with wide text or JSON columns.
|
|
112
|
-
|
|
113
|
-
**Test existence with EXISTS, not COUNT.**
|
|
114
|
-
|
|
115
|
-
```sql
|
|
116
|
-
-- wrong
|
|
117
|
-
SELECT count(*) FROM orders WHERE user_id = $1; -- then check > 0
|
|
118
|
-
-- right
|
|
119
|
-
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = $1);
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
**Batch inserts.** One statement per row means one round trip per row.
|
|
123
|
-
|
|
124
|
-
```sql
|
|
125
|
-
-- wrong: a loop of single-row INSERTs
|
|
126
|
-
-- right: multi-row VALUES (hundreds to a few thousand rows per statement)
|
|
127
|
-
INSERT INTO events (user_id, action) VALUES ($1, $2), ($3, $4), ($5, $6);
|
|
128
|
-
-- bulk loads: COPY, or the driver's copy or bulk helper
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
**Upsert atomically.** Select-then-insert races under concurrency.
|
|
132
|
-
|
|
133
|
-
```sql
|
|
134
|
-
-- wrong: two requests both see "not found", both insert, one fails
|
|
135
|
-
SELECT 1 FROM settings WHERE user_id = $1 AND key = $2;
|
|
136
|
-
INSERT INTO settings (user_id, key, value) VALUES ($1, $2, $3);
|
|
137
|
-
-- right (needs a unique constraint on user_id, key)
|
|
138
|
-
INSERT INTO settings (user_id, key, value) VALUES ($1, $2, $3)
|
|
139
|
-
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
**Beware `NOT IN (subquery)`** when the subquery can return NULL: it returns no rows. Use `NOT EXISTS`.
|
|
143
|
-
|
|
144
|
-
## Transactions and locks
|
|
145
|
-
|
|
146
|
-
**Keep transactions short.** Never hold a transaction (and its row locks) open across an HTTP call, a queue publish or user input.
|
|
147
|
-
|
|
148
|
-
```sql
|
|
149
|
-
-- wrong
|
|
150
|
-
BEGIN;
|
|
151
|
-
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
|
|
152
|
-
-- app calls the payment API for 3 seconds while the row stays locked
|
|
153
|
-
UPDATE orders SET status = 'paid' WHERE id = 1;
|
|
154
|
-
COMMIT;
|
|
155
|
-
-- right: call the API first, then a short conditional update
|
|
156
|
-
UPDATE orders SET status = 'paid', payment_id = $1
|
|
157
|
-
WHERE id = $2 AND status = 'pending'
|
|
158
|
-
RETURNING id;
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
**Lock rows in a consistent order** to avoid deadlocks, for example `SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE` before updating several rows, or do it in one UPDATE statement.
|
|
162
|
-
|
|
163
|
-
**Job queues: `FOR UPDATE SKIP LOCKED`.** Workers then take different rows instead of waiting on each other.
|
|
164
|
-
|
|
165
|
-
```sql
|
|
166
|
-
UPDATE jobs SET status = 'running', started_at = now(), worker_id = $1
|
|
167
|
-
WHERE id = (
|
|
168
|
-
SELECT id FROM jobs WHERE status = 'pending'
|
|
169
|
-
ORDER BY created_at LIMIT 1
|
|
170
|
-
FOR UPDATE SKIP LOCKED
|
|
171
|
-
)
|
|
172
|
-
RETURNING *;
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
**Advisory locks** coordinate app-level work (one cron runner at a time) without dummy lock rows: `pg_try_advisory_xact_lock(key)` inside a transaction releases automatically on commit. Session-level advisory locks behave badly with transaction-mode poolers; prefer the transaction-level form.
|
|
176
|
-
|
|
177
|
-
**Statement timeouts.** A `statement_timeout` (per role, per session or `SET LOCAL` per transaction) stops runaway queries. Recommend values in the report; do not change server settings.
|
|
178
|
-
|
|
179
|
-
## Connections
|
|
180
|
-
|
|
181
|
-
**Use one pool per process, created once.** A new client per request exhausts the server's connections under load, and each Postgres connection costs memory.
|
|
182
|
-
|
|
183
|
-
```js
|
|
184
|
-
// wrong: a client per request
|
|
185
|
-
app.get('/orders', async () => { const c = new Client(url); await c.connect(); /* ... */ });
|
|
186
|
-
// right: module-level pool (or the ORM's single client), reused
|
|
187
|
-
const pool = new Pool({ connectionString: url, max: 10 });
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
In serverless and edge runtimes, keep the client outside the handler and use the platform's pooler or a transaction-mode pooler such as PgBouncer.
|
|
191
|
-
|
|
192
|
-
**Transaction-mode poolers do not keep session state.** Named prepared statements, `SET` without `LOCAL`, temp tables, `LISTEN` and session advisory locks can break, because the next transaction may run on another server connection. Use the driver's or ORM's setting for pooled connections (many have a flag to disable named prepared statements) or a session-mode connection for those features.
|
|
193
|
-
|
|
194
|
-
**Always release connections**, including on errors (`try/finally`, the pool's helper that releases for you). A leak looks like "too many connections" after a while; raising limits only delays it.
|
|
195
|
-
|
|
196
|
-
Server-side settings (`max_connections`, `idle_in_transaction_session_timeout`, pool sizes) belong in the report as recommendations.
|
|
197
|
-
|
|
198
|
-
## Row-level security
|
|
199
|
-
|
|
200
|
-
**Call per-request functions once, not per row.** Wrap them in a scalar subquery so the planner evaluates them once.
|
|
201
|
-
|
|
202
|
-
```sql
|
|
203
|
-
-- wrong: current_setting() evaluated for every row
|
|
204
|
-
CREATE POLICY orders_owner ON orders
|
|
205
|
-
USING (user_id = current_setting('app.user_id')::bigint);
|
|
206
|
-
-- right
|
|
207
|
-
CREATE POLICY orders_owner ON orders
|
|
208
|
-
USING (user_id = (SELECT current_setting('app.user_id')::bigint));
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
The same applies to platform helpers that return the current user (for example `auth.uid()` on Supabase).
|
|
212
|
-
|
|
213
|
-
**Index every column the policy filters on** (`user_id`, `tenant_id`, `team_id`). The policy is an extra WHERE clause on every query.
|
|
214
|
-
|
|
215
|
-
**Complex membership checks:** a `SECURITY DEFINER` function can do an indexed lookup without nested policy evaluation, but it bypasses RLS on the tables it reads. Keep it in a schema the API does not expose, set `search_path = ''` and schema-qualify names, check the caller's identity inside it, and keep `EXECUTE` limited to the roles whose policies call it. Flag any new SECURITY DEFINER function for the `security-review` skill.
|
|
216
|
-
|
|
217
|
-
Remember that RLS does not apply to table owners unless `FORCE ROW LEVEL SECURITY` is set, and that the application role should not be a superuser.
|
|
218
|
-
|
|
219
|
-
## Types and keys
|
|
220
|
-
|
|
221
|
-
| Use | Instead of | Why |
|
|
222
|
-
|---|---|---|
|
|
223
|
-
| `bigint` identity (`GENERATED ALWAYS AS IDENTITY`) | `int`, `serial` | `int` ids overflow at about 2.1 billion |
|
|
224
|
-
| Time-ordered UUIDs (v7) when ids must be distributed or public | Random UUID v4 as the primary key of a big table | Random keys scatter inserts across the index |
|
|
225
|
-
| `timestamptz` | `timestamp` | Stores an absolute moment |
|
|
226
|
-
| `numeric(p, s)` or integer minor units for money | `float`, `real` | Exact arithmetic |
|
|
227
|
-
| `text` (plus a CHECK if a limit matters) | `varchar(n)` chosen arbitrarily | Same performance, fewer migrations |
|
|
228
|
-
| `boolean`, enums or CHECK constraints | Strings for flags and states | Smaller, validated |
|
|
229
|
-
|
|
230
|
-
UUID v7 is built in from PostgreSQL 18 (`uuidv7()`); older versions need an extension or ids generated by the application. Follow the project's existing key strategy unless the task is about changing it.
|
|
231
|
-
|
|
232
|
-
Very large time-series tables (hundreds of millions of rows) where old data is dropped in bulk are candidates for range partitioning; this is a design change, so recommend it rather than doing it inside an unrelated task.
|
|
233
|
-
|
|
234
|
-
## Search and JSONB
|
|
235
|
-
|
|
236
|
-
**Leading-wildcard LIKE cannot use a B-tree index.**
|
|
237
|
-
|
|
238
|
-
```sql
|
|
239
|
-
-- wrong on large tables
|
|
240
|
-
SELECT * FROM articles WHERE body ILIKE '%postgres%';
|
|
241
|
-
-- right for word search: full-text search with a GIN index
|
|
242
|
-
ALTER TABLE articles ADD COLUMN search tsvector
|
|
243
|
-
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))) STORED;
|
|
244
|
-
CREATE INDEX articles_search_idx ON articles USING gin (search);
|
|
245
|
-
SELECT * FROM articles WHERE search @@ websearch_to_tsquery('english', 'postgres performance');
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
For substring matching, a trigram index (`pg_trgm` extension, `gin (col gin_trgm_ops)`) supports `ILIKE '%...%'`. Adding an extension is an operator decision: mention it if the project does not already use it. Adding a stored generated column rewrites the table: follow `safe-database-changes`.
|
|
249
|
-
|
|
250
|
-
**JSONB:** a GIN index serves containment (`@>`); `jsonb_path_ops` is smaller if you only use `@>`. For lookups on one key, an expression index is better:
|
|
251
|
-
|
|
252
|
-
```sql
|
|
253
|
-
CREATE INDEX products_brand_idx ON products ((attributes ->> 'brand'));
|
|
254
|
-
SELECT * FROM products WHERE attributes ->> 'brand' = 'Acme';
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
If the same JSON keys are queried everywhere, they probably deserve real columns.
|
|
258
|
-
|
|
259
|
-
## Diagnostics
|
|
260
|
-
|
|
261
|
-
- `EXPLAIN (ANALYZE, BUFFERS)` on the throwaway database: see the skill for how to read it.
|
|
262
|
-
- `pg_stat_statements`, if the project or its operators use it, ranks queries by total and mean time; ask for its output in the report rather than querying a production database yourself.
|
|
263
|
-
- After large data changes, `ANALYZE table` refreshes planner statistics; bad estimates (estimated rows far from actual) often mean stale statistics.
|
|
264
|
-
|
|
265
|
-
Adapted from supabase/agent-skills `skills/supabase-postgres-best-practices` (MIT, commit c9be0e931b7930f7d02126d04774d904c381e7d7, Copyright (c) 2026 Supabase). Changed: made Postgres-generic (Supabase ports, roles and dashboards removed), condensed about 34 rule files into one page, fixed the SECURITY DEFINER example that revoked EXECUTE from the roles its policy needs, replaced server-setting changes with report recommendations, added expression indexes, EXISTS, NOT IN, trigram and ORM notes.
|
|
1
|
+
# PostgreSQL rules: wrong and right
|
|
2
|
+
|
|
3
|
+
Read the section that matches what you found in step 2 of the skill. Each rule shows the slow form and the fix. Table and column names are examples.
|
|
4
|
+
|
|
5
|
+
## Indexes
|
|
6
|
+
|
|
7
|
+
**Index the columns you filter and join on.** Without one, every lookup scans the whole table.
|
|
8
|
+
|
|
9
|
+
```sql
|
|
10
|
+
-- wrong: no index on customer_id, so Seq Scan on a large table
|
|
11
|
+
SELECT * FROM orders WHERE customer_id = 123;
|
|
12
|
+
-- right
|
|
13
|
+
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Composite indexes: equality columns first, range or sort columns last.** An index on `(a, b)` serves `a = ?` and `a = ? AND b > ?`, but not `b > ?` alone.
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
-- wrong: two single-column indexes, combined with a slower bitmap scan
|
|
20
|
+
CREATE INDEX ON orders (status);
|
|
21
|
+
CREATE INDEX ON orders (created_at);
|
|
22
|
+
-- right, for WHERE status = 'pending' AND created_at > ... ORDER BY created_at
|
|
23
|
+
CREATE INDEX orders_status_created_idx ON orders (status, created_at);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Index foreign key columns.** Postgres indexes the referenced primary key, not the referencing column. Without it, joins and `ON DELETE CASCADE` scan the child table.
|
|
27
|
+
|
|
28
|
+
```sql
|
|
29
|
+
-- find foreign keys whose columns lead no index
|
|
30
|
+
SELECT c.conrelid::regclass AS table_name, a.attname AS column_name
|
|
31
|
+
FROM pg_constraint c
|
|
32
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
|
|
33
|
+
WHERE c.contype = 'f'
|
|
34
|
+
AND NOT EXISTS (
|
|
35
|
+
SELECT 1 FROM pg_index i
|
|
36
|
+
WHERE i.indrelid = c.conrelid AND i.indkey[0] = a.attnum
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Partial indexes for queries that always filter the same way.** Smaller and faster than a full index.
|
|
41
|
+
|
|
42
|
+
```sql
|
|
43
|
+
-- queries always add deleted_at IS NULL
|
|
44
|
+
CREATE INDEX users_active_email_idx ON users (email) WHERE deleted_at IS NULL;
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Covering indexes to skip the table.** `INCLUDE` columns you select but do not filter on, so the plan can use an Index Only Scan.
|
|
48
|
+
|
|
49
|
+
```sql
|
|
50
|
+
CREATE INDEX orders_status_idx ON orders (status) INCLUDE (customer_id, total);
|
|
51
|
+
SELECT status, customer_id, total FROM orders WHERE status = 'shipped';
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Match the index to the expression.** A function or cast on the column hides it from a plain index.
|
|
55
|
+
|
|
56
|
+
```sql
|
|
57
|
+
-- wrong: index on email is not used
|
|
58
|
+
SELECT * FROM users WHERE lower(email) = lower($1);
|
|
59
|
+
-- right: expression index (or store a normalized column)
|
|
60
|
+
CREATE INDEX users_email_lower_idx ON users (lower(email));
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Also watch implicit casts: comparing a `bigint` column with a `text` parameter, or a `timestamptz` column with a date string in another type, can prevent index use.
|
|
64
|
+
|
|
65
|
+
**Pick the index type for the operator.**
|
|
66
|
+
|
|
67
|
+
| Data and operator | Index |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `=`, `<`, `>`, `BETWEEN`, `IN`, `ORDER BY` | B-tree (default) |
|
|
70
|
+
| JSONB containment `@>`, `?`, arrays, full-text `@@` | GIN |
|
|
71
|
+
| Ranges, geometry, nearest neighbour | GiST |
|
|
72
|
+
| Huge append-only tables queried by time ranges | BRIN |
|
|
73
|
+
|
|
74
|
+
**Do not over-index.** Each index costs write time, disk and vacuum work. Before adding one, list existing indexes (`\d table` or `pg_indexes`) and check whether one already has the needed leading columns. In reviews, flag new indexes that duplicate an existing prefix.
|
|
75
|
+
|
|
76
|
+
## N+1 queries
|
|
77
|
+
|
|
78
|
+
One query for the list, then one per item:
|
|
79
|
+
|
|
80
|
+
```sql
|
|
81
|
+
-- wrong: 1 + N round trips
|
|
82
|
+
SELECT id FROM users WHERE active;
|
|
83
|
+
SELECT * FROM orders WHERE user_id = 1;
|
|
84
|
+
SELECT * FROM orders WHERE user_id = 2;
|
|
85
|
+
-- ...
|
|
86
|
+
-- right: one query with an array parameter, or a join
|
|
87
|
+
SELECT * FROM orders WHERE user_id = ANY ($1::bigint[]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
In ORMs this hides behind lazy relations in a loop or a template. Use eager loading (`include`, `with`, `select_related` / `prefetch_related`, `includes` / `preload`, `joinedload` / `selectinload`) or collect ids and fetch once. GraphQL resolvers need a batching loader. Count queries per request to confirm.
|
|
91
|
+
|
|
92
|
+
## Pagination
|
|
93
|
+
|
|
94
|
+
OFFSET reads and discards every skipped row, so deep pages get slower and rows shift between pages when data changes.
|
|
95
|
+
|
|
96
|
+
```sql
|
|
97
|
+
-- wrong
|
|
98
|
+
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 19980;
|
|
99
|
+
-- right: keyset (cursor) pagination
|
|
100
|
+
SELECT * FROM products WHERE id > $last_id ORDER BY id LIMIT 20;
|
|
101
|
+
-- sorting by several columns: the cursor holds all of them, the index matches
|
|
102
|
+
SELECT * FROM products
|
|
103
|
+
WHERE (created_at, id) > ($last_created_at, $last_id)
|
|
104
|
+
ORDER BY created_at, id LIMIT 20;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
OFFSET is acceptable for small, bounded lists (admin tables with a few pages). Exact total counts on large tables are expensive too; consider "has more" instead of a full `COUNT(*)`.
|
|
108
|
+
|
|
109
|
+
## Query shape
|
|
110
|
+
|
|
111
|
+
**Bound every list query.** A `SELECT` without `LIMIT` on a table that grows will eventually return too much. Select only the columns you need, especially with wide text or JSON columns.
|
|
112
|
+
|
|
113
|
+
**Test existence with EXISTS, not COUNT.**
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
-- wrong
|
|
117
|
+
SELECT count(*) FROM orders WHERE user_id = $1; -- then check > 0
|
|
118
|
+
-- right
|
|
119
|
+
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = $1);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Batch inserts.** One statement per row means one round trip per row.
|
|
123
|
+
|
|
124
|
+
```sql
|
|
125
|
+
-- wrong: a loop of single-row INSERTs
|
|
126
|
+
-- right: multi-row VALUES (hundreds to a few thousand rows per statement)
|
|
127
|
+
INSERT INTO events (user_id, action) VALUES ($1, $2), ($3, $4), ($5, $6);
|
|
128
|
+
-- bulk loads: COPY, or the driver's copy or bulk helper
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Upsert atomically.** Select-then-insert races under concurrency.
|
|
132
|
+
|
|
133
|
+
```sql
|
|
134
|
+
-- wrong: two requests both see "not found", both insert, one fails
|
|
135
|
+
SELECT 1 FROM settings WHERE user_id = $1 AND key = $2;
|
|
136
|
+
INSERT INTO settings (user_id, key, value) VALUES ($1, $2, $3);
|
|
137
|
+
-- right (needs a unique constraint on user_id, key)
|
|
138
|
+
INSERT INTO settings (user_id, key, value) VALUES ($1, $2, $3)
|
|
139
|
+
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Beware `NOT IN (subquery)`** when the subquery can return NULL: it returns no rows. Use `NOT EXISTS`.
|
|
143
|
+
|
|
144
|
+
## Transactions and locks
|
|
145
|
+
|
|
146
|
+
**Keep transactions short.** Never hold a transaction (and its row locks) open across an HTTP call, a queue publish or user input.
|
|
147
|
+
|
|
148
|
+
```sql
|
|
149
|
+
-- wrong
|
|
150
|
+
BEGIN;
|
|
151
|
+
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
|
|
152
|
+
-- app calls the payment API for 3 seconds while the row stays locked
|
|
153
|
+
UPDATE orders SET status = 'paid' WHERE id = 1;
|
|
154
|
+
COMMIT;
|
|
155
|
+
-- right: call the API first, then a short conditional update
|
|
156
|
+
UPDATE orders SET status = 'paid', payment_id = $1
|
|
157
|
+
WHERE id = $2 AND status = 'pending'
|
|
158
|
+
RETURNING id;
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Lock rows in a consistent order** to avoid deadlocks, for example `SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE` before updating several rows, or do it in one UPDATE statement.
|
|
162
|
+
|
|
163
|
+
**Job queues: `FOR UPDATE SKIP LOCKED`.** Workers then take different rows instead of waiting on each other.
|
|
164
|
+
|
|
165
|
+
```sql
|
|
166
|
+
UPDATE jobs SET status = 'running', started_at = now(), worker_id = $1
|
|
167
|
+
WHERE id = (
|
|
168
|
+
SELECT id FROM jobs WHERE status = 'pending'
|
|
169
|
+
ORDER BY created_at LIMIT 1
|
|
170
|
+
FOR UPDATE SKIP LOCKED
|
|
171
|
+
)
|
|
172
|
+
RETURNING *;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Advisory locks** coordinate app-level work (one cron runner at a time) without dummy lock rows: `pg_try_advisory_xact_lock(key)` inside a transaction releases automatically on commit. Session-level advisory locks behave badly with transaction-mode poolers; prefer the transaction-level form.
|
|
176
|
+
|
|
177
|
+
**Statement timeouts.** A `statement_timeout` (per role, per session or `SET LOCAL` per transaction) stops runaway queries. Recommend values in the report; do not change server settings.
|
|
178
|
+
|
|
179
|
+
## Connections
|
|
180
|
+
|
|
181
|
+
**Use one pool per process, created once.** A new client per request exhausts the server's connections under load, and each Postgres connection costs memory.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// wrong: a client per request
|
|
185
|
+
app.get('/orders', async () => { const c = new Client(url); await c.connect(); /* ... */ });
|
|
186
|
+
// right: module-level pool (or the ORM's single client), reused
|
|
187
|
+
const pool = new Pool({ connectionString: url, max: 10 });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
In serverless and edge runtimes, keep the client outside the handler and use the platform's pooler or a transaction-mode pooler such as PgBouncer.
|
|
191
|
+
|
|
192
|
+
**Transaction-mode poolers do not keep session state.** Named prepared statements, `SET` without `LOCAL`, temp tables, `LISTEN` and session advisory locks can break, because the next transaction may run on another server connection. Use the driver's or ORM's setting for pooled connections (many have a flag to disable named prepared statements) or a session-mode connection for those features.
|
|
193
|
+
|
|
194
|
+
**Always release connections**, including on errors (`try/finally`, the pool's helper that releases for you). A leak looks like "too many connections" after a while; raising limits only delays it.
|
|
195
|
+
|
|
196
|
+
Server-side settings (`max_connections`, `idle_in_transaction_session_timeout`, pool sizes) belong in the report as recommendations.
|
|
197
|
+
|
|
198
|
+
## Row-level security
|
|
199
|
+
|
|
200
|
+
**Call per-request functions once, not per row.** Wrap them in a scalar subquery so the planner evaluates them once.
|
|
201
|
+
|
|
202
|
+
```sql
|
|
203
|
+
-- wrong: current_setting() evaluated for every row
|
|
204
|
+
CREATE POLICY orders_owner ON orders
|
|
205
|
+
USING (user_id = current_setting('app.user_id')::bigint);
|
|
206
|
+
-- right
|
|
207
|
+
CREATE POLICY orders_owner ON orders
|
|
208
|
+
USING (user_id = (SELECT current_setting('app.user_id')::bigint));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The same applies to platform helpers that return the current user (for example `auth.uid()` on Supabase).
|
|
212
|
+
|
|
213
|
+
**Index every column the policy filters on** (`user_id`, `tenant_id`, `team_id`). The policy is an extra WHERE clause on every query.
|
|
214
|
+
|
|
215
|
+
**Complex membership checks:** a `SECURITY DEFINER` function can do an indexed lookup without nested policy evaluation, but it bypasses RLS on the tables it reads. Keep it in a schema the API does not expose, set `search_path = ''` and schema-qualify names, check the caller's identity inside it, and keep `EXECUTE` limited to the roles whose policies call it. Flag any new SECURITY DEFINER function for the `security-review` skill.
|
|
216
|
+
|
|
217
|
+
Remember that RLS does not apply to table owners unless `FORCE ROW LEVEL SECURITY` is set, and that the application role should not be a superuser.
|
|
218
|
+
|
|
219
|
+
## Types and keys
|
|
220
|
+
|
|
221
|
+
| Use | Instead of | Why |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `bigint` identity (`GENERATED ALWAYS AS IDENTITY`) | `int`, `serial` | `int` ids overflow at about 2.1 billion |
|
|
224
|
+
| Time-ordered UUIDs (v7) when ids must be distributed or public | Random UUID v4 as the primary key of a big table | Random keys scatter inserts across the index |
|
|
225
|
+
| `timestamptz` | `timestamp` | Stores an absolute moment |
|
|
226
|
+
| `numeric(p, s)` or integer minor units for money | `float`, `real` | Exact arithmetic |
|
|
227
|
+
| `text` (plus a CHECK if a limit matters) | `varchar(n)` chosen arbitrarily | Same performance, fewer migrations |
|
|
228
|
+
| `boolean`, enums or CHECK constraints | Strings for flags and states | Smaller, validated |
|
|
229
|
+
|
|
230
|
+
UUID v7 is built in from PostgreSQL 18 (`uuidv7()`); older versions need an extension or ids generated by the application. Follow the project's existing key strategy unless the task is about changing it.
|
|
231
|
+
|
|
232
|
+
Very large time-series tables (hundreds of millions of rows) where old data is dropped in bulk are candidates for range partitioning; this is a design change, so recommend it rather than doing it inside an unrelated task.
|
|
233
|
+
|
|
234
|
+
## Search and JSONB
|
|
235
|
+
|
|
236
|
+
**Leading-wildcard LIKE cannot use a B-tree index.**
|
|
237
|
+
|
|
238
|
+
```sql
|
|
239
|
+
-- wrong on large tables
|
|
240
|
+
SELECT * FROM articles WHERE body ILIKE '%postgres%';
|
|
241
|
+
-- right for word search: full-text search with a GIN index
|
|
242
|
+
ALTER TABLE articles ADD COLUMN search tsvector
|
|
243
|
+
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))) STORED;
|
|
244
|
+
CREATE INDEX articles_search_idx ON articles USING gin (search);
|
|
245
|
+
SELECT * FROM articles WHERE search @@ websearch_to_tsquery('english', 'postgres performance');
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
For substring matching, a trigram index (`pg_trgm` extension, `gin (col gin_trgm_ops)`) supports `ILIKE '%...%'`. Adding an extension is an operator decision: mention it if the project does not already use it. Adding a stored generated column rewrites the table: follow `safe-database-changes`.
|
|
249
|
+
|
|
250
|
+
**JSONB:** a GIN index serves containment (`@>`); `jsonb_path_ops` is smaller if you only use `@>`. For lookups on one key, an expression index is better:
|
|
251
|
+
|
|
252
|
+
```sql
|
|
253
|
+
CREATE INDEX products_brand_idx ON products ((attributes ->> 'brand'));
|
|
254
|
+
SELECT * FROM products WHERE attributes ->> 'brand' = 'Acme';
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
If the same JSON keys are queried everywhere, they probably deserve real columns.
|
|
258
|
+
|
|
259
|
+
## Diagnostics
|
|
260
|
+
|
|
261
|
+
- `EXPLAIN (ANALYZE, BUFFERS)` on the throwaway database: see the skill for how to read it.
|
|
262
|
+
- `pg_stat_statements`, if the project or its operators use it, ranks queries by total and mean time; ask for its output in the report rather than querying a production database yourself.
|
|
263
|
+
- After large data changes, `ANALYZE table` refreshes planner statistics; bad estimates (estimated rows far from actual) often mean stale statistics.
|
|
264
|
+
|
|
265
|
+
Adapted from supabase/agent-skills `skills/supabase-postgres-best-practices` (MIT, commit c9be0e931b7930f7d02126d04774d904c381e7d7, Copyright (c) 2026 Supabase). Changed: made Postgres-generic (Supabase ports, roles and dashboards removed), condensed about 34 rule files into one page, fixed the SECURITY DEFINER example that revoked EXECUTE from the roles its policy needs, replaced server-setting changes with report recommendations, added expression indexes, EXISTS, NOT IN, trigram and ORM notes.
|