diff --git a/dbhub.toml.example b/dbhub.toml.example
index e8e4b4ae..3ee268b9 100644
--- a/dbhub.toml.example
+++ b/dbhub.toml.example
@@ -135,6 +135,15 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# charset = "utf8mb4" # Connection character set (uses its default collation)
# collation = "utf8mb4_0900_ai_ci" # Connection collation
+# MySQL with guardrails re-applied before every read-only execution (MySQL/MariaDB only)
+# [[sources]]
+# id = "local_mysql_guarded"
+# dsn = "mysql://root:mysql@localhost:3306/myapp"
+# readonly_session_sql = """
+# SET SESSION max_execution_time = 30000;
+# SET SESSION lock_wait_timeout = 5;
+# """
+
# MySQL on AWS RDS with IAM authentication (no password in config)
# [[sources]]
# id = "rds_mysql_iam"
diff --git a/docs/config/toml.mdx b/docs/config/toml.mdx
index 5e251a81..22306705 100644
--- a/docs/config/toml.mdx
+++ b/docs/config/toml.mdx
@@ -331,6 +331,32 @@ Sources define database connections. Each source represents a database that DBHu
+### readonly_session_sql
+
+
+ Session-setting statements that DBHub runs at the start of every read-only execution on this source, inside the read-only transaction it already opens. Use it for limits the database enforces itself, such as `max_execution_time` or `lock_wait_timeout`.
+
+ Supported databases: MySQL, MariaDB.
+
+ ```toml
+ [[sources]]
+ id = "production"
+ dsn = "mysql://user:pass@localhost:3306/mydb"
+ readonly_session_sql = """
+ SET SESSION max_execution_time = 30000;
+ SET SESSION lock_wait_timeout = 5;
+ """
+ ```
+
+ Only `SET SESSION name = value` statements are accepted, one assignment each. Any other statement fails at startup, as does a setting that controls the transaction they run in (`transaction_read_only`, `tx_read_only`, `autocommit`, `completion_type`).
+
+ The statements run on every read-only execution rather than once at connect time, so a value changed on the pooled connection in the meantime is put back before the next read-only statement.
+
+
+ Every read-only execution runs it, including `explain_sql` and read-only custom tools. Writable executions do not. The settings stay on the connection after the execution, so a writable tool sharing the same source may see them.
+
+
+
### lazy
@@ -894,6 +920,7 @@ default = 10
| `connection_timeout` | number | ❌ | Connection timeout (seconds) |
| `query_timeout` | number | ❌ | Query timeout (seconds) |
| `pool_max_connections` | integer | ❌ | PostgreSQL pool size per process and source (default: `10`, range: `1`-`1000`) |
+| `readonly_session_sql` | string | ❌ | MySQL/MariaDB: session-setting statements run before every read-only execution |
| `sslmode` | string | ❌ | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` |
| `sslrootcert` | string | ❌ | CA certificate path (PostgreSQL only, requires `verify-ca` or `verify-full`) |
| `sslcert` | string | ❌ | PEM client certificate path for client certificate authentication (PostgreSQL only, requires `sslkey` and TLS enabled) |
diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts
index 344bc399..542ba223 100644
--- a/src/config/__tests__/toml-loader.test.ts
+++ b/src/config/__tests__/toml-loader.test.ts
@@ -1431,6 +1431,61 @@ pool_max_connections = 5
});
});
+ describe('readonly_session_sql validation', () => {
+ it('should accept SET SESSION statements for MySQL', () => {
+ const tomlContent = `
+[[sources]]
+id = "test_db"
+dsn = "mysql://user:pass@localhost:3306/testdb"
+readonly_session_sql = """
+SET SESSION max_execution_time = 30000;
+SET SESSION lock_wait_timeout = 5;
+"""
+`;
+ fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
+
+ const result = loadTomlConfig();
+
+ expect(result?.sources[0].readonly_session_sql).toContain('SET SESSION lock_wait_timeout');
+ });
+
+ it('should reject a statement that is not a session setting', () => {
+ const tomlContent = `
+[[sources]]
+id = "test_db"
+dsn = "mysql://user:pass@localhost:3306/testdb"
+readonly_session_sql = "SET SESSION lock_wait_timeout = 5; DELETE FROM users"
+`;
+ fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
+
+ expect(() => loadTomlConfig()).toThrow("source 'test_db' has invalid readonly_session_sql");
+ });
+
+ it('should reject readonly_session_sql for unsupported source types', () => {
+ const tomlContent = `
+[[sources]]
+id = "test_db"
+dsn = "postgres://user:pass@localhost:5432/testdb"
+readonly_session_sql = "SET LOCAL lock_timeout = '5s'"
+`;
+ fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
+
+ expect(() => loadTomlConfig()).toThrow('not supported for postgres');
+ });
+
+ it('should reject a non-string value', () => {
+ const tomlContent = `
+[[sources]]
+id = "test_db"
+dsn = "mysql://user:pass@localhost:3306/testdb"
+readonly_session_sql = ["SET SESSION lock_wait_timeout = 5"]
+`;
+ fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
+
+ expect(() => loadTomlConfig()).toThrow('invalid readonly_session_sql');
+ });
+ });
+
describe('search_path validation', () => {
it('should accept search_path for PostgreSQL source', () => {
const tomlContent = `
diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts
index 8d285c49..e55820d3 100644
--- a/src/config/toml-loader.ts
+++ b/src/config/toml-loader.ts
@@ -6,6 +6,8 @@ import type { SourceConfig, TomlConfig, ToolConfig } from "../types/config.js";
import { parseCommandLineArgs, requireFlagValue } from "./env.js";
import { parseConnectionInfoFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js";
import { SafeURL } from "../utils/safe-url.js";
+import { parseReadonlySessionSQL } from "../utils/readonly-session-sql.js";
+import type { ConnectorType } from "../connectors/interface.js";
import { BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS, ALL_BUILTIN_TOOL_NAMES } from "../tools/builtin-tools.js";
/**
@@ -719,6 +721,26 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
}
+ // Validate readonly_session_sql up front so a bad statement fails at startup rather
+ // than on the first read-only query. source.type is populated from the DSN
+ // by processSourceConfigs before validation runs.
+ if (source.readonly_session_sql !== undefined) {
+ if (typeof source.readonly_session_sql !== "string") {
+ throw new Error(
+ `Configuration file ${configPath}: source '${source.id}' has invalid readonly_session_sql. ` +
+ `Must be a string of session-setting statements.`
+ );
+ }
+ try {
+ parseReadonlySessionSQL(source.readonly_session_sql, source.type as ConnectorType);
+ } catch (error) {
+ throw new Error(
+ `Configuration file ${configPath}: source '${source.id}' has invalid readonly_session_sql: ` +
+ (error as Error).message
+ );
+ }
+ }
+
// Validate timezone (MySQL/MariaDB only)
if (source.timezone !== undefined) {
if (source.type !== "mysql" && source.type !== "mariadb") {
diff --git a/src/connectors/__tests__/mariadb.integration.test.ts b/src/connectors/__tests__/mariadb.integration.test.ts
index 669e3d26..c064c445 100644
--- a/src/connectors/__tests__/mariadb.integration.test.ts
+++ b/src/connectors/__tests__/mariadb.integration.test.ts
@@ -597,4 +597,24 @@ describe('MariaDB Connector Integration Tests', () => {
}
});
});
-});
\ No newline at end of file
+
+ describe('readonly_session_sql', () => {
+ it('should apply the settings to read-only executions', async () => {
+ const connector = new MariaDBConnector();
+ try {
+ await connector.connect(mariadbTest.connectionString, undefined, {
+ readonlySessionSql: 'SET SESSION lock_wait_timeout = 7',
+ });
+
+ const result = await connector.executeSQL(
+ 'SELECT @@session.lock_wait_timeout AS lwt',
+ { readonly: true }
+ );
+
+ expect(Number(result.resultSets[0].rows[0].lwt)).toBe(7);
+ } finally {
+ await connector.disconnect();
+ }
+ });
+ });
+});
diff --git a/src/connectors/__tests__/mysql.integration.test.ts b/src/connectors/__tests__/mysql.integration.test.ts
index edbe87a4..502d22e7 100644
--- a/src/connectors/__tests__/mysql.integration.test.ts
+++ b/src/connectors/__tests__/mysql.integration.test.ts
@@ -606,4 +606,63 @@ describe('MySQL Connector Integration Tests', () => {
}
});
});
-});
\ No newline at end of file
+
+ describe('readonly_session_sql', () => {
+ it('should apply the settings to read-only executions', async () => {
+ const connector = new MySQLConnector();
+ try {
+ await connector.connect(mysqlTest.connectionString, undefined, {
+ readonlySessionSql: 'SET SESSION max_execution_time = 1234',
+ });
+
+ const result = await connector.executeSQL(
+ 'SELECT @@session.max_execution_time AS met',
+ { readonly: true }
+ );
+
+ expect(Number(result.resultSets[0].rows[0].met)).toBe(1234);
+ } finally {
+ await connector.disconnect();
+ }
+ });
+
+ it('should put a value back after it was changed on the pooled connection', async () => {
+ const connector = new MySQLConnector();
+ try {
+ await connector.connect(mysqlTest.connectionString, undefined, {
+ readonlySessionSql: 'SET SESSION max_execution_time = 1234',
+ });
+
+ await connector.executeSQL('SET SESSION max_execution_time = 0', {});
+ const result = await connector.executeSQL(
+ 'SELECT @@session.max_execution_time AS met',
+ { readonly: true }
+ );
+
+ expect(Number(result.resultSets[0].rows[0].met)).toBe(1234);
+ } finally {
+ await connector.disconnect();
+ }
+ });
+
+ it('should let the server stop a read-only SELECT at max_execution_time', async () => {
+ const connector = new MySQLConnector();
+ try {
+ await connector.connect(mysqlTest.connectionString, undefined, {
+ readonlySessionSql: 'SET SESSION max_execution_time = 500',
+ });
+
+ const started = Date.now();
+ const result = await connector.executeSQL('SELECT SLEEP(5) AS interrupted', {
+ readonly: true,
+ });
+
+ // SLEEP returns 1 when the server interrupts it.
+ expect(Number(result.resultSets[0].rows[0].interrupted)).toBe(1);
+ expect(Date.now() - started).toBeLessThan(4000);
+ } finally {
+ await connector.disconnect();
+ }
+ });
+ });
+});
diff --git a/src/connectors/interface.ts b/src/connectors/interface.ts
index ff93cf05..86e84d73 100644
--- a/src/connectors/interface.ts
+++ b/src/connectors/interface.ts
@@ -177,6 +177,12 @@ export interface ConnectorConfig {
* set, the driver falls back to its built-in default (mysql2: `utf8mb4_unicode_ci`).
*/
collation?: string;
+ /**
+ * Session-setting statements re-run at the start of every read-only execution
+ * (MySQL, MariaDB). Validated by parseReadonlySessionSQL; see
+ * src/utils/readonly-session-sql.ts for why they run per execution.
+ */
+ readonlySessionSql?: string;
}
/**
diff --git a/src/connectors/manager.ts b/src/connectors/manager.ts
index 1b4c309a..b0e2fe3f 100644
--- a/src/connectors/manager.ts
+++ b/src/connectors/manager.ts
@@ -284,6 +284,10 @@ export class ConnectorManager {
if (source.collation) {
config.collation = source.collation;
}
+ // Pass readonly_session_sql (MySQL, MariaDB)
+ if (source.readonly_session_sql) {
+ config.readonlySessionSql = source.readonly_session_sql;
+ }
// Connect to the database with config and optional init script. If this fails,
// close the tunnel established for this attempt: the source may be retried (lazy
diff --git a/src/connectors/mariadb/index.ts b/src/connectors/mariadb/index.ts
index 63b679dd..f77933c6 100644
--- a/src/connectors/mariadb/index.ts
+++ b/src/connectors/mariadb/index.ts
@@ -19,6 +19,7 @@ import { requireDatabaseInDSN, MissingDatabaseError } from "../../utils/dsn-data
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
import { parseQueryResultSets } from "../../utils/multi-statement-result-parser.js";
import { splitSQLStatements } from "../../utils/sql-parser.js";
+import { parseReadonlySessionSQL } from "../../utils/readonly-session-sql.js";
import { withReadOnlyTransaction } from "../../utils/readonly-transaction.js";
import { quoteIdentifier } from "../../utils/identifier-quoter.js";
import { isTiDBVersion } from "../../utils/server-flavor.js";
@@ -147,6 +148,8 @@ export class MariaDBConnector implements Connector {
// TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY`
// unless tidb_enable_noop_functions is on. Detected once at connect time.
private supportsReadOnlyTransaction: boolean = true;
+ // Per-source readonly_session_sql, re-run inside every read-only transaction
+ private sessionStatements: string[] = [];
getId(): string {
return this.sourceId;
@@ -159,6 +162,7 @@ export class MariaDBConnector implements Connector {
async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise {
try {
const connectionConfig = await this.dsnParser.parse(dsn, config);
+ this.sessionStatements = config?.readonlySessionSql ? parseReadonlySessionSQL(config.readonlySessionSql, "mariadb") : [];
this.pool = mariadb.createPool(connectionConfig);
@@ -703,7 +707,8 @@ export class MariaDBConnector implements Connector {
}
return { resultSets };
- }
+ },
+ this.sessionStatements
);
} finally {
// Always release the connection back to the pool
diff --git a/src/connectors/mysql/index.ts b/src/connectors/mysql/index.ts
index ec8dcd2a..63bd2c52 100644
--- a/src/connectors/mysql/index.ts
+++ b/src/connectors/mysql/index.ts
@@ -19,6 +19,7 @@ import { requireDatabaseInDSN, MissingDatabaseError } from "../../utils/dsn-data
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
import { parseQueryResultSets } from "../../utils/multi-statement-result-parser.js";
import { splitSQLStatements } from "../../utils/sql-parser.js";
+import { parseReadonlySessionSQL } from "../../utils/readonly-session-sql.js";
import { withReadOnlyTransaction, isClientSideTimeout } from "../../utils/readonly-transaction.js";
import { quoteIdentifier } from "../../utils/identifier-quoter.js";
import { isTiDBVersion } from "../../utils/server-flavor.js";
@@ -165,6 +166,8 @@ export class MySQLConnector implements Connector {
// TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY`
// unless tidb_enable_noop_functions is on. Detected once at connect time.
private supportsReadOnlyTransaction: boolean = true;
+ // Per-source readonly_session_sql, re-run inside every read-only transaction
+ private sessionStatements: string[] = [];
getId(): string {
return this.sourceId;
@@ -177,6 +180,7 @@ export class MySQLConnector implements Connector {
async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise {
try {
const connectionOptions = await this.dsnParser.parse(dsn, config);
+ this.sessionStatements = config?.readonlySessionSql ? parseReadonlySessionSQL(config.readonlySessionSql, "mysql") : [];
this.pool = mysql.createPool(connectionOptions);
// Store query timeout for per-query application
@@ -723,7 +727,8 @@ export class MySQLConnector implements Connector {
}
return { resultSets };
- }
+ },
+ this.sessionStatements
);
} catch (error) {
if (isClientSideTimeout(error)) {
diff --git a/src/types/config.ts b/src/types/config.ts
index 6f2a3459..9c7bb9c1 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -57,6 +57,7 @@ export interface SourceConfig extends ConnectionParams, SSHConfig {
query_timeout?: number; // Query timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server)
pool_max_connections?: number; // Maximum PostgreSQL connections per source (1-1000)
init_script?: string; // Optional SQL script to run on connection (for demo mode or initialization)
+ readonly_session_sql?: string; // Session-setting statements re-run before every read-only execution (MySQL, MariaDB)
lazy?: boolean; // Defer connection until first query (default: false)
search_path?: string; // Comma-separated list of schemas for PostgreSQL search_path (e.g., "myschema,public")
timezone?: string; // MySQL/MariaDB: how the driver interprets DATETIME values. "Z" (UTC), "local", or "±HH:MM" (e.g., "+09:00")
diff --git a/src/utils/__tests__/readonly-session-sql.test.ts b/src/utils/__tests__/readonly-session-sql.test.ts
new file mode 100644
index 00000000..660cea9c
--- /dev/null
+++ b/src/utils/__tests__/readonly-session-sql.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from "vitest";
+import { parseReadonlySessionSQL } from "../readonly-session-sql.js";
+
+describe("parseReadonlySessionSQL", () => {
+ describe("accepted forms", () => {
+ it.each(["mysql", "mariadb"] as const)("splits SET SESSION statements in order for %s", dialect => {
+ const sql = `
+ SET SESSION max_execution_time = 30000;
+ SET SESSION lock_wait_timeout = 5;
+ `;
+ expect(parseReadonlySessionSQL(sql, dialect)).toEqual([
+ "SET SESSION max_execution_time = 30000",
+ "SET SESSION lock_wait_timeout = 5",
+ ]);
+ });
+
+ it("accepts a comma inside a string value", () => {
+ expect(
+ parseReadonlySessionSQL("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE'", "mysql")
+ ).toHaveLength(1);
+ });
+
+ it("ignores a leading comment", () => {
+ expect(parseReadonlySessionSQL("-- guardrails\nSET SESSION lock_wait_timeout = 5", "mysql")).toHaveLength(1);
+ });
+ });
+
+ describe("rejected statements", () => {
+ it.each([
+ ["a query", "SELECT 1"],
+ ["a write", "DELETE FROM users"],
+ ["a SET without SESSION", "SET max_execution_time = 1"],
+ ["a GLOBAL assignment", "SET GLOBAL max_execution_time = 1"],
+ ["a subquery value", "SET SESSION max_execution_time = (SELECT 1)"],
+ ["a second assignment in another scope", "SET SESSION max_execution_time = 1, GLOBAL max_connections = 1"],
+ ["an executable comment hiding a subquery", "SET SESSION max_execution_time = 1 /*!, b = (SELECT 1) */"],
+ ["a keyword hidden in a comment", "/* SET SESSION x = 1 */ DELETE FROM users"],
+ ])("rejects %s", (_label, sql) => {
+ expect(() => parseReadonlySessionSQL(sql, "mysql")).toThrow("SET SESSION name = value");
+ });
+
+ it("rejects a second statement after a valid one", () => {
+ expect(() =>
+ parseReadonlySessionSQL("SET SESSION lock_wait_timeout = 5; DROP TABLE users", "mysql")
+ ).toThrow("got: DROP TABLE users");
+ });
+
+ it.each([
+ ["mysql", "SET SESSION transaction_read_only = 0"],
+ ["mariadb", "SET SESSION tx_read_only = 0"],
+ ["mysql", "SET SESSION autocommit = 0"],
+ ["mariadb", "SET SESSION completion_type = 2"],
+ ] as const)("rejects a transaction-control setting on %s: %s", (dialect, sql) => {
+ expect(() => parseReadonlySessionSQL(sql, dialect)).toThrow("controls the transaction");
+ });
+
+ it("rejects an empty value", () => {
+ expect(() => parseReadonlySessionSQL(" ; ", "mysql")).toThrow("empty");
+ });
+
+ it.each(["postgres", "sqlserver", "oracle", "sqlite"] as const)(
+ "rejects the unsupported dialect %s",
+ dialect => {
+ expect(() => parseReadonlySessionSQL("SET SESSION lock_wait_timeout = 5", dialect)).toThrow(
+ "not supported"
+ );
+ }
+ );
+ });
+});
diff --git a/src/utils/readonly-session-sql.ts b/src/utils/readonly-session-sql.ts
new file mode 100644
index 00000000..52155608
--- /dev/null
+++ b/src/utils/readonly-session-sql.ts
@@ -0,0 +1,98 @@
+import type { ConnectorType } from "../connectors/interface.js";
+import { splitSQLStatements, stripCommentsAndStrings } from "./sql-parser.js";
+
+/**
+ * Per-source `readonly_session_sql`: session-setting statements re-run at the start of
+ * every read-only execution, inside the read-only transaction the connector
+ * already opens (START TRANSACTION READ ONLY).
+ *
+ * The pooled connection may be shared with writable tools on the same source,
+ * which can change session settings, so re-running these statements per
+ * execution puts the configured values back before every read-only statement.
+ *
+ * Only the dialect's session-setting form is accepted, so this field cannot
+ * become a general SQL execution path. The check runs on comment- and
+ * literal-stripped text: a quoted keyword or a leading comment cannot disguise
+ * another statement. MySQL/MariaDB executable comments (`/*! ... *\/`) are kept
+ * by the stripper, so their contents are checked too.
+ */
+
+/**
+ * Accepted statement shape per dialect. Values are not inspected beyond the
+ * assignment operator: string literals are blanked by the stripper, and the
+ * server rejects a malformed value on the first execution.
+ */
+const sessionStatementPatterns: Partial> = {
+ // One assignment per statement: a comma list could mix in another scope
+ // (`SET SESSION a = 1, GLOBAL b = 2`). Commas inside string values are
+ // blanked by the stripper, so `sql_mode = 'A,B'` still passes.
+ mysql: /^SET\s+SESSION\s+([a-z_][a-z0-9_]*)\s*=[^,]*$/i,
+ mariadb: /^SET\s+SESSION\s+([a-z_][a-z0-9_]*)\s*=[^,]*$/i,
+};
+
+/** Human-readable form of each pattern, for error messages. */
+const sessionStatementForms: Partial> = {
+ mysql: "SET SESSION name = value",
+ mariadb: "SET SESSION name = value",
+};
+
+/**
+ * Settings that control the transaction the statements run in, rejected so a
+ * source-level setting cannot undo tool-level `readonly = true` or break the
+ * pooled connection: the read-only switches, and on MySQL/MariaDB `autocommit`
+ * and `completion_type`, which change how the closing COMMIT behaves and stay
+ * on the connection for whichever execution draws it next.
+ */
+const transactionControlSettings: Partial> = {
+ mysql: ["transaction_read_only", "tx_read_only", "autocommit", "completion_type"],
+ mariadb: ["transaction_read_only", "tx_read_only", "autocommit", "completion_type"],
+};
+
+/** Dialects that support `readonly_session_sql`. */
+export const READONLY_SESSION_SQL_DIALECTS: readonly ConnectorType[] = Object.keys(
+ sessionStatementPatterns
+) as ConnectorType[];
+
+/**
+ * Split and validate a `readonly_session_sql` value.
+ *
+ * @returns The statements to run, in order, as written in the config.
+ * @throws If the dialect is unsupported, or a statement is not the dialect's
+ * session-setting form or names a transaction-control setting.
+ */
+export function parseReadonlySessionSQL(sql: string, dialect: ConnectorType): string[] {
+ const pattern = sessionStatementPatterns[dialect];
+ if (!pattern) {
+ throw new Error(
+ `readonly_session_sql is not supported for ${dialect} sources ` +
+ `(supported: ${READONLY_SESSION_SQL_DIALECTS.join(", ")}).`
+ );
+ }
+
+ const statements = splitSQLStatements(sql, dialect);
+ if (statements.length === 0) {
+ throw new Error("readonly_session_sql is empty.");
+ }
+
+ for (const statement of statements) {
+ const cleaned = stripCommentsAndStrings(statement, dialect).trim();
+ const match = pattern.exec(cleaned);
+ // A SET value can be a subquery on MySQL/MariaDB (`SET SESSION x = (SELECT ...)`);
+ // reject it so the field stays limited to plain assignments.
+ if (!match || /\bselect\b/i.test(cleaned)) {
+ throw new Error(
+ `readonly_session_sql only accepts statements of the form '${sessionStatementForms[dialect]}' for ${dialect}; ` +
+ `got: ${statement}`
+ );
+ }
+ const name = match[1].toLowerCase();
+ if (transactionControlSettings[dialect]?.includes(name)) {
+ throw new Error(
+ `readonly_session_sql cannot set '${name}': it controls the transaction ` +
+ `the statements run in.`
+ );
+ }
+ }
+
+ return statements;
+}
diff --git a/src/utils/readonly-transaction.ts b/src/utils/readonly-transaction.ts
index 1906d2ab..a87f5282 100644
--- a/src/utils/readonly-transaction.ts
+++ b/src/utils/readonly-transaction.ts
@@ -53,12 +53,17 @@ export function isClientSideTimeout(error: unknown): boolean {
* @param readonly Whether read-only enforcement is active for this execution.
* @param supportsReadOnlyTransaction False for TiDB, which rejects READ ONLY.
* @param execute Driver-specific query + result parsing.
+ * @param sessionStatements Per-source `readonly_session_sql`, run right after the
+ * transaction opens (see src/utils/readonly-session-sql.ts). These are SET SESSION
+ * statements, so they stay on the pooled connection afterwards; that is why
+ * they are re-run on every read-only execution rather than once.
*/
export async function withReadOnlyTransaction(
conn: ReadOnlyTransactionConnection,
readonly: boolean | undefined,
supportsReadOnlyTransaction: boolean,
- execute: () => Promise
+ execute: () => Promise,
+ sessionStatements: readonly string[] = []
): Promise {
if (!readonly) {
return execute();
@@ -72,6 +77,9 @@ export async function withReadOnlyTransaction(
await conn.query(
supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
);
+ for (const statement of sessionStatements) {
+ await conn.query(statement);
+ }
const result = await execute();
await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
return result;