@tamsensedev/runner 0.13.0 → 0.19.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.
Files changed (82) hide show
  1. package/LICENSE +5 -5
  2. package/README.md +76 -57
  3. package/dist/cli.js +757 -379
  4. package/dist/cli.js.map +1 -1
  5. package/dist/recorder.cjs.map +1 -1
  6. package/library/.claude-plugin/plugin.json +5 -5
  7. package/library/THIRD_PARTY_NOTICES.md +123 -123
  8. package/library/agents/code-architect.md +67 -67
  9. package/library/agents/code-explorer.md +61 -61
  10. package/library/agents/code-reviewer.md +73 -73
  11. package/library/agents/code-simplifier.md +62 -62
  12. package/library/agents/design-critic.md +52 -52
  13. package/library/agents/second-opinion.md +64 -64
  14. package/library/agents/security-auditor.md +86 -86
  15. package/library/agents/silent-failure-hunter.md +70 -70
  16. package/library/agents/test-coverage-analyzer.md +78 -78
  17. package/library/agents/test-runner.md +48 -48
  18. package/library/agents/ui-tester.md +50 -50
  19. package/library/licenses/Apache-2.0.txt +202 -202
  20. package/library/skills/accessibility/SKILL.md +144 -144
  21. package/library/skills/accessibility/references/wcag-checks.md +168 -168
  22. package/library/skills/addressing-review-feedback/SKILL.md +109 -109
  23. package/library/skills/app-screens/SKILL.md +96 -96
  24. package/library/skills/app-screens/references/shell-kpis-charts.md +108 -108
  25. package/library/skills/app-screens/references/states-density-phone.md +54 -54
  26. package/library/skills/app-screens/references/tables-forms-settings.md +104 -104
  27. package/library/skills/avoiding-generic-ai-design/SKILL.md +92 -92
  28. package/library/skills/avoiding-generic-ai-design/references/tells.md +112 -112
  29. package/library/skills/clean-commits/SKILL.md +85 -85
  30. package/library/skills/design-review/SKILL.md +107 -107
  31. package/library/skills/design-review/references/critique-passes.md +101 -101
  32. package/library/skills/design-review/references/screenshot-procedure.md +121 -121
  33. package/library/skills/differential-security-review/LICENSE +427 -427
  34. package/library/skills/differential-security-review/SKILL.md +105 -105
  35. package/library/skills/differential-security-review/references/adversarial.md +115 -115
  36. package/library/skills/differential-security-review/references/methodology.md +145 -145
  37. package/library/skills/differential-security-review/references/patterns.md +108 -108
  38. package/library/skills/false-positive-check/LICENSE +427 -427
  39. package/library/skills/false-positive-check/SKILL.md +95 -95
  40. package/library/skills/false-positive-check/references/bug-class-verification.md +128 -128
  41. package/library/skills/false-positive-check/references/deep-verification.md +113 -113
  42. package/library/skills/false-positive-check/references/evidence-templates.md +93 -93
  43. package/library/skills/false-positive-check/references/false-positive-patterns.md +122 -122
  44. package/library/skills/false-positive-check/references/gate-reviews.md +36 -36
  45. package/library/skills/false-positive-check/references/standard-verification.md +80 -80
  46. package/library/skills/focused-implementation/SKILL.md +104 -104
  47. package/library/skills/implementation-plan/SKILL.md +66 -66
  48. package/library/skills/interface-design/SKILL.md +144 -144
  49. package/library/skills/interface-design/references/craft-defaults.md +130 -130
  50. package/library/skills/interface-design/references/new-direction.md +62 -62
  51. package/library/skills/interface-design/references/writing.md +52 -52
  52. package/library/skills/interface-polish/SKILL.md +103 -103
  53. package/library/skills/interface-polish/references/detail-rules.md +154 -154
  54. package/library/skills/interface-polish/references/worst-case-data.md +158 -158
  55. package/library/skills/motion-design/SKILL.md +106 -106
  56. package/library/skills/motion-design/references/recipes.md +243 -243
  57. package/library/skills/postgres-performance/SKILL.md +97 -97
  58. package/library/skills/postgres-performance/references/rules.md +265 -265
  59. package/library/skills/project-orientation/SKILL.md +77 -77
  60. package/library/skills/reviewing-changes/SKILL.md +183 -183
  61. package/library/skills/safe-database-changes/SKILL.md +111 -111
  62. package/library/skills/safe-database-changes/references/backfills.md +75 -75
  63. package/library/skills/safe-database-changes/references/migration-tools.md +69 -69
  64. package/library/skills/safe-database-changes/references/postgres-ddl.md +199 -199
  65. package/library/skills/security-review/SKILL.md +132 -132
  66. package/library/skills/security-review/references/checks-by-area.md +107 -107
  67. package/library/skills/security-review/references/do-not-report.md +86 -86
  68. package/library/skills/spec-writing/SKILL.md +144 -144
  69. package/library/skills/systematic-debugging/SKILL.md +130 -130
  70. package/library/skills/systematic-debugging/references/flaky-tests.md +51 -51
  71. package/library/skills/systematic-debugging/references/root-cause-tracing.md +55 -55
  72. package/library/skills/task-interview/SKILL.md +84 -84
  73. package/library/skills/test-driven-development/SKILL.md +116 -116
  74. package/library/skills/test-driven-development/references/writing-good-tests.md +85 -85
  75. package/library/skills/ui-testing/SKILL.md +102 -102
  76. package/library/skills/ui-testing/references/headless-check.md +295 -295
  77. package/library/skills/ui-testing/references/local-stand.md +51 -51
  78. package/library/skills/ui-testing/references/playwright-patterns.md +134 -134
  79. package/library/skills/ui-testing/references/recording.md +61 -61
  80. package/library/skills/verification-before-completion/SKILL.md +113 -113
  81. package/library/skills/web-interface-guidelines/SKILL.md +202 -202
  82. 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.