Repository: nodejs/node
Pull request: sqlite: add limits property to DatabaseSync

Fixes: #61268

add limits property to databaseSync

Changed files:
filename: doc/api/sqlite.md
patch: @@ -163,6 +163,23 @@ changes:
     language features that allow ordinary SQL to deliberately corrupt the database file are disabled.
     The defensive flag can also be set using `enableDefensive()`.
     **Default:** `true`.
+  * `limits` {Object} Configuration for various SQLite limits. These limits
+    can be used to prevent excessive resource consumption when handling
+    potentially malicious input. See [Run-Time Limits][] and [Limit Constants][]
+    in the SQLite documentation for details. Default values are determined by
+    SQLite's compile-time defaults and may vary depending on how SQLite was
+    built. The following properties are supported:
+    * `length` {number} Maximum length of a string or BLOB.
+    * `sqlLength` {number} Maximum length of an SQL statement.
+    * `column` {number} Maximum number of columns.
+    * `exprDepth` {number} Maximum depth of an expression tree.
+    * `compoundSelect` {number} Maximum number of terms in a compound SELECT.
+    * `vdbeOp` {number} Maximum number of VDBE instructions.
+    * `functionArg` {number} Maximum number of function arguments.
+    * `attach` {number} Maximum number of attached databases.
+    * `likePatternLength` {number} Maximum length of a LIKE pattern.
+    * `variableNumber` {number} Maximum number of SQL variables.
+    * `triggerDepth` {number} Maximum trigger recursion depth.
 
 Constructs a new `DatabaseSync` instance.
 
@@ -451,6 +468,36 @@ added:
 * Type: {boolean} Whether the database is currently within a transaction. This method
   is a wrapper around [`sqlite3_get_autocommit()`][].
 
+### `database.limits`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {Object}
+
+An object for getting and setting SQLite database limits at runtime.
+Each property corresponds to an SQLite limit and can be read or written.
+
+```js
+const db = new DatabaseSync(':memory:');
+
+// Read current limit
+console.log(db.limits.length);
+
+// Set a new limit
+db.limits.sqlLength = 100000;
+
+// Reset a limit to its compile-time maximum
+db.limits.sqlLength = Infinity;
+```
+
+Available properties: `length`, `sqlLength`, `column`, `exprDepth`,
+`compoundSelect`, `vdbeOp`, `functionArg`, `attach`, `likePatternLength`,
+`variableNumber`, `triggerDepth`.
+
+Setting a property to `Infinity` resets the limit to its compile-time maximum value.
+
 ### `database.open()`
 
 <!-- YAML
@@ -1478,6 +1525,8 @@ callback function to indicate what type of operation is being authorized.
 [Changesets and Patchsets]: https://www.sqlite.org/sessionintro.html#changesets_and_patchsets
 [Constants Passed To The Conflict Handler]: https://www.sqlite.org/session/c_changeset_conflict.html
 [Constants Returned From The Conflict Handler]: https://www.sqlite.org/session/c_changeset_abort.html
+[Limit Constants]: https://www.sqlite.org/c3ref/c_limit_attached.html
+[Run-Time Limits]: https://www.sqlite.org/c3ref/limit.html
 [SQL injection]: https://en.wikipedia.org/wiki/SQL_injection
 [Type conversion between JavaScript and SQLite]: #type-conversion-between-javascript-and-sqlite
 [`ATTACH DATABASE`]: https://www.sqlite.org/lang_attach.html

filename: src/env_properties.h
patch: @@ -237,6 +237,7 @@
   V(kind_string, "kind")                                                       \
   V(last_insert_rowid_string, "lastInsertRowid")                               \
   V(length_string, "length")                                                   \
+  V(limits_string, "limits")                                                   \
   V(library_string, "library")                                                 \
   V(loop_count, "loopCount")                                                   \
   V(max_buffer_string, "maxBuffer")                                            \
@@ -435,6 +436,7 @@
   V(socketaddress_constructor_template, v8::FunctionTemplate)                  \
   V(space_stats_template, v8::DictionaryTemplate)                              \
   V(sqlite_column_template, v8::DictionaryTemplate)                            \
