From 5dd1cd3265e97b2a48454189a9230fe75f501b9c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 7 Nov 2025 13:33:58 +0200 Subject: [PATCH 01/12] feat: implement tracing for db0 --- src/index.ts | 6 + src/tracing.ts | 97 ++++++++++ test/tracing.test.ts | 451 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 554 insertions(+) create mode 100644 src/tracing.ts create mode 100644 test/tracing.test.ts diff --git a/src/index.ts b/src/index.ts index 2ce54c19..12616634 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,9 @@ export type { } from "./types.ts"; export type { ConnectorName, ConnectorOptions } from "./_connectors.ts"; + +export { + withTracing, + type TraceContext, + type TracedOperation, +} from "./tracing.ts"; diff --git a/src/tracing.ts b/src/tracing.ts new file mode 100644 index 00000000..e0f5203c --- /dev/null +++ b/src/tracing.ts @@ -0,0 +1,97 @@ +import { type TracingChannel, tracingChannel } from "node:diagnostics_channel"; +import type { Connector, Database } from "./types.ts"; +import { sqlTemplate } from "./template.ts"; + +export type TracedOperation = "query"; + +export interface TraceContext { + query: string; + method: "exec" | "sql" | "prepare.all" | "prepare.run" | "prepare.get"; +} + +const channels: Record = { + query: createChannel("query"), +}; + +/** + * Create a tracing channel for a given operation. + */ +function createChannel(operation: TracedOperation) { + return tracingChannel(`db0.${operation}`); +} + +/** + * Trace a promise with a given operation and data. + */ +async function tracePromise( + operation: TracedOperation, + exec: () => Promise, + data: TraceContext, +): Promise { + const channel = channels[operation]; + + // TODO: Remove this cast once the @types/node types are updated. + // The @types/node types incorrectly mark tracePromise as returning void, + // but according to the JSDoc and actual implementation, it returns the promise. + // This is fixed in later versions of Node.js. + // See: https://nodejs.org/api/diagnostics_channel.html#channelstracepromisefn-context-thisarg-args + return channel.tracePromise(exec, data) as unknown as Promise; +} + +type MaybeTracedDatabase = + Database & { + __traced?: boolean; + }; + +/** + * Wrap a database instance with tracing functionality. + */ +export function withTracing( + db: MaybeTracedDatabase, +): Database { + // Avoids double patching + if (db.__traced) { + return db; + } + + const tracedDb: MaybeTracedDatabase = { ...db, __traced: true }; + + tracedDb.exec = (query) => + tracePromise("query", () => db.exec(query), { query, method: "exec" }); + + tracedDb.sql = (strings, ...values) => + tracePromise("query", () => db.sql(strings, ...values), { + query: sqlTemplate(strings, ...values)[0], + method: "sql", + }); + + /** + * Prepare needs a special treatment because it returns a statement instance that needs to be patched. + */ + tracedDb.prepare = (query) => { + const statement = db.prepare(query); + const tracedStatement = { ...statement }; + + tracedStatement.all = (...params) => + tracePromise("query", () => statement.all(...params), { + query, + method: "prepare.all", + }); + + tracedStatement.run = (...params) => + tracePromise("query", () => statement.run(...params), { + query, + method: "prepare.run", + }); + + tracedStatement.get = (...params) => + tracePromise("query", () => statement.get(...params), { + query, + method: "prepare.get", + }); + + return tracedStatement; + }; + + return tracedDb; +} diff --git a/test/tracing.test.ts b/test/tracing.test.ts new file mode 100644 index 00000000..3d931de5 --- /dev/null +++ b/test/tracing.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { tracingChannel } from "node:diagnostics_channel"; +import { createDatabase } from "../src/database.ts"; +import { withTracing } from "../src/tracing.ts"; +import type { Database } from "../src/types.ts"; +import type { TracedOperation, TraceContext } from "../src/tracing.ts"; +import connector from "../src/connectors/better-sqlite3.ts"; + +type TracingEvent = { + start?: { data: TraceContext }; + end?: { data: TraceContext }; + asyncStart?: { data: TraceContext }; + asyncEnd?: { data: TraceContext; result?: any; error?: Error }; + error?: { data: TraceContext; error: Error }; +}; + +function createTracingListener(operationName: TracedOperation) { + const events: TracingEvent = {}; + + // Create tracing channel + const channel = tracingChannel(`db0.${operationName}`); + + // Create handlers + const startHandler = vi.fn((message: any) => { + events.start = { data: message }; + }); + + const endHandler = vi.fn((message: any) => { + events.end = { data: message }; + }); + + const asyncStartHandler = vi.fn((message: any) => { + events.asyncStart = { data: message }; + }); + + const asyncEndHandler = vi.fn((message: any) => { + events.asyncEnd = { + data: message, + result: message.result, + error: message.error, + }; + }); + + const errorHandler = vi.fn((message: any) => { + events.error = { data: message, error: message.error }; + }); + + // Subscribe using the subscribe method which listens to all events + channel.subscribe({ + start: startHandler, + end: endHandler, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: errorHandler, + }); + + return { + events, + handlers: { + start: startHandler, + end: endHandler, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: errorHandler, + }, + cleanup: () => { + channel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: errorHandler, + }); + }, + }; +} + +describe("tracing", () => { + let db: Database; + + beforeEach(async () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + db = withTracing(plainDb); + + // Create a test table + await db.exec( + `CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`, + ); + }); + + describe("opt-in behavior", () => { + it("should not emit tracing events without withTracing wrapper", async () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + const listener = createTracingListener("query"); + + await plainDb.exec(`CREATE TABLE test (id INTEGER PRIMARY KEY)`); + await plainDb.sql`SELECT * FROM test`; + + // No tracing events should be emitted + expect(listener.handlers.start).not.toHaveBeenCalled(); + expect(listener.handlers.end).not.toHaveBeenCalled(); + expect(listener.handlers.asyncStart).not.toHaveBeenCalled(); + expect(listener.handlers.asyncEnd).not.toHaveBeenCalled(); + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should prevent double tracing when wrapped multiple times", async () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + const tracedOnce = withTracing(plainDb); + const tracedTwice = withTracing(tracedOnce); + + const listener = createTracingListener("query"); + + await tracedTwice.exec(`CREATE TABLE test (id INTEGER PRIMARY KEY)`); + + // Should only be called once, not twice + expect(listener.handlers.start).toHaveBeenCalledTimes(1); + expect(listener.handlers.end).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncStart).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncEnd).toHaveBeenCalledTimes(1); + + listener.cleanup(); + }); + }); + + describe("exec", () => { + it("should emit correct tracing events on success", async () => { + const listener = createTracingListener("query"); + + const result = await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + expect(result).toBeDefined(); + expect(listener.handlers.start).toHaveBeenCalledTimes(1); + expect(listener.handlers.end).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncStart).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncEnd).toHaveBeenCalledTimes(1); + expect(listener.handlers.error).not.toHaveBeenCalled(); + + expect(listener.events.start?.data.query).toContain("INSERT INTO users"); + expect(listener.events.start?.data.method).toBe("exec"); + + listener.cleanup(); + }); + + it("should emit error event on failure", async () => { + const listener = createTracingListener("query"); + + await expect( + db.exec(`INSERT INTO non_existing_table VALUES (1, 'test')`), + ).rejects.toThrow(); + + expect(listener.handlers.start).toHaveBeenCalledTimes(1); + // asyncStart might not be called if error is thrown synchronously + expect(listener.handlers.asyncStart).not.toHaveBeenCalledTimes(1); + expect(listener.handlers.error).toHaveBeenCalledTimes(1); + expect(listener.events.error?.error).toBeDefined(); + expect(listener.events.error?.data.query).toContain( + "INSERT INTO non_existing_table", + ); + expect(listener.events.error?.data.method).toBe("exec"); + + listener.cleanup(); + }); + }); + + describe("sql", () => { + it("should emit correct tracing events on SELECT success", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + const result = await db.sql`SELECT * FROM users WHERE id = ${1}`; + + expect(result.rows).toHaveLength(1); + expect(listener.handlers.start).toHaveBeenCalled(); + expect(listener.handlers.end).toHaveBeenCalled(); + expect(listener.handlers.asyncStart).toHaveBeenCalled(); + expect(listener.handlers.asyncEnd).toHaveBeenCalled(); + expect(listener.handlers.error).not.toHaveBeenCalled(); + + // Find the SELECT query event + const selectCalls = listener.handlers.start.mock.calls.filter((call) => + call[0].query.includes("SELECT"), + ); + expect(selectCalls.length).toBeGreaterThan(0); + expect(selectCalls[0][0].method).toBe("sql"); + expect(selectCalls[0][0].query).toContain("SELECT * FROM users"); + + listener.cleanup(); + }); + + it("should emit correct tracing events on INSERT with RETURNING", async () => { + const listener = createTracingListener("query"); + + const result = + await db.sql`INSERT INTO users (id, name, email) VALUES (${2}, ${"Jane Doe"}, ${"jane@example.com"}) RETURNING *`; + + expect(result.rows).toHaveLength(1); + expect(listener.handlers.start).toHaveBeenCalled(); + expect(listener.handlers.end).toHaveBeenCalled(); + expect(listener.handlers.asyncStart).toHaveBeenCalled(); + expect(listener.handlers.asyncEnd).toHaveBeenCalled(); + expect(listener.handlers.error).not.toHaveBeenCalled(); + + // Find the INSERT query event + const insertCalls = listener.handlers.start.mock.calls.filter((call) => + call[0].query.includes("INSERT"), + ); + expect(insertCalls.length).toBeGreaterThan(0); + expect(insertCalls[0][0].method).toBe("sql"); + expect(insertCalls[0][0].query).toContain("INSERT INTO users"); + expect(insertCalls[0][0].query).toContain("RETURNING"); + + listener.cleanup(); + }); + + it("should emit error event on failure", async () => { + const listener = createTracingListener("query"); + + await expect( + db.sql`SELECT * FROM non_existing_table WHERE id = ${1}`, + ).rejects.toThrow(); + + expect(listener.handlers.start).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncStart).toHaveBeenCalledTimes(1); + expect(listener.handlers.error).toHaveBeenCalledTimes(1); + expect(listener.events.error?.error).toBeDefined(); + expect(listener.events.error?.data.query).toContain( + "SELECT * FROM non_existing_table", + ); + expect(listener.events.error?.data.method).toBe("sql"); + + listener.cleanup(); + }); + }); + + describe("prepare.all", () => { + it("should emit correct tracing events on success", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + await db.exec( + `INSERT INTO users (id, name, email) VALUES (2, 'Jane Doe', 'jane@example.com')`, + ); + + const stmt = db.prepare("SELECT * FROM users WHERE id > ?"); + const rows = await stmt.all(0); + + expect(rows).toHaveLength(2); + + // Find the prepare.all query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.all", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); + expect(prepareCalls[0][0].method).toBe("prepare.all"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should emit error event on failure", async () => { + const listener = createTracingListener("query"); + + const stmt = db.prepare("SELECT * FROM non_existing_table WHERE id = ?"); + + await expect(stmt.all(1)).rejects.toThrow(); + + const prepareCalls = listener.handlers.error.mock.calls.filter( + (call) => call[0].method === "prepare.all", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].error).toBeDefined(); + expect(prepareCalls[0][0].query).toContain( + "SELECT * FROM non_existing_table", + ); + expect(prepareCalls[0][0].method).toBe("prepare.all"); + + listener.cleanup(); + }); + }); + + describe("prepare.run", () => { + it("should emit correct tracing events on success", async () => { + const listener = createTracingListener("query"); + + const stmt = db.prepare( + "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + ); + const result = await stmt.run(3, "Bob Smith", "bob@example.com"); + + expect(result).toBeDefined(); + + // Find the prepare.run query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.run", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("INSERT INTO users"); + expect(prepareCalls[0][0].method).toBe("prepare.run"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should emit error event on failure", async () => { + const listener = createTracingListener("query"); + + const stmt = db.prepare( + "INSERT INTO non_existing_table (id, name) VALUES (?, ?)", + ); + + await expect(stmt.run(1, "test")).rejects.toThrow(); + + const prepareCalls = listener.handlers.error.mock.calls.filter( + (call) => call[0].method === "prepare.run", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].error).toBeDefined(); + expect(prepareCalls[0][0].query).toContain( + "INSERT INTO non_existing_table", + ); + expect(prepareCalls[0][0].method).toBe("prepare.run"); + + listener.cleanup(); + }); + }); + + describe("prepare.get", () => { + it("should emit correct tracing events on success", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); + const row = await stmt.get(1); + + expect(row).toBeDefined(); + expect((row as any).name).toBe("John Doe"); + + // Find the prepare.get query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.get", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); + expect(prepareCalls[0][0].method).toBe("prepare.get"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should emit error event on failure", async () => { + const listener = createTracingListener("query"); + + const stmt = db.prepare("SELECT * FROM non_existing_table WHERE id = ?"); + + await expect(stmt.get(1)).rejects.toThrow(); + + const prepareCalls = listener.handlers.error.mock.calls.filter( + (call) => call[0].method === "prepare.get", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].error).toBeDefined(); + expect(prepareCalls[0][0].query).toContain( + "SELECT * FROM non_existing_table", + ); + expect(prepareCalls[0][0].method).toBe("prepare.get"); + + listener.cleanup(); + }); + }); + + describe("query reconstruction in sql method", () => { + it("should correctly reconstruct query with template literals", async () => { + const listener = createTracingListener("query"); + + const name = "John Doe"; + const email = "john@example.com"; + await db.sql`SELECT * FROM users WHERE name = ${name} AND email = ${email}`; + + const selectCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "sql" && call[0].query.includes("SELECT"), + ); + expect(selectCalls.length).toBeGreaterThan(0); + + // Query should be reconstructed with placeholders + const query = selectCalls[0][0].query; + expect(query).toContain("SELECT * FROM users"); + expect(query).toContain("WHERE"); + expect(query).toContain("name"); + expect(query).toContain("email"); + + listener.cleanup(); + }); + }); + + describe("multiple operations", () => { + it("should emit separate events for each operation", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + await db.sql`SELECT * FROM users WHERE id = ${1}`; + const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); + await stmt.all(1); + + // Should have events for: exec, sql (prepare.all internally), and prepare.all + expect(listener.handlers.start.mock.calls.length).toBeGreaterThanOrEqual( + 3, + ); + + // Check that different methods were called + const methods = listener.handlers.start.mock.calls.map( + (call) => call[0].method, + ); + expect(methods).toContain("exec"); + expect(methods).toContain("sql"); + expect(methods).toContain("prepare.all"); + + listener.cleanup(); + }); + }); +}); From 0ae9a84afa835f2dd9a142c22af26b40d3bcfb50 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Nov 2025 00:25:33 +0200 Subject: [PATCH 02/12] feat: add dialect to the context data --- src/tracing.ts | 20 +++++++++++++++----- test/tracing.test.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index e0f5203c..a171d4c0 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,5 +1,5 @@ import { type TracingChannel, tracingChannel } from "node:diagnostics_channel"; -import type { Connector, Database } from "./types.ts"; +import type { Connector, Database, SQLDialect } from "./types.ts"; import { sqlTemplate } from "./template.ts"; export type TracedOperation = "query"; @@ -7,6 +7,7 @@ export type TracedOperation = "query"; export interface TraceContext { query: string; method: "exec" | "sql" | "prepare.all" | "prepare.run" | "prepare.get"; + dialect: SQLDialect; } const channels: Record = { @@ -57,12 +58,17 @@ export function withTracing( const tracedDb: MaybeTracedDatabase = { ...db, __traced: true }; tracedDb.exec = (query) => - tracePromise("query", () => db.exec(query), { query, method: "exec" }); + tracePromise("query", () => db.exec(query), { + query, + method: "exec", + dialect: db.dialect, + }); tracedDb.sql = (strings, ...values) => tracePromise("query", () => db.sql(strings, ...values), { query: sqlTemplate(strings, ...values)[0], method: "sql", + dialect: db.dialect, }); /** @@ -71,23 +77,27 @@ export function withTracing( tracedDb.prepare = (query) => { const statement = db.prepare(query); const tracedStatement = { ...statement }; + const partialContext = { + query, + dialect: db.dialect, + }; tracedStatement.all = (...params) => tracePromise("query", () => statement.all(...params), { - query, method: "prepare.all", + ...partialContext, }); tracedStatement.run = (...params) => tracePromise("query", () => statement.run(...params), { - query, method: "prepare.run", + ...partialContext, }); tracedStatement.get = (...params) => tracePromise("query", () => statement.get(...params), { - query, method: "prepare.get", + ...partialContext, }); return tracedStatement; diff --git a/test/tracing.test.ts b/test/tracing.test.ts index 3d931de5..6708e191 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -154,6 +154,7 @@ describe("tracing", () => { expect(listener.events.start?.data.query).toContain("INSERT INTO users"); expect(listener.events.start?.data.method).toBe("exec"); + expect(listener.events.start?.data.dialect).toBe("sqlite"); listener.cleanup(); }); @@ -174,6 +175,7 @@ describe("tracing", () => { "INSERT INTO non_existing_table", ); expect(listener.events.error?.data.method).toBe("exec"); + expect(listener.events.error?.data.dialect).toBe("sqlite"); listener.cleanup(); }); @@ -203,6 +205,7 @@ describe("tracing", () => { expect(selectCalls.length).toBeGreaterThan(0); expect(selectCalls[0][0].method).toBe("sql"); expect(selectCalls[0][0].query).toContain("SELECT * FROM users"); + expect(selectCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); @@ -228,6 +231,7 @@ describe("tracing", () => { expect(insertCalls[0][0].method).toBe("sql"); expect(insertCalls[0][0].query).toContain("INSERT INTO users"); expect(insertCalls[0][0].query).toContain("RETURNING"); + expect(insertCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); @@ -247,6 +251,7 @@ describe("tracing", () => { "SELECT * FROM non_existing_table", ); expect(listener.events.error?.data.method).toBe("sql"); + expect(listener.events.error?.data.dialect).toBe("sqlite"); listener.cleanup(); }); @@ -275,6 +280,7 @@ describe("tracing", () => { expect(prepareCalls.length).toBeGreaterThan(0); expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); expect(prepareCalls[0][0].method).toBe("prepare.all"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); @@ -297,6 +303,7 @@ describe("tracing", () => { "SELECT * FROM non_existing_table", ); expect(prepareCalls[0][0].method).toBe("prepare.all"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); @@ -320,6 +327,7 @@ describe("tracing", () => { expect(prepareCalls.length).toBeGreaterThan(0); expect(prepareCalls[0][0].query).toContain("INSERT INTO users"); expect(prepareCalls[0][0].method).toBe("prepare.run"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); @@ -344,6 +352,7 @@ describe("tracing", () => { "INSERT INTO non_existing_table", ); expect(prepareCalls[0][0].method).toBe("prepare.run"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); @@ -370,6 +379,7 @@ describe("tracing", () => { expect(prepareCalls.length).toBeGreaterThan(0); expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); expect(prepareCalls[0][0].method).toBe("prepare.get"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); @@ -392,6 +402,7 @@ describe("tracing", () => { "SELECT * FROM non_existing_table", ); expect(prepareCalls[0][0].method).toBe("prepare.get"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); @@ -416,6 +427,7 @@ describe("tracing", () => { expect(query).toContain("WHERE"); expect(query).toContain("name"); expect(query).toContain("email"); + expect(selectCalls[0][0].dialect).toBe("sqlite"); listener.cleanup(); }); From 3ce185a2d24068f0e79d89221875be0e611a1d2a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Dec 2025 14:31:50 +0200 Subject: [PATCH 03/12] refactor: avoid explicit import of diag channel --- src/tracing.ts | 60 +++++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index a171d4c0..d8ae2564 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,4 +1,3 @@ -import { type TracingChannel, tracingChannel } from "node:diagnostics_channel"; import type { Connector, Database, SQLDialect } from "./types.ts"; import { sqlTemplate } from "./template.ts"; @@ -10,35 +9,6 @@ export interface TraceContext { dialect: SQLDialect; } -const channels: Record = { - query: createChannel("query"), -}; - -/** - * Create a tracing channel for a given operation. - */ -function createChannel(operation: TracedOperation) { - return tracingChannel(`db0.${operation}`); -} - -/** - * Trace a promise with a given operation and data. - */ -async function tracePromise( - operation: TracedOperation, - exec: () => Promise, - data: TraceContext, -): Promise { - const channel = channels[operation]; - - // TODO: Remove this cast once the @types/node types are updated. - // The @types/node types incorrectly mark tracePromise as returning void, - // but according to the JSDoc and actual implementation, it returns the promise. - // This is fixed in later versions of Node.js. - // See: https://nodejs.org/api/diagnostics_channel.html#channelstracepromisefn-context-thisarg-args - return channel.tracePromise(exec, data) as unknown as Promise; -} - type MaybeTracedDatabase = Database & { __traced?: boolean; @@ -55,17 +25,37 @@ export function withTracing( return db; } + const { tracingChannel } = + globalThis.process?.getBuiltinModule?.("node:diagnostics_channel") || {}; + if (!tracingChannel) { + return db; + } + + const queryChannel = tracingChannel(`db0.query`); + + async function tracePromise( + exec: () => Promise, + data: TraceContext, + ): Promise { + // TODO: Remove this cast once the @types/node types are updated. + // The @types/node types incorrectly mark tracePromise as returning void, + // but according to the JSDoc and actual implementation, it returns the promise. + // This is fixed in later versions of Node.js. + // See: https://nodejs.org/api/diagnostics_channel.html#channelstracepromisefn-context-thisarg-args + return queryChannel.tracePromise(exec, data) as unknown as Promise; + } + const tracedDb: MaybeTracedDatabase = { ...db, __traced: true }; tracedDb.exec = (query) => - tracePromise("query", () => db.exec(query), { + tracePromise(() => db.exec(query), { query, method: "exec", dialect: db.dialect, }); tracedDb.sql = (strings, ...values) => - tracePromise("query", () => db.sql(strings, ...values), { + tracePromise(() => db.sql(strings, ...values), { query: sqlTemplate(strings, ...values)[0], method: "sql", dialect: db.dialect, @@ -83,19 +73,19 @@ export function withTracing( }; tracedStatement.all = (...params) => - tracePromise("query", () => statement.all(...params), { + tracePromise(() => statement.all(...params), { method: "prepare.all", ...partialContext, }); tracedStatement.run = (...params) => - tracePromise("query", () => statement.run(...params), { + tracePromise(() => statement.run(...params), { method: "prepare.run", ...partialContext, }); tracedStatement.get = (...params) => - tracePromise("query", () => statement.get(...params), { + tracePromise(() => statement.get(...params), { method: "prepare.get", ...partialContext, }); From c0b97d18c762e8a185c07dffb2e92f93f6991598 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Dec 2025 14:32:19 +0200 Subject: [PATCH 04/12] refactor: expose tracing as a subpath export --- build.config.ts | 1 + package.json | 4 ++++ src/index.ts | 6 ------ 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/build.config.ts b/build.config.ts index 96e5ede8..c422c8bb 100644 --- a/build.config.ts +++ b/build.config.ts @@ -3,6 +3,7 @@ import { defineBuildConfig } from "obuild/config"; export default defineBuildConfig({ entries: [ { type: "bundle", input: "src/index.ts" }, + { type: "bundle", input: "src/tracing.ts" }, { type: "transform", input: "src/connectors/", outDir: "dist/connectors" }, { type: "transform", diff --git a/package.json b/package.json index af8793bd..ab2a2243 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, + "./tracing": { + "types": "./dist/tracing.d.mts", + "default": "./dist/tracing.mjs" + }, "./connectors/*": { "types": "./dist/connectors/*.d.ts", "default": "./dist/connectors/*.mjs" diff --git a/src/index.ts b/src/index.ts index 12616634..2ce54c19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,3 @@ export type { } from "./types.ts"; export type { ConnectorName, ConnectorOptions } from "./_connectors.ts"; - -export { - withTracing, - type TraceContext, - type TracedOperation, -} from "./tracing.ts"; From f858d8921050ec37960db0081371c5c881bca776 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Dec 2025 14:53:23 +0200 Subject: [PATCH 05/12] docs: added info and examples to docs --- docs/1.guide/1.index.md | 2 ++ docs/1.guide/2.tracing.md | 53 +++++++++++++++++++++++++++++++++++ examples/tracing/index.ts | 50 +++++++++++++++++++++++++++++++++ examples/tracing/package.json | 11 ++++++++ 4 files changed, 116 insertions(+) create mode 100644 docs/1.guide/2.tracing.md create mode 100644 examples/tracing/index.ts create mode 100644 examples/tracing/package.json diff --git a/docs/1.guide/1.index.md b/docs/1.guide/1.index.md index 7ff5715b..ab87543f 100644 --- a/docs/1.guide/1.index.md +++ b/docs/1.guide/1.index.md @@ -57,6 +57,8 @@ console.log(rows); ## Next steps +:read-more{to="/guide/tracing"} + :read-more{to="/connectors"} :read-more{to="/integrations"} diff --git a/docs/1.guide/2.tracing.md b/docs/1.guide/2.tracing.md new file mode 100644 index 00000000..6595fb56 --- /dev/null +++ b/docs/1.guide/2.tracing.md @@ -0,0 +1,53 @@ +--- +icon: ph:chart-bar-horizontal-duotone +--- + +# Tracing + +> db0 provides first-class support for tracing capabilities out of the box. + +## Overview + +db0 uses [tracing channels](https://nodejs.org/api/diagnostics_channel.html) of the diagnostics channel module to emit traceable actions for query operations. + +Common use cases for tracing are: + +- Query performance monitoring and profiling +- Logging +- Query debugging +- Error tracking + +## Query Tracing Channel + +db0 uses `db0.query` for the tracing channel name. + +To set up tracing, you need to subscribe to the tracing channel. Since query operations are asynchronous, you need to subscribe to the `asyncEnd` event as it signals the completion of the operation. + +```ts +import { tracingChannel } from "node:diagnostics_channel"; + +const queryChannel = tracingChannel("db0.query"); + +queryChannel.subscribe({ + start: (data) => { + console.log("start", data.query); + }, + asyncEnd: (data) => { + console.log("end", data.query, data.result); + }, +}); +``` + +The event payload contains several properties that can be used to track the query operation: + +- `query`: The query string. +- `method`: The method used to execute the query (`exec`, `sql`, `prepare.all`, `prepare.run`, `prepare.get`). +- `dialect`: The dialect of the database connection (e.g. `sqlite`, `postgres`, `mysql`). +- `result`: The result of the query (only available for `asyncEnd` event). +- `error`: The error that occurred during the query operation (only available for `error` and `asyncEnd` events). + +## Next steps + +:read-more{to="/connectors"} + +:read-more{to="/integrations"} diff --git a/examples/tracing/index.ts b/examples/tracing/index.ts new file mode 100644 index 00000000..c040c772 --- /dev/null +++ b/examples/tracing/index.ts @@ -0,0 +1,50 @@ +import { createDatabase } from "../../src"; +import { TraceContext, withTracing } from "../../src/tracing"; +import { tracingChannel } from "node:diagnostics_channel"; + +import sqlite from "../../src/connectors/better-sqlite3"; + +async function main() { + const db = withTracing(createDatabase(sqlite({}))); + + // Subscribe to tracing events + subscribeToTracing(); + + await db.sql`create table if not exists users ( + id integer primary key autoincrement, + full_name text + )`; + + const res = await db.sql`insert into users (full_name) values ('John Doe')`; + + console.log({ res }); +} + +function subscribeToTracing() { + const queryChannel = tracingChannel("db0.query"); + + queryChannel.subscribe({ + start: (data) => { + console.log("start", data.query); + }, + end: (message) => { + console.log("end", message.query); + }, + asyncStart: (data) => { + console.log("asyncStart", data.query); + }, + asyncEnd: (data) => { + console.log("asyncEnd", data.query, data.result); + }, + error: (data) => { + console.log("error", data.error); + }, + }); +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +main().catch((error) => { + console.error(error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +}); diff --git a/examples/tracing/package.json b/examples/tracing/package.json new file mode 100644 index 00000000..b27aff44 --- /dev/null +++ b/examples/tracing/package.json @@ -0,0 +1,11 @@ +{ + "name": "db0-with-tracing", + "private": true, + "scripts": { + "start": "jiti ./index.ts" + }, + "devDependencies": { + "jiti": "^1.21.0", + "db0": "latest" + } +} From d219d5eb46216b722aa952d403ae9a967ffaefb5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Dec 2025 13:20:20 +0200 Subject: [PATCH 06/12] feat: traced statement class --- src/tracing.ts | 72 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index d8ae2564..209df7ab 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,4 +1,10 @@ -import type { Connector, Database, SQLDialect } from "./types.ts"; +import type { + Connector, + Database, + Primitive, + SQLDialect, + Statement, +} from "./types.ts"; import { sqlTemplate } from "./template.ts"; export type TracedOperation = "query"; @@ -61,37 +67,47 @@ export function withTracing( dialect: db.dialect, }); - /** - * Prepare needs a special treatment because it returns a statement instance that needs to be patched. - */ - tracedDb.prepare = (query) => { - const statement = db.prepare(query); - const tracedStatement = { ...statement }; - const partialContext = { - query, - dialect: db.dialect, - }; - - tracedStatement.all = (...params) => - tracePromise(() => statement.all(...params), { - method: "prepare.all", - ...partialContext, + class TracedStatement implements Statement { + #statement: Statement; + #query: string; + + constructor(statement: Statement, query: string) { + this.#statement = statement; + this.#query = query; + } + + private withTrace( + fn: () => Promise, + method: "prepare.all" | "prepare.run" | "prepare.get", + ) { + return tracePromise(() => fn(), { + method, + query: this.#query, + dialect: db.dialect, }); + } - tracedStatement.run = (...params) => - tracePromise(() => statement.run(...params), { - method: "prepare.run", - ...partialContext, - }); + bind(...args: Primitive[]) { + return this.#statement.bind(...args); + } - tracedStatement.get = (...params) => - tracePromise(() => statement.get(...params), { - method: "prepare.get", - ...partialContext, - }); + all(...args: Primitive[]) { + return this.withTrace(() => this.#statement.all(...args), "prepare.all"); + } - return tracedStatement; - }; + run(...args: Primitive[]) { + return this.withTrace(() => this.#statement.run(...args), "prepare.run"); + } + + get(...args: Primitive[]) { + return this.withTrace(() => this.#statement.get(...args), "prepare.get"); + } + } + + /** + * Prepare needs a special treatment because it returns a statement instance that needs to be patched. + */ + tracedDb.prepare = (query) => new TracedStatement(db.prepare(query), query); return tracedDb; } From b36f56700f87c35058757f3d616023b2c14d67e6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Dec 2025 14:24:08 +0200 Subject: [PATCH 07/12] fix: use prepared statement as a base instead --- src/tracing.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index 209df7ab..512c3588 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,9 +1,9 @@ import type { Connector, Database, + PreparedStatement, Primitive, SQLDialect, - Statement, } from "./types.ts"; import { sqlTemplate } from "./template.ts"; @@ -67,11 +67,11 @@ export function withTracing( dialect: db.dialect, }); - class TracedStatement implements Statement { - #statement: Statement; + class TracedStatement implements PreparedStatement { + #statement: PreparedStatement; #query: string; - constructor(statement: Statement, query: string) { + constructor(statement: PreparedStatement, query: string) { this.#statement = statement; this.#query = query; } @@ -88,19 +88,19 @@ export function withTracing( } bind(...args: Primitive[]) { - return this.#statement.bind(...args); + return new TracedStatement(this.#statement.bind(...args), this.#query); } - all(...args: Primitive[]) { - return this.withTrace(() => this.#statement.all(...args), "prepare.all"); + all() { + return this.withTrace(() => this.#statement.all(), "prepare.all"); } - run(...args: Primitive[]) { - return this.withTrace(() => this.#statement.run(...args), "prepare.run"); + run() { + return this.withTrace(() => this.#statement.run(), "prepare.run"); } - get(...args: Primitive[]) { - return this.withTrace(() => this.#statement.get(...args), "prepare.get"); + get() { + return this.withTrace(() => this.#statement.get(), "prepare.get"); } } From 7cdb8959427eea910ca635bff0496ef4cd6edcc2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Dec 2025 14:29:52 +0200 Subject: [PATCH 08/12] fix: handle nested bind statements --- src/tracing.ts | 20 +++--- test/tracing.test.ts | 146 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 10 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index 512c3588..5a3f6f0a 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,9 +1,9 @@ import type { Connector, Database, - PreparedStatement, Primitive, SQLDialect, + Statement, } from "./types.ts"; import { sqlTemplate } from "./template.ts"; @@ -67,11 +67,11 @@ export function withTracing( dialect: db.dialect, }); - class TracedStatement implements PreparedStatement { - #statement: PreparedStatement; + class TracedStatement implements Statement { + #statement: Statement; #query: string; - constructor(statement: PreparedStatement, query: string) { + constructor(statement: Statement, query: string) { this.#statement = statement; this.#query = query; } @@ -91,16 +91,16 @@ export function withTracing( return new TracedStatement(this.#statement.bind(...args), this.#query); } - all() { - return this.withTrace(() => this.#statement.all(), "prepare.all"); + all(...args: Primitive[]) { + return this.withTrace(() => this.#statement.all(...args), "prepare.all"); } - run() { - return this.withTrace(() => this.#statement.run(), "prepare.run"); + run(...args: Primitive[]) { + return this.withTrace(() => this.#statement.run(...args), "prepare.run"); } - get() { - return this.withTrace(() => this.#statement.get(), "prepare.get"); + get(...args: Primitive[]) { + return this.withTrace(() => this.#statement.get(...args), "prepare.get"); } } diff --git a/test/tracing.test.ts b/test/tracing.test.ts index 6708e191..43ca347c 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -460,4 +460,150 @@ describe("tracing", () => { listener.cleanup(); }); }); + + describe("nested bind support", () => { + it("should support chained bind calls with all()", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + await db.exec( + `INSERT INTO users (id, name, email) VALUES (2, 'Jane Doe', 'jane@example.com')`, + ); + + const stmt = db.prepare("SELECT * FROM users WHERE id > ?"); + const boundStmt = stmt.bind(0); + const rows = await boundStmt.all(); + + expect(rows).toHaveLength(2); + + // Find the prepare.all query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.all", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); + expect(prepareCalls[0][0].method).toBe("prepare.all"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should support chained bind calls with run()", async () => { + const listener = createTracingListener("query"); + + const stmt = db.prepare( + "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + ); + const boundStmt = stmt.bind(10, "Alice Smith", "alice@example.com"); + const result = await boundStmt.run(); + + expect(result).toBeDefined(); + + // Find the prepare.run query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.run", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("INSERT INTO users"); + expect(prepareCalls[0][0].method).toBe("prepare.run"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should support chained bind calls with get()", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); + const boundStmt = stmt.bind(1); + const row = await boundStmt.get(); + + expect(row).toBeDefined(); + expect((row as any).name).toBe("John Doe"); + + // Find the prepare.get query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.get", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); + expect(prepareCalls[0][0].method).toBe("prepare.get"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should support multiple nested bind calls", async () => { + const listener = createTracingListener("query"); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + const stmt = db.prepare( + "SELECT * FROM users WHERE id = ? AND name = ? AND email = ?", + ); + const row = await stmt.bind(1, "John Doe", "john@example.com").get(); + + expect(row).toBeDefined(); + expect((row as any).name).toBe("John Doe"); + expect((row as any).email).toBe("john@example.com"); + + // Find the prepare.get query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.get", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + expect(prepareCalls[0][0].query).toContain("SELECT * FROM users"); + expect(prepareCalls[0][0].method).toBe("prepare.get"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + expect(listener.handlers.error).not.toHaveBeenCalled(); + + listener.cleanup(); + }); + + it("should preserve query context through nested binds", async () => { + const listener = createTracingListener("query"); + + const query = "SELECT * FROM users WHERE id = ?"; + const stmt = db.prepare(query); + + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + // Bind in multiple steps - each rebind replaces the parameters + const step1 = stmt.bind(999); // This will be replaced + const step2 = step1.bind(1); // This is the final binding + const row = await step2.get(); + + expect(row).toBeDefined(); + expect((row as any).name).toBe("John Doe"); + + // Find the prepare.get query event + const prepareCalls = listener.handlers.start.mock.calls.filter( + (call) => call[0].method === "prepare.get", + ); + expect(prepareCalls.length).toBeGreaterThan(0); + + // The original query should be preserved through all bind operations + expect(prepareCalls[0][0].query).toBe(query); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + listener.cleanup(); + }); + }); }); From c335701492524983b2903606bbe3e7c6a711f302 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 24 Dec 2025 15:17:26 +0200 Subject: [PATCH 09/12] fix: make sure to preserver dialect and disposed getters --- src/tracing.ts | 6 ++++- test/tracing.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/tracing.ts b/src/tracing.ts index 5a3f6f0a..d09c3f01 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -51,7 +51,11 @@ export function withTracing( return queryChannel.tracePromise(exec, data) as unknown as Promise; } - const tracedDb: MaybeTracedDatabase = { ...db, __traced: true }; + // Use Object.create to preserve getter properties like `dialect` and `disposed` + // The spread operator would evaluate getters at spread-time, making `disposed` + // always return the initial value rather than the current state. + const tracedDb = Object.create(db) as MaybeTracedDatabase; + tracedDb.__traced = true; tracedDb.exec = (query) => tracePromise(() => db.exec(query), { diff --git a/test/tracing.test.ts b/test/tracing.test.ts index 43ca347c..f28caa00 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -137,6 +137,57 @@ describe("tracing", () => { }); }); + describe("getter properties", () => { + it("should preserve dialect getter from original database", () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + const tracedDb = withTracing(plainDb); + + expect(tracedDb.dialect).toBe("sqlite"); + expect(tracedDb.dialect).toBe(plainDb.dialect); + }); + + it("should preserve disposed getter and reflect current state", async () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + const tracedDb = withTracing(plainDb); + + // Initially not disposed + expect(tracedDb.disposed).toBe(false); + expect(tracedDb.disposed).toBe(plainDb.disposed); + + // Dispose the database + await tracedDb.dispose(); + + // disposed should now be true (testing the getter reflects current state) + expect(tracedDb.disposed).toBe(true); + expect(tracedDb.disposed).toBe(plainDb.disposed); + }); + + it("should reflect disposed state when original db is disposed", async () => { + const plainDb = createDatabase( + connector({ + name: ":memory:", + }), + ); + const tracedDb = withTracing(plainDb); + + expect(tracedDb.disposed).toBe(false); + + // Dispose via the original database + await plainDb.dispose(); + + // tracedDb.disposed should reflect the change + expect(tracedDb.disposed).toBe(true); + }); + }); + describe("exec", () => { it("should emit correct tracing events on success", async () => { const listener = createTracingListener("query"); @@ -168,7 +219,7 @@ describe("tracing", () => { expect(listener.handlers.start).toHaveBeenCalledTimes(1); // asyncStart might not be called if error is thrown synchronously - expect(listener.handlers.asyncStart).not.toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncStart).not.toHaveBeenCalled(); expect(listener.handlers.error).toHaveBeenCalledTimes(1); expect(listener.events.error?.error).toBeDefined(); expect(listener.events.error?.data.query).toContain( From b1244f6f1b076f54e242d939eb04b5d5deb671bd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Dec 2025 16:28:14 +0200 Subject: [PATCH 10/12] chore: update lock --- pnpm-lock.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff25f4d..5183ee19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,15 @@ importers: specifier: ^1.21.0 version: 1.21.7 + examples/tracing: + devDependencies: + db0: + specifier: latest + version: 0.3.4(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7))(mysql2@3.15.3)(sqlite3@5.1.7) + jiti: + specifier: ^1.21.0 + version: 1.21.7 + packages: '@babel/generator@7.28.5': @@ -5275,6 +5284,15 @@ snapshots: mysql2: 3.15.3 sqlite3: 5.1.7 + db0@0.3.4(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7))(mysql2@3.15.3)(sqlite3@5.1.7): + optionalDependencies: + '@electric-sql/pglite': 0.3.14 + '@libsql/client': 0.15.15 + better-sqlite3: 12.4.1 + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7) + mysql2: 3.15.3 + sqlite3: 5.1.7 + debug@4.4.3: dependencies: ms: 2.1.3 From c0a27918c6933ce18ae83f3354a43ef5497c090a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 13 Jul 2026 09:55:43 +0000 Subject: [PATCH 11/12] refactor(tracing): normalize error events, skip work without subscribers - wrap traced calls in async fns so connectors that throw synchronously emit the same event sequence (error/asyncStart/asyncEnd) as async ones - build the trace context lazily behind `hasSubscribers`, removing the double `sqlTemplate` parse per `db.sql` call - add `connector` to the trace context (the channel is process-global) - drop the stale `tracePromise` cast, @types/node returns the promise now - document `withTracing` and the `db0/tracing` entry point Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/2.tracing.md | 26 +++++++++++++- examples/tracing/index.ts | 3 +- pnpm-lock.yaml | 11 +----- src/tracing.ts | 75 +++++++++++++++++++++++++-------------- test/tracing.test.ts | 32 +++++++++++++++-- 5 files changed, 106 insertions(+), 41 deletions(-) diff --git a/docs/1.guide/2.tracing.md b/docs/1.guide/2.tracing.md index 6595fb56..36ecbb01 100644 --- a/docs/1.guide/2.tracing.md +++ b/docs/1.guide/2.tracing.md @@ -4,7 +4,7 @@ icon: ph:chart-bar-horizontal-duotone # Tracing -> db0 provides first-class support for tracing capabilities out of the box. +> db0 can emit tracing events for every query it runs. ## Overview @@ -17,6 +17,25 @@ Common use cases for tracing are: - Query debugging - Error tracking +## Enabling tracing + +Tracing is opt-in. Wrap the database instance with `withTracing` from `db0/tracing` and use the returned instance: + +```ts +import { createDatabase } from "db0"; +import { withTracing } from "db0/tracing"; +import sqlite from "db0/connectors/better-sqlite3"; + +const db = withTracing(createDatabase(sqlite({}))); + +// Queries made with `db` now emit tracing events +const rows = await db.sql`SELECT * FROM users`; +``` + +Only the wrapped instance emits events, so tracing costs nothing for databases you do not wrap. Wrapping an already wrapped instance is a no-op. + +Tracing relies on `node:diagnostics_channel`, which requires Node.js `20.16+` or `22.3+`. On runtimes where it is unavailable (e.g. Cloudflare Workers), `withTracing` returns the database unchanged and no events are emitted. + ## Query Tracing Channel db0 uses `db0.query` for the tracing channel name. @@ -42,10 +61,15 @@ The event payload contains several properties that can be used to track the quer - `query`: The query string. - `method`: The method used to execute the query (`exec`, `sql`, `prepare.all`, `prepare.run`, `prepare.get`). +- `connector`: The name of the connector (e.g. `better-sqlite3`, `postgresql`). - `dialect`: The dialect of the database connection (e.g. `sqlite`, `postgres`, `mysql`). - `result`: The result of the query (only available for `asyncEnd` event). - `error`: The error that occurred during the query operation (only available for `error` and `asyncEnd` events). +::note +Bound parameters are never included in the traced context. Note that the `query` string itself can still contain literal values, either from raw `db.exec()` strings or from static interpolations (`${}` inside `{}`) in the `sql` template. Diagnostics channels are process-wide, so any subscriber can read them. +:: + ## Next steps :read-more{to="/connectors"} diff --git a/examples/tracing/index.ts b/examples/tracing/index.ts index c040c772..c24b896a 100644 --- a/examples/tracing/index.ts +++ b/examples/tracing/index.ts @@ -1,5 +1,6 @@ import { createDatabase } from "../../src"; -import { TraceContext, withTracing } from "../../src/tracing"; +import { withTracing } from "../../src/tracing"; +import type { TraceContext } from "../../src/tracing"; import { tracingChannel } from "node:diagnostics_channel"; import sqlite from "../../src/connectors/better-sqlite3"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31eb72b2..0b7c5972 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,7 +116,7 @@ importers: devDependencies: db0: specifier: latest - version: 0.3.4(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7))(mysql2@3.15.3)(sqlite3@5.1.7) + version: 0.3.4(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260711.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@planetscale/database@1.20.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.3)(mysql2@3.22.6(@types/node@26.1.1))(pg@8.22.0)(sqlite3@6.0.1))(mysql2@3.22.6(@types/node@26.1.1))(sqlite3@6.0.1) jiti: specifier: ^1.21.0 version: 1.21.7 @@ -4639,15 +4639,6 @@ snapshots: mysql2: 3.22.6(@types/node@26.1.1) sqlite3: 6.0.1 - db0@0.3.4(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7))(mysql2@3.15.3)(sqlite3@5.1.7): - optionalDependencies: - '@electric-sql/pglite': 0.3.14 - '@libsql/client': 0.15.15 - better-sqlite3: 12.4.1 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20251120.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.4.1)(bun-types@1.3.2(@types/react@19.1.13))(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7) - mysql2: 3.15.3 - sqlite3: 5.1.7 - debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/src/tracing.ts b/src/tracing.ts index d09c3f01..6015f5ad 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,3 +1,4 @@ +import type { ConnectorName } from "./_connectors.ts"; import type { Connector, Database, @@ -9,9 +10,12 @@ import { sqlTemplate } from "./template.ts"; export type TracedOperation = "query"; +const QUERY_OPERATION: TracedOperation = "query"; + export interface TraceContext { query: string; method: "exec" | "sql" | "prepare.all" | "prepare.run" | "prepare.get"; + connector: ConnectorName; dialect: SQLDialect; } @@ -37,18 +41,17 @@ export function withTracing( return db; } - const queryChannel = tracingChannel(`db0.query`); + const queryChannel = tracingChannel(`db0.${QUERY_OPERATION}`); - async function tracePromise( + // The context is built lazily to avoid any work when nobody is subscribed. + function tracePromise( exec: () => Promise, - data: TraceContext, + context: () => TraceContext, ): Promise { - // TODO: Remove this cast once the @types/node types are updated. - // The @types/node types incorrectly mark tracePromise as returning void, - // but according to the JSDoc and actual implementation, it returns the promise. - // This is fixed in later versions of Node.js. - // See: https://nodejs.org/api/diagnostics_channel.html#channelstracepromisefn-context-thisarg-args - return queryChannel.tracePromise(exec, data) as unknown as Promise; + if (!queryChannel.hasSubscribers) { + return exec(); + } + return queryChannel.tracePromise(exec, context()); } // Use Object.create to preserve getter properties like `dialect` and `disposed` @@ -57,19 +60,29 @@ export function withTracing( const tracedDb = Object.create(db) as MaybeTracedDatabase; tracedDb.__traced = true; + // `exec` is wrapped in an async function so connectors throwing synchronously + // emit the same event sequence as connectors rejecting asynchronously. tracedDb.exec = (query) => - tracePromise(() => db.exec(query), { - query, - method: "exec", - dialect: db.dialect, - }); + tracePromise( + async () => db.exec(query), + () => ({ + query, + method: "exec", + connector: db.connector, + dialect: db.dialect, + }), + ); tracedDb.sql = (strings, ...values) => - tracePromise(() => db.sql(strings, ...values), { - query: sqlTemplate(strings, ...values)[0], - method: "sql", - dialect: db.dialect, - }); + tracePromise( + async () => db.sql(strings, ...values), + () => ({ + query: sqlTemplate(strings, ...values)[0], + method: "sql", + connector: db.connector, + dialect: db.dialect, + }), + ); class TracedStatement implements Statement { #statement: Statement; @@ -80,15 +93,16 @@ export function withTracing( this.#query = query; } - private withTrace( + #withTrace( fn: () => Promise, method: "prepare.all" | "prepare.run" | "prepare.get", ) { - return tracePromise(() => fn(), { - method, + return tracePromise(fn, () => ({ query: this.#query, + method, + connector: db.connector, dialect: db.dialect, - }); + })); } bind(...args: Primitive[]) { @@ -96,15 +110,24 @@ export function withTracing( } all(...args: Primitive[]) { - return this.withTrace(() => this.#statement.all(...args), "prepare.all"); + return this.#withTrace( + async () => this.#statement.all(...args), + "prepare.all", + ); } run(...args: Primitive[]) { - return this.withTrace(() => this.#statement.run(...args), "prepare.run"); + return this.#withTrace( + async () => this.#statement.run(...args), + "prepare.run", + ); } get(...args: Primitive[]) { - return this.withTrace(() => this.#statement.get(...args), "prepare.get"); + return this.#withTrace( + async () => this.#statement.get(...args), + "prepare.get", + ); } } diff --git a/test/tracing.test.ts b/test/tracing.test.ts index f28caa00..011ae5a5 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { tracingChannel } from "node:diagnostics_channel"; import { createDatabase } from "../src/database.ts"; import { withTracing } from "../src/tracing.ts"; @@ -92,6 +92,10 @@ describe("tracing", () => { ); }); + afterEach(async () => { + await db.dispose(); + }); + describe("opt-in behavior", () => { it("should not emit tracing events without withTracing wrapper", async () => { const plainDb = createDatabase( @@ -134,6 +138,24 @@ describe("tracing", () => { expect(listener.handlers.asyncEnd).toHaveBeenCalledTimes(1); listener.cleanup(); + await tracedOnce.dispose(); + }); + + it("should behave like an untraced database when nobody is subscribed", async () => { + // No listener is created here, so the tracing fast path is used + await db.exec( + `INSERT INTO users (id, name, email) VALUES (1, 'John Doe', 'john@example.com')`, + ); + + const result = await db.sql`SELECT * FROM users WHERE id = ${1}`; + expect(result.rows).toHaveLength(1); + + const row = await db.prepare("SELECT * FROM users WHERE id = ?").get(1); + expect((row as any).name).toBe("John Doe"); + + await expect( + db.exec(`SELECT * FROM non_existing_table`), + ).rejects.toThrow(); }); }); @@ -205,6 +227,7 @@ describe("tracing", () => { expect(listener.events.start?.data.query).toContain("INSERT INTO users"); expect(listener.events.start?.data.method).toBe("exec"); + expect(listener.events.start?.data.connector).toBe("sqlite"); expect(listener.events.start?.data.dialect).toBe("sqlite"); listener.cleanup(); @@ -218,14 +241,17 @@ describe("tracing", () => { ).rejects.toThrow(); expect(listener.handlers.start).toHaveBeenCalledTimes(1); - // asyncStart might not be called if error is thrown synchronously - expect(listener.handlers.asyncStart).not.toHaveBeenCalled(); + // Connectors throwing synchronously emit the same events as async ones + expect(listener.handlers.asyncStart).toHaveBeenCalledTimes(1); + expect(listener.handlers.asyncEnd).toHaveBeenCalledTimes(1); + expect(listener.events.asyncEnd?.error).toBeDefined(); expect(listener.handlers.error).toHaveBeenCalledTimes(1); expect(listener.events.error?.error).toBeDefined(); expect(listener.events.error?.data.query).toContain( "INSERT INTO non_existing_table", ); expect(listener.events.error?.data.method).toBe("exec"); + expect(listener.events.error?.data.connector).toBe("sqlite"); expect(listener.events.error?.data.dialect).toBe("sqlite"); listener.cleanup(); From f854c4433d15cab98df4229406a433244ef21ded Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 13 Jul 2026 11:38:44 +0000 Subject: [PATCH 12/12] fix(tracing): keep the wrapper transparent - make the trace wrapper async so subscribing never turns a rejection (e.g. an invalid sql template) into a synchronous throw - copy property descriptors instead of Object.create, so spreading or serializing a traced database keeps connector/dialect/disposed - mark traced instances with a non-enumerable symbol instead of `__traced` - correct the documented connector/dialect values and event ordering - cover param privacy, event ordering and subscriber cleanup in tests Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/2.tracing.md | 17 +++-- src/tracing.ts | 24 ++++--- test/tracing.test.ts | 139 ++++++++++++++++++++++++-------------- 3 files changed, 117 insertions(+), 63 deletions(-) diff --git a/docs/1.guide/2.tracing.md b/docs/1.guide/2.tracing.md index 36ecbb01..c3b3917f 100644 --- a/docs/1.guide/2.tracing.md +++ b/docs/1.guide/2.tracing.md @@ -34,7 +34,7 @@ const rows = await db.sql`SELECT * FROM users`; Only the wrapped instance emits events, so tracing costs nothing for databases you do not wrap. Wrapping an already wrapped instance is a no-op. -Tracing relies on `node:diagnostics_channel`, which requires Node.js `20.16+` or `22.3+`. On runtimes where it is unavailable (e.g. Cloudflare Workers), `withTracing` returns the database unchanged and no events are emitted. +Tracing is looked up through `process.getBuiltinModule("node:diagnostics_channel")`, which requires Node.js `20.16+` or `22.3+`. On older versions, or on runtimes where diagnostics channels are unavailable, `withTracing` returns the database unchanged and silently emits no events. ## Query Tracing Channel @@ -61,10 +61,17 @@ The event payload contains several properties that can be used to track the quer - `query`: The query string. - `method`: The method used to execute the query (`exec`, `sql`, `prepare.all`, `prepare.run`, `prepare.get`). -- `connector`: The name of the connector (e.g. `better-sqlite3`, `postgresql`). -- `dialect`: The dialect of the database connection (e.g. `sqlite`, `postgres`, `mysql`). -- `result`: The result of the query (only available for `asyncEnd` event). -- `error`: The error that occurred during the query operation (only available for `error` and `asyncEnd` events). +- `connector`: The name of the connector (e.g. `sqlite`, `postgresql`). +- `dialect`: The dialect of the database connection (`sqlite`, `libsql`, `postgresql` or `mysql`). +- `result`: The result of the query (available on the `asyncStart` and `asyncEnd` events). +- `error`: The error that occurred during the query operation (available on the `error`, `asyncStart` and `asyncEnd` events). + +The events are emitted in the following order: + +- On success: `start` → `end` → `asyncStart` → `asyncEnd` +- On failure: `start` → `end` → `error` → `asyncStart` → `asyncEnd` + +Queries always settle asynchronously, even with connectors that fail synchronously, so `asyncEnd` is emitted for every traced query. ::note Bound parameters are never included in the traced context. Note that the `query` string itself can still contain literal values, either from raw `db.exec()` strings or from static interpolations (`${}` inside `{}`) in the `sql` template. Diagnostics channels are process-wide, so any subscriber can read them. diff --git a/src/tracing.ts b/src/tracing.ts index 6015f5ad..b8bab1cd 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -19,9 +19,11 @@ export interface TraceContext { dialect: SQLDialect; } +const TRACED: unique symbol = Symbol.for("db0.traced"); + type MaybeTracedDatabase = Database & { - __traced?: boolean; + [TRACED]?: boolean; }; /** @@ -31,7 +33,7 @@ export function withTracing( db: MaybeTracedDatabase, ): Database { // Avoids double patching - if (db.__traced) { + if (db[TRACED]) { return db; } @@ -44,7 +46,9 @@ export function withTracing( const queryChannel = tracingChannel(`db0.${QUERY_OPERATION}`); // The context is built lazily to avoid any work when nobody is subscribed. - function tracePromise( + // This is an async function so that subscribing never turns a rejection + // (e.g. an invalid `sql` template) into a synchronous throw. + async function tracePromise( exec: () => Promise, context: () => TraceContext, ): Promise { @@ -54,11 +58,15 @@ export function withTracing( return queryChannel.tracePromise(exec, context()); } - // Use Object.create to preserve getter properties like `dialect` and `disposed` - // The spread operator would evaluate getters at spread-time, making `disposed` - // always return the initial value rather than the current state. - const tracedDb = Object.create(db) as MaybeTracedDatabase; - tracedDb.__traced = true; + // Copy the property descriptors instead of spreading, so that getters like + // `dialect` and `disposed` stay getters. Spreading would evaluate them once, + // making `disposed` report the state at wrap-time forever after. + const tracedDb = Object.defineProperties( + {} as MaybeTracedDatabase, + Object.getOwnPropertyDescriptors(db), + ); + // Non-enumerable, so spreading or serializing a traced database does not leak it. + Object.defineProperty(tracedDb, TRACED, { value: true }); // `exec` is wrapped in an async function so connectors throwing synchronously // emit the same event sequence as connectors rejecting asynchronously. diff --git a/test/tracing.test.ts b/test/tracing.test.ts index 011ae5a5..bb6137d3 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { + describe, + it, + expect, + afterEach, + beforeEach, + onTestFinished, + vi, +} from "vitest"; import { tracingChannel } from "node:diagnostics_channel"; import { createDatabase } from "../src/database.ts"; import { withTracing } from "../src/tracing.ts"; @@ -54,6 +62,18 @@ function createTracingListener(operationName: TracedOperation) { error: errorHandler, }); + // Diagnostics channels are process-global, so a leaked subscriber would + // affect every following test. + onTestFinished(() => { + channel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: errorHandler, + }); + }); + return { events, handlers: { @@ -63,15 +83,6 @@ function createTracingListener(operationName: TracedOperation) { asyncEnd: asyncEndHandler, error: errorHandler, }, - cleanup: () => { - channel.unsubscribe({ - start: startHandler, - end: endHandler, - asyncStart: asyncStartHandler, - asyncEnd: asyncEndHandler, - error: errorHandler, - }); - }, }; } @@ -114,8 +125,6 @@ describe("tracing", () => { expect(listener.handlers.asyncStart).not.toHaveBeenCalled(); expect(listener.handlers.asyncEnd).not.toHaveBeenCalled(); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should prevent double tracing when wrapped multiple times", async () => { @@ -136,8 +145,6 @@ describe("tracing", () => { expect(listener.handlers.end).toHaveBeenCalledTimes(1); expect(listener.handlers.asyncStart).toHaveBeenCalledTimes(1); expect(listener.handlers.asyncEnd).toHaveBeenCalledTimes(1); - - listener.cleanup(); await tracedOnce.dispose(); }); @@ -157,6 +164,74 @@ describe("tracing", () => { db.exec(`SELECT * FROM non_existing_table`), ).rejects.toThrow(); }); + + it("should reject, never throw synchronously, on an invalid sql template", () => { + createTracingListener("query"); + + // Subscribing must not turn a rejection into a synchronous throw + expect(() => (db.sql as any)("SELECT 1").catch(() => {})).not.toThrow(); + }); + + it("should not leak internal properties of the traced instance", () => { + const plainDb = createDatabase(connector({ name: ":memory:" })); + const tracedDb = withTracing(plainDb); + + expect(JSON.stringify(tracedDb)).toBe(JSON.stringify(plainDb)); + }); + }); + + describe("trace context", () => { + it("should not include bound parameters", async () => { + const listener = createTracingListener("query"); + + const secret = "super-secret@example.com"; + await db.sql`SELECT * FROM users WHERE email = ${secret}`; + await db.prepare("SELECT * FROM users WHERE email = ?").all(secret); + + const traced = listener.handlers.start.mock.calls.map( + (call) => call[0].query, + ); + expect(traced.length).toBeGreaterThanOrEqual(2); + for (const query of traced) { + expect(query).not.toContain(secret); + } + }); + + it("should emit events in order on success", async () => { + const order: string[] = []; + const listener = createTracingListener("query"); + for (const [name, handler] of Object.entries(listener.handlers)) { + handler.mockImplementation(() => { + order.push(name); + }); + } + + await db.sql`SELECT * FROM users`; + + expect(order).toEqual(["start", "end", "asyncStart", "asyncEnd"]); + }); + + it("should emit events in order on failure", async () => { + const order: string[] = []; + const listener = createTracingListener("query"); + for (const [name, handler] of Object.entries(listener.handlers)) { + handler.mockImplementation(() => { + order.push(name); + }); + } + + await expect( + db.exec(`SELECT * FROM non_existing_table`), + ).rejects.toThrow(); + + expect(order).toEqual([ + "start", + "end", + "error", + "asyncStart", + "asyncEnd", + ]); + }); }); describe("getter properties", () => { @@ -229,8 +304,6 @@ describe("tracing", () => { expect(listener.events.start?.data.method).toBe("exec"); expect(listener.events.start?.data.connector).toBe("sqlite"); expect(listener.events.start?.data.dialect).toBe("sqlite"); - - listener.cleanup(); }); it("should emit error event on failure", async () => { @@ -253,8 +326,6 @@ describe("tracing", () => { expect(listener.events.error?.data.method).toBe("exec"); expect(listener.events.error?.data.connector).toBe("sqlite"); expect(listener.events.error?.data.dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -283,8 +354,6 @@ describe("tracing", () => { expect(selectCalls[0][0].method).toBe("sql"); expect(selectCalls[0][0].query).toContain("SELECT * FROM users"); expect(selectCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); it("should emit correct tracing events on INSERT with RETURNING", async () => { @@ -309,8 +378,6 @@ describe("tracing", () => { expect(insertCalls[0][0].query).toContain("INSERT INTO users"); expect(insertCalls[0][0].query).toContain("RETURNING"); expect(insertCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); it("should emit error event on failure", async () => { @@ -329,8 +396,6 @@ describe("tracing", () => { ); expect(listener.events.error?.data.method).toBe("sql"); expect(listener.events.error?.data.dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -360,8 +425,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should emit error event on failure", async () => { @@ -381,8 +444,6 @@ describe("tracing", () => { ); expect(prepareCalls[0][0].method).toBe("prepare.all"); expect(prepareCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -407,8 +468,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should emit error event on failure", async () => { @@ -430,8 +489,6 @@ describe("tracing", () => { ); expect(prepareCalls[0][0].method).toBe("prepare.run"); expect(prepareCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -459,8 +516,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should emit error event on failure", async () => { @@ -480,8 +535,6 @@ describe("tracing", () => { ); expect(prepareCalls[0][0].method).toBe("prepare.get"); expect(prepareCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -505,8 +558,6 @@ describe("tracing", () => { expect(query).toContain("name"); expect(query).toContain("email"); expect(selectCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); }); @@ -533,8 +584,6 @@ describe("tracing", () => { expect(methods).toContain("exec"); expect(methods).toContain("sql"); expect(methods).toContain("prepare.all"); - - listener.cleanup(); }); }); @@ -565,8 +614,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should support chained bind calls with run()", async () => { @@ -590,8 +637,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should support chained bind calls with get()", async () => { @@ -618,8 +663,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should support multiple nested bind calls", async () => { @@ -648,8 +691,6 @@ describe("tracing", () => { expect(prepareCalls[0][0].dialect).toBe("sqlite"); expect(listener.handlers.error).not.toHaveBeenCalled(); - - listener.cleanup(); }); it("should preserve query context through nested binds", async () => { @@ -679,8 +720,6 @@ describe("tracing", () => { // The original query should be preserved through all bind operations expect(prepareCalls[0][0].query).toBe(query); expect(prepareCalls[0][0].dialect).toBe("sqlite"); - - listener.cleanup(); }); }); });