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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Features

- Add automatic Vitest enforcement through the `qguard/vitest/setup` export and the
`qguardSetup` path for `test.setupFiles`. Each test receives an isolated query context and fails
with `QueryGuardError` when it produces an N+1 pattern.

## 0.3.1

### Fixes
Expand Down
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ npm install qguard
```

```ts
import { assertNoNPlusOne } from 'qguard/vitest'

test('listing users does not N+1', async () => {
await assertNoNPlusOne(() => handler(req, res))
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { qguardSetup } from 'qguard/vitest'

export default defineConfig({
test: {
setupFiles: [qguardSetup],
},
})
```

Every test now fails when it produces an N+1 query pattern. No per-test wrapper is required.

## Why

Every ORM makes it easy to write a loop that fires one query per row. Load 100 users, each with a profile: that's 101 queries instead of 2. The database barely notices in development, then the page takes 4 seconds in production with real data.
Expand Down Expand Up @@ -58,9 +64,39 @@ queryguard monkey-patches `pg.Client.prototype.query`, `pg.Pool.prototype.query`

## API

### Automatic Vitest enforcement

Add `qguardSetup` to Vitest's setup files to guard the complete suite:

```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { qguardSetup } from 'qguard/vitest'

export default defineConfig({
test: {
setupFiles: [qguardSetup],
},
})
```

`qguardSetup` is the absolute path to the published `qguard/vitest/setup` entry point. Vitest
resolves string values in `setupFiles` relative to the project root, so use the exported path
instead of the bare string `'qguard/vitest/setup'`.

The setup module installs the database hooks once and creates a separate `AsyncLocalStorage`
context for every test. It includes queries from the test's `beforeEach` hooks, test body, and
`afterEach` hooks, and safely isolates concurrent tests. Any detection is reported as a
`QueryGuardError` on the test that produced it.

Queries executed while test files are being imported or in `beforeAll`/`afterAll` cannot be
attributed to an individual test and are not tracked by this integration. Remove `qguardSetup`
from the config to disable suite-wide enforcement. The explicit APIs below remain useful for
narrower scopes, query budgets, and scaling checks.

### assertNoNPlusOne

Runs a function and throws if any N+1 pattern is detected. Available from `queryguard/vitest` and `queryguard/jest`.
Runs a function and throws if any N+1 pattern is detected. Available from `qguard/vitest` and `qguard/jest`.

```ts
import { assertNoNPlusOne } from 'qguard/vitest'
Expand Down
10 changes: 5 additions & 5 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -444,21 +444,21 @@ <h1>Catch N+1 queries before they hit production</h1>
<section id="how">
<div class="container">
<div class="section-label">How it works</div>
<div class="section-title">Three lines of code. No config files.</div>
<div class="section-title">One config entry. Every test guarded.</div>
<div class="section-desc">QueryGuard intercepts <code>pg.Client</code> and <code>pg.Pool</code> queries at the driver level. Every query is fingerprinted and tracked per async context. If the same query repeats above a threshold, your test fails.</div>

<div class="steps">
<div class="step">
<div class="step-num">01</div>
<h3>Install</h3>
<p><code>npm install qguard</code></p>
<p style="margin-top:12px">No plugins to register. No setup files to create. No environment variables to set.</p>
<p style="margin-top:12px">No plugin package, generated setup file, or environment variable required.</p>
</div>
<div class="step">
<div class="step-num">02</div>
<h3>Wrap your test</h3>
<p><code>await assertNoNPlusOne(() => handler(req, res))</code></p>
<p style="margin-top:12px">Import from <code>queryguard/vitest</code> or <code>queryguard/jest</code>. One function call wraps your handler.</p>
<h3>Enable the suite</h3>
<p><code>setupFiles: [qguardSetup]</code></p>
<p style="margin-top:12px">Import <code>qguardSetup</code> from <code>qguard/vitest</code>. Every test is guarded automatically.</p>
</div>
<div class="step">
<div class="step-num">03</div>
Expand Down
37 changes: 37 additions & 0 deletions docs/plans/2026-08-31-vitest-auto-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Vitest automatic setup

Date: 2026-08-31
Issue: [#9](https://github.com/oniani1/queryguard/issues/9)

## Goal

Allow a Vitest suite to enforce qguard for every test from one configuration entry:

```ts
import { qguardSetup } from 'qguard/vitest'

export default defineConfig({
test: {
setupFiles: [qguardSetup],
},
})
```

The existing `qguard/vitest` assertion helpers remain explicit and side-effect free. Importing
`qguardSetup` loads the `qguard/vitest/setup` entry point, which installs the database hooks once,
opens an isolated tracking context for each test, dispatches configured notifications, and fails
the affected test with `QueryGuardError` when the context contains an N+1 detection.

## Plan

- [x] Add a runner-level failing test proving setup-file enforcement and clean-test behavior.
- [x] Implement the Vitest lifecycle adapter with per-test `AsyncLocalStorage` isolation.
- [x] Publish the `qguard/vitest/setup` package export and verify the packed artifact.
- [x] Document automatic setup, configuration, opt-out, and interaction with explicit assertions.
- [x] Run formatting, type checking, linting, unit tests, build, and relevant integration tests.

## Non-goals

- Baseline or acknowledgement files.
- Jest, Mocha, or other runner adapters.
- Changes to the N+1 detection algorithm.
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"typescript"
],
"types": "./dist/index.d.ts",
"sideEffects": false,
"sideEffects": [
"./dist/integrations/vitest-setup.js"
],
"exports": {
".": {
"import": "./dist/index.js",
Expand All @@ -41,6 +43,11 @@
"require": "./dist/integrations/vitest.js",
"types": "./dist/integrations/vitest.d.ts"
},
"./vitest/setup": {
"import": "./dist/integrations/vitest-setup.js",
"require": "./dist/integrations/vitest-setup.js",
"types": "./dist/integrations/vitest-setup.d.ts"
},
"./jest": {
"import": "./dist/integrations/jest.js",
"require": "./dist/integrations/jest.js",
Expand Down
10 changes: 5 additions & 5 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -444,21 +444,21 @@ <h1>Catch N+1 queries before they hit production</h1>
<section id="how">
<div class="container">
<div class="section-label">How it works</div>
<div class="section-title">Three lines of code. No config files.</div>
<div class="section-title">One config entry. Every test guarded.</div>
<div class="section-desc">QueryGuard intercepts <code>pg.Client</code> and <code>pg.Pool</code> queries at the driver level. Every query is fingerprinted and tracked per async context. If the same query repeats above a threshold, your test fails.</div>

<div class="steps">
<div class="step">
<div class="step-num">01</div>
<h3>Install</h3>
<p><code>npm install qguard</code></p>
<p style="margin-top:12px">No plugins to register. No setup files to create. No environment variables to set.</p>
<p style="margin-top:12px">No plugin package, generated setup file, or environment variable required.</p>
</div>
<div class="step">
<div class="step-num">02</div>
<h3>Wrap your test</h3>
<p><code>await assertNoNPlusOne(() => handler(req, res))</code></p>
<p style="margin-top:12px">Import from <code>queryguard/vitest</code> or <code>queryguard/jest</code>. One function call wraps your handler.</p>
<h3>Enable the suite</h3>
<p><code>setupFiles: [qguardSetup]</code></p>
<p style="margin-top:12px">Import <code>qguardSetup</code> from <code>qguard/vitest</code>. Every test is guarded automatically.</p>
</div>
<div class="step">
<div class="step-num">03</div>
Expand Down
38 changes: 38 additions & 0 deletions src/integrations/vitest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { afterEach, beforeEach } from 'vitest'
import { detect } from '../core/detector.js'
import { dispatchNotifications } from '../core/notify.js'
import { createContext, trackingAls } from '../core/tracker.js'
import type { TrackingContext } from '../core/tracker.js'
import { install } from '../drivers/install.js'
import { QueryGuardError } from './shared.js'

const contexts = new WeakMap<object, TrackingContext>()

await install()

// Keep this hook synchronous: entering ALS after an await does not propagate the store back to
// Vitest's runner, so queries from the test would escape the context.
beforeEach((testContext) => {
const context = createContext()
contexts.set(testContext, context)
trackingAls.enterWith(context)
})

afterEach(async (testContext) => {
const context = contexts.get(testContext)
contexts.delete(testContext)
if (!context) return

const report = detect(context)
if (report.detections.length === 0) return

await dispatchNotifications({
report,
environment: 'test',
test: {
name: testContext.task.name,
file: testContext.task.file.filepath,
},
})
throw new QueryGuardError(report)
})
5 changes: 5 additions & 0 deletions src/integrations/vitest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url'

export type {
AssertOptions,
AssertScalingOptions,
Expand All @@ -8,3 +10,6 @@ export { QueryGuardError, ScalingError } from './shared.js'
export { runAssertNoNPlusOne as assertNoNPlusOne } from './shared.js'
export { runQueryBudget as queryBudget } from './shared.js'
export { runAssertScaling as assertScaling } from './shared.js'

/** Absolute path for Vitest's `test.setupFiles` configuration. */
export const qguardSetup = fileURLToPath(new URL('./vitest-setup.js', import.meta.url))
17 changes: 17 additions & 0 deletions test/fixtures/vitest-auto-setup/concurrency.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, test } from 'vitest'
import { recordQuery } from '../../../src/core/tracker.js'

async function recordOneQuery(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 5))
recordQuery('SELECT * FROM users WHERE id = 1', 1)
}

test.concurrent('isolates the first concurrent test', async () => {
await recordOneQuery()
expect(true).toBe(true)
})

test.concurrent('isolates the second concurrent test', async () => {
await recordOneQuery()
expect(true).toBe(true)
})
23 changes: 23 additions & 0 deletions test/fixtures/vitest-auto-setup/detection.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from 'vitest'
import { configure } from '../../../src/core/config.js'
import { recordQuery } from '../../../src/core/tracker.js'

configure({
onDetection: [
({ test: metadata }) => {
console.log(`[qguard notification] ${metadata?.name} @ ${metadata?.file}`)
},
],
})

test('passes a clean test without an explicit qguard assertion', () => {
recordQuery('SELECT * FROM users WHERE id = 1', 1)
expect(true).toBe(true)
})

test('fails an N+1 without an explicit qguard assertion', async () => {
recordQuery('SELECT * FROM users WHERE id = 1', 1)
await new Promise<void>((resolve) => queueMicrotask(resolve))
recordQuery('SELECT * FROM users WHERE id = 2', 1)
expect(true).toBe(true)
})
16 changes: 16 additions & 0 deletions test/fixtures/vitest-auto-setup/hooks.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { afterEach, beforeEach, expect, test } from 'vitest'
import { recordQuery } from '../../../src/core/tracker.js'

beforeEach(() => {
recordQuery('SELECT * FROM users WHERE id = 1', 1)
})

afterEach(() => {
recordQuery('SELECT * FROM users WHERE id = 3', 1)
})

test('tracks queries across the complete per-test lifecycle', async () => {
await new Promise<void>((resolve) => queueMicrotask(resolve))
recordQuery('SELECT * FROM users WHERE id = 2', 1)
expect(true).toBe(true)
})
9 changes: 9 additions & 0 deletions test/fixtures/vitest-auto-setup/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
import { qguardSetup } from '../../../src/integrations/vitest.js'

export default defineConfig({
test: {
include: ['test/fixtures/vitest-auto-setup/*.fixture.ts'],
setupFiles: [qguardSetup],
},
})
57 changes: 57 additions & 0 deletions test/unit/vitest-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { qguardSetup } from '../../src/integrations/vitest.js'

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const vitestCli = path.join(root, 'node_modules/vitest/vitest.mjs')
const config = path.join(root, 'test/fixtures/vitest-auto-setup/vitest.config.ts')

function runFixture(filename: string) {
const result = spawnSync(process.execPath, [vitestCli, 'run', '--config', config, filename], {
cwd: root,
encoding: 'utf8',
env: { ...process.env, FORCE_COLOR: '0' },
})

return {
status: result.status,
output: `${result.stdout}${result.stderr}`,
}
}

describe('qguard/vitest/setup', () => {
it('exports the built setup-file path for Vitest configuration', () => {
expect(path.isAbsolute(qguardSetup)).toBe(true)
expect(qguardSetup).toMatch(/[/\\]integrations[/\\]vitest-setup\.js$/)
})

it('fails only the test that produces an N+1 query pattern', () => {
const result = runFixture('detection.fixture.ts')

expect(result.status).toBe(1)
expect(result.output).toContain('1 failed | 1 passed')
expect(result.output).toContain('QueryGuardError: N+1 query detected')
expect(result.output).toContain('fails an N+1 without an explicit qguard assertion')
expect(result.output).toContain(
'[qguard notification] fails an N+1 without an explicit qguard assertion',
)
expect(result.output).toContain('detection.fixture.ts')
})

it('isolates concurrent tests from one another', () => {
const result = runFixture('concurrency.fixture.ts')

expect(result.status).toBe(0)
expect(result.output).toContain('2 passed')
})

it('tracks queries from user beforeEach and afterEach hooks', () => {
const result = runFixture('hooks.fixture.ts')

expect(result.status).toBe(1)
expect(result.output).toContain('QueryGuardError: N+1 query detected')
expect(result.output).toContain('Repeated query executed 3 times')
})
})