+  V(sqlite_limits_template, v8::ObjectTemplate)                                \
   V(sqlite_run_result_template, v8::DictionaryTemplate)                        \
   V(sqlite_statement_sync_constructor_template, v8::FunctionTemplate)          \
   V(sqlite_statement_sync_iterator_constructor_template, v8::FunctionTemplate) \

filename: src/node_sqlite.cc
patch: @@ -12,7 +12,10 @@
 #include "threadpoolwork-inl.h"
 #include "util-inl.h"
 
+#include <array>
 #include <cinttypes>
+#include <cmath>
+#include <limits>
 
 namespace node {
 namespace sqlite {
@@ -36,19 +39,24 @@ using v8::Global;
 using v8::HandleScope;
 using v8::Int32;
 using v8::Integer;
+using v8::Intercepted;
 using v8::Isolate;
 using v8::JustVoid;
 using v8::Local;
 using v8::LocalVector;
 using v8::Maybe;
 using v8::MaybeLocal;
 using v8::Name;
+using v8::NamedPropertyHandlerConfiguration;
 using v8::NewStringType;
 using v8::Nothing;
 using v8::Null;
 using v8::Number;
 using v8::Object;
+using v8::ObjectTemplate;
 using v8::Promise;
+using v8::PropertyCallbackInfo;
+using v8::PropertyHandlerFlags;
 using v8::SideEffectType;
 using v8::String;
 using v8::TryCatch;
@@ -133,6 +141,16 @@ Local<DictionaryTemplate> getLazyIterTemplate(Environment* env) {
 }
 }  // namespace
 
+// Helper function to find limit info from JS property name
+static constexpr const LimitInfo* GetLimitInfoFromName(std::string_view name) {
+  for (const auto& info : kLimitMapping) {
+    if (name == info.js_name) {
+      return &info;
+    }
+  }
+  return nullptr;
+}
+
 inline MaybeLocal<Object> CreateSQLiteError(Isolate* isolate,
                                             const char* message) {
   Local<String> js_msg;
@@ -668,6 +686,175 @@ void UserDefinedFunction::xDestroy(void* self) {
   delete static_cast<UserDefinedFunction*>(self);
 }
 
+DatabaseSyncLimits::DatabaseSyncLimits(Environment* env,
+                                       Local<Object> object,
+                                       BaseObjectWeakPtr<DatabaseSync> database)
+    : BaseObject(env, object), database_(std::move(database)) {
+  MakeWeak();
+}
+
+DatabaseSyncLimits::~DatabaseSyncLimits() = default;
+
+void DatabaseSyncLimits::MemoryInfo(MemoryTracker* tracker) const {
+  tracker->TrackField("database", database_);
+}
+
+Local<ObjectTemplate> DatabaseSyncLimits::GetTemplate(Environment* env) {
+  Local<ObjectTemplate> tmpl = env->sqlite_limits_template();
+  if (!tmpl.IsEmpty()) return tmpl;
+
+  Isolate* isolate = env->isolate();
+  tmpl = ObjectTemplate::New(isolate);
+  tmpl->SetInternalFieldCount(DatabaseSyncLimits::kInternalFieldCount);
+  tmpl->SetHandler(NamedPropertyHandlerConfiguration(
+      LimitsGetter,
+      LimitsSetter,
+      LimitsQuery,
+      nullptr,  // deleter - not allowed
+      LimitsEnumerator,
+      nullptr,  // definer
+      nullptr,
+      Local<Value>(),
+      PropertyHandlerFlags::kHasNoSideEffect));
+
+  env->set_sqlite_limits_template(tmpl);
+  return tmpl;
+}
+
+Intercepted DatabaseSyncLimits::LimitsGetter(
+    Local<Name> property, const PropertyCallbackInfo<Value>& info) {
+  // Skip symbols
+  if (!property->IsString()) {
+    return Intercepted::kNo;
+  }
+
+  DatabaseSyncLimits* limits;
+  ASSIGN_OR_RETURN_UNWRAP(&limits, info.This(), Intercepted::kNo);
+
+  Environment* env = limits->env();
+  Isolate* isolate = env->isolate();
+
+  Utf8Value prop_name(isolate, property);
+  const LimitInfo* limit_info = GetLimitInfoFromName(prop_name.ToStringView());
+
+  if (limit_info == nullptr) {
+    return Intercepted::kNo;  // Unknown property, let default handling occur
+  }
+
+  if (!limits->database_ || !limits->database_->IsOpen()) {
+    THROW_ERR_INVALID_STATE(env, "database is not open");
+    return Intercepted::kYes;
+  }
+
+  int current_value = sqlite3_limit(
+      limits->database_->Connection(), limit_info->sqlite_limit_id, -1);
+  info.GetReturnValue().Set(Integer::New(isolate, current_value));
+  return Intercepted::kYes;
+}
+
+Intercepted DatabaseSyncLimits::LimitsSetter(
+    Local<Name> property,
+    Local<Value> value,
+    const PropertyCallbackInfo<void>& info) {
+  if (!property->IsString()) {
+    return Intercepted::kNo;
+  }
+
+  DatabaseSyncLimits* limits;
+  ASSIGN_OR_RETURN_UNWRAP(&limits, info.This(), Intercepted::kNo);
+
+  Environment* env = limits->env();
+  Isolate* isolate = env->isolate();
+
+  Utf8Value prop_name(isolate, property);
+  const LimitInfo* limit_info = GetLimitInfoFromName(prop_name.ToStringView());
+
+  if (limit_info == nullptr) {
+    return Intercepted::kNo;
+  }
+
+  if (!limits->database_ || !limits->database_->IsOpen()) {
+    THROW_ERR_INVALID_STATE(env, "database is not open");
+    return Intercepted::kYes;
+  }
+
+  if (!value->IsNumber()) {
+    THROW_ERR_INVALID_ARG_TYPE(
+        isolate, "Limit value must be a non-negative integer or Infinity.");
+    return Intercepted::kYes;
+  }
+
+  const double num_value = value.As<Number>()->Value();
+  int new_value;
+
+  if (std::isinf(num_value) && num_value > 0) {
+    // Positive Infinity resets the limit to the compile-time maximum
+    new_value = std::numeric_limits<int>::max();
+  } else if (!value->IsInt32()) {
+    THROW_ERR_INVALID_ARG_TYPE(
+        isolate, "Limit value must be a non-negative integer or Infinity.");
+    return Intercepted::kYes;
+  } else {
+    new_value = value.As<Int32>()->Value();
+    if (new_value < 0) {
+      THROW_ERR_OUT_OF_RANGE(isolate, "Limit value must be non-negative.");
+      return Intercepted::kYes;
+    }
+  }
+
+  sqlite3_limit(
+      limits->database_->Connection(), limit_info->sqlite_limit_id, new_value);
+  return Intercepted::kYes;
+}
+
+Intercepted DatabaseSyncLimits::LimitsQuery(
+    Local<Name> property, const PropertyCallbackInfo<Integer>& info) {
+  if (!property->IsString()) {
+    return Intercepted::kNo;
+  }
+
+  Isolate* isolate = info.GetIsolate();
+  Utf8Value prop_name(isolate, property);
+  const LimitInfo* limit_info = GetLimitInfoFromName(prop_name.ToStringView());
+
+  if (!limit_info) {
+    return Intercepted::kNo;
+  }
+
+  // Property exists and is writable
+  info.GetReturnValue().Set(
+      Integer::New(isolate, v8::PropertyAttribute::DontDelete));
+  return Intercepted::kYes;
+}
+
+void DatabaseSyncLimits::LimitsEnumerator(
+    const PropertyCallbackInfo<Array>& info) {
+  Isolate* isolate = info.GetIsolate();
+  LocalVector<Value> names(isolate);
+
+  for (const auto& [js_name, sqlite_limit_id] : kLimitMapping) {
+    Local<String> name;
+    if (!String::NewFromUtf8(
+             isolate, js_name.data(), NewStringType::kNormal, js_name.size())
+             .ToLocal(&name)) {
+      return;
+    }
+    names.push_back(name);
+  }
+
+  info.GetReturnValue().Set(Array::New(isolate, names.data(), names.size()));
+}
+
+BaseObjectPtr<DatabaseSyncLimits> DatabaseSyncLimits::Create(
+    Environment* env, BaseObjectWeakPtr<DatabaseSync> database) {
+  Local<Object> obj;
+  if (!GetTemplate(env)->NewInstance(env->context()).ToLocal(&obj)) {
+    return nullptr;
+  }
+
+  return MakeBaseObject<DatabaseSyncLimits>(env, obj, std::move(database));
+}
+
 DatabaseSync::DatabaseSync(Environment* env,
                            Local<Object> object,
                            DatabaseOpenConfiguration&& open_config,
@@ -766,6 +953,14 @@ bool DatabaseSync::Open() {
 
   sqlite3_busy_timeout(connection_, open_config_.get_timeout());
 
+  // Apply initial limits
+  for (const auto& [js_name, sqlite_limit_id] : kLimitMapping) {
+    const auto& limit_value = open_config_.initial_limits()[sqlite_limit_id];
+    if (limit_value.has_value()) {
+      sqlite3_limit(connection_, sqlite_limit_id, *limit_value);
+    }
+  }
+
   if (allow_load_extension_) {
     if (env()->permission()->enabled()) [[unlikely]] {
       THROW_ERR_LOAD_SQLITE_EXTENSION(env(),
@@ -1091,6 +1286,59 @@ void DatabaseSync::New(const FunctionCallbackInfo<Value>& args) {
       }
       open_config.set_enable_defensive(defensive_v.As<Boolean>()->Value());
     }
+
+    // Parse limits option
+    Local<Value> limits_v;
+    if (!options->Get(env->context(), env->limits_string())
+             .ToLocal(&limits_v)) {
+      return;
+    }
+    if (!limits_v->IsUndefined()) {
+      if (!limits_v->IsObject()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.limits\" argument must be an object.");
+        return;
+      }
+
+      Local<Object> limits_obj = limits_v.As<Object>();
+
+      // Iterate through known limit names and extract values
+      for (const auto& [js_name, sqlite_limit_id] : kLimitMapping) {
+        Local<String> key;
+        if (!String::NewFromUtf8(env->isolate(),
+                                 js_name.data(),
+                                 NewStringType::kNormal,
+                                 js_name.size())
+                 .ToLocal(&key)) {
+          return;
+        }
+
+        Local<Value> val;
+        if (!limits_obj->Get(env->context(), key).ToLocal(&val)) {
+          return;
+        }
+
+        if (!val->IsUndefined()) {
+          if (!val->IsInt32()) {
+            std::string msg = "The \"options.limits." + std::string(js_name) +
+                              "\" argument must be an integer.";
+            THROW_ERR_INVALID_ARG_TYPE(env->isolate(), msg);
+            return;
+          }
+
+          int limit_val = val.As<Int32>()->Value();
+          if (limit_val < 0) {
+            std::string msg = "The \"options.limits." + std::string(js_name) +
+                              "\" argument must be non-negative.";
+            THROW_ERR_OUT_OF_RANGE(env->isolate(), msg);
+            return;
+          }
+
+          open_config.set_initial_limit(sqlite_limit_id, limit_val);
+        }
+      }
+    }
   }
 
   new DatabaseSync(
@@ -1118,6 +1366,26 @@ void DatabaseSync::IsTransactionGetter(
   args.GetReturnValue().Set(sqlite3_get_autocommit(db->connection_) == 0);
 }
 
+void DatabaseSync::LimitsGetter(const FunctionCallbackInfo<Value>& args) {
+  DatabaseSync* db;
+  ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
+  Environment* env = Environment::GetCurrent(args);
+
+  Local<Value> limits_val =
+      db->object()->GetInternalField(kLimitsObject).template As<Value>();
+
+  if (limits_val->IsUndefined()) {
+    BaseObjectPtr<DatabaseSyncLimits> limits =
+        DatabaseSyncLimits::Create(env, BaseObjectWeakPtr<DatabaseSync>(db));
+    if (limits) {
+      db->object()->SetInternalField(kLimitsObject, limits->object());
+      args.GetReturnValue().Set(limits->object());
+    }
+  } else {
+    args.GetReturnValue().Set(limits_val);
+  }
+}
+
 void DatabaseSync::Close(const FunctionCallbackInfo<Value>& args) {
   DatabaseSync* db;
   ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
@@ -3496,6 +3764,10 @@ static void Initialize(Local<Object> target,
                           db_tmpl,
                           FIXED_ONE_BYTE_STRING(isolate, "isTransaction"),
                           DatabaseSync::IsTransactionGetter);
+  SetSideEffectFreeGetter(isolate,
+                          db_tmpl,
+                          FIXED_ONE_BYTE_STRING(isolate, "limits"),
+                          DatabaseSync::LimitsGetter);
   Local<String> sqlite_type_key = FIXED_ONE_BYTE_STRING(isolate, "sqlite-type");
   Local<v8::Symbol> sqlite_type_symbol =
       v8::Symbol::For(isolate, sqlite_type_key);

filename: src/node_sqlite.h
patch: @@ -9,13 +9,48 @@
 #include "sqlite3.h"
 #include "util.h"
 
+#include <array>
 #include <list>
 #include <map>
+#include <optional>
+#include <string_view>
 #include <unordered_set>
 
 namespace node {
 namespace sqlite {
 
+// Mapping from JavaScript property names to SQLite limit constants
+struct LimitInfo {
+  std::string_view js_name;
+  int sqlite_limit_id;
+};
+
+inline constexpr std::array<LimitInfo, 11> kLimitMapping = {{
+    {"length", SQLITE_LIMIT_LENGTH},
+    {"sqlLength", SQLITE_LIMIT_SQL_LENGTH},
+    {"column", SQLITE_LIMIT_COLUMN},
+    {"exprDepth", SQLITE_LIMIT_EXPR_DEPTH},
+    {"compoundSelect", SQLITE_LIMIT_COMPOUND_SELECT},
+    {"vdbeOp", SQLITE_LIMIT_VDBE_OP},
+    {"functionArg", SQLITE_LIMIT_FUNCTION_ARG},
+    {"attach", SQLITE_LIMIT_ATTACHED},
+    {"likePatternLength", SQLITE_LIMIT_LIKE_PATTERN_LENGTH},
+    {"variableNumber", SQLITE_LIMIT_VARIABLE_NUMBER},
+    {"triggerDepth", SQLITE_LIMIT_TRIGGER_DEPTH},
+}};
+
+constexpr bool CheckLimitIndices() {
+  for (size_t i = 0; i < kLimitMapping.size(); ++i) {
+    if (kLimitMapping[i].sqlite_limit_id != static_cast<int>(i)) {
+      return false;
+    }
+  }
+  return true;
+}
+static_assert(
+    CheckLimitIndices(),
+    "Each kLimitMapping entry's sqlite_limit_id must match its index");
+
 class DatabaseOpenConfiguration {
  public:
   explicit DatabaseOpenConfiguration(std::string&& location)
@@ -69,6 +104,15 @@ class DatabaseOpenConfiguration {
 
   inline bool get_enable_defensive() const { return defensive_; }
 
+  inline void set_initial_limit(int sqlite_limit_id, int value) {
+    initial_limits_.at(sqlite_limit_id) = value;
+  }
+
+  inline const std::array<std::optional<int>, kLimitMapping.size()>&
+  initial_limits() const {
+    return initial_limits_;
+  }
+
  private:
   std::string location_;
   bool read_only_ = false;
@@ -80,9 +124,11 @@ class DatabaseOpenConfiguration {
   bool allow_bare_named_params_ = true;
   bool allow_unknown_named_params_ = false;
   bool defensive_ = true;
+  std::array<std::optional<int>, kLimitMapping.size()> initial_limits_{};
 };
 
 class DatabaseSync;
+class DatabaseSyncLimits;
 class StatementSyncIterator;
 class StatementSync;
 class BackupJob;
@@ -118,6 +164,7 @@ class DatabaseSync : public BaseObject {
  public:
   enum InternalFields {
     kAuthorizerCallback = BaseObject::kInternalFieldCount,
+    kLimitsObject,
     kInternalFieldCount
   };
 
@@ -146,6 +193,7 @@ class DatabaseSync : public BaseObject {
   static void EnableLoadExtension(
       const v8::FunctionCallbackInfo<v8::Value>& args);
   static void EnableDefensive(const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void LimitsGetter(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void LoadExtension(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void SetAuthorizer(const v8::FunctionCallbackInfo<v8::Value>& args);
   static int AuthorizerCallback(void* user_data,
@@ -195,6 +243,7 @@ class DatabaseSync : public BaseObject {
   std::set<sqlite3_session*> sessions_;
   std::unordered_set<StatementSync*> statements_;
 
+  friend class DatabaseSyncLimits;
   friend class Session;
   friend class SQLTagStore;
   friend class StatementExecutionHelper;
@@ -356,6 +405,37 @@ class UserDefinedFunction {
   bool use_bigint_args_;
 };
 
+class DatabaseSyncLimits : public BaseObject {
+ public:
+  DatabaseSyncLimits(Environment* env,
+                     v8::Local<v8::Object> object,
+                     BaseObjectWeakPtr<DatabaseSync> database);
+  ~DatabaseSyncLimits() override;
+
+  void MemoryInfo(MemoryTracker* tracker) const override;
+  static v8::Local<v8::ObjectTemplate> GetTemplate(Environment* env);
+  static BaseObjectPtr<DatabaseSyncLimits> Create(
+      Environment* env, BaseObjectWeakPtr<DatabaseSync> database);
+
+  static v8::Intercepted LimitsGetter(
+      v8::Local<v8::Name> property,
+      const v8::PropertyCallbackInfo<v8::Value>& info);
+  static v8::Intercepted LimitsSetter(
+      v8::Local<v8::Name> property,
+      v8::Local<v8::Value> value,
+      const v8::PropertyCallbackInfo<void>& info);
+  static v8::Intercepted LimitsQuery(
+      v8::Local<v8::Name> property,
+      const v8::PropertyCallbackInfo<v8::Integer>& info);
+  static void LimitsEnumerator(const v8::PropertyCallbackInfo<v8::Array>& info);
+
+  SET_MEMORY_INFO_NAME(DatabaseSyncLimits)
+  SET_SELF_SIZE(DatabaseSyncLimits)
+
+ private:
+  BaseObjectWeakPtr<DatabaseSync> database_;
+};
+
 }  // namespace sqlite
 }  // namespace node
 

filename: test/parallel/test-sqlite-limits.js
patch: @@ -0,0 +1,304 @@
+'use strict';
+const { skipIfSQLiteMissing } = require('../common');
+skipIfSQLiteMissing();
+const { DatabaseSync } = require('node:sqlite');
+const { suite, test } = require('node:test');
+
+suite('DatabaseSync limits', () => {
+  test('limits object has expected properties with positive values', (t) => {
+    const db = new DatabaseSync(':memory:');
+    const expectedProperties = [
+      'length',
+      'sqlLength',
+      'column',
+      'exprDepth',
+      'compoundSelect',
+      'vdbeOp',
+      'functionArg',
+      'attach',
+      'likePatternLength',
+      'variableNumber',
+      'triggerDepth',
+    ];
+
+    for (const prop of expectedProperties) {
+      t.assert.strictEqual(typeof db.limits[prop], 'number',
+                           `${prop} should be a number`);
+      t.assert.ok(db.limits[prop] > 0,
+                  `${prop} should be positive`);
+    }
+  });
+
+  test('constructor accepts limits option', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        length: 500000,
+        sqlLength: 50000,
+        column: 100,
+        exprDepth: 50,
+        compoundSelect: 10,
+        vdbeOp: 10000,
+        functionArg: 8,
+        attach: 5,
+        likePatternLength: 1000,
+        variableNumber: 100,
+        triggerDepth: 5,
+      }
+    });
+
+    t.assert.strictEqual(db.limits.length, 500000);
+    t.assert.strictEqual(db.limits.sqlLength, 50000);
+    t.assert.strictEqual(db.limits.column, 100);
+    t.assert.strictEqual(db.limits.exprDepth, 50);
+    t.assert.strictEqual(db.limits.compoundSelect, 10);
+    t.assert.strictEqual(db.limits.vdbeOp, 10000);
+    t.assert.strictEqual(db.limits.functionArg, 8);
+    t.assert.strictEqual(db.limits.attach, 5);
+    t.assert.strictEqual(db.limits.likePatternLength, 1000);
+    t.assert.strictEqual(db.limits.variableNumber, 100);
+    t.assert.strictEqual(db.limits.triggerDepth, 5);
+  });
+
+  test('getter returns current limit value', (t) => {
+    const db = new DatabaseSync(':memory:');
+    t.assert.strictEqual(typeof db.limits.length, 'number');
+    t.assert.ok(db.limits.length > 0);
+    t.assert.strictEqual(typeof db.limits.sqlLength, 'number');
+    t.assert.ok(db.limits.sqlLength > 0);
+  });
+
+  test('setter modifies limit value', (t) => {
+    const db = new DatabaseSync(':memory:');
+
+    db.limits.length = 100000;
+    t.assert.strictEqual(db.limits.length, 100000);
+
+    db.limits.sqlLength = 50000;
+    t.assert.strictEqual(db.limits.sqlLength, 50000);
+
+    db.limits.column = 50;
+    t.assert.strictEqual(db.limits.column, 50);
+  });
+
+  test('Infinity resets limit to maximum', (t) => {
+    const db = new DatabaseSync(':memory:');
+    const originalLength = db.limits.length;
+
+    // Set to a lower value
+    db.limits.length = 100;
+    t.assert.strictEqual(db.limits.length, 100);
+
+    // Reset to maximum using Infinity
+    db.limits.length = Infinity;
+    t.assert.strictEqual(db.limits.length, originalLength);
+  });
+
+  test('throws on invalid argument type', (t) => {
+    const db = new DatabaseSync(':memory:');
+    t.assert.throws(() => {
+      db.limits.length = 'invalid';
+    }, {
+      name: 'TypeError',
+      message: /Limit value must be a non-negative integer or Infinity/,
+    });
+  });
+
+  test('throws on negative value', (t) => {
+    const db = new DatabaseSync(':memory:');
+    t.assert.throws(() => {
+      db.limits.length = -1;
+    }, {
+      name: 'RangeError',
+      message: /Limit value must be non-negative/,
+    });
+  });
+
+  test('throws on null value', (t) => {
+    const db = new DatabaseSync(':memory:');
+    t.assert.throws(() => {
+      db.limits.length = null;
+    }, {
+      name: 'TypeError',
+      message: /Limit value must be a non-negative integer or Infinity/,
+    });
+  });
+
+  test('throws on negative Infinity', (t) => {
+    const db = new DatabaseSync(':memory:');
+    t.assert.throws(() => {
+      db.limits.length = -Infinity;
+    }, {
+      name: 'TypeError',
+      message: /Limit value must be a non-negative integer or Infinity/,
+    });
+  });
+
+  test('throws on getter access after close', (t) => {
+    const db = new DatabaseSync(':memory:');
+    db.close();
+    t.assert.throws(() => {
+      return db.limits.length;
+    }, {
+      code: 'ERR_INVALID_STATE',
+      message: /database is not open/,
+    });
+  });
+
+  test('throws on setter access after close', (t) => {
+    const db = new DatabaseSync(':memory:');
+    db.close();
+    t.assert.throws(() => {
+      db.limits.length = 100;
+    }, {
+      code: 'ERR_INVALID_STATE',
+      message: /database is not open/,
+    });
+  });
+
+  test('limits object is enumerable', (t) => {
+    const db = new DatabaseSync(':memory:');
+    const keys = Object.keys(db.limits);
+    t.assert.ok(keys.includes('length'));
+    t.assert.ok(keys.includes('sqlLength'));
+    t.assert.ok(keys.includes('column'));
+    t.assert.ok(keys.includes('exprDepth'));
+    t.assert.ok(keys.includes('compoundSelect'));
+    t.assert.ok(keys.includes('vdbeOp'));
+    t.assert.ok(keys.includes('functionArg'));
+    t.assert.ok(keys.includes('attach'));
+    t.assert.ok(keys.includes('likePatternLength'));
+    t.assert.ok(keys.includes('variableNumber'));
+    t.assert.ok(keys.includes('triggerDepth'));
+  });
+
+  test('throws on invalid limits option type', (t) => {
+    t.assert.throws(() => {
+      new DatabaseSync(':memory:', { limits: 'invalid' });
+    }, {
+      name: 'TypeError',
+      message: /options\.limits.*must be an object/,
+    });
+  });
+
+  test('throws on invalid limit value type in constructor', (t) => {
+    t.assert.throws(() => {
+      new DatabaseSync(':memory:', { limits: { length: 'invalid' } });
+    }, {
+      name: 'TypeError',
+      message: /options\.limits\.length.*must be an integer/,
+    });
+  });
+
+  test('throws on negative limit value in constructor', (t) => {
+    t.assert.throws(() => {
+      new DatabaseSync(':memory:', { limits: { length: -100 } });
+    }, {
+      name: 'RangeError',
+      message: /options\.limits\.length.*must be non-negative/,
+    });
+  });
+
+  test('throws on Infinity limit value in constructor', (t) => {
+    t.assert.throws(() => {
+      new DatabaseSync(':memory:', { limits: { length: Infinity } });
+    }, {
+      name: 'TypeError',
+      message: /options\.limits\.length.*must be an integer/,
+    });
+  });
+
+  test('partial limits in constructor', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        length: 100000,
+      }
+    });
+    t.assert.strictEqual(db.limits.length, 100000);
+    t.assert.strictEqual(typeof db.limits.sqlLength, 'number');
+  });
+
+  test('throws when exceeding column limit', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        column: 10,
+      }
+    });
+
+    db.exec('CREATE TABLE t1 (c1, c2, c3, c4, c5, c6, c7, c8, c9, c10)');
+
+    t.assert.throws(() => {
+      db.exec('CREATE TABLE t2 (c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11)');
+    }, {
+      message: /too many columns/,
+    });
+  });
+
+  test('throws when exceeding attach limit', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        attach: 0,
+      }
+    });
+
+    t.assert.throws(() => {
+      db.exec("ATTACH DATABASE ':memory:' AS db1");
+    }, {
+      message: /too many attached databases/,
+    });
+  });
+
+  test('throws when exceeding variable number limit', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        variableNumber: 2,
+      }
+    });
+
+    t.assert.throws(() => {
+      const stmt = db.prepare('SELECT ?, ?, ?');
+      stmt.all(1, 2, 3);
+    }, {
+      message: /too many SQL variables/,
+    });
+  });
+
+  test('throws when exceeding compound select limit', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        compoundSelect: 1,
+      }
+    });
+
+    t.assert.throws(() => {
+      db.exec('SELECT 1 UNION SELECT 2 UNION SELECT 3');
+    }, {
+      message: /too many terms in compound SELECT/,
+    });
+  });
+
+  test('throws when exceeding function arg limit', (t) => {
+    const db = new DatabaseSync(':memory:', {
+      limits: {
+        functionArg: 2,
+      }
+    });
+
+    t.assert.throws(() => {
+      db.exec('SELECT max(1, 2, 3)');
+    }, {
+      message: /too many arguments on function max/,
+    });
+  });
+
+  test('setter applies limit to SQLite immediately', (t) => {
+    const db = new DatabaseSync(':memory:');
+
+    db.limits.attach = 0;
+
+    t.assert.throws(() => {
+      db.exec("ATTACH DATABASE ':memory:' AS db1");
+    }, {
+      message: /too many attached databases/,
+    });
+  });
+});