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