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/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
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
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',