From ea91f2c1dec9f8d7f1188475f9d74640bb73bbc9 Mon Sep 17 00:00:00 2001 From: Jeff Kwan Date: Tue, 27 Oct 2026 01:20:46 -0700 Subject: [PATCH] Add Database.limit() and Database.setLimit() Expose SQLite's per-connection run-time limits so callers can, for example, cap SQLITE_LIMIT_LENGTH and stop a single query from building a huge string or BLOB. Adds a Limit constants object that matches the SQLITE_LIMIT_* values. setLimit() returns the previous value. Requires libsql's Connection::limit and Connection::set_limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 +- compat.js | 32 +++++++++++++ docs/api.md | 32 +++++++++++++ index.d.ts | 21 +++++++++ integration-tests/tests/async.test.js | 20 ++++++++ integration-tests/tests/extensions.test.js | 42 ++++++++++++++++- limits.js | 38 +++++++++++++++ package.json | 2 + promise.js | 32 +++++++++++++ src/lib.rs | 55 ++++++++++++++++++++++ 10 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 limits.js diff --git a/.gitignore b/.gitignore index 3bfd06a..7f88f88 100644 --- a/.gitignore +++ b/.gitignore @@ -121,7 +121,7 @@ integration-tests/*.db .AppleDouble .LSOverride -# Icon must end with two +# Icon must end with two Icon @@ -197,6 +197,7 @@ Cargo.lock *.node auth.d.ts +limits.d.ts compat.d.ts promise.d.ts sqlite-error.d.ts diff --git a/compat.js b/compat.js index f3f922d..82e2ab7 100644 --- a/compat.js +++ b/compat.js @@ -3,6 +3,7 @@ const { Database: NativeDb, databasePrepareSync, databaseSyncSync, databaseExecSync, statementRunSync, statementGetSync, statementIterateSync, iteratorNextSync } = require("./index.js"); const SqliteError = require("./sqlite-error.js"); const { Authorization, Action } = require("./auth"); +const { Limit } = require("./limits"); function convertError(err) { // Handle errors from Rust with JSON-encoded message @@ -290,6 +291,36 @@ class Database { return this; } + /** + * Returns the current value of a run-time limit for this connection. + * + * @param {number} id - The limit category, one of the `Limit` constants. + * @returns {number} + */ + limit(id) { + try { + return this.db.limit(id); + } catch (err) { + throw convertError(err); + } + } + + /** + * Changes a run-time limit for this connection and returns its previous value. + * + * @param {number} id - The limit category, one of the `Limit` constants. + * @param {number} value - The new limit. SQLite never raises a limit past its + * compile-time maximum, and a negative value leaves the limit unchanged. + * @returns {number} + */ + setLimit(id, value) { + try { + return this.db.setLimit(id, value); + } catch (err) { + throw convertError(err); + } + } + /** * Toggle 64-bit integer support. */ @@ -463,6 +494,7 @@ module.exports = Database; module.exports.SqliteError = SqliteError; module.exports.Authorization = Authorization; module.exports.Action = Action; +module.exports.Limit = Limit; function normalizeBatchMode(mode) { switch (String(mode).toLowerCase()) { diff --git a/docs/api.md b/docs/api.md index 195439e..db9c315 100644 --- a/docs/api.md +++ b/docs/api.md @@ -252,6 +252,38 @@ Pass `null` to remove the authorizer and allow all operations: db.authorizer(null); ``` +### limit(id) ⇒ number + +Returns the current value of a run-time limit for this connection. `id` is one of the `Limit` constants. + +### setLimit(id, value) ⇒ number + +Changes a run-time limit for this connection and returns its previous value. The limit applies only to this connection. + +SQLite never raises a limit past its compile-time maximum, so `setLimit()` can only lower a limit below that maximum. A negative `value` leaves the limit unchanged. + +| Param | Type | Description | +| ----- | ------------------- | -------------------------------------------- | +| id | number | The limit category, one of the `Limit` constants. | +| value | number | The new limit. | + +For example, to cap the size of any string or BLOB at 1 MiB: + +```javascript +const { Limit } = require('libsql'); + +db.setLimit(Limit.LENGTH, 1024 * 1024); + +// Throws SQLITE_TOOBIG. +db.prepare("SELECT zeroblob(2000000)").get(); +``` + +SQLite's `printf()` and `format()` functions return `NULL` instead of throwing when their result would exceed `Limit.LENGTH`. + +The `Limit` constants match SQLite's `SQLITE_LIMIT_*` values: `LENGTH`, `SQL_LENGTH`, `COLUMN`, `EXPR_DEPTH`, `COMPOUND_SELECT`, `VDBE_OP`, `FUNCTION_ARG`, `ATTACHED`, `LIKE_PATTERN_LENGTH`, `VARIABLE_NUMBER`, `TRIGGER_DEPTH` and `WORKER_THREADS`. See the [SQLite documentation](https://sqlite.org/c3ref/c_limit_attached.html) for what each one controls. + +**Note:** This is an extension in libSQL and not available in `better-sqlite3`. + ### loadExtension(path, [entryPoint]) ⇒ this Loads a SQLite3 extension diff --git a/index.d.ts b/index.d.ts index f78f970..0dc4550 100644 --- a/index.d.ts +++ b/index.d.ts @@ -153,6 +153,27 @@ export declare class Database { * * `env` - The environment. */ interrupt(): void + /** + * Returns the current value of a run-time limit for this connection. + * + * # Arguments + * + * * `id` - The limit category, one of the `Limit` constants. + */ + limit(id: number): number + /** + * Changes a run-time limit for this connection and returns its previous value. + * + * SQLite caps the new value at the limit's compile-time maximum, so this can + * lower a limit but never raise it past that maximum. A negative value leaves + * the limit unchanged. + * + * # Arguments + * + * * `id` - The limit category, one of the `Limit` constants. + * * `value` - The new limit. + */ + setLimit(id: number, value: number): number /** Closes the database connection. */ close(): void /** diff --git a/integration-tests/tests/async.test.js b/integration-tests/tests/async.test.js index f6536a7..abbd3d4 100644 --- a/integration-tests/tests/async.test.js +++ b/integration-tests/tests/async.test.js @@ -987,6 +987,26 @@ test.serial("Database.batch() rejects non-array argument", async (t) => { await t.throwsAsync(() => db.batch("SELECT 1"), { instanceOf: TypeError }); }); +test.serial("Database.setLimit() lowers the string length limit", async (t) => { + const db = t.context.db; + const { Limit } = await import("libsql/promise"); + const bigFormat = "SELECT length(format('%2000000s', 'x')) AS n"; + t.is((await (await db.prepare(bigFormat)).get()).n, 2000000); + + const defaultLength = db.limit(Limit.LENGTH); + t.is(db.setLimit(Limit.LENGTH, 1024 * 1024), defaultLength); + t.is(db.limit(Limit.LENGTH), 1024 * 1024); + + t.is((await (await db.prepare(bigFormat)).get()).n, null); + await t.throwsAsync( + async () => { + const stmt = await db.prepare("SELECT zeroblob(2000000)"); + await stmt.get(); + }, + { instanceOf: t.context.errorType, code: "SQLITE_TOOBIG" } + ); +}); + const connect = async (path_opt, options = {}) => { const path = path_opt ?? "hello.db"; const provider = process.env.PROVIDER; diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js index 09d8265..7086b6d 100644 --- a/integration-tests/tests/extensions.test.js +++ b/integration-tests/tests/extensions.test.js @@ -1,5 +1,5 @@ import test from "ava"; -import { Authorization, Action } from "libsql"; +import { Authorization, Action, Limit } from "libsql"; test.serial("Statement.run() returning duration", async (t) => { const db = t.context.db; @@ -550,6 +550,46 @@ test.serial("Accessor: blocks subquery escape to underlying table", async (t) => // ---- Setup ---- +// ---- Limits ---- + +test.serial("Database.setLimit() lowers the string length limit", async (t) => { + const db = t.context.db; + const bigFormat = "SELECT length(format('%2000000s', 'x')) AS n"; + t.is(db.prepare(bigFormat).get().n, 2000000); + + const defaultLength = db.limit(Limit.LENGTH); + t.is(db.setLimit(Limit.LENGTH, 1024 * 1024), defaultLength); + t.is(db.limit(Limit.LENGTH), 1024 * 1024); + + // SQLite's printf() family stops at the limit and returns NULL. Other + // functions fail with SQLITE_TOOBIG. + t.is(db.prepare(bigFormat).get().n, null); + t.throws(() => db.prepare("SELECT zeroblob(2000000)").get(), { + instanceOf: t.context.errorType, + code: "SQLITE_TOOBIG", + }); + t.is(db.prepare("SELECT length(format('%1000s', 'x')) AS n").get().n, 1000); +}); + +test.serial("Database.setLimit() with a negative value leaves the limit unchanged", async (t) => { + const db = t.context.db; + db.setLimit(Limit.LENGTH, 4096); + t.is(db.setLimit(Limit.LENGTH, -1), 4096); + t.is(db.limit(Limit.LENGTH), 4096); +}); + +test.serial("Database.limit() rejects unknown limit IDs", async (t) => { + const db = t.context.db; + t.throws(() => db.limit(99), { message: /Invalid limit '99'/ }); + t.throws(() => db.setLimit(-5, 10), { message: /Invalid limit '-5'/ }); +}); + +test.serial("Database.limit() throws after close", async (t) => { + const [db] = await connect(":memory:"); + db.close(); + t.throws(() => db.limit(Limit.LENGTH), { instanceOf: TypeError }); +}); + const connect = async (path_opt) => { const path = path_opt ?? "hello.db"; const x = await import("libsql"); diff --git a/limits.js b/limits.js new file mode 100644 index 0000000..1c656de --- /dev/null +++ b/limits.js @@ -0,0 +1,38 @@ +/** + * SQLite run-time limit categories, for use with `Database.limit()` and + * `Database.setLimit()`. + * + * The values match SQLite's `SQLITE_LIMIT_*` constants. See + * https://sqlite.org/c3ref/c_limit_attached.html for details. + * + * @readonly + * @enum {number} + */ +const Limit = { + /** The maximum size of any string or BLOB or table row, in bytes. */ + LENGTH: 0, + /** The maximum length of an SQL statement, in bytes. */ + SQL_LENGTH: 1, + /** The maximum number of columns in a table, index, result set, ORDER BY or GROUP BY. */ + COLUMN: 2, + /** The maximum depth of the parse tree on any expression. */ + EXPR_DEPTH: 3, + /** The maximum number of terms in a compound SELECT statement. */ + COMPOUND_SELECT: 4, + /** The maximum number of instructions in a virtual machine program. */ + VDBE_OP: 5, + /** The maximum number of arguments on a function. */ + FUNCTION_ARG: 6, + /** The maximum number of attached databases. */ + ATTACHED: 7, + /** The maximum length of the pattern argument to LIKE or GLOB. */ + LIKE_PATTERN_LENGTH: 8, + /** The maximum index number of any parameter in an SQL statement. */ + VARIABLE_NUMBER: 9, + /** The maximum depth of recursion for triggers. */ + TRIGGER_DEPTH: 10, + /** The maximum number of auxiliary worker threads per prepared statement. */ + WORKER_THREADS: 11, +}; + +module.exports = { Limit }; diff --git a/package.json b/package.json index b2963a6..97fb155 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "compat.js", "index.d.ts", "index.js", + "limits.d.ts", + "limits.js", "promise.d.ts", "promise.js", "sqlite-error.d.ts", diff --git a/promise.js b/promise.js index f507a52..c1a7607 100644 --- a/promise.js +++ b/promise.js @@ -3,6 +3,7 @@ const { Database: NativeDb, connect: nativeConnect } = require("./index.js"); const SqliteError = require("./sqlite-error.js"); const { Authorization, Action } = require("./auth"); +const { Limit } = require("./limits"); /** * @import {Options as NativeOptions, Statement as NativeStatement} from './index.js' @@ -382,6 +383,36 @@ class Database { return this; } + /** + * Returns the current value of a run-time limit for this connection. + * + * @param {number} id - The limit category, one of the `Limit` constants. + * @returns {number} + */ + limit(id) { + try { + return this.db.limit(id); + } catch (err) { + throw convertError(err); + } + } + + /** + * Changes a run-time limit for this connection and returns its previous value. + * + * @param {number} id - The limit category, one of the `Limit` constants. + * @param {number} value - The new limit. SQLite never raises a limit past its + * compile-time maximum, and a negative value leaves the limit unchanged. + * @returns {number} + */ + setLimit(id, value) { + try { + return this.db.setLimit(id, value); + } catch (err) { + throw convertError(err); + } + } + /** * Toggle 64-bit integer support. * @param {boolean} [toggle] - Whether to use safe integers by default. @@ -590,6 +621,7 @@ module.exports = { Action, Authorization, Database, + Limit, SqliteError, Statement, connect, diff --git a/src/lib.rs b/src/lib.rs index d4c0270..cc57508 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -688,6 +688,41 @@ impl Database { Ok(()) } + /// Returns the current value of a run-time limit for this connection. + /// + /// # Arguments + /// + /// * `id` - The limit category, one of the `Limit` constants. + #[napi] + pub fn limit(&self, env: Env, id: i32) -> Result { + let conn = match &self.conn { + Some(conn) => conn.clone(), + None => return Err(throw_database_closed_error(&env)), + }; + let limit = int_to_limit(id)?; + Ok(conn.limit(limit).map_err(Error::from)?) + } + + /// Changes a run-time limit for this connection and returns its previous value. + /// + /// SQLite caps the new value at the limit's compile-time maximum, so this can + /// lower a limit but never raise it past that maximum. A negative value leaves + /// the limit unchanged. + /// + /// # Arguments + /// + /// * `id` - The limit category, one of the `Limit` constants. + /// * `value` - The new limit. + #[napi] + pub fn set_limit(&self, env: Env, id: i32, value: i32) -> Result { + let conn = match &self.conn { + Some(conn) => conn.clone(), + None => return Err(throw_database_closed_error(&env)), + }; + let limit = int_to_limit(id)?; + Ok(conn.set_limit(limit, value).map_err(Error::from)?) + } + /// Closes the database connection. #[napi] pub fn close(&mut self) -> Result<()> { @@ -708,6 +743,26 @@ impl Database { } } +fn int_to_limit(val: i32) -> Result { + match val { + 0 => Ok(libsql::Limit::Length), + 1 => Ok(libsql::Limit::SqlLength), + 2 => Ok(libsql::Limit::Column), + 3 => Ok(libsql::Limit::ExprDepth), + 4 => Ok(libsql::Limit::CompoundSelect), + 5 => Ok(libsql::Limit::VdbeOp), + 6 => Ok(libsql::Limit::FunctionArg), + 7 => Ok(libsql::Limit::Attached), + 8 => Ok(libsql::Limit::LikePatternLength), + 9 => Ok(libsql::Limit::VariableNumber), + 10 => Ok(libsql::Limit::TriggerDepth), + 11 => Ok(libsql::Limit::WorkerThreads), + _ => Err(napi::Error::from_reason(format!( + "Invalid limit '{val}'. Expected one of the Limit constants (0 to 11).", + ))), + } +} + fn int_to_authorization(val: i32) -> Result { match val { 0 => Ok(libsql::Authorization::Allow),