Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.3.1

### Fixes

- `fingerprint`: normalize negative numeric literals consistently with positive values. Previously `WHERE id = -1` and `WHERE id = 1` produced different fingerprints, silently missing N+1 patterns with negative values.
- `drivers`: skip re-recording when an outer patched method is already active. mysql2 Pool.query internally delegates to Connection.query; both prototypes are patched, and AsyncLocalStorage propagated through the boundary, causing the same query to be recorded twice.
- `drivers`: mysql2 queries that emit an `error` event without `end` (connection-level failures) now record correctly. Previously the record was lost.

### Features

- New type exports from the package root: `StackFrame`, `QueryGuardError`, `ScalingError`, `ScalingDetection`, `ScalingReport`, `AssertScalingOptions`, `AssertOptions`.

### Internal

- Extract shared driver patching into `src/drivers/shared.ts`. pg and mysql2 now share callback, sync-throw, promise, and event-end handling.
- Enable `noUncheckedIndexedAccess` in tsconfig with guards across detector, stack, report, integrations, and middleware.
- CI: Node 20/22/24 matrix; integration tests against pg16 and mysql8 services; pack size and zero-deps gates; overhead regression bench (warn-only); provenance-signed releases via `pnpm publish --provenance`.
- Tests: 184 → 242 unit tests; property-based fingerprint fuzzing (fast-check); self-dogfood setup that wraps every integration test in an outer tracking context.
- Integration test URLs are env-driven (`TEST_PG_URL`, `TEST_MYSQL_URL`) with conventional defaults.

## 0.3.0

- Add `ignore()` for scoped query suppression within tracking contexts
Expand Down
8 changes: 4 additions & 4 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,9 @@ <h1>Catch N+1 queries before they hit production</h1>

<div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">Runtime dependencies</div></div>
<div class="stat"><div class="stat-num">18KB</div><div class="stat-label">Package size</div></div>
<div class="stat"><div class="stat-num">128</div><div class="stat-label">Tests passing</div></div>
<div class="stat"><div class="stat-num">0%</div><div class="stat-label">Performance overhead</div></div>
<div class="stat"><div class="stat-num">&lt;40KB</div><div class="stat-label">Package size</div></div>
<div class="stat"><div class="stat-num">242</div><div class="stat-label">Tests passing</div></div>
<div class="stat"><div class="stat-num">&lt;1%</div><div class="stat-label">Performance overhead</div></div>
</div>
</div>
</section>
Expand Down Expand Up @@ -538,7 +538,7 @@ <h3>Payload CMS</h3>
<div class="proof-stars">41,700 stars on GitHub</div>
<div class="proof-stat"><span class="psl">Tests run with QueryGuard</span><span class="psv">136</span></div>
<div class="proof-stat"><span class="psl">False positives</span><span class="psv good">0</span></div>
<div class="proof-stat"><span class="psl">Performance overhead</span><span class="psv good">0%</span></div>
<div class="proof-stat"><span class="psl">Performance overhead</span><span class="psv good">&lt;1%</span></div>
<div class="proof-stat"><span class="psl">Tests broken</span><span class="psv good">0</span></div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "qguard",
"version": "0.3.0",
"version": "0.3.1",
"description": "Detect N+1 queries at the driver level. Zero config. Works with any ORM.",
"type": "module",
"license": "MIT",
Expand Down
8 changes: 4 additions & 4 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,9 @@ <h1>Catch N+1 queries before they hit production</h1>

<div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">Runtime dependencies</div></div>
<div class="stat"><div class="stat-num">18KB</div><div class="stat-label">Package size</div></div>
<div class="stat"><div class="stat-num">128</div><div class="stat-label">Tests passing</div></div>
<div class="stat"><div class="stat-num">0%</div><div class="stat-label">Performance overhead</div></div>
<div class="stat"><div class="stat-num">&lt;40KB</div><div class="stat-label">Package size</div></div>
<div class="stat"><div class="stat-num">242</div><div class="stat-label">Tests passing</div></div>
<div class="stat"><div class="stat-num">&lt;1%</div><div class="stat-label">Performance overhead</div></div>
</div>
</div>
</section>
Expand Down Expand Up @@ -538,7 +538,7 @@ <h3>Payload CMS</h3>
<div class="proof-stars">41,700 stars on GitHub</div>
<div class="proof-stat"><span class="psl">Tests run with QueryGuard</span><span class="psv">136</span></div>
<div class="proof-stat"><span class="psl">False positives</span><span class="psv good">0</span></div>
<div class="proof-stat"><span class="psl">Performance overhead</span><span class="psv good">0%</span></div>
<div class="proof-stat"><span class="psl">Performance overhead</span><span class="psv good">&lt;1%</span></div>
<div class="proof-stat"><span class="psl">Tests broken</span><span class="psv good">0</span></div>
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions src/drivers/shared.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { recordQuery } from '../core/tracker.js'

export type ExtractSql = (args: unknown[]) => string

export type CompleteQuery = (result: unknown, done: () => void) => unknown

// Set by the outermost patched call. A nested call (e.g. mysql2's Pool.query
// delegating to Connection.query — both patched — through a context that
// preserves AsyncLocalStorage) sees this flag and skips re-recording so a
// single user-observable query is counted once.
const insidePatchedCall = new AsyncLocalStorage<boolean>()

export function createPatchedMethod(options: {
extractSql: ExtractSql
original: (...args: unknown[]) => unknown
Expand All @@ -13,6 +20,13 @@ export function createPatchedMethod(options: {
const { extractSql, original, completeQuery, name } = options

const patched = function patched(this: unknown, ...args: unknown[]): unknown {
if (insidePatchedCall.getStore()) {
return original.apply(this, args)
}
return insidePatchedCall.run(true, () => runPatched.call(this, args))
}

function runPatched(this: unknown, args: unknown[]): unknown {
const sql = extractSql(args)
const startTime = performance.now()
let recorded = false
Expand Down
30 changes: 30 additions & 0 deletions test/unit/driver-shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,36 @@ describe('createPatchedMethod', () => {
expect(ctx.queries).toHaveLength(1)
})

it('a nested patched call does not re-record the same query', async () => {
// Simulates mysql2 Pool.query delegating to Connection.query while
// AsyncLocalStorage context survives the boundary. Only the outer call
// should record; the inner call must bypass recording.
const innerOriginal = vi.fn((_sql: string) => 'inner-result')
const innerPatched = createPatchedMethod({
extractSql: (args) => args[0] as string,
original: innerOriginal as (...args: unknown[]) => unknown,
completeQuery: (result, done) => {
done()
return result
},
})

const outerPatched = createPatchedMethod({
extractSql: (args) => args[0] as string,
original: (...args: unknown[]) => innerPatched(...args),
completeQuery: (result, done) => {
done()
return result
},
})

const ctx = createContext()
await runInContext(ctx, () => outerPatched('SELECT * FROM users'))

expect(ctx.queries).toHaveLength(1)
expect(innerOriginal).toHaveBeenCalledOnce()
})

it('respects the name option', () => {
const patched = createPatchedMethod({
extractSql: () => 'SELECT 1',
Expand Down
Loading