api-database-mysql
Direct MySQL database access with mysql2 driver -- connection pools, prepared statements, transactions, streaming, typed queries, error handling
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-mysql/skills/api-database-mysql
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
MySQL Patterns (mysql2)
Quick Guide: Use mysql2/promise for all new code -- it provides async/await support over the mysql2 callback API. Always use
createPool()(nevercreateConnection()in production) withexecute()for parameterized queries (prepared statements, LRU-cached). Type query results withRowDataPacketgenerics for SELECTs andResultSetHeaderfor INSERT/UPDATE/DELETE. For transactions, acquire a dedicated connection withpool.getConnection(), wrap in try/finally to guaranteeconnection.release(). Never interpolate user input into SQL strings -- always use?placeholders. HandleER_DUP_ENTRYandER_LOCK_DEADLOCKexplicitly in catch blocks.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use execute() with ? placeholders for ALL queries containing user input -- NEVER interpolate values into SQL strings with template literals or string concatenation)
(You MUST use pool.getConnection() for transactions and release the connection in a finally block -- pool convenience methods (pool.execute()) use a different connection per call and cannot maintain transaction state)
(You MUST always import from mysql2/promise for async/await code -- the base mysql2 module returns callback-based objects that do not support await)
(You MUST handle the pool error event -- unhandled connection errors crash the Node.js process)
</critical_requirements>
Examples
- Core Patterns -- Pool setup, typed queries, prepared statements, connection lifecycle
- Transactions -- Manual transactions, savepoints, deadlock retry, nested operations
- Streaming & Batch -- Streaming large result sets, batch inserts, multiple statements
- Error Handling -- MySQL error codes, connection errors, retry strategies, graceful degradation
- Configuration -- SSL/TLS, named placeholders, pool tuning, monitoring events
Additional resources:
- reference.md -- Type cheat sheet, pool options, error codes, production checklist
Auto-detection: MySQL, mysql2, mysql2/promise, createPool, createConnection, RowDataPacket, ResultSetHeader, execute, prepared statement, pool.getConnection, beginTransaction, commit, rollback, ER_DUP_ENTRY, ER_LOCK_DEADLOCK, connectionLimit, SHOW TABLES, mysqldump, InnoDB, MariaDB
When to use:
- Direct SQL queries against MySQL or MariaDB databases
- Connection pool management for server applications
- Transactions requiring atomicity across multiple queries
- Streaming large result sets without loading all rows into memory
- Typed query results with TypeScript generics
- Batch inserts or multi-statement operations
Key patterns covered:
- Pool creation with
mysql2/promiseand proper configuration - Prepared statements via
execute()with?placeholders - TypeScript generics with
RowDataPacketandResultSetHeader - Transaction lifecycle:
getConnection->beginTransaction->commit/rollback->release - Streaming with
connection.query().stream()on the non-promise API - Error handling for
ER_DUP_ENTRY,ER_LOCK_DEADLOCK, connection failures - Pool events (
acquire,release,enqueue) for monitoring - SSL/TLS and named placeholders configuration
When NOT to use:
- When your project already uses an ORM or query builder for MySQL -- use that tool's skill instead
- For in-memory caching or key-value storage (use a dedicated caching solution)
- For document databases or graph queries (wrong database type)
- For one-off CLI scripts where a single connection suffices and pool overhead is unnecessary
<decision_framework>
Decision Framework
Pool vs Connection
What am I building?
-- Production server handling concurrent requests? -> createPool()
-- One-off CLI script or migration? -> createConnection() is acceptable
-- Serverless function (Lambda, Vercel)? -> createPool() with connectionLimit: 1
execute() vs query()
Does the SQL have user-provided parameters?
-- YES -> execute() with ? placeholders (ALWAYS)
-- NO, but same SQL runs repeatedly? -> execute() (benefits from LRU cache)
-- NO, SQL text itself is dynamic? -> query() (cannot prepare dynamic SQL)
-- Need to stream results? -> query().stream() on the callback API
Pool method vs getConnection()
Is this a single query?
-- YES -> pool.execute() or pool.query() (auto-acquires and releases)
-- NO, multiple queries needing same connection? -> pool.getConnection()
-- Transaction? -> pool.getConnection() (REQUIRED)
Error Handling Strategy
What MySQL error did I get?
-- ER_DUP_ENTRY (1062) -> Handle as business logic (return conflict, not throw)
-- ER_LOCK_DEADLOCK (1213) -> Retry the entire transaction (MySQL rolled it back)
-- ER_LOCK_WAIT_TIMEOUT (1205) -> Retry or fail with timeout message
-- ECONNREFUSED / PROTOCOL_CONNECTION_LOST -> Connection issue, pool will reconnect
-- ER_ACCESS_DENIED_ERROR (1045) -> Configuration error, fail fast
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Interpolating user input into SQL strings (
\SELECT * FROM users WHERE id = $``) -- SQL injection vulnerability, always use?placeholders withexecute() - Using
pool.execute()orpool.query()for transactions -- each call may use a different connection, breaking transaction isolation; usepool.getConnection() - Importing from
mysql2instead ofmysql2/promisefor async/await code -- the base module returns callback-based objects,awaitwill not work as expected - Not releasing connections acquired with
pool.getConnection()-- connection leak exhausts the pool; always release in afinallyblock
Medium Priority Issues:
- Using
query()instead ofexecute()for parameterized queries -- misses prepared statement caching and binary protocol efficiency - Missing pool
errorevent handler -- unhandled connection errors crash the Node.js process - Setting
connectionLimittoo high -- each MySQL connection uses ~10 MB of server memory; 10-20 is usually sufficient - Not setting
enableKeepAlive: true-- idle connections get dropped by firewalls/load balancers causingECONNRESET
Common Mistakes:
- Expecting
pool.end()to wait for active queries -- it immediately destroys all connections; drain queries first - Using
rows.lengthto check if an UPDATE affected rows -- useresult.affectedRowsfromResultSetHeaderinstead - Assuming
insertIdis always the auto-increment value -- forINSERT ... ON DUPLICATE KEY UPDATE,insertIdis0if the existing row was updated, not inserted - Calling
connection.release()afterconnection.destroy()-- destroy removes the connection from the pool entirely; release returns it - Treating
nullandundefinedas interchangeable in parameter arrays -- mysql2 convertsnullto SQLNULLbutundefinedcauses a protocol error
Gotchas & Edge Cases:
DECIMALandBIGINTcolumns are returned as strings by default to avoid JavaScript floating-point precision loss -- parse explicitly if you need numbersDATEcolumns return JavaScriptDateobjects, butDATETIMEprecision beyond milliseconds is truncated -- MySQL supports microsecond precision, JavaScriptDatedoes notexecute()with named placeholders requiresnamedPlaceholders: trueon the pool/connection config -- the default is unnamed?onlymultipleStatements: trueis a security risk -- it enables SQL injection via;in user input if combined withquery(); only enable when needed and never with user-provided SQL- Pool
waitForConnections: falsethrows immediately when all connections are in use instead of queuing -- the defaulttrueis almost always what you want ResultSetHeader.warningStatusindicates server warnings -- check it after DDL operations (the deprecatedOkPackettype had a separatewarningCountfield;ResultSetHeaderhas always usedwarningStatus)
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use execute() with ? placeholders for ALL queries containing user input -- NEVER interpolate values into SQL strings with template literals or string concatenation)
(You MUST use pool.getConnection() for transactions and release the connection in a finally block -- pool convenience methods (pool.execute()) use a different connection per call and cannot maintain transaction state)
(You MUST always import from mysql2/promise for async/await code -- the base mysql2 module returns callback-based objects that do not support await)
(You MUST handle the pool error event -- unhandled connection errors crash the Node.js process)
Failure to follow these rules will cause SQL injection vulnerabilities, transaction corruption, connection pool exhaustion, and application crashes.
</critical_reminders>
Files (skills)
-
examples
-
configuration.md 8.5 KB
# MySQL (mysql2) -- Configuration Examples > SSL/TLS, named placeholders, pool tuning, and monitoring events. See [core.md](core.md) for basic pool setup. **Related examples:** - [core.md](core.md) -- Pool setup, connection lifecycle - [error-handling.md](error-handling.md) -- Connection error handling --- ## Named Placeholders Named placeholders use `:name` syntax instead of positional `?`. Enable with `namedPlaceholders: true`. ```typescript import mysql from "mysql2/promise"; import type { Pool, RowDataPacket } from "mysql2/promise"; interface ProductRow extends RowDataPacket { id: number; name: string; price: number; category: string; } function createPoolWithNamedPlaceholders(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, namedPlaceholders: true, }); } async function searchProducts( pool: Pool, filters: { category: string; minPrice: number; maxPrice: number }, ): Promise<ProductRow[]> { const [rows] = await pool.execute<ProductRow[]>( "SELECT id, name, price, category FROM products WHERE category = :category AND price BETWEEN :minPrice AND :maxPrice", filters, // Object keys match :placeholder names ); return rows; } export { createPoolWithNamedPlaceholders, searchProducts }; ``` **Why good:** Named placeholders are self-documenting for queries with many parameters, object keys match placeholder names -- no positional confusion **When to use:** Queries with 4+ parameters where positional `?` becomes hard to track **Gotcha:** Named placeholders are converted to positional `?` on the client side -- the MySQL protocol does not support them natively. This means the SQL sent to the server still uses `?`, and the conversion happens in the mysql2 driver. --- ## SSL/TLS Configuration ```typescript import mysql from "mysql2/promise"; import { readFileSync } from "node:fs"; import type { Pool } from "mysql2/promise"; // Option 1: Cloud databases (PlanetScale, AWS RDS, etc.) // Most cloud providers' CA certs are already trusted by the system function createCloudPool(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, ssl: {}, }); } // Option 2: Custom CA certificate function createPoolWithCA(caCertPath: string): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, ssl: { ca: readFileSync(caCertPath), rejectUnauthorized: true, }, }); } // Option 3: Mutual TLS (client certificate authentication) function createPoolWithMTLS( caCertPath: string, clientCertPath: string, clientKeyPath: string, ): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, ssl: { ca: readFileSync(caCertPath), cert: readFileSync(clientCertPath), key: readFileSync(clientKeyPath), rejectUnauthorized: true, }, }); } export { createCloudPool, createPoolWithCA, createPoolWithMTLS }; ``` **Why good:** Three common TLS patterns covered, `rejectUnauthorized: true` prevents MITM attacks **Gotcha:** Setting `ssl: {}` (empty object) enables SSL with system CA trust -- this is sufficient for most cloud databases. Setting `rejectUnauthorized: false` disables certificate validation entirely and should NEVER be used in production. --- ## Pool Event Monitoring ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; function createMonitoredPool(): Pool { const pool = mysql.createPool({ uri: process.env.DATABASE_URL, waitForConnections: true, }); pool.on("acquire", (connection) => { console.log("Connection %d acquired from pool", connection.threadId); }); pool.on("release", (connection) => { console.log("Connection %d released to pool", connection.threadId); }); pool.on("enqueue", () => { // This fires when the pool has no available connections and a request is queued // If you see this frequently, increase connectionLimit console.warn("Waiting for available connection -- pool may be exhausted"); }); pool.on("error", (err) => { console.error("Pool error:", err.message); }); return pool; } export { createMonitoredPool }; ``` **Why good:** `enqueue` event is an early warning for pool exhaustion, `acquire`/`release` track connection lifecycle **When to use:** Development debugging, production monitoring integration, pool sizing validation --- ## Pool Size Tuning ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; // Standard server application const STANDARD_POOL_LIMIT = 10; const STANDARD_IDLE_TIMEOUT_MS = 60_000; // Serverless function (Lambda, Vercel) const SERVERLESS_POOL_LIMIT = 1; const SERVERLESS_IDLE_TIMEOUT_MS = 10_000; // High-throughput batch processing const BATCH_POOL_LIMIT = 25; const BATCH_IDLE_TIMEOUT_MS = 120_000; function createServerPool(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: STANDARD_POOL_LIMIT, maxIdle: STANDARD_POOL_LIMIT, idleTimeout: STANDARD_IDLE_TIMEOUT_MS, waitForConnections: true, queueLimit: 0, }); } function createServerlessPool(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: SERVERLESS_POOL_LIMIT, maxIdle: SERVERLESS_POOL_LIMIT, idleTimeout: SERVERLESS_IDLE_TIMEOUT_MS, waitForConnections: true, }); } function createBatchPool(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: BATCH_POOL_LIMIT, maxIdle: STANDARD_POOL_LIMIT, // Keep fewer idle connections idleTimeout: BATCH_IDLE_TIMEOUT_MS, waitForConnections: true, queueLimit: 0, }); } export { createServerPool, createServerlessPool, createBatchPool }; ``` **Why good:** Named constants for each environment, `maxIdle` different from `connectionLimit` for batch (scales down when idle), serverless uses `connectionLimit: 1` to avoid connection exhaustion **Key insight:** Each MySQL connection uses ~10 MB of server memory. With 5 application instances at `connectionLimit: 10` each, the MySQL server needs capacity for 50 connections. Check `SHOW VARIABLES LIKE 'max_connections'` to verify. --- ## Date and Timezone Configuration ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; // Option 1: Return dates as JavaScript Date objects (default) function createPoolWithDates(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "+00:00", // Store and retrieve in UTC }); } // Option 2: Return dates as strings (avoids timezone conversion issues) function createPoolWithDateStrings(): Pool { return mysql.createPool({ uri: process.env.DATABASE_URL, dateStrings: true, // DATE -> "2025-01-15", DATETIME -> "2025-01-15 10:30:00" }); } ``` **When to use `dateStrings: true`:** When you need exact date/time values without JavaScript's timezone conversion, or when passing dates to APIs that expect ISO strings. **Gotcha:** With `timezone: "local"` (default), JavaScript `Date` objects are converted using the local timezone of the Node.js process. This causes inconsistencies between environments. Use `timezone: "+00:00"` and store all dates in UTC. --- ## Testing Configuration ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; const TEST_POOL_LIMIT = 2; function createTestPool(): Pool { const url = process.env.TEST_DATABASE_URL; if (!url) { throw new Error("TEST_DATABASE_URL environment variable is required"); } return mysql.createPool({ uri: url, connectionLimit: TEST_POOL_LIMIT, waitForConnections: true, }); } // Setup/teardown for test suites async function setupTestDatabase(pool: Pool): Promise<void> { // Truncate tables in dependency order (children first) await pool.execute("SET FOREIGN_KEY_CHECKS = 0"); await pool.execute("TRUNCATE TABLE order_items"); await pool.execute("TRUNCATE TABLE orders"); await pool.execute("TRUNCATE TABLE users"); await pool.execute("SET FOREIGN_KEY_CHECKS = 1"); } async function teardownTestDatabase(pool: Pool): Promise<void> { await pool.end(); } export { createTestPool, setupTestDatabase, teardownTestDatabase }; ``` **Why good:** Separate env var for test database, low connection limit for tests, `FOREIGN_KEY_CHECKS = 0` allows truncation regardless of FK constraints, cleanup in dependency order **Gotcha:** Run tests with `--runInBand` (or equivalent serial mode) when sharing a test database -- parallel tests will clobber each other's data. Alternatively, use unique database names per test worker. --- _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
core.md 7.1 KB
# MySQL (mysql2) -- Core Examples > Pool setup, typed queries, prepared statements, and connection lifecycle. Reference from [SKILL.md](../SKILL.md). **Related examples:** - [transactions.md](transactions.md) -- Manual transactions, savepoints, deadlock retry - [streaming.md](streaming.md) -- Streaming large result sets, batch inserts - [error-handling.md](error-handling.md) -- MySQL error codes, retry strategies - [configuration.md](configuration.md) -- SSL/TLS, named placeholders, pool tuning --- ## Pool Setup ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; const DEFAULT_CONNECTION_LIMIT = 10; const DEFAULT_IDLE_TIMEOUT_MS = 60_000; function createDatabasePool(): Pool { const url = process.env.DATABASE_URL; if (!url) { throw new Error("DATABASE_URL environment variable is required"); } const pool = mysql.createPool({ uri: url, waitForConnections: true, connectionLimit: DEFAULT_CONNECTION_LIMIT, maxIdle: DEFAULT_CONNECTION_LIMIT, idleTimeout: DEFAULT_IDLE_TIMEOUT_MS, queueLimit: 0, enableKeepAlive: true, keepAliveInitialDelay: 0, }); return pool; } export { createDatabasePool }; ``` **Why good:** Environment variable validation, named constants, `waitForConnections: true` queues requests instead of throwing, `enableKeepAlive` prevents stale connections from firewall timeouts ```typescript // ❌ Bad Example - Hardcoded credentials, single connection import mysql from "mysql2/promise"; const connection = await mysql.createConnection({ host: "localhost", user: "root", password: "password123", database: "mydb", }); // Hardcoded credentials leak in version control // Single connection cannot handle concurrent requests // No automatic reconnection on failure ``` **Why bad:** Hardcoded credentials, single connection exhausts under concurrent load, no reconnection strategy --- ## Typed SELECT with RowDataPacket ```typescript import type { Pool, RowDataPacket } from "mysql2/promise"; interface UserRow extends RowDataPacket { id: number; email: string; name: string; created_at: Date; } async function getUserById( pool: Pool, userId: number, ): Promise<UserRow | null> { const [rows] = await pool.execute<UserRow[]>( "SELECT id, email, name, created_at FROM users WHERE id = ?", [userId], ); return rows[0] ?? null; } async function getUsersByStatus( pool: Pool, status: string, limit: number, ): Promise<UserRow[]> { const [rows] = await pool.execute<UserRow[]>( "SELECT id, email, name, created_at FROM users WHERE status = ? LIMIT ?", [status, limit], ); return rows; } export { getUserById, getUsersByStatus }; export type { UserRow }; ``` **Why good:** Interface extends `RowDataPacket` for type-safe destructuring, `execute()` uses prepared statements with LRU cache, explicit column list avoids `SELECT *`, null-safe return for missing rows ```typescript // ❌ Bad Example - Untyped, SELECT *, string interpolation const [rows] = await pool.query(`SELECT * FROM users WHERE id = ${userId}`); const user = rows[0]; // type: any ``` **Why bad:** SQL injection via template literal, `any`-typed results, `SELECT *` pulls unnecessary columns and breaks on schema changes --- ## INSERT with ResultSetHeader ```typescript import type { Pool, ResultSetHeader } from "mysql2/promise"; async function createUser( pool: Pool, email: string, name: string, ): Promise<number> { const [result] = await pool.execute<ResultSetHeader>( "INSERT INTO users (email, name) VALUES (?, ?)", [email, name], ); return result.insertId; } export { createUser }; ``` **Why good:** `ResultSetHeader` provides typed access to `insertId` and `affectedRows` --- ## UPDATE and DELETE with Affected Rows ```typescript import type { Pool, ResultSetHeader } from "mysql2/promise"; async function updateUserName( pool: Pool, userId: number, newName: string, ): Promise<boolean> { const [result] = await pool.execute<ResultSetHeader>( "UPDATE users SET name = ? WHERE id = ?", [newName, userId], ); // affectedRows: rows matching WHERE clause // changedRows is deprecated -- use affectedRows instead return result.affectedRows > 0; } async function deleteUser(pool: Pool, userId: number): Promise<boolean> { const [result] = await pool.execute<ResultSetHeader>( "DELETE FROM users WHERE id = ?", [userId], ); return result.affectedRows > 0; } export { updateUserName, deleteUser }; ``` **Why good:** Returns boolean indicating whether the operation found a matching row, uses `affectedRows` (not deprecated `changedRows`) **Gotcha:** `affectedRows` counts rows matching the WHERE clause. The deprecated `changedRows` used to count rows where values actually changed -- use `affectedRows` instead and check `result.info` if you need the distinction (it contains `"Rows matched: 1 Changed: 0"` for unchanged UPDATEs). --- ## Connection Lifecycle ```typescript import type { Pool, PoolConnection } from "mysql2/promise"; // Pattern 1: Pool convenience method (auto-acquires and releases) async function simpleQuery(pool: Pool): Promise<void> { // Connection is acquired, query runs, connection is released automatically const [rows] = await pool.execute("SELECT 1"); } // Pattern 2: Manual connection for multiple operations on same connection async function multiStepOperation(pool: Pool): Promise<void> { const connection = await pool.getConnection(); try { // All queries run on the SAME connection await connection.execute("SET @var = 1"); const [rows] = await connection.execute("SELECT @var AS result"); // Session variables, temporary tables, and locks persist across queries } finally { connection.release(); // ALWAYS release in finally } } ``` **Why good:** Pattern 1 for simple queries -- zero boilerplate. Pattern 2 for when you need session state, temp tables, or transactions. `finally` block guarantees the connection is returned to the pool. ```typescript // ❌ Bad Example - Leaked connection const connection = await pool.getConnection(); await connection.execute("SELECT 1"); // If execute() throws, connection is never released // Pool eventually exhausts all connections connection.release(); // Never reached on error ``` **Why bad:** No `finally` block means the connection leaks on any error, eventually exhausting the pool --- ## Graceful Shutdown ```typescript import type { Pool } from "mysql2/promise"; async function shutdownPool(pool: Pool): Promise<void> { try { await pool.end(); } catch (error) { // Pool may already be closed or connections may be in use console.error("Error closing pool:", (error as Error).message); } } // Usage with process signals process.on("SIGTERM", async () => { await shutdownPool(pool); process.exit(0); }); ``` **Why good:** Graceful shutdown closes all idle connections, `SIGTERM` handler for container orchestrators **Gotcha:** `pool.end()` immediately destroys all connections, including ones with in-flight queries. In production, drain requests before calling `pool.end()`. --- _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
error-handling.md 6.7 KB
# MySQL (mysql2) -- Error Handling Examples > MySQL error codes, connection errors, retry strategies, and graceful degradation. See [core.md](core.md) for pool setup and [transactions.md](transactions.md) for deadlock retry. **Related examples:** - [core.md](core.md) -- Pool setup, typed queries - [transactions.md](transactions.md) -- Transaction patterns, deadlock retry - [configuration.md](configuration.md) -- Pool tuning, monitoring events --- ## MySQL Error Type Guard ```typescript interface MysqlError extends Error { code: string; errno: number; sqlState: string; sqlMessage: string; } function isMysqlError(error: unknown): error is MysqlError { return error instanceof Error && "code" in error && "errno" in error; } export { isMysqlError }; export type { MysqlError }; ``` **Why good:** Type guard narrows `unknown` to structured MySQL error, allows safe access to `code`, `errno`, `sqlState` --- ## Handling ER_DUP_ENTRY (Duplicate Key) ```typescript import type { Pool, ResultSetHeader } from "mysql2/promise"; import { isMysqlError } from "./error-utils"; const MYSQL_ER_DUP_ENTRY = "ER_DUP_ENTRY"; type CreateResult = | { success: true; id: number } | { success: false; reason: "duplicate" }; async function createUserSafe( pool: Pool, email: string, name: string, ): Promise<CreateResult> { try { const [result] = await pool.execute<ResultSetHeader>( "INSERT INTO users (email, name) VALUES (?, ?)", [email, name], ); return { success: true, id: result.insertId }; } catch (error) { if (isMysqlError(error) && error.code === MYSQL_ER_DUP_ENTRY) { return { success: false, reason: "duplicate" }; } throw error; // Re-throw unexpected errors } } export { createUserSafe }; ``` **Why good:** Discriminated union return type, named constant for error code, expected errors (duplicate) return a result instead of throwing, unexpected errors propagate ```typescript // ❌ Bad Example - Catching all errors silently try { await pool.execute("INSERT INTO users (email, name) VALUES (?, ?)", [ email, name, ]); } catch { return null; // Swallows ALL errors -- connection failures, syntax errors, everything } ``` **Why bad:** Silent catch swallows connection errors, syntax errors, and other critical failures -- only catch specific error codes --- ## Handling ER_LOCK_DEADLOCK Deadlock handling belongs at the transaction level. See [transactions.md](transactions.md) for the full `withDeadlockRetry` pattern. ```typescript import { isMysqlError } from "./error-utils"; const MYSQL_ER_LOCK_DEADLOCK = "ER_LOCK_DEADLOCK"; function isDeadlockError(error: unknown): boolean { return isMysqlError(error) && error.code === MYSQL_ER_LOCK_DEADLOCK; } export { isDeadlockError }; ``` **Gotcha:** When MySQL detects a deadlock, it automatically rolls back the **entire transaction** for the selected victim. You cannot retry just the last query -- you must retry the entire transaction from `beginTransaction()`. --- ## Handling ER_LOCK_WAIT_TIMEOUT ```typescript import type { Pool } from "mysql2/promise"; import { isMysqlError } from "./error-utils"; const MYSQL_ER_LOCK_WAIT_TIMEOUT = "ER_LOCK_WAIT_TIMEOUT"; async function executeWithLockTimeout<T>( pool: Pool, operation: () => Promise<T>, ): Promise<T> { try { return await operation(); } catch (error) { if (isMysqlError(error) && error.code === MYSQL_ER_LOCK_WAIT_TIMEOUT) { throw new Error( "Operation timed out waiting for a database lock. Another transaction may be holding the lock.", ); } throw error; } } export { executeWithLockTimeout }; ``` **Gotcha:** Unlike `ER_LOCK_DEADLOCK`, a lock wait timeout does NOT automatically roll back the transaction. The transaction is still active -- you must explicitly `ROLLBACK` if you want to abort it. --- ## Connection Error Handling ```typescript import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; function createPoolWithErrorHandling(): Pool { const pool = mysql.createPool({ uri: process.env.DATABASE_URL, waitForConnections: true, }); // Pool-level error handler -- prevents unhandled error crashes pool.on("error", (err) => { console.error("Pool error:", err.message); // Do NOT exit the process -- the pool will attempt to reconnect }); return pool; } export { createPoolWithErrorHandling }; ``` **Why good:** Pool `error` event prevents process crash, pool handles reconnection automatically **Gotcha:** The pool's `error` event fires for connection-level errors (e.g., the server closes an idle connection). Individual query errors are thrown from `execute()`/`query()` calls and are NOT emitted on the pool `error` event. --- ## Health Check Query ```typescript import type { Pool, RowDataPacket } from "mysql2/promise"; const HEALTH_CHECK_TIMEOUT_MS = 3000; interface HealthCheckRow extends RowDataPacket { result: number; } async function checkDatabaseHealth(pool: Pool): Promise<boolean> { try { const connection = await pool.getConnection(); try { const [rows] = await connection.execute<HealthCheckRow[]>({ sql: "SELECT 1 AS result", timeout: HEALTH_CHECK_TIMEOUT_MS, }); return rows[0]?.result === 1; } finally { connection.release(); } } catch { return false; } } export { checkDatabaseHealth }; ``` **Why good:** Dedicated connection for health check, query-level timeout prevents hanging, returns boolean instead of throwing --- ## Error Classification Helper ```typescript import { isMysqlError, type MysqlError } from "./error-utils"; type ErrorCategory = | "duplicate" | "deadlock" | "lock_timeout" | "connection" | "validation" | "unknown"; const ERROR_CATEGORY_MAP: Record<string, ErrorCategory> = { ER_DUP_ENTRY: "duplicate", ER_LOCK_DEADLOCK: "deadlock", ER_LOCK_WAIT_TIMEOUT: "lock_timeout", ER_DATA_TOO_LONG: "validation", ER_BAD_NULL_ERROR: "validation", ER_TRUNCATED_WRONG_VALUE: "validation", ER_NO_SUCH_TABLE: "validation", ER_BAD_FIELD_ERROR: "validation", ER_ACCESS_DENIED_ERROR: "connection", ER_BAD_DB_ERROR: "connection", ER_CON_COUNT_ERROR: "connection", }; function classifyMysqlError(error: unknown): ErrorCategory { if (!isMysqlError(error)) return "unknown"; return ERROR_CATEGORY_MAP[error.code] ?? "unknown"; } function isRetryableError(error: unknown): boolean { const category = classifyMysqlError(error); return category === "deadlock" || category === "lock_timeout"; } export { classifyMysqlError, isRetryableError }; ``` **Why good:** Centralizes error classification, separates retryable from non-retryable errors, extensible map for new error codes --- _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
streaming.md 7.7 KB
# MySQL (mysql2) -- Streaming & Batch Examples > Streaming large result sets, batch inserts, and multiple statements. See [core.md](core.md) for pool setup and basic queries. **Related examples:** - [core.md](core.md) -- Pool setup, typed queries, connection lifecycle - [transactions.md](transactions.md) -- Transactions for batch operations - [error-handling.md](error-handling.md) -- Error handling for long-running operations --- ## Streaming Large Result Sets Streaming uses the **callback-based** API (not `mysql2/promise`) because `.stream()` is only available on the callback `query()` return value. ```typescript import mysql from "mysql2"; import type { RowDataPacket } from "mysql2"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; interface UserRow extends RowDataPacket { id: number; email: string; name: string; } const STREAM_HIGH_WATER_MARK = 100; function createCallbackPool(): ReturnType<typeof mysql.createPool> { const url = process.env.DATABASE_URL; if (!url) { throw new Error("DATABASE_URL environment variable is required"); } return mysql.createPool({ uri: url }); } async function streamUsersToFile( pool: ReturnType<typeof mysql.createPool>, outputPath: string, ): Promise<number> { let count = 0; const queryStream = pool .query("SELECT id, email, name FROM users WHERE active = 1") .stream({ highWaterMark: STREAM_HIGH_WATER_MARK }); const transform = new Transform({ objectMode: true, transform(row: UserRow, _encoding, callback) { count++; const line = `${row.id},${row.email},${row.name}\n`; callback(null, line); }, }); const { createWriteStream } = await import("node:fs"); const fileStream = createWriteStream(outputPath); await pipeline(queryStream, transform, fileStream); return count; } export { createCallbackPool, streamUsersToFile }; ``` **Why good:** `highWaterMark` limits buffer size, `pipeline()` handles backpressure and error propagation automatically, constant memory usage regardless of result set size **When to use:** Result sets with 10K+ rows, CSV/JSON exports, ETL pipelines, data migrations **Gotcha:** `.stream()` is NOT available on the promise API's `execute()` or `query()`. You must use the callback-based `mysql2` import (not `mysql2/promise`) for streaming. You can still use both APIs in the same application -- create separate pool instances. --- ## Streaming with Connection (Not Pool) When you need streaming within a transaction or on a specific connection: ```typescript import mysql from "mysql2"; import type { RowDataPacket } from "mysql2"; interface OrderRow extends RowDataPacket { id: number; total: number; created_at: Date; } const STREAM_BATCH_SIZE = 50; async function processLargeOrders( pool: ReturnType<typeof mysql.createPool>, ): Promise<number> { return new Promise((resolve, reject) => { pool.getConnection((err, connection) => { if (err) { reject(err); return; } let processed = 0; const stream = connection .query("SELECT id, total, created_at FROM orders WHERE total > 1000") .stream({ highWaterMark: STREAM_BATCH_SIZE }); stream.on("data", (row: OrderRow) => { processed++; // Process each row }); stream.on("end", () => { connection.release(); resolve(processed); }); stream.on("error", (error) => { connection.release(); reject(error); }); }); }); } export { processLargeOrders }; ``` **Why good:** Manual connection management for streaming, proper cleanup on both success and error paths --- ## Batch INSERT (Single Statement) Insert many rows in a single query for maximum throughput. ```typescript import type { Pool, ResultSetHeader } from "mysql2/promise"; const MAX_BATCH_SIZE = 1000; interface NewUser { email: string; name: string; } async function batchInsertUsers(pool: Pool, users: NewUser[]): Promise<number> { if (users.length === 0) return 0; let totalInserted = 0; // Process in chunks to avoid MySQL's max_allowed_packet limit for (let i = 0; i < users.length; i += MAX_BATCH_SIZE) { const chunk = users.slice(i, i + MAX_BATCH_SIZE); const placeholders = chunk.map(() => "(?, ?)").join(", "); const values = chunk.flatMap((u) => [u.email, u.name]); const [result] = await pool.query<ResultSetHeader>( `INSERT INTO users (email, name) VALUES ${placeholders}`, values, ); totalInserted += result.affectedRows; } return totalInserted; } export { batchInsertUsers }; ``` **Why good:** Chunking prevents exceeding `max_allowed_packet`, single INSERT per chunk is far faster than individual INSERTs, `query()` is used here because the SQL text changes per chunk (dynamic placeholder count) **Gotcha:** `execute()` (prepared statements) cannot be used for dynamic batch inserts because the number of `?` placeholders changes per chunk. Use `query()` with parameterized values -- the parameters are still escaped, so this is safe from SQL injection. ```typescript // ❌ Bad Example - Individual inserts in a loop for (const user of users) { await pool.execute("INSERT INTO users (email, name) VALUES (?, ?)", [ user.email, user.name, ]); } // 1000 users = 1000 round-trips to MySQL ``` **Why bad:** Each INSERT is a separate network round-trip, orders of magnitude slower than a single batch INSERT --- ## INSERT ... ON DUPLICATE KEY UPDATE (Upsert) ```typescript import type { Pool, ResultSetHeader } from "mysql2/promise"; async function upsertUser( pool: Pool, email: string, name: string, ): Promise<{ inserted: boolean }> { const [result] = await pool.execute<ResultSetHeader>( `INSERT INTO users (email, name) VALUES (?, ?) AS new_row ON DUPLICATE KEY UPDATE name = new_row.name`, [email, name], ); // affectedRows: 1 = inserted, 2 = updated existing row return { inserted: result.affectedRows === 1 }; } export { upsertUser }; ``` **Why good:** Atomic upsert without separate SELECT + INSERT/UPDATE, `affectedRows` distinguishes insert from update **Gotcha:** `affectedRows` is `1` for a new insert, `2` for an update (MySQL counts the delete + insert internally), and `0` if the row exists but no values changed. `insertId` is `0` when an existing row was updated. The `VALUES()` function in `ON DUPLICATE KEY UPDATE` is deprecated since MySQL 8.0.20 -- use row alias syntax (`AS new_row`) instead. --- ## Multiple Statements Multiple statements in a single call require `multipleStatements: true` on the pool/connection config. This is disabled by default for security. ```typescript import mysql from "mysql2/promise"; import type { RowDataPacket } from "mysql2/promise"; // Enable ONLY when needed -- security risk with user input const pool = mysql.createPool({ uri: process.env.DATABASE_URL, multipleStatements: true, }); interface CountRow extends RowDataPacket { count: number; } async function getTableStats( pool: mysql.Pool, ): Promise<{ users: number; orders: number }> { const [results] = await pool.query<CountRow[][]>( "SELECT COUNT(*) AS count FROM users; SELECT COUNT(*) AS count FROM orders", ); // results is an array of result sets -- one per statement return { users: results[0][0].count, orders: results[1][0].count, }; } export { getTableStats }; ``` **Why good:** Single round-trip for multiple independent queries, typed as `RowDataPacket[][]` (array of arrays) **When to use:** Dashboard queries, statistics gathering, schema introspection -- situations where all SQL is developer-authored. **When NOT to use:** Any query involving user input. `multipleStatements: true` allows `;`-separated injection attacks. --- _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
transactions.md 8.8 KB
# MySQL (mysql2) -- Transaction Examples > Manual transactions, savepoints, deadlock retry, and nested operations. See [core.md](core.md) for pool setup and basic queries. **Related examples:** - [core.md](core.md) -- Pool setup, typed queries, connection lifecycle - [error-handling.md](error-handling.md) -- MySQL error codes, retry strategies --- ## Basic Transaction Pattern ```typescript import type { Pool, PoolConnection, ResultSetHeader } from "mysql2/promise"; async function transferFunds( pool: Pool, fromAccountId: number, toAccountId: number, amount: number, ): Promise<void> { const connection = await pool.getConnection(); try { await connection.beginTransaction(); // Debit source account (balance check in SQL prevents overdraft) const [debitResult] = await connection.execute<ResultSetHeader>( "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", [amount, fromAccountId, amount], ); if (debitResult.affectedRows === 0) { throw new Error("Insufficient balance or account not found"); } // Credit destination account await connection.execute<ResultSetHeader>( "UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, toAccountId], ); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } export { transferFunds }; ``` **Why good:** `getConnection()` pins one connection for the entire transaction, `finally` guarantees release, `rollback()` in catch prevents partial commits, balance check in SQL is atomic with the UPDATE ```typescript // ❌ Bad Example - Transaction on pool convenience methods await pool.execute("BEGIN"); await pool.execute( "UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1], ); await pool.execute( "UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2], ); await pool.execute("COMMIT"); // Each pool.execute() may use a DIFFERENT connection -- transaction is split across connections ``` **Why bad:** Pool convenience methods do not guarantee the same connection -- BEGIN, queries, and COMMIT may run on different connections, completely breaking transaction isolation --- ## Transaction Helper (Reusable) ```typescript import type { Pool, PoolConnection } from "mysql2/promise"; async function withTransaction<T>( pool: Pool, operation: (connection: PoolConnection) => Promise<T>, ): Promise<T> { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const result = await operation(connection); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } export { withTransaction }; ``` **Usage:** ```typescript const orderId = await withTransaction(pool, async (conn) => { const [orderResult] = await conn.execute<ResultSetHeader>( "INSERT INTO orders (user_id, total) VALUES (?, ?)", [userId, total], ); for (const item of items) { await conn.execute( "INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)", [orderResult.insertId, item.productId, item.quantity], ); } return orderResult.insertId; }); ``` **Why good:** Encapsulates the acquire/begin/commit/rollback/release boilerplate, generic return type, caller only handles the business logic --- ## Savepoints (Nested Transaction Boundaries) MySQL does not support nested transactions, but savepoints provide similar functionality within a transaction. ```typescript import type { PoolConnection, ResultSetHeader } from "mysql2/promise"; async function createOrderWithOptionalNotification( connection: PoolConnection, userId: number, items: Array<{ productId: number; quantity: number }>, ): Promise<number> { // Main transaction already started by caller const [orderResult] = await connection.execute<ResultSetHeader>( "INSERT INTO orders (user_id, status) VALUES (?, 'pending')", [userId], ); const orderId = orderResult.insertId; for (const item of items) { await connection.execute( "INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)", [orderId, item.productId, item.quantity], ); } // Savepoint for optional notification -- failure should not roll back the order await connection.execute("SAVEPOINT notification_attempt"); try { await connection.execute( "INSERT INTO notifications (user_id, type, reference_id) VALUES (?, 'order_created', ?)", [userId, orderId], ); } catch { // Notification failed -- roll back to savepoint but keep the order await connection.execute("ROLLBACK TO SAVEPOINT notification_attempt"); } await connection.execute("RELEASE SAVEPOINT notification_attempt"); return orderId; } export { createOrderWithOptionalNotification }; ``` **Why good:** Savepoint isolates optional work -- notification failure does not roll back the order, RELEASE SAVEPOINT frees server resources **Gotcha:** Savepoints are NOT nested transactions. A top-level `ROLLBACK` rolls back everything, including all savepoints. A `ROLLBACK TO SAVEPOINT` only rolls back to that savepoint. --- ## Deadlock Retry When MySQL detects a deadlock, it automatically rolls back one of the competing transactions and returns `ER_LOCK_DEADLOCK`. The application should retry the entire transaction. ```typescript import type { Pool } from "mysql2/promise"; const MYSQL_ER_LOCK_DEADLOCK = "ER_LOCK_DEADLOCK"; const MAX_DEADLOCK_RETRIES = 3; const RETRY_BASE_DELAY_MS = 100; interface MysqlError extends Error { code: string; errno: number; } function isMysqlError(error: unknown): error is MysqlError { return error instanceof Error && "code" in error; } async function withDeadlockRetry<T>( pool: Pool, operation: () => Promise<T>, maxRetries: number = MAX_DEADLOCK_RETRIES, ): Promise<T> { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { const isDeadlock = isMysqlError(error) && error.code === MYSQL_ER_LOCK_DEADLOCK; const hasRetriesLeft = attempt < maxRetries; if (isDeadlock && hasRetriesLeft) { // Exponential backoff with jitter const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) * (0.5 + Math.random()); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } // Unreachable, but TypeScript needs it throw new Error("Exhausted deadlock retries"); } export { withDeadlockRetry }; ``` **Usage:** ```typescript await withDeadlockRetry(pool, async () => { await withTransaction(pool, async (conn) => { await conn.execute( "UPDATE inventory SET stock = stock - 1 WHERE product_id = ?", [productId], ); await conn.execute( "INSERT INTO reservations (product_id, user_id) VALUES (?, ?)", [productId, userId], ); }); }); ``` **Why good:** Wraps entire transaction (not individual queries), exponential backoff with jitter prevents retry storms, configurable retry count, only retries deadlock errors -- other errors propagate immediately **Gotcha:** The deadlocked transaction is already rolled back by MySQL when you receive `ER_LOCK_DEADLOCK`. You must retry the entire transaction from scratch, not just the last query. --- ## SELECT ... FOR UPDATE (Row Locking) ```typescript import type { Pool, RowDataPacket, ResultSetHeader } from "mysql2/promise"; interface InventoryRow extends RowDataPacket { product_id: number; stock: number; } async function reserveStock( pool: Pool, productId: number, quantity: number, ): Promise<boolean> { const connection = await pool.getConnection(); try { await connection.beginTransaction(); // FOR UPDATE locks the row until commit/rollback const [rows] = await connection.execute<InventoryRow[]>( "SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE", [productId], ); if (rows.length === 0 || rows[0].stock < quantity) { await connection.rollback(); return false; } await connection.execute<ResultSetHeader>( "UPDATE inventory SET stock = stock - ? WHERE product_id = ?", [quantity, productId], ); await connection.commit(); return true; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } export { reserveStock }; ``` **Why good:** `FOR UPDATE` prevents other transactions from reading or modifying the row until this transaction completes, eliminates race conditions in read-then-write patterns **When to use:** Inventory checks, seat reservations, counter increments where the new value depends on the current value --- _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_
-
-
reference.md 9.8 KB
# MySQL (mysql2) Quick Reference > Type cheat sheet, pool options, error codes, and production checklist. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. --- ## TypeScript Type Cheat Sheet ### Query Result Types | Operation | Generic Type | Return Shape | | ------------------------------- | -------------------------------------- | ------------------------------- | | `SELECT` (single statement) | `RowDataPacket[]` | `[rows, fields]` | | `SELECT` (multiple statements) | `RowDataPacket[][]` | `[[rows1, rows2, ...], fields]` | | `INSERT / UPDATE / DELETE` | `ResultSetHeader` | `[result, fields]` | | Multiple mutations | `ResultSetHeader[]` | `[results[], fields]` | | Stored procedure (returns rows) | `ProcedureCallPacket<RowDataPacket[]>` | `[[rows, header], fields]` | | Stored procedure (mutation) | `ProcedureCallPacket<ResultSetHeader>` | `[[header], fields]` | ### Custom Row Interfaces ```typescript import type { RowDataPacket } from "mysql2/promise"; interface UserRow extends RowDataPacket { id: number; email: string; name: string; created_at: Date; } // Usage: pool.execute<UserRow[]>("SELECT ...", params) ``` ### ResultSetHeader Fields | Field | Type | Description | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `affectedRows` | `number` | Rows affected by INSERT/UPDATE/DELETE | | `insertId` | `number` | Auto-increment ID of inserted row | | `changedRows` | `number` | **Deprecated** -- rows actually changed (UPDATE only -- excludes unchanged matches). Parsed from server text messages, fragile across MySQL versions. Use `affectedRows` instead. | | `fieldCount` | `number` | Number of columns in result | | `info` | `string` | Server info message (e.g., `"Rows matched: 1 Changed: 1"`) | | `warningStatus` | `number` | Number of server warnings (check after DDL) | --- ## Pool Configuration Options | Option | Default | Description | | ----------------------- | ------------------- | ------------------------------------------------------------- | | `uri` | `undefined` | Connection string (`mysql://user:pass@host:3306/db`) | | `host` | `"localhost"` | MySQL server hostname | | `port` | `3306` | MySQL server port | | `user` | `undefined` | Authentication user | | `password` | `undefined` | Authentication password | | `database` | `undefined` | Default database | | `connectionLimit` | `10` | Max concurrent connections | | `maxIdle` | `10` | Max idle connections to keep (others are closed) | | `idleTimeout` | `60000` | Milliseconds before idle connection is closed | | `waitForConnections` | `true` | Queue requests when all connections are busy | | `queueLimit` | `0` | Max queued requests (0 = unlimited) | | `enableKeepAlive` | `false` | Send TCP keep-alive packets (set to `true` in production) | | `keepAliveInitialDelay` | `0` | Milliseconds before first keep-alive | | `namedPlaceholders` | `false` | Enable `:name` syntax for parameters | | `multipleStatements` | `false` | Allow multiple SQL statements per query (security risk) | | `charset` | `"UTF8_GENERAL_CI"` | Connection character set | | `timezone` | `"local"` | Timezone for date conversion | | `dateStrings` | `false` | Return DATE/DATETIME as strings instead of Date objects | | `typeCast` | `true` | Convert MySQL types to JavaScript types | | `decimalNumbers` | `false` | Return DECIMAL as numbers instead of strings (precision risk) | ### Recommended Configurations See [examples/core.md](examples/core.md) for the production pool setup pattern and [examples/configuration.md](examples/configuration.md) for environment-specific configurations (standard, serverless, batch, test). --- ## MySQL Error Codes ### Common Error Codes | Code | errno | Description | Typical Response | | -------------------------- | ----- | ------------------------------ | -------------------------------------------- | | `ER_DUP_ENTRY` | 1062 | Duplicate unique key violation | Return conflict, don't throw | | `ER_LOCK_DEADLOCK` | 1213 | Transaction deadlocked | Retry entire transaction | | `ER_LOCK_WAIT_TIMEOUT` | 1205 | Lock wait timeout exceeded | Retry or fail with message | | `ER_ACCESS_DENIED_ERROR` | 1045 | Bad credentials | Fail fast, log config issue | | `ER_BAD_DB_ERROR` | 1049 | Unknown database | Fail fast, check DATABASE_URL | | `ER_NO_SUCH_TABLE` | 1146 | Table doesn't exist | Migration issue | | `ER_PARSE_ERROR` | 1064 | SQL syntax error | Fix the query | | `ER_DATA_TOO_LONG` | 1406 | Data exceeds column length | Validate input before insert | | `ER_TRUNCATED_WRONG_VALUE` | 1292 | Invalid date/datetime value | Validate date format | | `ER_BAD_NULL_ERROR` | 1048 | Column cannot be NULL | Provide required value | | `ER_BAD_FIELD_ERROR` | 1054 | Unknown column | Fix column name | | `ER_TABLE_EXISTS_ERROR` | 1050 | Table already exists | Use IF NOT EXISTS | | `ER_CON_COUNT_ERROR` | 1040 | Too many connections | Increase max_connections or reduce pool size | ### Connection-Level Errors | Error | Description | Recovery | | -------------------------- | -------------------------------- | ----------------------------- | | `ECONNREFUSED` | Server not accepting connections | Check if MySQL is running | | `ECONNRESET` | Connection reset by server | Pool reconnects automatically | | `PROTOCOL_CONNECTION_LOST` | Server closed connection | Pool reconnects automatically | | `ETIMEDOUT` | Connection timed out | Check network, firewall | | `ER_SERVER_SHUTDOWN` | Server shutting down | Wait and retry | --- ## SSL/TLS Configuration See [examples/configuration.md](examples/configuration.md) for SSL/TLS setup patterns (cloud, CA cert, mutual TLS). --- ## Production Checklist ### Connection Management - [ ] Using `createPool()` (not `createConnection()`) - [ ] `DATABASE_URL` from environment variable (not hardcoded) - [ ] `enableKeepAlive: true` to prevent stale connections - [ ] `waitForConnections: true` (default) to queue under load - [ ] Pool `error` event handler registered - [ ] `connectionLimit` appropriate for server capacity (start at 10) - [ ] SSL/TLS enabled for production connections - [ ] Pool `end()` called on graceful shutdown ### Query Safety - [ ] All parameterized queries use `execute()` with `?` placeholders - [ ] No string interpolation in SQL statements - [ ] `multipleStatements` disabled (default) unless explicitly needed - [ ] User-facing queries have LIMIT clauses to prevent unbounded results - [ ] BIGINT and DECIMAL columns handled as strings (not numbers) ### Transaction Safety - [ ] `pool.getConnection()` used for all transactions - [ ] `connection.release()` in `finally` block - [ ] `connection.rollback()` in `catch` block - [ ] `ER_LOCK_DEADLOCK` handled with retry logic - [ ] Transaction scope is as narrow as possible (hold locks briefly) ### Monitoring - [ ] Pool `enqueue` event logged (indicates pool exhaustion) - [ ] Query execution time tracked - [ ] Connection count monitored - [ ] Error rates tracked by error code --- _Full skill documentation: [SKILL.md](SKILL.md) | Examples: [examples/](examples/)_ -
SKILL.md 18 KB
--- name: api-database-mysql description: Direct MySQL database access with mysql2 driver -- connection pools, prepared statements, transactions, streaming, typed queries, error handling --- # MySQL Patterns (mysql2) > **Quick Guide:** Use **mysql2/promise** for all new code -- it provides async/await support over the mysql2 callback API. Always use `createPool()` (never `createConnection()` in production) with `execute()` for parameterized queries (prepared statements, LRU-cached). Type query results with `RowDataPacket` generics for SELECTs and `ResultSetHeader` for INSERT/UPDATE/DELETE. For transactions, acquire a dedicated connection with `pool.getConnection()`, wrap in try/finally to guarantee `connection.release()`. Never interpolate user input into SQL strings -- always use `?` placeholders. Handle `ER_DUP_ENTRY` and `ER_LOCK_DEADLOCK` explicitly in catch blocks. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use `execute()` with `?` placeholders for ALL queries containing user input -- NEVER interpolate values into SQL strings with template literals or string concatenation)** **(You MUST use `pool.getConnection()` for transactions and release the connection in a `finally` block -- pool convenience methods (`pool.execute()`) use a different connection per call and cannot maintain transaction state)** **(You MUST always import from `mysql2/promise` for async/await code -- the base `mysql2` module returns callback-based objects that do not support `await`)** **(You MUST handle the pool `error` event -- unhandled connection errors crash the Node.js process)** </critical_requirements> --- ## Examples - [Core Patterns](examples/core.md) -- Pool setup, typed queries, prepared statements, connection lifecycle - [Transactions](examples/transactions.md) -- Manual transactions, savepoints, deadlock retry, nested operations - [Streaming & Batch](examples/streaming.md) -- Streaming large result sets, batch inserts, multiple statements - [Error Handling](examples/error-handling.md) -- MySQL error codes, connection errors, retry strategies, graceful degradation - [Configuration](examples/configuration.md) -- SSL/TLS, named placeholders, pool tuning, monitoring events **Additional resources:** - [reference.md](reference.md) -- Type cheat sheet, pool options, error codes, production checklist --- **Auto-detection:** MySQL, mysql2, mysql2/promise, createPool, createConnection, RowDataPacket, ResultSetHeader, execute, prepared statement, pool.getConnection, beginTransaction, commit, rollback, ER_DUP_ENTRY, ER_LOCK_DEADLOCK, connectionLimit, SHOW TABLES, mysqldump, InnoDB, MariaDB **When to use:** - Direct SQL queries against MySQL or MariaDB databases - Connection pool management for server applications - Transactions requiring atomicity across multiple queries - Streaming large result sets without loading all rows into memory - Typed query results with TypeScript generics - Batch inserts or multi-statement operations **Key patterns covered:** - Pool creation with `mysql2/promise` and proper configuration - Prepared statements via `execute()` with `?` placeholders - TypeScript generics with `RowDataPacket` and `ResultSetHeader` - Transaction lifecycle: `getConnection` -> `beginTransaction` -> `commit`/`rollback` -> `release` - Streaming with `connection.query().stream()` on the non-promise API - Error handling for `ER_DUP_ENTRY`, `ER_LOCK_DEADLOCK`, connection failures - Pool events (`acquire`, `release`, `enqueue`) for monitoring - SSL/TLS and named placeholders configuration **When NOT to use:** - When your project already uses an ORM or query builder for MySQL -- use that tool's skill instead - For in-memory caching or key-value storage (use a dedicated caching solution) - For document databases or graph queries (wrong database type) - For one-off CLI scripts where a single connection suffices and pool overhead is unnecessary --- <philosophy> ## Philosophy mysql2 is a **low-level MySQL driver** -- it sends SQL to MySQL and returns typed results. It does not generate SQL, manage migrations, or handle schema changes. **Core principles:** 1. **Pools, not connections** -- Production applications should always use `createPool()`. Pools manage connection lifecycle, handle reconnection, and prevent connection exhaustion. `createConnection()` is only appropriate for one-off scripts. 2. **Prepared statements always** -- `execute()` sends parameterized queries to MySQL's prepared statement protocol. The driver caches prepared statements in an LRU cache, so repeated queries skip the preparation step. Never use `query()` with string interpolation. 3. **Type your results** -- MySQL2's TypeScript generics (`RowDataPacket`, `ResultSetHeader`) eliminate `any` from query results. Define interfaces extending `RowDataPacket` for each table shape. 4. **Transactions need dedicated connections** -- Pool convenience methods (`pool.execute()`, `pool.query()`) may use different connections for each call. Transactions require `pool.getConnection()` to pin a single connection, with `connection.release()` in a `finally` block. 5. **Fail explicitly** -- MySQL errors carry structured `code` fields (`ER_DUP_ENTRY`, `ER_LOCK_DEADLOCK`). Check `error.code` in catch blocks rather than parsing message strings. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Pool Setup with mysql2/promise Create a connection pool with environment-based configuration and error handling. See [examples/core.md](examples/core.md) for the complete setup pattern. ```typescript // Good Example - Production pool setup import mysql from "mysql2/promise"; import type { Pool } from "mysql2/promise"; const DEFAULT_CONNECTION_LIMIT = 10; const DEFAULT_IDLE_TIMEOUT_MS = 60_000; function createDatabasePool(): Pool { const url = process.env.DATABASE_URL; if (!url) { throw new Error("DATABASE_URL environment variable is required"); } return mysql.createPool({ uri: url, waitForConnections: true, connectionLimit: DEFAULT_CONNECTION_LIMIT, maxIdle: DEFAULT_CONNECTION_LIMIT, idleTimeout: DEFAULT_IDLE_TIMEOUT_MS, enableKeepAlive: true, keepAliveInitialDelay: 0, }); } export { createDatabasePool }; ``` **Why good:** Environment variable validation, named constants for limits, `waitForConnections: true` queues requests instead of throwing, `enableKeepAlive` prevents stale connections ```typescript // Bad Example - Hardcoded single connection import mysql from "mysql2/promise"; const connection = await mysql.createConnection({ host: "localhost", user: "root", password: "password123", database: "mydb", }); // Hardcoded credentials, single connection exhausts under load, no pool ``` **Why bad:** Hardcoded credentials leak in version control, single connection cannot handle concurrent requests, no automatic reconnection --- ### Pattern 2: Typed Queries with Generics Use `RowDataPacket` for SELECTs and `ResultSetHeader` for mutations. See [examples/core.md](examples/core.md) for all type patterns. ```typescript // Good Example - Typed SELECT and INSERT import type { Pool, RowDataPacket, ResultSetHeader } from "mysql2/promise"; interface UserRow extends RowDataPacket { id: number; email: string; name: string; created_at: Date; } async function getUserById( pool: Pool, userId: number, ): Promise<UserRow | null> { const [rows] = await pool.execute<UserRow[]>( "SELECT id, email, name, created_at FROM users WHERE id = ?", [userId], ); return rows[0] ?? null; } async function createUser( pool: Pool, email: string, name: string, ): Promise<number> { const [result] = await pool.execute<ResultSetHeader>( "INSERT INTO users (email, name) VALUES (?, ?)", [email, name], ); return result.insertId; } ``` **Why good:** Interface extends `RowDataPacket` for type safety, `execute()` uses prepared statements, destructured `[rows]` skips field metadata, null check for missing rows ```typescript // Bad Example - Untyped query with interpolation const [rows] = await pool.query(`SELECT * FROM users WHERE id = ${userId}`); // SQL injection vulnerability, untyped results, query() skips prepared statements ``` **Why bad:** SQL injection via string interpolation, `any`-typed results, `query()` does not use prepared statement protocol --- ### Pattern 3: Transaction with Dedicated Connection Transactions require a single connection from the pool. See [examples/transactions.md](examples/transactions.md) for savepoints, deadlock retry, and nested operations. ```typescript // Good Example - Transfer with transaction async function transferFunds( pool: Pool, fromId: number, toId: number, amount: number, ): Promise<void> { const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.execute( "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", [amount, fromId, amount], ); await connection.execute( "UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, toId], ); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } ``` **Why good:** `getConnection()` pins one connection, `finally` guarantees release even on error, `rollback()` in catch prevents partial commits, balance check in SQL prevents overdraft --- ### Pattern 4: Streaming Large Result Sets Use the callback-based API for streaming -- the promise API does not support `.stream()`. See [examples/streaming.md](examples/streaming.md) for backpressure handling and transform streams. ```typescript // Good Example - Stream rows without loading all into memory import mysql from "mysql2"; function streamUsers( pool: ReturnType<typeof mysql.createPool>, ): NodeJS.ReadableStream { return pool .query("SELECT * FROM users WHERE active = 1") .stream({ highWaterMark: 100 }); } ``` **Why good:** `highWaterMark` controls buffer size, rows are emitted one at a time via Node.js stream interface, constant memory usage regardless of result set size **When to use:** Result sets with 10K+ rows, ETL pipelines, CSV exports, data migrations --- ### Pattern 5: Error Code Handling MySQL errors carry structured `code` and `errno` fields. See [examples/error-handling.md](examples/error-handling.md) for retry strategies and connection error handling. ```typescript // Good Example - Structured error handling import type { Pool, ResultSetHeader } from "mysql2/promise"; const MYSQL_ER_DUP_ENTRY = "ER_DUP_ENTRY"; interface MysqlError extends Error { code: string; errno: number; sqlState: string; sqlMessage: string; } function isMysqlError(error: unknown): error is MysqlError { return error instanceof Error && "code" in error && "errno" in error; } async function createUserSafe( pool: Pool, email: string, name: string, ): Promise<{ insertId: number } | { duplicate: true }> { try { const [result] = await pool.execute<ResultSetHeader>( "INSERT INTO users (email, name) VALUES (?, ?)", [email, name], ); return { insertId: result.insertId }; } catch (error) { if (isMysqlError(error) && error.code === MYSQL_ER_DUP_ENTRY) { return { duplicate: true }; } throw error; } } ``` **Why good:** Type guard for MySQL errors, named constant for error code, returns discriminated union instead of throwing on expected errors, re-throws unexpected errors </patterns> --- <performance> ## Performance Optimization ### execute() vs query() `execute()` uses MySQL's binary prepared statement protocol with an LRU cache. The first call prepares the statement; subsequent calls with the same SQL reuse the cached preparation, skipping the parse step. Use `execute()` for all parameterized queries. `query()` sends the full SQL text each time. Use `query()` only for dynamic SQL where the statement text itself changes (e.g., dynamic column lists), or when streaming (`.stream()` is not available on the promise API's execute). ### Pool Sizing ``` connectionLimit = (number of CPU cores * 2) + number of disk spindles ``` For cloud databases, start with `connectionLimit: 10` and increase under load testing. The MySQL server's `max_connections` must accommodate all application instances' pools combined. ### enableKeepAlive Set `enableKeepAlive: true` to prevent firewalls and load balancers from dropping idle connections. Without this, connections that idle for longer than the intermediary's timeout are silently dropped, causing `ECONNRESET` errors on the next query. ### Batch Inserts For inserting many rows, use a single `INSERT ... VALUES (...), (...), (...)` statement instead of individual inserts -- see [examples/streaming.md](examples/streaming.md). </performance> --- <decision_framework> ## Decision Framework ### Pool vs Connection ``` What am I building? -- Production server handling concurrent requests? -> createPool() -- One-off CLI script or migration? -> createConnection() is acceptable -- Serverless function (Lambda, Vercel)? -> createPool() with connectionLimit: 1 ``` ### execute() vs query() ``` Does the SQL have user-provided parameters? -- YES -> execute() with ? placeholders (ALWAYS) -- NO, but same SQL runs repeatedly? -> execute() (benefits from LRU cache) -- NO, SQL text itself is dynamic? -> query() (cannot prepare dynamic SQL) -- Need to stream results? -> query().stream() on the callback API ``` ### Pool method vs getConnection() ``` Is this a single query? -- YES -> pool.execute() or pool.query() (auto-acquires and releases) -- NO, multiple queries needing same connection? -> pool.getConnection() -- Transaction? -> pool.getConnection() (REQUIRED) ``` ### Error Handling Strategy ``` What MySQL error did I get? -- ER_DUP_ENTRY (1062) -> Handle as business logic (return conflict, not throw) -- ER_LOCK_DEADLOCK (1213) -> Retry the entire transaction (MySQL rolled it back) -- ER_LOCK_WAIT_TIMEOUT (1205) -> Retry or fail with timeout message -- ECONNREFUSED / PROTOCOL_CONNECTION_LOST -> Connection issue, pool will reconnect -- ER_ACCESS_DENIED_ERROR (1045) -> Configuration error, fail fast ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Interpolating user input into SQL strings (`\`SELECT \* FROM users WHERE id = ${id}\``) -- SQL injection vulnerability, always use `?`placeholders with`execute()` - Using `pool.execute()` or `pool.query()` for transactions -- each call may use a different connection, breaking transaction isolation; use `pool.getConnection()` - Importing from `mysql2` instead of `mysql2/promise` for async/await code -- the base module returns callback-based objects, `await` will not work as expected - Not releasing connections acquired with `pool.getConnection()` -- connection leak exhausts the pool; always release in a `finally` block **Medium Priority Issues:** - Using `query()` instead of `execute()` for parameterized queries -- misses prepared statement caching and binary protocol efficiency - Missing pool `error` event handler -- unhandled connection errors crash the Node.js process - Setting `connectionLimit` too high -- each MySQL connection uses ~10 MB of server memory; 10-20 is usually sufficient - Not setting `enableKeepAlive: true` -- idle connections get dropped by firewalls/load balancers causing `ECONNRESET` **Common Mistakes:** - Expecting `pool.end()` to wait for active queries -- it immediately destroys all connections; drain queries first - Using `rows.length` to check if an UPDATE affected rows -- use `result.affectedRows` from `ResultSetHeader` instead - Assuming `insertId` is always the auto-increment value -- for `INSERT ... ON DUPLICATE KEY UPDATE`, `insertId` is `0` if the existing row was updated, not inserted - Calling `connection.release()` after `connection.destroy()` -- destroy removes the connection from the pool entirely; release returns it - Treating `null` and `undefined` as interchangeable in parameter arrays -- mysql2 converts `null` to SQL `NULL` but `undefined` causes a protocol error **Gotchas & Edge Cases:** - `DECIMAL` and `BIGINT` columns are returned as strings by default to avoid JavaScript floating-point precision loss -- parse explicitly if you need numbers - `DATE` columns return JavaScript `Date` objects, but `DATETIME` precision beyond milliseconds is truncated -- MySQL supports microsecond precision, JavaScript `Date` does not - `execute()` with named placeholders requires `namedPlaceholders: true` on the pool/connection config -- the default is unnamed `?` only - `multipleStatements: true` is a security risk -- it enables SQL injection via `;` in user input if combined with `query()`; only enable when needed and never with user-provided SQL - Pool `waitForConnections: false` throws immediately when all connections are in use instead of queuing -- the default `true` is almost always what you want - `ResultSetHeader.warningStatus` indicates server warnings -- check it after DDL operations (the deprecated `OkPacket` type had a separate `warningCount` field; `ResultSetHeader` has always used `warningStatus`) </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use `execute()` with `?` placeholders for ALL queries containing user input -- NEVER interpolate values into SQL strings with template literals or string concatenation)** **(You MUST use `pool.getConnection()` for transactions and release the connection in a `finally` block -- pool convenience methods (`pool.execute()`) use a different connection per call and cannot maintain transaction state)** **(You MUST always import from `mysql2/promise` for async/await code -- the base `mysql2` module returns callback-based objects that do not support `await`)** **(You MUST handle the pool `error` event -- unhandled connection errors crash the Node.js process)** **Failure to follow these rules will cause SQL injection vulnerabilities, transaction corruption, connection pool exhaustion, and application crashes.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.