From 58b94e33309ba1070628c6ff674118ddb2fcf56f Mon Sep 17 00:00:00 2001 From: oniani1 Date: Fri, 17 Apr 2026 00:12:05 +0400 Subject: [PATCH 1/3] fix(drivers): skip re-recording when an outer patched call is already active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mysql2 Pool.query internally delegates to Connection.query. Both prototypes are patched and AsyncLocalStorage propagates through mysql2's getConnection boundary, so the same user-observable query recorded twice — surfacing in CI as a Pool N+1 test that saw 6 occurrences instead of 3. Guard with an AsyncLocalStorage flag set by the outermost patched call. A nested patched call sees the flag and invokes the original directly without recording. pg is unaffected because its Pool.connect callback does not preserve ALS (the existing Pool-layer patching still records as before). Add a unit test that exercises the nested-call path with a custom original that delegates to an inner patched method. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/drivers/shared.ts | 14 ++++++++++++++ test/unit/driver-shared.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/drivers/shared.ts b/src/drivers/shared.ts index 85dbf03..22ae4ea 100644 --- a/src/drivers/shared.ts +++ b/src/drivers/shared.ts @@ -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() + export function createPatchedMethod(options: { extractSql: ExtractSql original: (...args: unknown[]) => unknown @@ -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 diff --git a/test/unit/driver-shared.test.ts b/test/unit/driver-shared.test.ts index b04da93..e83f912 100644 --- a/test/unit/driver-shared.test.ts +++ b/test/unit/driver-shared.test.ts @@ -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', From 9389904da4f9e2a12839768e0ab11be6f75a9ad2 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Fri, 17 Apr 2026 00:13:00 +0400 Subject: [PATCH 2/3] chore(release): v0.3.1 Bump package version, add changelog entry, and refresh landing-page stats (package size, test count, overhead) to reflect the hardening pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ package.json | 2 +- site/index.html | 8 ++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8067f47..a85a9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 8a4058b..f4dd12c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/site/index.html b/site/index.html index 8243243..ee61c91 100644 --- a/site/index.html +++ b/site/index.html @@ -433,9 +433,9 @@

Catch N+1 queries before they hit production

0
Runtime dependencies
-
18KB
Package size
-
128
Tests passing
-
0%
Performance overhead
+
<40KB
Package size
+
242
Tests passing
+
<1%
Performance overhead
@@ -538,7 +538,7 @@

Payload CMS

41,700 stars on GitHub
Tests run with QueryGuard136
False positives0
-
Performance overhead0%
+
Performance overhead<1%
Tests broken0
From 75b3926563ff40634b9f52d20c7e5449b68da1f7 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Fri, 17 Apr 2026 00:15:29 +0400 Subject: [PATCH 3/3] chore(site): sync docs/index.html with site/index.html for GitHub Pages GitHub Pages deploys from docs/. The previous commit only updated site/, so the live landing page kept the stale 18KB / 128 tests / 0% numbers. Sync the same refresh to docs/. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/index.html b/docs/index.html index 8243243..ee61c91 100644 --- a/docs/index.html +++ b/docs/index.html @@ -433,9 +433,9 @@

Catch N+1 queries before they hit production

0
Runtime dependencies
-
18KB
Package size
-
128
Tests passing
-
0%
Performance overhead
+
<40KB
Package size
+
242
Tests passing
+
<1%
Performance overhead
@@ -538,7 +538,7 @@

Payload CMS

41,700 stars on GitHub
Tests run with QueryGuard136
False positives0
-
Performance overhead0%
+
Performance overhead<1%
Tests broken0