@tamsensedev/runner 0.0.0-stage → 0.2.1
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 -0
- package/README.md +47 -3
- package/dist/cli.js +2914 -0
- package/dist/cli.js.map +1 -0
- package/dist/recorder.cjs +396 -0
- package/dist/recorder.cjs.map +1 -0
- package/library/.claude-plugin/plugin.json +5 -0
- package/library/THIRD_PARTY_NOTICES.md +123 -0
- package/library/agents/code-architect.md +67 -0
- package/library/agents/code-explorer.md +61 -0
- package/library/agents/code-reviewer.md +73 -0
- package/library/agents/code-simplifier.md +62 -0
- package/library/agents/design-critic.md +52 -0
- package/library/agents/second-opinion.md +64 -0
- package/library/agents/security-auditor.md +86 -0
- package/library/agents/silent-failure-hunter.md +70 -0
- package/library/agents/test-coverage-analyzer.md +78 -0
- package/library/agents/test-runner.md +48 -0
- package/library/agents/ui-tester.md +49 -0
- package/library/licenses/Apache-2.0.txt +202 -0
- package/library/skills/accessibility/SKILL.md +144 -0
- package/library/skills/accessibility/references/wcag-checks.md +168 -0
- package/library/skills/addressing-review-feedback/SKILL.md +109 -0
- package/library/skills/app-screens/SKILL.md +96 -0
- package/library/skills/app-screens/references/shell-kpis-charts.md +108 -0
- package/library/skills/app-screens/references/states-density-phone.md +54 -0
- package/library/skills/app-screens/references/tables-forms-settings.md +104 -0
- package/library/skills/avoiding-generic-ai-design/SKILL.md +92 -0
- package/library/skills/avoiding-generic-ai-design/references/tells.md +112 -0
- package/library/skills/clean-commits/SKILL.md +85 -0
- package/library/skills/design-review/SKILL.md +107 -0
- package/library/skills/design-review/references/critique-passes.md +101 -0
- package/library/skills/design-review/references/screenshot-procedure.md +121 -0
- package/library/skills/differential-security-review/LICENSE +427 -0
- package/library/skills/differential-security-review/SKILL.md +105 -0
- package/library/skills/differential-security-review/references/adversarial.md +115 -0
- package/library/skills/differential-security-review/references/methodology.md +145 -0
- package/library/skills/differential-security-review/references/patterns.md +108 -0
- package/library/skills/false-positive-check/LICENSE +427 -0
- package/library/skills/false-positive-check/SKILL.md +95 -0
- package/library/skills/false-positive-check/references/bug-class-verification.md +128 -0
- package/library/skills/false-positive-check/references/deep-verification.md +113 -0
- package/library/skills/false-positive-check/references/evidence-templates.md +93 -0
- package/library/skills/false-positive-check/references/false-positive-patterns.md +122 -0
- package/library/skills/false-positive-check/references/gate-reviews.md +36 -0
- package/library/skills/false-positive-check/references/standard-verification.md +80 -0
- package/library/skills/focused-implementation/SKILL.md +104 -0
- package/library/skills/implementation-plan/SKILL.md +66 -0
- package/library/skills/interface-design/SKILL.md +144 -0
- package/library/skills/interface-design/references/craft-defaults.md +130 -0
- package/library/skills/interface-design/references/new-direction.md +62 -0
- package/library/skills/interface-design/references/writing.md +52 -0
- package/library/skills/interface-polish/SKILL.md +103 -0
- package/library/skills/interface-polish/references/detail-rules.md +154 -0
- package/library/skills/interface-polish/references/worst-case-data.md +158 -0
- package/library/skills/motion-design/SKILL.md +106 -0
- package/library/skills/motion-design/references/recipes.md +243 -0
- package/library/skills/postgres-performance/SKILL.md +97 -0
- package/library/skills/postgres-performance/references/rules.md +265 -0
- package/library/skills/project-orientation/SKILL.md +77 -0
- package/library/skills/reviewing-changes/SKILL.md +183 -0
- package/library/skills/safe-database-changes/SKILL.md +111 -0
- package/library/skills/safe-database-changes/references/backfills.md +75 -0
- package/library/skills/safe-database-changes/references/migration-tools.md +69 -0
- package/library/skills/safe-database-changes/references/postgres-ddl.md +199 -0
- package/library/skills/security-review/SKILL.md +132 -0
- package/library/skills/security-review/references/checks-by-area.md +107 -0
- package/library/skills/security-review/references/do-not-report.md +86 -0
- package/library/skills/spec-writing/SKILL.md +144 -0
- package/library/skills/systematic-debugging/SKILL.md +130 -0
- package/library/skills/systematic-debugging/references/flaky-tests.md +51 -0
- package/library/skills/systematic-debugging/references/root-cause-tracing.md +55 -0
- package/library/skills/task-interview/SKILL.md +84 -0
- package/library/skills/test-driven-development/SKILL.md +116 -0
- package/library/skills/test-driven-development/references/writing-good-tests.md +85 -0
- package/library/skills/ui-testing/SKILL.md +102 -0
- package/library/skills/ui-testing/references/headless-check.md +280 -0
- package/library/skills/ui-testing/references/playwright-patterns.md +134 -0
- package/library/skills/ui-testing/references/recording.md +57 -0
- package/library/skills/verification-before-completion/SKILL.md +113 -0
- package/library/skills/web-interface-guidelines/SKILL.md +202 -0
- package/package.json +46 -6
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Motion recipes
|
|
2
|
+
|
|
3
|
+
Read this when implementing a specific transition. Use the project's existing motion tokens and library if it has them; these recipes are CSS first, then examples in common libraries. Do not add a library to use a recipe.
|
|
4
|
+
|
|
5
|
+
## Tokens
|
|
6
|
+
|
|
7
|
+
```css
|
|
8
|
+
:root {
|
|
9
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
10
|
+
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
|
11
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
|
12
|
+
--duration-press: 150ms;
|
|
13
|
+
--duration-popover: 180ms;
|
|
14
|
+
--duration-dialog: 240ms;
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Reduced motion pattern
|
|
19
|
+
|
|
20
|
+
Define movement only for people who have not asked to reduce it; keep the opacity change for everyone.
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
.toast { transition: opacity 200ms ease; }
|
|
24
|
+
|
|
25
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
26
|
+
.toast {
|
|
27
|
+
transition:
|
|
28
|
+
opacity 200ms var(--ease-out),
|
|
29
|
+
transform 200ms var(--ease-out);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Press feedback
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
.button {
|
|
38
|
+
transition: transform var(--duration-press) var(--ease-out);
|
|
39
|
+
}
|
|
40
|
+
.button:active:not(:disabled) { transform: scale(0.97); }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Popover or menu (native popover, no JavaScript)
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
.menu[popover] {
|
|
47
|
+
transform-origin: top left;
|
|
48
|
+
opacity: 0;
|
|
49
|
+
transform: scale(0.97);
|
|
50
|
+
transition:
|
|
51
|
+
opacity var(--duration-popover) var(--ease-out),
|
|
52
|
+
transform var(--duration-popover) var(--ease-out),
|
|
53
|
+
overlay var(--duration-popover) allow-discrete,
|
|
54
|
+
display var(--duration-popover) allow-discrete;
|
|
55
|
+
}
|
|
56
|
+
.menu[popover]:popover-open { opacity: 1; transform: scale(1); }
|
|
57
|
+
|
|
58
|
+
@starting-style {
|
|
59
|
+
.menu[popover]:popover-open { opacity: 0; transform: scale(0.97); }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Browsers without `@starting-style` simply show the menu without animation. Set `transform-origin` to the side of the trigger. With a headless library, use the origin variable it exposes (for example `var(--radix-popover-content-transform-origin)` or `var(--transform-origin)`).
|
|
64
|
+
|
|
65
|
+
## Dialog
|
|
66
|
+
|
|
67
|
+
Same pattern on `dialog[open]`, centred origin, `--duration-dialog`, plus a backdrop fade:
|
|
68
|
+
|
|
69
|
+
```css
|
|
70
|
+
dialog::backdrop { background: rgb(0 0 0 / 0.4); transition: opacity var(--duration-dialog) ease; }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Exit about 75% of the enter duration.
|
|
74
|
+
|
|
75
|
+
## Drawer or bottom sheet
|
|
76
|
+
|
|
77
|
+
```css
|
|
78
|
+
.sheet {
|
|
79
|
+
transform: translateY(100%);
|
|
80
|
+
transition: transform 300ms var(--ease-drawer);
|
|
81
|
+
}
|
|
82
|
+
.sheet[data-open="true"] { transform: translateY(0); }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Percent translations move by the element's own size, so they work at any height. Add `overscroll-behavior: contain` on the sheet's scroll area.
|
|
86
|
+
|
|
87
|
+
## Expand and collapse without animating height
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
.collapsible {
|
|
91
|
+
display: grid;
|
|
92
|
+
grid-template-rows: 0fr;
|
|
93
|
+
transition: grid-template-rows 250ms var(--ease-out);
|
|
94
|
+
}
|
|
95
|
+
.collapsible[data-open="true"] { grid-template-rows: 1fr; }
|
|
96
|
+
.collapsible > .inner { overflow: hidden; }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Toast
|
|
100
|
+
|
|
101
|
+
Enter from the edge it lives on, leave the same way, a little faster:
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
.toast { transform: translateY(0); opacity: 1; }
|
|
105
|
+
.toast[data-state="entering"], .toast[data-state="leaving"] { transform: translateY(100%); opacity: 0; }
|
|
106
|
+
.toast[data-state="leaving"] { transition-duration: 150ms; }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pause auto-dismiss timers while the toast is hovered or the tab is hidden.
|
|
110
|
+
|
|
111
|
+
## Icon swap (copy to copied, play to pause)
|
|
112
|
+
|
|
113
|
+
Keep both icons in the DOM, one positioned over the other, and cross-fade:
|
|
114
|
+
|
|
115
|
+
```css
|
|
116
|
+
.icon-swap { position: relative; display: inline-grid; }
|
|
117
|
+
.icon-swap > * {
|
|
118
|
+
grid-area: 1 / 1;
|
|
119
|
+
transition:
|
|
120
|
+
opacity 250ms cubic-bezier(0.2, 0, 0, 1),
|
|
121
|
+
transform 250ms cubic-bezier(0.2, 0, 0, 1),
|
|
122
|
+
filter 250ms cubic-bezier(0.2, 0, 0, 1);
|
|
123
|
+
}
|
|
124
|
+
.icon-swap > [data-hidden="true"] { opacity: 0; transform: scale(0.25); filter: blur(4px); }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Skeleton
|
|
128
|
+
|
|
129
|
+
A flat muted block at the final size. If the project pulses skeletons, use a slow opacity pulse (1.5 to 2s) and stop it under reduced motion. No shimmer gradient sweeping across the screen.
|
|
130
|
+
|
|
131
|
+
## Web Animations API
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
135
|
+
element.animate(
|
|
136
|
+
reduce
|
|
137
|
+
? [{ opacity: 0 }, { opacity: 1 }]
|
|
138
|
+
: [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }],
|
|
139
|
+
{ duration: 200, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'backwards' },
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## View Transitions
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
function update(change) {
|
|
147
|
+
if (!document.startViewTransition) return change()
|
|
148
|
+
document.startViewTransition(change)
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
::view-transition-old(root),
|
|
154
|
+
::view-transition-new(root) { animation-duration: 200ms; }
|
|
155
|
+
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
::view-transition-group(*),
|
|
158
|
+
::view-transition-old(*),
|
|
159
|
+
::view-transition-new(*) { animation: none; }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Use them when continuity between two states is the point (a card expanding into its detail), not on every route change. Many routers (Astro, Nuxt, SvelteKit, Next.js) have their own switch for this; use it.
|
|
164
|
+
|
|
165
|
+
## Vue
|
|
166
|
+
|
|
167
|
+
```vue
|
|
168
|
+
<template>
|
|
169
|
+
<Transition name="pop">
|
|
170
|
+
<div v-if="open" class="menu">...</div>
|
|
171
|
+
</Transition>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<style>
|
|
175
|
+
.pop-enter-active { transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out); }
|
|
176
|
+
.pop-leave-active { transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out); }
|
|
177
|
+
.pop-enter-from,
|
|
178
|
+
.pop-leave-to { opacity: 0; transform: scale(0.97); }
|
|
179
|
+
</style>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
For lists, `<TransitionGroup>` with a `.list-move { transition: transform 200ms var(--ease-out); }` class animates reordering.
|
|
183
|
+
|
|
184
|
+
## Svelte
|
|
185
|
+
|
|
186
|
+
```svelte
|
|
187
|
+
<script>
|
|
188
|
+
import { fly } from 'svelte/transition'
|
|
189
|
+
import { cubicOut } from 'svelte/easing'
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
{#if open}
|
|
193
|
+
<div class="menu" transition:fly={{ y: -4, duration: 180, easing: cubicOut }}>...</div>
|
|
194
|
+
{/if}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Motion for React
|
|
198
|
+
|
|
199
|
+
Only if `motion` or `framer-motion` is already installed; import from whichever the project uses.
|
|
200
|
+
|
|
201
|
+
```jsx
|
|
202
|
+
import { AnimatePresence, motion } from 'motion/react'
|
|
203
|
+
|
|
204
|
+
<AnimatePresence initial={false}>
|
|
205
|
+
{open && (
|
|
206
|
+
<motion.div
|
|
207
|
+
key="panel"
|
|
208
|
+
initial={{ opacity: 0, transform: 'scale(0.97)' }}
|
|
209
|
+
animate={{ opacity: 1, transform: 'scale(1)' }}
|
|
210
|
+
exit={{ opacity: 0, transform: 'scale(0.97)', transition: { duration: 0.12 } }}
|
|
211
|
+
transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</AnimatePresence>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`initial={false}` stops elements already present on first render from animating in. Full `transform` strings stay on the compositor when the main thread is busy; the `x` and `y` shorthands do not. For gestures and drags, springs (`{ type: 'spring', duration: 0.3, bounce: 0 }`) keep their velocity when interrupted; keep bounce at 0 in product UI. `useReducedMotion()` returns the user's preference.
|
|
218
|
+
|
|
219
|
+
## GSAP
|
|
220
|
+
|
|
221
|
+
Only if GSAP is already installed.
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
import { gsap } from 'gsap'
|
|
225
|
+
|
|
226
|
+
const mm = gsap.matchMedia()
|
|
227
|
+
mm.add('(prefers-reduced-motion: no-preference)', () => {
|
|
228
|
+
gsap.from('.step', { opacity: 0, y: 12, duration: 0.4, ease: 'power3.out', stagger: 0.06 })
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`gsap.matchMedia()` reverts the animation when the media query stops matching. Keep scroll-triggered work for marketing pages, and give it a version that leaves content visible.
|
|
233
|
+
|
|
234
|
+
## Drag and gestures
|
|
235
|
+
|
|
236
|
+
- Capture the pointer once dragging starts (`setPointerCapture`) so the drag continues outside the element.
|
|
237
|
+
- Ignore extra touch points after the drag begins.
|
|
238
|
+
- Dismiss on velocity as well as distance (a quick flick is enough).
|
|
239
|
+
- Past a boundary, apply friction instead of a hard stop.
|
|
240
|
+
- On `pointercancel`, `lostpointercapture` and window `blur`, clear the dragging state so the next drag works.
|
|
241
|
+
- Offer a click or keyboard alternative for every gesture.
|
|
242
|
+
|
|
243
|
+
Sources: adapted from emilkowalski/skills emil-design-eng (MIT, changed: examples made framework-neutral, Vue, Svelte and GSAP examples added), jakubkrehel/make-interfaces-feel-better animations reference (MIT, changed: icon swap duration aligned to 250ms, CSS grid variant) and pbakaus/impeccable animate and harden references (Apache-2.0, changed: rewritten).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: postgres-performance
|
|
3
|
+
description: Use when writing or reviewing PostgreSQL queries, ORM data access, indexes, pagination, row-level security policies or connection handling, or when an endpoint or page is slow. Checks missing indexes, N+1 queries, OFFSET pagination, long transactions, racy upserts and pool misuse, and verifies fixes with EXPLAIN on a local database.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PostgreSQL performance
|
|
7
|
+
|
|
8
|
+
Most slow Postgres code comes from a short list of mistakes: a query without a usable index, a query inside a loop, deep OFFSET pages, transactions held open across network calls, and connections opened per request. This skill finds them in a diff or in slow code and proves the fix with a query plan.
|
|
9
|
+
|
|
10
|
+
The project's conventions win (naming, ORM style, how indexes are declared, which database version it targets). Schema changes that come out of this work, such as new indexes, must follow the `safe-database-changes` skill (build indexes without blocking writes, separate migration).
|
|
11
|
+
|
|
12
|
+
## Safety
|
|
13
|
+
|
|
14
|
+
- Never connect to a real, shared or production database to "check" a plan. Work on a throwaway local database (see `safe-database-changes` for how to get one) or reason from the code.
|
|
15
|
+
- `EXPLAIN ANALYZE` executes the statement. For INSERT, UPDATE or DELETE, run it only on the throwaway database, inside `BEGIN; ... ROLLBACK;`.
|
|
16
|
+
- Do not change server settings (`ALTER SYSTEM`, `max_connections`, pooler config) from a task run. Put recommended settings in the report.
|
|
17
|
+
|
|
18
|
+
## Process
|
|
19
|
+
|
|
20
|
+
### 1. Find the SQL that actually runs
|
|
21
|
+
|
|
22
|
+
ORMs hide the real queries. Read the data-access code and, when you can run it locally, turn on query logging for one request or test: Prisma `log: ['query']`, Django `connection.queries` or `DEBUG` SQL logging, Rails log output, SQLAlchemy `echo=True`, Sequelize `logging`, TypeORM `logging: true`, Drizzle `logger: true`, or `log_statement = 'all'` on the throwaway database. Count queries per request, not just their speed.
|
|
23
|
+
|
|
24
|
+
### 2. Check against the list, highest impact first
|
|
25
|
+
|
|
26
|
+
| Priority | Look for | Rule |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| 1 | Filters, joins and sorts on columns with no matching index | [Indexes](references/rules.md#indexes) |
|
|
29
|
+
| 1 | A query per item inside a loop, or lazy-loaded relations in a list | [N+1](references/rules.md#n1-queries) |
|
|
30
|
+
| 1 | A new connection or client per request; a pool per module | [Connections](references/rules.md#connections) |
|
|
31
|
+
| 1 | RLS policies calling a function per row, unindexed policy columns | [Row-level security](references/rules.md#row-level-security) |
|
|
32
|
+
| 2 | Composite index in the wrong column order; foreign key without an index | [Indexes](references/rules.md#indexes) |
|
|
33
|
+
| 2 | Deep `OFFSET` pagination | [Pagination](references/rules.md#pagination) |
|
|
34
|
+
| 2 | Unbounded `SELECT` (no `LIMIT`) on growing tables; `SELECT *` on wide rows | [Query shape](references/rules.md#query-shape) |
|
|
35
|
+
| 2 | Transactions held across HTTP calls or user input; check-then-insert | [Transactions and locks](references/rules.md#transactions-and-locks) |
|
|
36
|
+
| 3 | Row-by-row inserts; `COUNT(*)` to test existence | [Query shape](references/rules.md#query-shape) |
|
|
37
|
+
| 3 | Functions or casts on indexed columns (`lower(email) = ...`), leading-wildcard `LIKE` | [Indexes](references/rules.md#indexes), [Search](references/rules.md#search-and-jsonb) |
|
|
38
|
+
| 3 | Worker queues that block each other | [Transactions and locks](references/rules.md#transactions-and-locks) |
|
|
39
|
+
| 4 | Column types (int ids that will overflow, `timestamp` without zone, float money) | [Types](references/rules.md#types-and-keys) |
|
|
40
|
+
|
|
41
|
+
Table size matters. An index on a 200-row lookup table is noise; a missing one on an events table is an outage. When you do not know the size, say what you assumed.
|
|
42
|
+
|
|
43
|
+
### 3. Fix with the smallest change
|
|
44
|
+
|
|
45
|
+
- Prefer fixing the query (batch the loop, keyset pagination, select fewer columns) over adding an index; prefer one composite index that serves several queries over several single-column ones.
|
|
46
|
+
- Every index slows writes and takes space. Add one only for a query that runs often or on a large table, and check that an existing index does not already cover it (a composite index on `(a, b)` serves queries on `a` alone).
|
|
47
|
+
- In ORMs, use the ORM's own tools first: eager loading (`include`, `select_related` / `prefetch_related`, `includes`, `joinedload` / `selectinload`), batch `findMany` with `in`, `createMany`, `bulk_create`, `insert_all`, upsert helpers.
|
|
48
|
+
|
|
49
|
+
### 4. Verify
|
|
50
|
+
|
|
51
|
+
On the throwaway database, with enough rows for the planner to behave like production (generate them with `generate_series`; a few hundred rows often get a sequential scan no matter what):
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
INSERT INTO orders (customer_id, status, created_at)
|
|
55
|
+
SELECT (random() * 10000)::int, (ARRAY['pending','paid','shipped'])[1 + (random() * 2)::int],
|
|
56
|
+
now() - random() * interval '365 days'
|
|
57
|
+
FROM generate_series(1, 200000);
|
|
58
|
+
ANALYZE orders;
|
|
59
|
+
|
|
60
|
+
EXPLAIN (ANALYZE, BUFFERS)
|
|
61
|
+
SELECT id, total FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Read the plan:
|
|
65
|
+
|
|
66
|
+
- `Seq Scan` on a big table with a selective filter, and a large `Rows Removed by Filter`: missing or unusable index.
|
|
67
|
+
- Estimated rows far from actual rows: stale statistics (run `ANALYZE`) or a correlated filter.
|
|
68
|
+
- `Sort` with `external merge`: sorting on disk; an index matching `ORDER BY` can remove the sort.
|
|
69
|
+
- `Nested Loop` with a huge `loops` count on the inner side: missing index on the join column.
|
|
70
|
+
- `Index Scan` followed by many heap fetches where a covering index would allow an `Index Only Scan`.
|
|
71
|
+
|
|
72
|
+
Compare the plan before and after the fix. For N+1 fixes, count the queries for one request before and after, or assert the count in a test if the project has a helper for it.
|
|
73
|
+
|
|
74
|
+
If you cannot run a database, say so and explain from the code why the fix helps.
|
|
75
|
+
|
|
76
|
+
## Report
|
|
77
|
+
|
|
78
|
+
For each finding: file and line, the query or ORM call, why it is slow (with the plan or the query count if you measured), the fix, and its cost (new index, extra memory). Separate what you changed from recommendations for operators (pool size, timeouts, server settings).
|
|
79
|
+
|
|
80
|
+
## Common mistakes
|
|
81
|
+
|
|
82
|
+
- Adding an index for every column in a WHERE clause, or duplicating an existing index's leading columns.
|
|
83
|
+
- Indexing a low-selectivity column alone (a boolean, a status with three values) when a partial or composite index is what the query needs.
|
|
84
|
+
- Wrapping the indexed column in a function or cast, so the index cannot be used.
|
|
85
|
+
- Measuring on an empty table and concluding the plan is fine.
|
|
86
|
+
- "Fixing" N+1 by caching instead of batching.
|
|
87
|
+
- Raising pool size or `max_connections` to hide connection leaks.
|
|
88
|
+
- Running `EXPLAIN ANALYZE` on a write statement against a real database.
|
|
89
|
+
|
|
90
|
+
## Done when
|
|
91
|
+
|
|
92
|
+
- Each slow pattern found is fixed or listed with a reason it was left.
|
|
93
|
+
- New indexes go through a migration that follows `safe-database-changes`.
|
|
94
|
+
- The fix is backed by a plan or query count from a throwaway database, or the report says it could not be measured.
|
|
95
|
+
- Operator-level recommendations are in the report, not applied.
|
|
96
|
+
|
|
97
|
+
Sources: supabase/agent-skills supabase-postgres-best-practices (MIT, commit c9be0e9). Changed: made Postgres-generic and condensed.
|
|
@@ -0,0 +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.
|