diff --git a/build.config.ts b/build.config.ts index 96e5ede..c422c8b 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/docs/1.guide/1.index.md b/docs/1.guide/1.index.md index 7ff5715..ab87543 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 0000000..6595fb5 --- /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 0000000..c040c77 --- /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 0000000..b27aff4 --- /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" + } +} diff --git a/package.json b/package.json index af8793b..ab2a224 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/pnpm-lock.yaml b/pnpm-lock.yaml index dff25f4..5183ee1 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 diff --git a/src/tracing.ts b/src/tracing.ts new file mode 100644 index 0000000..d09c3f0 --- /dev/null +++ b/src/tracing.ts @@ -0,0 +1,117 @@ +import type { + Connector, + Database, + Primitive, + SQLDialect, + Statement, +} 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"; + dialect: SQLDialect; +} + +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 { 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; + } + + // 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), { + query, + method: "exec", + dialect: db.dialect, + }); + + tracedDb.sql = (strings, ...values) => + tracePromise(() => db.sql(strings, ...values), { + query: sqlTemplate(strings, ...values)[0], + method: "sql", + dialect: db.dialect, + }); + + 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, + }); + } + + bind(...args: Primitive[]) { + return new TracedStatement(this.#statement.bind(...args), this.#query); + } + + all(...args: Primitive[]) { + return this.withTrace(() => this.#statement.all(...args), "prepare.all"); + } + + 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; +} diff --git a/test/tracing.test.ts b/test/tracing.test.ts new file mode 100644 index 0000000..f28caa0 --- /dev/null +++ b/test/tracing.test.ts @@ -0,0 +1,660 @@ +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("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"); + + 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"); + expect(listener.events.start?.data.dialect).toBe("sqlite"); + + 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.toHaveBeenCalled(); + 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.dialect).toBe("sqlite"); + + 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"); + expect(selectCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(insertCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(listener.events.error?.data.dialect).toBe("sqlite"); + + 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(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(prepareCalls[0][0].dialect).toBe("sqlite"); + + 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"); + expect(selectCalls[0][0].dialect).toBe("sqlite"); + + 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(); + }); + }); + + 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(); + }); + }); +});