sql-dev-inspector 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ import type { QueryEvent, RequestTrace } from "./types.js";
2
+ export interface StoreOptions {
3
+ maxRequests: number;
4
+ maxQueriesPerRequest: number;
5
+ }
6
+ export type StoreChange = {
7
+ type: "query" | "request-end";
8
+ requestId: string;
9
+ } | {
10
+ type: "clear";
11
+ };
12
+ export declare const BACKGROUND_REQUEST_ID = "background";
13
+ /**
14
+ * In-memory ring of the most recent requests that ran at least one statement.
15
+ * A request that never touches the database (static assets, HMR pings) is never kept.
16
+ */
17
+ export declare class TraceStore {
18
+ private readonly options;
19
+ private readonly traces;
20
+ private readonly listeners;
21
+ private background;
22
+ constructor(options: StoreOptions);
23
+ startRequest(method: string, url: string): RequestTrace;
24
+ /** Statements issued outside any request (startup, timers, queues) are gathered into one trace. */
25
+ backgroundTrace(): RequestTrace;
26
+ record(trace: RequestTrace, query: QueryEvent): void;
27
+ /** A statement finished after it was recorded; listeners only need to know the trace changed. */
28
+ touch(trace: RequestTrace): void;
29
+ finishRequest(trace: RequestTrace, status: number): void;
30
+ /** Newest first. */
31
+ list(): RequestTrace[];
32
+ get(id: string): RequestTrace | undefined;
33
+ clear(): void;
34
+ subscribe(listener: (change: StoreChange) => void): () => void;
35
+ private emit;
36
+ }
package/dist/store.js ADDED
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const BACKGROUND_REQUEST_ID = "background";
3
+ /**
4
+ * In-memory ring of the most recent requests that ran at least one statement.
5
+ * A request that never touches the database (static assets, HMR pings) is never kept.
6
+ */
7
+ export class TraceStore {
8
+ options;
9
+ traces = [];
10
+ listeners = new Set();
11
+ background = null;
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ startRequest(method, url) {
16
+ return {
17
+ id: randomUUID(),
18
+ method,
19
+ url,
20
+ status: null,
21
+ startedAt: Date.now(),
22
+ durationMs: null,
23
+ queries: [],
24
+ droppedQueries: 0
25
+ };
26
+ }
27
+ /** Statements issued outside any request (startup, timers, queues) are gathered into one trace. */
28
+ backgroundTrace() {
29
+ this.background ??= {
30
+ ...this.startRequest("-", "(outside any request)"),
31
+ id: BACKGROUND_REQUEST_ID
32
+ };
33
+ return this.background;
34
+ }
35
+ record(trace, query) {
36
+ if (!this.traces.includes(trace)) {
37
+ this.traces.push(trace);
38
+ if (this.traces.length > this.options.maxRequests) {
39
+ this.traces.shift();
40
+ }
41
+ }
42
+ trace.queries.push(query);
43
+ if (trace.queries.length > this.options.maxQueriesPerRequest) {
44
+ trace.queries.shift();
45
+ trace.droppedQueries++;
46
+ }
47
+ this.emit({ type: "query", requestId: trace.id });
48
+ }
49
+ /** A statement finished after it was recorded; listeners only need to know the trace changed. */
50
+ touch(trace) {
51
+ this.emit({ type: "query", requestId: trace.id });
52
+ }
53
+ finishRequest(trace, status) {
54
+ trace.status = status;
55
+ trace.durationMs = Date.now() - trace.startedAt;
56
+ if (this.traces.includes(trace)) {
57
+ this.emit({ type: "request-end", requestId: trace.id });
58
+ }
59
+ }
60
+ /** Newest first. */
61
+ list() {
62
+ return [...this.traces].reverse();
63
+ }
64
+ get(id) {
65
+ return this.traces.find(trace => trace.id === id);
66
+ }
67
+ clear() {
68
+ this.traces.length = 0;
69
+ this.background = null;
70
+ this.emit({ type: "clear" });
71
+ }
72
+ subscribe(listener) {
73
+ this.listeners.add(listener);
74
+ return () => this.listeners.delete(listener);
75
+ }
76
+ emit(change) {
77
+ for (const listener of this.listeners) {
78
+ try {
79
+ listener(change);
80
+ }
81
+ catch {
82
+ // a broken listener (closed SSE socket, terminal printer) must not break the query path
83
+ }
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,5 @@
1
+ import type { RequestReport } from "./types.js";
2
+ import { type Locale } from "./i18n/index.js";
3
+ export declare function renderReport(report: RequestReport, locale?: Locale): string;
4
+ /** Statements worth a terminal line; transaction plumbing is already shown by the tree. */
5
+ export declare function hasVisibleStatements(report: RequestReport): boolean;
@@ -0,0 +1,64 @@
1
+ import process from "node:process";
2
+ import { translate } from "./i18n/index.js";
3
+ import { isControlStatement } from "./sql-analyzer.js";
4
+ const useColor = Boolean(process.stdout?.isTTY) && !process.env.NO_COLOR;
5
+ const paint = (code) => (text) => useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
6
+ const dim = paint(2);
7
+ const bold = paint(1);
8
+ const red = paint(31);
9
+ const yellow = paint(33);
10
+ const green = paint(32);
11
+ const cyan = paint(36);
12
+ const WHITESPACE_RUN = /\s+/g;
13
+ const ms = (value) => value === null ? "…" : `${value.toFixed(value < 10 ? 2 : 1)}ms`;
14
+ function oneLine(sql, max = 140) {
15
+ const flat = sql.replace(WHITESPACE_RUN, " ").trim();
16
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
17
+ }
18
+ function describeQuery(query, locale) {
19
+ const { analysis } = query;
20
+ const where = analysis.tables.length > 0 ? ` ${cyan(analysis.tables.join(", "))}` : "";
21
+ const nested = analysis.subqueryDepth > 0
22
+ ? yellow(` ⤷${translate(locale, "term.subquery", { count: analysis.subqueryCount, depth: analysis.subqueryDepth })}`)
23
+ : "";
24
+ const rows = query.rowCount !== null
25
+ ? translate(locale, "term.rows", { count: query.rowCount })
26
+ : query.affectedRows !== null ? translate(locale, "term.affected", { count: query.affectedRows }) : "";
27
+ const stats = dim(`${ms(query.durationMs)}${rows ? ` · ${rows}` : ""}`);
28
+ const failed = query.error ? red(` ✖ ${query.error}`) : "";
29
+ return `${bold(analysis.kind.toUpperCase())}${where}${nested} ${stats}${failed}\n${oneLine(query.sql)}`;
30
+ }
31
+ function renderNodes(nodes, prefix, lines, locale) {
32
+ nodes.forEach((node, index) => {
33
+ const last = index === nodes.length - 1;
34
+ const branch = `${prefix}${last ? "└─ " : "├─ "}`;
35
+ const childPrefix = `${prefix}${last ? " " : "│ "}`;
36
+ if (node.type === "query") {
37
+ const [head, ...rest] = describeQuery(node.query, locale).split("\n");
38
+ lines.push(`${branch}${head}`);
39
+ rest.forEach(line => lines.push(`${childPrefix}${dim(line)}`));
40
+ return;
41
+ }
42
+ const label = node.type === "transaction" ? translate(locale, "tx.transaction").toUpperCase() : translate(locale, "tx.savepoint", { name: node.name ?? "" }).toUpperCase();
43
+ const statusText = translate(locale, `status.${node.status}`);
44
+ const status = node.status === "committed" ? green(statusText) : node.status === "rolled-back" ? red(statusText) : yellow(statusText);
45
+ lines.push(`${branch}${bold(label)} ${dim(`[conn ${node.connectionId}]`)} ${status} ${dim(ms(node.durationMs))}`);
46
+ renderNodes(node.children, childPrefix, lines, locale);
47
+ });
48
+ }
49
+ export function renderReport(report, locale = "en") {
50
+ const { trace, totals } = report;
51
+ const lines = [
52
+ `${bold(`${trace.method} ${trace.id === "background" ? translate(locale, "requests.background") : trace.url}`)} → ${trace.status ?? "…"} ${dim([translate(locale, "term.total", { ms: ms(trace.durationMs) }), translate(locale, "term.queries", { count: totals.queryCount }), translate(locale, "term.inSql", { ms: ms(totals.sqlTimeMs) })].join(" · "))}`
53
+ ];
54
+ renderNodes(report.tree, "", lines, locale);
55
+ for (const insight of report.insights) {
56
+ const mark = insight.severity === "error" ? red("✖") : insight.severity === "warn" ? yellow("⚠") : dim("ℹ");
57
+ lines.push(`${mark} ${insight.message}`);
58
+ }
59
+ return lines.join("\n");
60
+ }
61
+ /** Statements worth a terminal line; transaction plumbing is already shown by the tree. */
62
+ export function hasVisibleStatements(report) {
63
+ return report.trace.queries.some(query => !isControlStatement(query.analysis.kind));
64
+ }
@@ -0,0 +1,36 @@
1
+ import type { TraceStore } from "./store.js";
2
+ import type { RequestTrace } from "./types.js";
3
+ /** Everything awaited inside `fn` is attributed to `trace`, however deep the call goes. */
4
+ export declare function runInRequest<T>(trace: RequestTrace, fn: () => T): T;
5
+ type Method = "query" | "execute";
6
+ type PatchableMethod = (this: ConnectionLike, ...args: unknown[]) => CommandLike;
7
+ interface ConnectionLike {
8
+ threadId?: number | null;
9
+ }
10
+ interface CommandLike {
11
+ sql: string;
12
+ values?: unknown[];
13
+ parameters?: unknown[];
14
+ onResult?: (error: Error | null, result?: unknown, fields?: unknown) => unknown;
15
+ once: (event: string, listener: (...args: unknown[]) => void) => unknown;
16
+ on: (event: string, listener: (...args: unknown[]) => void) => unknown;
17
+ }
18
+ type GetConnection = (this: unknown, callback: (...args: unknown[]) => unknown) => unknown;
19
+ /** The classes to patch; tests pass stand-ins. */
20
+ export interface TracerTargets {
21
+ connection: {
22
+ prototype: Record<Method, PatchableMethod>;
23
+ };
24
+ pool: {
25
+ prototype: {
26
+ getConnection: GetConnection;
27
+ };
28
+ };
29
+ }
30
+ /**
31
+ * Patches `query` / `execute` on the connection class and `getConnection` on the pool class,
32
+ * once. Returns the function that restores the originals. Calling it again first undoes the
33
+ * previous install, so a reloaded module never stacks two wrappers.
34
+ */
35
+ export declare function installTracer(store: TraceStore, targets?: TracerTargets): () => void;
36
+ export {};
package/dist/tracer.js ADDED
@@ -0,0 +1,143 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import * as mysql from "mysql2";
3
+ import { analyzeSql } from "./sql-analyzer.js";
4
+ /**
5
+ * Why patch mysql2 and not use Drizzle's `logger` or mysql2's diagnostics channels:
6
+ * - Drizzle's logger sees SQL + params only: no duration, no result, no connection, so
7
+ * transactions and savepoints cannot be told apart from each other.
8
+ * - mysql2's tracing channels add timing but still hide the connection, which is what
9
+ * separates two transactions running at once in the same request.
10
+ * - `Connection.prototype.query/execute` is the single funnel under Drizzle, pools,
11
+ * transactions and streaming, and `this` gives the connection.
12
+ */
13
+ const requestScope = new AsyncLocalStorage();
14
+ /** Everything awaited inside `fn` is attributed to `trace`, however deep the call goes. */
15
+ export function runInRequest(trace, fn) {
16
+ return requestScope.run(trace, fn);
17
+ }
18
+ const INSTALLED = Symbol.for("sqlDevInspector.installed");
19
+ let connectionCounter = 0;
20
+ const connectionIds = new WeakMap();
21
+ /** The MySQL thread id once the handshake is done; a stable negative id for a connection that has none yet. */
22
+ function connectionIdOf(connection) {
23
+ if (typeof connection.threadId === "number") {
24
+ return connection.threadId;
25
+ }
26
+ let id = connectionIds.get(connection);
27
+ if (id === undefined) {
28
+ id = -(++connectionCounter);
29
+ connectionIds.set(connection, id);
30
+ }
31
+ return id;
32
+ }
33
+ function describeResult(result) {
34
+ if (Array.isArray(result)) {
35
+ return { rowCount: result.length, affectedRows: null };
36
+ }
37
+ if (result && typeof result === "object" && "affectedRows" in result) {
38
+ return { rowCount: null, affectedRows: Number(result.affectedRows) };
39
+ }
40
+ return { rowCount: null, affectedRows: null };
41
+ }
42
+ let nextSeq = 0;
43
+ function wrap(store, method, original) {
44
+ return function patched(...args) {
45
+ const startedAt = Date.now();
46
+ const t0 = performance.now();
47
+ const command = original.apply(this, args);
48
+ try {
49
+ const trace = requestScope.getStore() ?? store.backgroundTrace();
50
+ const sql = String(command.sql);
51
+ const event = {
52
+ seq: nextSeq++,
53
+ requestId: trace.id,
54
+ connectionId: connectionIdOf(this),
55
+ api: method,
56
+ sql,
57
+ params: (command.parameters ?? null),
58
+ startedAt,
59
+ durationMs: null,
60
+ rowCount: null,
61
+ affectedRows: null,
62
+ error: null,
63
+ analysis: analyzeSql(sql)
64
+ };
65
+ store.record(trace, event);
66
+ let done = false;
67
+ const finish = (error, result) => {
68
+ if (done) {
69
+ return;
70
+ }
71
+ done = true;
72
+ event.durationMs = performance.now() - t0;
73
+ event.error = error ? error.message : null;
74
+ Object.assign(event, describeResult(result));
75
+ store.touch(trace);
76
+ };
77
+ if (typeof command.onResult === "function") {
78
+ const callback = command.onResult;
79
+ command.onResult = function onResult(error, result, fields) {
80
+ finish(error ?? null, result);
81
+ return callback.call(this, error, result, fields);
82
+ };
83
+ }
84
+ else {
85
+ // Streaming / event mode: rows arrive as events, nobody gets a result array.
86
+ let rows = 0;
87
+ command.on("result", () => void rows++);
88
+ command.once("error", error => finish(error));
89
+ command.once("end", () => {
90
+ event.rowCount = rows;
91
+ finish(null);
92
+ });
93
+ }
94
+ }
95
+ catch {
96
+ // Recording is best effort; the real query is already on its way and must be returned untouched.
97
+ }
98
+ return command;
99
+ };
100
+ }
101
+ /**
102
+ * `pool.query()` and `pool.getConnection(cb)` hand the statement to a connection from a
103
+ * callback. When every connection is busy that callback runs later, from whichever request
104
+ * released one, so without this the statement would be attributed to that other request.
105
+ * Binding the callback at the call site keeps the caller's request.
106
+ */
107
+ function keepRequestContext(original) {
108
+ return function getConnection(callback) {
109
+ return original.call(this, typeof callback === "function" ? AsyncLocalStorage.bind(callback) : callback);
110
+ };
111
+ }
112
+ // `Pool` is a class at runtime but only a type in mysql2's typings.
113
+ const DEFAULT_TARGETS = { connection: mysql.Connection, pool: mysql.Pool };
114
+ /**
115
+ * Patches `query` / `execute` on the connection class and `getConnection` on the pool class,
116
+ * once. Returns the function that restores the originals. Calling it again first undoes the
117
+ * previous install, so a reloaded module never stacks two wrappers.
118
+ */
119
+ export function installTracer(store, targets = DEFAULT_TARGETS) {
120
+ const registry = targets.connection;
121
+ registry[INSTALLED]?.();
122
+ const patches = [
123
+ { prototype: targets.connection.prototype, name: "query", make: (fn) => wrap(store, "query", fn) },
124
+ { prototype: targets.connection.prototype, name: "execute", make: (fn) => wrap(store, "execute", fn) },
125
+ { prototype: targets.pool.prototype, name: "getConnection", make: (fn) => keepRequestContext(fn) }
126
+ ].map(patch => ({ ...patch, original: patch.prototype[patch.name], hadOwn: Object.hasOwn(patch.prototype, patch.name) }));
127
+ for (const patch of patches) {
128
+ patch.prototype[patch.name] = patch.make(patch.original);
129
+ }
130
+ const uninstall = () => {
131
+ for (const patch of patches) {
132
+ if (patch.hadOwn) {
133
+ patch.prototype[patch.name] = patch.original;
134
+ }
135
+ else {
136
+ delete patch.prototype[patch.name];
137
+ }
138
+ }
139
+ registry[INSTALLED] = undefined;
140
+ };
141
+ registry[INSTALLED] = uninstall;
142
+ return uninstall;
143
+ }
@@ -0,0 +1,79 @@
1
+ export type StatementKind = "select" | "insert" | "update" | "delete" | "replace" | "tx-begin" | "tx-commit" | "tx-rollback" | "tx-config" | "savepoint" | "savepoint-release" | "savepoint-rollback" | "other";
2
+ export interface SqlAnalysis {
3
+ kind: StatementKind;
4
+ tables: string[];
5
+ /** Deepest level of `(SELECT ...)` nesting; 0 = no subquery. */
6
+ subqueryDepth: number;
7
+ subqueryCount: number;
8
+ /** SQL with literals masked, so two calls that differ only in values share one fingerprint. */
9
+ fingerprint: string;
10
+ selectStar: boolean;
11
+ /** Savepoint name for savepoint / release / rollback-to statements. */
12
+ savepoint: string | null;
13
+ }
14
+ export interface QueryEvent {
15
+ /** Monotonic across the process; also the call order. */
16
+ seq: number;
17
+ requestId: string | null;
18
+ /** MySQL thread id of the connection that ran the statement (0 until the handshake is known). */
19
+ connectionId: number;
20
+ api: "query" | "execute";
21
+ sql: string;
22
+ params: unknown[] | null;
23
+ /** Wall-clock start, ms since epoch. */
24
+ startedAt: number;
25
+ /** null while the statement is still running. */
26
+ durationMs: number | null;
27
+ rowCount: number | null;
28
+ affectedRows: number | null;
29
+ error: string | null;
30
+ analysis: SqlAnalysis;
31
+ }
32
+ export interface RequestTrace {
33
+ id: string;
34
+ method: string;
35
+ url: string;
36
+ status: number | null;
37
+ startedAt: number;
38
+ durationMs: number | null;
39
+ queries: QueryEvent[];
40
+ /** Statements dropped from the front of `queries` once the per-request cap was hit. */
41
+ droppedQueries: number;
42
+ }
43
+ export type TraceNode = {
44
+ type: "query";
45
+ query: QueryEvent;
46
+ } | TransactionNode;
47
+ export interface TransactionNode {
48
+ type: "transaction" | "savepoint";
49
+ connectionId: number;
50
+ /** Savepoint name; null for a top-level transaction. */
51
+ name: string | null;
52
+ status: "open" | "committed" | "rolled-back";
53
+ startedAt: number;
54
+ durationMs: number | null;
55
+ children: TraceNode[];
56
+ }
57
+ export type InsightType = "n-plus-one" | "duplicate" | "slow" | "select-star" | "select-in-transaction" | "write-outside-transaction" | "deep-subquery" | "error" | "many-queries";
58
+ export interface Insight {
59
+ type: InsightType;
60
+ severity: "info" | "warn" | "error";
61
+ /** Language-neutral values for the message template `insight.msg.<type>`. */
62
+ params: Record<string, string | number>;
63
+ /** Already phrased in the server's locale (terminal, API consumers); front ends re-phrase from `params`. */
64
+ message: string;
65
+ /** `seq` of the statements the insight is about. */
66
+ seqs: number[];
67
+ }
68
+ export interface RequestReport {
69
+ trace: RequestTrace;
70
+ tree: TraceNode[];
71
+ insights: Insight[];
72
+ totals: {
73
+ queryCount: number;
74
+ /** Sum of statement durations; overlapping statements (Promise.all) are counted in full. */
75
+ sqlTimeMs: number;
76
+ /** Deepest `(SELECT ...)` nesting across the request's statements. */
77
+ maxSubqueryDepth: number;
78
+ };
79
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const CLIENT_SCRIPT: string;