From 980e26b0d9a34c46e06f7076d2841b17d7543348 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Fri, 3 Jul 2026 14:29:55 -0700 Subject: [PATCH 01/33] node:sqlite: implement the module and pass the Node v26.3.0 test suite Add a native `node:sqlite` module backed by the bundled SQLite, with the full Node-compatible API: - DatabaseSync: open/close, exec/prepare, function()/aggregate(), createSession()/applyChangeset(), backup(), setAuthorizer(), limit()/limits(), serialize()/deserialize(), tagged-template helper - StatementSync: run/get/all/iterate, columns(), expandedSQL, setReadBigInts/setAllowBareNamedParameters/setReturnArrays - Session: changeset()/patchset()/close() - constants and ERR_SQLITE_ERROR - process.versions.sqlite Bundle and always build the SQLite amalgamation (3.53.2, with the session and percentile extensions) so the module is available on every target, and fix a heap-use-after-free in Bun__closeAllSQLiteDatabasesForTermination (the handle was closed but not nulled, so the GC finalizer running during VM teardown closed it again). Close unclosed node:sqlite databases on process exit. DatabaseSync handles live only as GC cells and Bun does not destruct the VM on a normal exit, so a file-backed database the user never close()d never reached sqlite3_close_v2() and its WAL was never checkpointed, unlike Node and bun:sqlite. open() registers the handle (mapped to its owning VM, captured on the owning thread) and closeInternal() unregisters it; Bun__closeAllNodeSqliteDatabasesForTermination() walks the registry from the exit handler, filtering on the stored VM pointer so it never dereferences a cell another thread's heap may be sweeping. Busy connections are skipped by both the walker and the destructor: a process.exit() inside a UDF/authorizer gets here with sqlite3_step() still on the C stack, and closing that connection is a use-after-free. On that destructor branch the non-SQLite bookkeeping still runs (each session record is flagged dbGone, so the session destructor never follows a pointer into the swept database cell, and the handle is unregistered). Because sqlite3_close_v2 only zombifies a connection that still has un-finalized statements, the walker checkpoints the WAL explicitly first so the data lands in the main database file regardless. Use the sqlite3_bind_*64/sqlite3_result_*64 variants so a length over INT_MAX fails with SQLITE_TOOBIG instead of narrowing into a negative or wrapped int, and reject an oversize changeset before sqlite3changeset_apply (which has no 64-bit variant) allocates a copy of it. Vendor and pass the Node v26.3.0 `test-sqlite-*` suite, and make node:test's TestContext.mock return the MockTracker so the upstream tests can use `t.mock.fn()`. [skip size check] (bundling SQLite costs ~1.8 MB on darwin and ~200 KB elsewhere; previously it was lazily-loaded only for `bun:sqlite`). --- scripts/build/config.ts | 5 +- scripts/build/deps/sqlite.ts | 29 +- scripts/build/unified.ts | 1 + src/js/node/test.ts | 6 +- src/jsc/ErrorCode.rs | 6 +- src/jsc/VirtualMachine.rs | 2 + src/jsc/bindings/BunProcess.cpp | 2 + src/jsc/bindings/ErrorCode.ts | 1 + src/jsc/bindings/ZigGlobalObject.cpp | 64 + src/jsc/bindings/ZigGlobalObject.h | 6 + src/jsc/bindings/isBuiltinModule.cpp | 1 + src/jsc/bindings/sqlite/JSSQLStatement.cpp | 12 +- src/jsc/bindings/sqlite/NodeSqlite.cpp | 3827 +++++++++++++++++ src/jsc/bindings/sqlite/NodeSqlite.h | 821 ++++ src/jsc/bindings/sqlite/sqlite3.c | 1028 ++++- src/jsc/bindings/sqlite/sqlite3_local.h | 24 +- .../bindings/webcore/DOMClientIsoSubspaces.h | 6 + src/jsc/bindings/webcore/DOMIsoSubspaces.h | 6 + src/jsc/modules/NodeModuleModule.cpp | 1 + src/jsc/modules/NodeSqliteModule.h | 35 + src/jsc/modules/_NativeModule.h | 1 + src/resolve_builtins/HardcodedModule.rs | 4 + .../js/node/module/node-module-module.test.js | 2 +- test/js/node/process/process.test.js | 4 + test/js/node/sqlite/node-sqlite.test.ts | 1397 ++++++ test/js/node/test/common/index.js | 15 + test/js/node/test/common/index.mjs | 4 + .../test-sqlite-aggregate-function.mjs | 428 ++ .../node/test/parallel/test-sqlite-authz.js | 278 ++ .../node/test/parallel/test-sqlite-backup.mjs | 357 ++ .../node/test/parallel/test-sqlite-config.js | 63 + .../parallel/test-sqlite-custom-functions.js | 414 ++ .../test/parallel/test-sqlite-data-types.js | 196 + .../parallel/test-sqlite-database-sync.js | 525 +++ .../node/test/parallel/test-sqlite-limits.js | 304 ++ .../parallel/test-sqlite-named-parameters.js | 221 + .../test/parallel/test-sqlite-serialize.js | 305 ++ .../node/test/parallel/test-sqlite-session.js | 676 +++ .../test-sqlite-statement-sync-columns.js | 162 + .../parallel/test-sqlite-statement-sync.js | 911 ++++ .../test/parallel/test-sqlite-template-tag.js | 171 + .../node/test/parallel/test-sqlite-timeout.js | 73 + .../test/parallel/test-sqlite-transactions.js | 67 + .../test-sqlite-typed-array-and-data-view.js | 62 + test/js/node/test/parallel/test-sqlite.js | 340 ++ test/js/node/test/sqlite/next-db.js | 14 + test/js/node/test/sqlite/worker.js | 24 + test/regression/issue/25707.test.ts | 12 +- 48 files changed, 12650 insertions(+), 263 deletions(-) create mode 100644 src/jsc/bindings/sqlite/NodeSqlite.cpp create mode 100644 src/jsc/bindings/sqlite/NodeSqlite.h create mode 100644 src/jsc/modules/NodeSqliteModule.h create mode 100644 test/js/node/sqlite/node-sqlite.test.ts create mode 100644 test/js/node/test/parallel/test-sqlite-aggregate-function.mjs create mode 100644 test/js/node/test/parallel/test-sqlite-authz.js create mode 100644 test/js/node/test/parallel/test-sqlite-backup.mjs create mode 100644 test/js/node/test/parallel/test-sqlite-config.js create mode 100644 test/js/node/test/parallel/test-sqlite-custom-functions.js create mode 100644 test/js/node/test/parallel/test-sqlite-data-types.js create mode 100644 test/js/node/test/parallel/test-sqlite-database-sync.js create mode 100644 test/js/node/test/parallel/test-sqlite-limits.js create mode 100644 test/js/node/test/parallel/test-sqlite-named-parameters.js create mode 100644 test/js/node/test/parallel/test-sqlite-serialize.js create mode 100644 test/js/node/test/parallel/test-sqlite-session.js create mode 100644 test/js/node/test/parallel/test-sqlite-statement-sync-columns.js create mode 100644 test/js/node/test/parallel/test-sqlite-statement-sync.js create mode 100644 test/js/node/test/parallel/test-sqlite-template-tag.js create mode 100644 test/js/node/test/parallel/test-sqlite-timeout.js create mode 100644 test/js/node/test/parallel/test-sqlite-transactions.js create mode 100644 test/js/node/test/parallel/test-sqlite-typed-array-and-data-view.js create mode 100644 test/js/node/test/parallel/test-sqlite.js create mode 100644 test/js/node/test/sqlite/next-db.js create mode 100644 test/js/node/test/sqlite/worker.js diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 20b5a2382771..17b137772961 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -855,7 +855,10 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con const canary = partial.canary ?? true; const canaryRevision = canary ? "1" : "0"; - // Static SQLite: off on Apple (uses system), on elsewhere + // Whether bun:sqlite links the bundled sqlite3 directly (LAZY_LOAD_SQLITE=0) + // or dlopens the system library at runtime (the macOS default). The bundled + // sqlite3.c is compiled on every platform regardless — node:sqlite always + // uses it (see scripts/build/deps/sqlite.ts). const staticSqlite = partial.staticSqlite ?? !darwin; // Static libatomic: on by default. Arch/Manjaro don't ship libatomic.a — diff --git a/scripts/build/deps/sqlite.ts b/scripts/build/deps/sqlite.ts index 0f0138fa6b0c..06e59bcfce67 100644 --- a/scripts/build/deps/sqlite.ts +++ b/scripts/build/deps/sqlite.ts @@ -1,12 +1,21 @@ /** - * SQLite — embedded SQL database. Backs bun:sqlite. + * SQLite — embedded SQL database. Backs bun:sqlite and node:sqlite. * * Source lives IN THE BUN REPO at src/jsc/bindings/sqlite/ — it's the * sqlite3 amalgamation (single .c file). No fetch step; tracked in git. * - * Only built when staticSqlite=true. Otherwise bun dlopen()s the system - * sqlite at runtime (macOS ships a recent sqlite; most linux distros don't, - * so static is the default on linux). + * Always built: node:sqlite uses the bundled copy unconditionally (matching + * Node.js). bun:sqlite additionally supports dlopen()ing the system sqlite + * on macOS when staticSqlite=false (LAZY_LOAD_SQLITE=1), but NodeSqlite.cpp + * includes sqlite3_local.h directly and links against these symbols on + * every platform. + * + * Bundling on macOS (previously dlopen-only there) grows the darwin binaries + * by ~1.8 MB. That is the cost of node:sqlite parity: Apple's system + * libsqlite3 ships without the session extension or percentile() and with + * extension loading disabled, so the bundled build is required — Node.js + * bundles SQLite for the same reason. Linux/Windows already linked the + * bundled copy. */ import type { Dependency } from "../source.ts"; @@ -14,7 +23,7 @@ import type { Dependency } from "../source.ts"; export const sqlite: Dependency = { name: "sqlite", - enabled: cfg => cfg.staticSqlite, + enabled: () => true, source: () => ({ kind: "in-tree", @@ -36,6 +45,16 @@ export const sqlite: Dependency = { SQLITE_ENABLE_MATH_FUNCTIONS: 1, SQLITE_ENABLE_UPDATE_DELETE_LIMIT: 1, SQLITE_UDL_CAPABLE_PARSER: 1, + // node:sqlite exposes createSession/applyChangeset + columns() + // metadata. Match Node.js's compile-time feature set so those + // APIs work identically. PREUPDATE_HOOK is a prerequisite for the + // session extension. + SQLITE_ENABLE_SESSION: 1, + SQLITE_ENABLE_PREUPDATE_HOOK: 1, + SQLITE_ENABLE_DBSTAT_VTAB: 1, + SQLITE_ENABLE_GEOPOLY: 1, + SQLITE_ENABLE_RBU: 1, + SQLITE_ENABLE_PERCENTILE: 1, }, cflags: [ "-Wno-incompatible-pointer-types-discards-qualifiers", diff --git a/scripts/build/unified.ts b/scripts/build/unified.ts index 509bf4b8ae4b..37a472e9fcfb 100644 --- a/scripts/build/unified.ts +++ b/scripts/build/unified.ts @@ -72,6 +72,7 @@ const noUnify: readonly string[] = [ "src/jsc/bindings/webcore/JSDOMPromiseDeferred.cpp", "src/jsc/bindings/webcore/JSMessageEventCustom.cpp", "src/jsc/bindings/sqlite/JSSQLStatement.cpp", + "src/jsc/bindings/sqlite/NodeSqlite.cpp", // WebKit-derived crypto algorithm impls share file-static helper names // (`aesAlgorithm`, `cryptEncrypt`, `ALG128`, `IVSIZE`, ...) — upstream diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 6a75a9768313..3e02223934c1 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -450,8 +450,10 @@ class TestContext { } get mock() { - throwNotImplemented("mock", 5090, "Use `bun:test` in the interim."); - return undefined; + // Node gives each TestContext its own tracker so after-test restoration + // is scoped; sharing the module-level tracker is enough for what's + // implemented today (Node's own sqlite tests use t.mock.fn()). + return mock; } runOnly(_value?: boolean) { diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 7e5d18b0b575..60bb15d41741 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -717,9 +717,11 @@ impl ErrorCode { pub const TRACE_EVENTS_CATEGORY_REQUIRED: ErrorCode = ErrorCode(329); /// `ERR_TRACE_EVENTS_UNAVAILABLE` (instanceof Error) pub const TRACE_EVENTS_UNAVAILABLE: ErrorCode = ErrorCode(330); + /// `ERR_SQLITE_ERROR` (instanceof Error) + pub const SQLITE_ERROR: ErrorCode = ErrorCode(331); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 331; + pub const COUNT: u16 = 332; } // ────────────────────────────────────────────────────────────────────────── @@ -1094,6 +1096,7 @@ impl ErrorCode { pub const ERR_SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode::SECRETS_INTERACTION_REQUIRED; pub const ERR_HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode::HTTP2_GOAWAY_SESSION; pub const ERR_PROXY_TUNNEL: ErrorCode = ErrorCode::PROXY_TUNNEL; + pub const ERR_SQLITE_ERROR: ErrorCode = ErrorCode::SQLITE_ERROR; // NOTE: `ERR_SYSTEM_ERROR` / `ERR_CHILD_CLOSED_BEFORE_REPLY` intentionally // do NOT live here. They belong to the unrelated enum @@ -1442,6 +1445,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_INVALID_BUFFER_SIZE", "ERR_TRACE_EVENTS_CATEGORY_REQUIRED", "ERR_TRACE_EVENTS_UNAVAILABLE", + "ERR_SQLITE_ERROR", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 97199c6811bf..6381ce537942 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -370,6 +370,7 @@ unsafe extern "C" { safe fn Process__dispatchOnBeforeExit(global: &JSGlobalObject, code: u8); safe fn Process__dispatchOnExit(global: &JSGlobalObject, code: u8); safe fn Bun__closeAllSQLiteDatabasesForTermination(); + safe fn Bun__closeAllNodeSqliteDatabasesForTermination(global: &JSGlobalObject); safe fn Bun__WebView__closeAllForTermination(); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); } @@ -506,6 +507,7 @@ impl ExitHandler { Process__dispatchOnExit(vm.global(), exit_code); if vm.worker.is_none() { Bun__closeAllSQLiteDatabasesForTermination(); + Bun__closeAllNodeSqliteDatabasesForTermination(vm.global()); Bun__WebView__closeAllForTermination(); } } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 7e0cf2334e06..156aba00598f 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -167,6 +167,7 @@ extern "C" bool Bun__GlobalObject__connectedIPC(JSGlobalObject*); extern "C" bool Bun__GlobalObject__hasIPC(JSGlobalObject*); extern "C" bool Bun__ensureProcessIPCInitialized(JSGlobalObject*); extern "C" const char* Bun__githubURL; +extern "C" const char* Bun__sqlite3_version(); BUN_DECLARE_HOST_FUNCTION(Bun__Process__send); extern "C" void Process__emitDisconnectEvent(Zig::GlobalObject* global); @@ -254,6 +255,7 @@ static JSValue constructVersions(VM& vm, JSObject* processObject) object->putDirect(vm, JSC::Identifier::fromString(vm, "icu"_s), JSValue(JSC::jsOwnedString(vm, String(ASCIILiteral::fromLiteralUnsafe(U_ICU_VERSION)))), 0); object->putDirect(vm, JSC::Identifier::fromString(vm, "unicode"_s), JSValue(JSC::jsOwnedString(vm, String(ASCIILiteral::fromLiteralUnsafe(U_UNICODE_VERSION)))), 0); + object->putDirect(vm, JSC::Identifier::fromString(vm, "sqlite"_s), JSValue(JSC::jsOwnedString(vm, String(ASCIILiteral::fromLiteralUnsafe(Bun__sqlite3_version())))), 0); #define STRINGIFY_IMPL(x) #x #define STRINGIFY(x) STRINGIFY_IMPL(x) diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index acd959d9bfc3..6ccfa03f6441 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -342,5 +342,6 @@ const errors: ErrorCodeMapping = [ ["ERR_INVALID_BUFFER_SIZE", RangeError], ["ERR_TRACE_EVENTS_CATEGORY_REQUIRED", TypeError], ["ERR_TRACE_EVENTS_UNAVAILABLE", Error], + ["ERR_SQLITE_ERROR", Error], ]; export default errors; diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index adb5ed6ecdda..aadfae0e471a 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -42,6 +42,7 @@ #include "JavaScriptCore/JSModuleNamespaceObjectInlines.h" #include "JavaScriptCore/JSModuleRecord.h" #include "JavaScriptCore/JSNativeStdFunction.h" +#include "JavaScriptCore/JSIteratorPrototype.h" #include "JavaScriptCore/JSObject.h" #include "JavaScriptCore/JSObjectInlines.h" #include "JavaScriptCore/JSPromise.h" @@ -131,6 +132,7 @@ #include "JSReactElement.h" #include "BunMarkdownMeta.h" #include "JSSQLStatement.h" +#include "sqlite/NodeSqlite.h" #include "JSStringDecoder.h" #include "JSTextEncoder.h" #include "JSTextEncoderStream.h" @@ -2638,6 +2640,68 @@ void GlobalObject::finishCreation(VM& vm) init.setConstructor(constructor); }); + m_JSDatabaseSyncClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + auto* prototype = Bun::JSDatabaseSyncPrototype::create( + init.vm, init.global, Bun::JSDatabaseSyncPrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); + auto* structure = Bun::JSDatabaseSync::createStructure(init.vm, init.global, prototype); + auto* constructor = Bun::JSDatabaseSyncConstructor::create( + init.vm, init.global, Bun::JSDatabaseSyncConstructor::createStructure(init.vm, init.global, init.global->functionPrototype()), prototype); + init.setPrototype(prototype); + init.setStructure(structure); + init.setConstructor(constructor); + }); + + m_JSStatementSyncClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + auto* prototype = Bun::JSStatementSyncPrototype::create( + init.vm, init.global, Bun::JSStatementSyncPrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); + auto* structure = Bun::JSStatementSync::createStructure(init.vm, init.global, prototype); + auto* constructor = Bun::JSStatementSyncConstructor::create( + init.vm, init.global, Bun::JSStatementSyncConstructor::createStructure(init.vm, init.global, init.global->functionPrototype()), prototype); + init.setPrototype(prototype); + init.setStructure(structure); + init.setConstructor(constructor); + }); + + m_JSStatementSyncIteratorClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + // Prototype chain: instance → iterator prototype → %IteratorPrototype% + // so for-of / spread / Iterator helpers all work out of the box. + auto* prototype = Bun::JSStatementSyncIteratorPrototype::create( + init.vm, init.global, Bun::JSStatementSyncIteratorPrototype::createStructure(init.vm, init.global, init.global->iteratorPrototype())); + auto* structure = Bun::JSStatementSyncIterator::createStructure(init.vm, init.global, prototype); + init.setPrototype(prototype); + init.setStructure(structure); + }); + + m_JSNodeSqliteSessionClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + auto* prototype = Bun::JSNodeSqliteSessionPrototype::create( + init.vm, init.global, Bun::JSNodeSqliteSessionPrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); + auto* structure = Bun::JSNodeSqliteSession::createStructure(init.vm, init.global, prototype); + init.setPrototype(prototype); + init.setStructure(structure); + }); + + m_JSNodeSqliteLimitsClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + // Null prototype: Node's DatabaseSyncLimits is an ObjectTemplate + // with only the named-property handler, so Object.prototype is + // NOT on its chain and can't shadow a limit name. + auto* structure = Bun::JSNodeSqliteLimits::createStructure(init.vm, init.global, JSC::jsNull()); + init.setStructure(structure); + }); + + m_JSNodeSqliteTagStoreClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + auto* prototype = Bun::JSNodeSqliteTagStorePrototype::create( + init.vm, init.global, Bun::JSNodeSqliteTagStorePrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); + auto* structure = Bun::JSNodeSqliteTagStore::createStructure(init.vm, init.global, prototype); + init.setPrototype(prototype); + init.setStructure(structure); + }); + m_JSFFIFunctionStructure.initLater( [](LazyClassStructure::Initializer& init) { init.setStructure(Zig::JSFFIFunction::createStructure(init.vm, init.global, init.global->functionPrototype())); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 9bba7344771e..2c6e5aa9441a 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -564,6 +564,12 @@ class GlobalObject : public Bun::GlobalScope { V(private, LazyClassStructure, m_JSH3ResponseSinkClassStructure) \ \ V(private, LazyClassStructure, m_JSStringDecoderClassStructure) \ + V(public, LazyClassStructure, m_JSDatabaseSyncClassStructure) \ + V(public, LazyClassStructure, m_JSStatementSyncClassStructure) \ + V(public, LazyClassStructure, m_JSStatementSyncIteratorClassStructure) \ + V(public, LazyClassStructure, m_JSNodeSqliteSessionClassStructure) \ + V(public, LazyClassStructure, m_JSNodeSqliteLimitsClassStructure) \ + V(public, LazyClassStructure, m_JSNodeSqliteTagStoreClassStructure) \ V(private, LazyClassStructure, m_NapiClassStructure) \ V(private, LazyClassStructure, m_callSiteStructure) \ V(public, LazyClassStructure, m_JSBufferClassStructure) \ diff --git a/src/jsc/bindings/isBuiltinModule.cpp b/src/jsc/bindings/isBuiltinModule.cpp index 1bfd6ad23f32..4286320f4af6 100644 --- a/src/jsc/bindings/isBuiltinModule.cpp +++ b/src/jsc/bindings/isBuiltinModule.cpp @@ -55,6 +55,7 @@ static constexpr ASCIILiteral builtinModuleNamesSortedLength[] = { "_tls_common"_s, "async_hooks"_s, "fs/promises"_s, + "node:sqlite"_s, "querystring"_s, "_http_client"_s, "_http_common"_s, diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 1e90204a307d..832ee947ea62 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -279,8 +279,16 @@ extern "C" void Bun__closeAllSQLiteDatabasesForTermination() auto& dbs = _instance->databases; for (auto& db : dbs) { - if (db->db) - sqlite3_close(db->db); + if (db->db) { + // close_v2: with unfinalized statements still alive, plain + // sqlite3_close() returns SQLITE_BUSY and leaves the connection + // open, which would leak it once the pointer is nulled below. + sqlite3_close_v2(db->db); + // Prevent VersionSqlite3::release() (invoked later by the GC + // finalizer during VM teardown) from closing the same handle + // again, which would be a use-after-free. + db->db = nullptr; + } } } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp new file mode 100644 index 000000000000..b9aff384d4cb --- /dev/null +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -0,0 +1,3827 @@ +// node:sqlite — native implementation of Node.js's `node:sqlite` module. +// See header for overview. + +// Always use the bundled amalgamation for node:sqlite, regardless of +// LAZY_LOAD_SQLITE — see the header comment for rationale. The session +// extension (createSession/applyChangeset) is only declared in the header +// when SQLITE_ENABLE_SESSION is defined — sqlite3.c is compiled with that +// flag via the sqlite build target (scripts/build/deps/sqlite.ts), so turn +// it on here as well to expose the prototypes. SQLITE_ENABLE_COLUMN_METADATA +// likewise gates sqlite3_column_{origin,table,database}_name. +#ifndef SQLITE_ENABLE_SESSION +#define SQLITE_ENABLE_SESSION 1 +#endif +#ifndef SQLITE_ENABLE_PREUPDATE_HOOK +#define SQLITE_ENABLE_PREUPDATE_HOOK 1 +#endif +#ifndef SQLITE_ENABLE_COLUMN_METADATA +#define SQLITE_ENABLE_COLUMN_METADATA 1 +#endif +#include "sqlite3_local.h" + +#include "NodeSqlite.h" + +#include "ZigGlobalObject.h" +#include "ErrorCode.h" +#include "JSDOMBinding.h" +#include "JSDOMGlobalObjectInlines.h" +#include "DOMIsoSubspaces.h" +#include "DOMClientIsoSubspaces.h" +#include "BunClientData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The SQLite session-extension constants (SQLITE_CHANGESET_*) are only +// defined in the amalgamation header when SQLITE_ENABLE_SESSION is set at +// compile time. node:sqlite exposes them unconditionally, so fall back to +// the documented values when the macros are unavailable. +#ifndef SQLITE_CHANGESET_OMIT +#define SQLITE_CHANGESET_OMIT 0 +#define SQLITE_CHANGESET_REPLACE 1 +#define SQLITE_CHANGESET_ABORT 2 +#define SQLITE_CHANGESET_DATA 1 +#define SQLITE_CHANGESET_NOTFOUND 2 +#define SQLITE_CHANGESET_CONFLICT 3 +#define SQLITE_CHANGESET_CONSTRAINT 4 +#define SQLITE_CHANGESET_FOREIGN_KEY 5 +#endif + +// process.versions.sqlite — reported from this TU (not JSSQLStatement.cpp) +// because on macOS's LAZY_LOAD_SQLITE path that file sees the *system* +// sqlite3.h and would report Apple's SDK version, whereas node:sqlite always +// links the bundled amalgamation included above. +extern "C" const char* Bun__sqlite3_version() +{ + return SQLITE_VERSION; +} + +namespace Bun { + +using namespace JSC; +using namespace WebCore; + +// ───────────────────────────────────────────────────────────────────────────── +// Error helpers (match Node.js node_sqlite.cc shapes) +// ───────────────────────────────────────────────────────────────────────────── + +static JSObject* createNodeSqliteError(JSGlobalObject* globalObject, sqlite3* db) +{ + auto& vm = getVM(globalObject); + int errcode = sqlite3_extended_errcode(db); + const char* errstr = sqlite3_errstr(errcode); + const char* errmsg = sqlite3_errmsg(db); + auto* zigGlobal = defaultGlobalObject(globalObject); + JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, WTF::String::fromUTF8(errmsg)); + error->putDirect(vm, Identifier::fromString(vm, "errcode"_s), jsNumber(errcode), 0); + error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(errstr)), 0); + return error; +} + +static void throwSqliteError(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3* db) +{ + scope.throwException(globalObject, createNodeSqliteError(globalObject, db)); +} + +static void throwSqliteMessage(JSGlobalObject* globalObject, ThrowScope& scope, int errcode, const WTF::String& message) +{ + auto& vm = getVM(globalObject); + auto* zigGlobal = defaultGlobalObject(globalObject); + JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, message); + const char* errstr = sqlite3_errstr(errcode); + error->putDirect(vm, Identifier::fromString(vm, "errcode"_s), jsNumber(errcode), 0); + error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(errstr)), 0); + scope.throwException(globalObject, error); +} + +// Node's THROW_ERR_INVALID_STATE(...) emits the message verbatim; Bun's +// generic helper prepends "Invalid state: ". Several upstream tests +// (test-sqlite-session.js, test-sqlite-template-tag.js, …) assert the +// exact message string, so use a local throw that matches Node's format. +static EncodedJSValue throwNodeState(JSGlobalObject* globalObject, ThrowScope& scope, const WTF::String& message) +{ + auto* zigGlobal = defaultGlobalObject(globalObject); + scope.throwException(globalObject, createError(zigGlobal, ErrorCode::ERR_INVALID_STATE, message)); + return {}; +} + +#define REQUIRE_DB_OPEN(db) \ + do { \ + if ((db)->connection() == nullptr) { \ + return throwNodeState(globalObject, scope, "database is not open"_s); \ + } \ + } while (0) + +#define REQUIRE_STMT(self) \ + do { \ + if ((self)->isFinalized()) { \ + return throwNodeState(globalObject, scope, "statement has been finalized"_s); \ + } \ + } while (0) + +// Pin the owning database for the duration of a StatementSync call that +// may re-enter JS (bindParams getters, UDFs, aggregate callbacks). Must +// follow REQUIRE_STMT so database() is known live. +#define BUSY_SCOPE_STMT(self) \ + JSDatabaseSync::BusyScope busy__ { (self)->database() } + +// Node.js's node_sqlite.cc validation errors use a fixed phrasing that the +// upstream test suite asserts on verbatim. Bun's generic ERR_INVALID_ARG_TYPE +// helper produces a slightly different sentence, so emit Node's form here. +static EncodedJSValue throwNodeArgType(JSGlobalObject* globalObject, ThrowScope& scope, ASCIILiteral argName, ASCIILiteral typeName) +{ + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + makeString("The \""_s, argName, "\" argument must be "_s, typeName, "."_s)); +} + +// `db.limits` property-name → SQLITE_LIMIT_* mapping. The ids are +// contiguous (0..10) and equal their array index; DatabaseSyncOpen- +// Configuration::initialLimits relies on that. static_assert picks up +// any drift if the amalgamation ever renumbers them. +struct NodeSqliteLimitInfo { + ASCIILiteral name; + int id; +}; +static constexpr std::array kLimitMapping { { + { "length"_s, SQLITE_LIMIT_LENGTH }, + { "sqlLength"_s, SQLITE_LIMIT_SQL_LENGTH }, + { "column"_s, SQLITE_LIMIT_COLUMN }, + { "exprDepth"_s, SQLITE_LIMIT_EXPR_DEPTH }, + { "compoundSelect"_s, SQLITE_LIMIT_COMPOUND_SELECT }, + { "vdbeOp"_s, SQLITE_LIMIT_VDBE_OP }, + { "functionArg"_s, SQLITE_LIMIT_FUNCTION_ARG }, + { "attach"_s, SQLITE_LIMIT_ATTACHED }, + { "likePatternLength"_s, SQLITE_LIMIT_LIKE_PATTERN_LENGTH }, + { "variableNumber"_s, SQLITE_LIMIT_VARIABLE_NUMBER }, + { "triggerDepth"_s, SQLITE_LIMIT_TRIGGER_DEPTH }, +} }; +static_assert(SQLITE_LIMIT_LENGTH == 0 && SQLITE_LIMIT_TRIGGER_DEPTH == 10, + "kLimitMapping / DatabaseSyncOpenConfiguration::initialLimits assume contiguous SQLITE_LIMIT_* ids"); + +static inline int findLimitId(const WTF::String& name) +{ + for (const auto& info : kLimitMapping) { + if (name == info.name) return info.id; + } + return -1; +} + +static bool readBoolOption(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* options, ASCIILiteral name, bool& out) +{ + auto& vm = getVM(globalObject); + JSValue v = options->get(globalObject, Identifier::fromString(vm, name)); + RETURN_IF_EXCEPTION(scope, false); + if (v.isUndefined()) return true; + if (!v.isBoolean()) { + // Match Node.js's node_sqlite.cc error text exactly — the upstream + // tests assert on the message string, not just the code. + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + makeString("The \"options."_s, name, "\" argument must be a boolean."_s)); + return false; + } + out = v.asBoolean(); + return true; +} + +// After a sqlite3_step/sqlite3_exec that may have re-entered JS via a +// user-defined function: if the JS callback threw, the pending exception +// on the VM is the real error and any SQLITE_ERROR from sqlite is just +// the "user function raised an exception" wrapper. Propagate the JS +// exception instead. Expands to RETURN_IF_EXCEPTION so JSC's +// validateExceptionChecks records the check after each step() — a plain +// `if (scope.exception())` does not satisfy it. +#define CHECK_UDF_EXCEPTION(scope, db) \ + do { \ + if (db) (db)->takeIgnoreNextSqliteError(); \ + RETURN_IF_EXCEPTION(scope, {}); \ + } while (0) + +// ───────────────────────────────────────────────────────────────────────────── +// sqlite3_value* ⇄ JSValue conversions for user-defined functions and +// aggregates. These mirror columnToJS() but operate on the xFunc argv. +// ───────────────────────────────────────────────────────────────────────────── +// +// These conversion helpers are called from sqlite's xFunc/xStep callbacks, +// which run INSIDE sqlite3_step() and may be re-invoked many times before +// control returns to the outer JS→native host function. A nested ThrowScope +// would simulateThrow() in its destructor on every iteration, tripping JSC's +// validateExceptionChecks on the next callback's constructor. So the callbacks +// use a TopExceptionScope (whose destructor does not simulate a throw) and +// this helper throws via vm.throwException directly rather than taking a +// ThrowScope&. The outer host function's own ThrowScope observes the final +// result via CHECK_UDF_EXCEPTION after sqlite3_step returns. + +static JSValue sqliteValueToJS(JSGlobalObject* globalObject, TopExceptionScope& outer, sqlite3_value* value, bool useBigInts) +{ + auto& vm = getVM(globalObject); + switch (sqlite3_value_type(value)) { + case SQLITE_INTEGER: { + int64_t v = sqlite3_value_int64(value); + if (useBigInts) { + return JSBigInt::makeHeapBigIntOrBigInt32(globalObject, v); + } + if (v > JSC::maxSafeInteger() || v < -JSC::maxSafeInteger()) { + // Rare edge case — open a transient ThrowScope just to raise + // the error. Its destructor's simulateThrow() sets the + // need-check flag, which the caller clears via + // outer.exception() immediately on return; release so the + // destructor's own verify doesn't object to the error we just + // threw. + auto scope = DECLARE_THROW_SCOPE(vm); + Bun::throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, + makeString("Value is too large to be represented as a JavaScript number: "_s, v)); + scope.release(); + return {}; + } + return jsNumber(static_cast(v)); + } + case SQLITE_FLOAT: + return jsDoubleNumber(sqlite3_value_double(value)); + case SQLITE_TEXT: { + size_t len = sqlite3_value_bytes(value); + const unsigned char* text = sqlite3_value_text(value); + if (len == 0 || text == nullptr) return jsEmptyString(vm); + return jsString(vm, WTF::String::fromUTF8({ reinterpret_cast(text), len })); + } + case SQLITE_NULL: + return jsNull(); + case SQLITE_BLOB: { + size_t len = sqlite3_value_bytes(value); + const void* blob = sqlite3_value_blob(value); + auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), len); + if (outer.exception()) [[unlikely]] + return {}; + if (len > 0) memcpy(array->typedVector(), blob, len); + return array; + } + default: + return jsNull(); + } + (void)outer; +} + +// Write a JS return value back into an sqlite3_context*. On type mismatch +// this calls sqlite3_result_error with the same strings Node.js uses; the +// outer step() will then surface that as ERR_SQLITE_ERROR. +static void jsValueToSqliteResult(JSGlobalObject* globalObject, sqlite3_context* ctx, JSValue value) +{ + if (value.isUndefinedOrNull()) { + sqlite3_result_null(ctx); + } else if (value.isInt32()) { + // Match bindValue(): int32 results keep INTEGER storage class so + // `typeof(udf())` on a function returning 42 yields 'integer'. + sqlite3_result_int(ctx, value.asInt32()); + } else if (value.isNumber()) { + sqlite3_result_double(ctx, value.asNumber()); + } else if (value.isString()) { + auto str = value.toWTFString(globalObject); + if (str.isNull()) { + sqlite3_result_error(ctx, "", 0); + return; + } + auto utf8 = str.utf8(); + // The *64 variants reject an over-INT_MAX length with SQLITE_TOOBIG + // instead of narrowing it into `int` (a negative length is undefined + // for the 32-bit bind/result API). Same in bindValue() below. + sqlite3_result_text64(ctx, utf8.data(), utf8.length(), SQLITE_TRANSIENT, SQLITE_UTF8); + } else if (auto* view = dynamicDowncast(value)) { + auto span = view->span(); + sqlite3_result_blob64(ctx, span.data(), span.size(), SQLITE_TRANSIENT); + } else if (value.isBigInt()) { + int64_t as_int = JSBigInt::toBigInt64(value); + JSValue roundTrip = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, as_int); + if (!roundTrip || JSBigInt::compare(value, roundTrip) != JSBigInt::ComparisonResult::Equal) { + sqlite3_result_error(ctx, "BigInt value is too large for SQLite", -1); + return; + } + sqlite3_result_int64(ctx, as_int); + } else if (value.inherits()) { + sqlite3_result_error(ctx, "Asynchronous user-defined functions are not supported", -1); + } else { + sqlite3_result_error(ctx, "Returned JavaScript value cannot be converted to a SQLite value", -1); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// User-defined scalar functions (DatabaseSync.prototype.function) +// +// The context object lives for as long as the function is registered on the +// connection. sqlite3_create_function_v2's xDestroy fires on sqlite3_close or +// when the function is re-registered/removed. The JS callback is held as a +// raw pointer here and rooted by JSDatabaseSync::m_registeredCallbacks (a +// GC-traced field on the cell, see addRegisteredCallback) — NOT by a C-side +// Strong<>, so a callback closure that captures the database does not pin the +// cell forever; the db → closure → db cycle stays collectable, exactly like +// m_authorizer. The raw fn_/db_ pointers are safe because the context is +// only invoked while a query runs on this connection (the cell is on the +// stack). xDestroy itself MUST NOT touch db_ or fn_ — with unfinalized +// statements the connection is zombified and xDestroy may run after the +// cell has been swept (see the comment on xDestroy below); superseded roots +// are released by releaseSupersededRegistration() at the registration site. +// ───────────────────────────────────────────────────────────────────────────── + +struct NodeSqliteUDF { + WTF_MAKE_TZONE_ALLOCATED_INLINE(NodeSqliteUDF); + +public: + NodeSqliteUDF(JSGlobalObject* globalObject, JSDatabaseSync* db, JSObject* fn, bool useBigIntArgs) + : globalObject_(globalObject) + , db_(db) + , fn_(fn) + , useBigIntArgs_(useBigIntArgs) + { + } + + static void xFunc(sqlite3_context* ctx, int argc, sqlite3_value** argv) + { + auto* self = static_cast(sqlite3_user_data(ctx)); + auto* globalObject = self->globalObject_; + auto& vm = getVM(globalObject); + // TopExceptionScope (not ThrowScope): sqlite may invoke this callback + // many times per sqlite3_step(), and a ThrowScope's destructor + // simulateThrow() would trip validateExceptionChecks on the next + // invocation's constructor. TopExceptionScope's destructor doesn't + // simulate, so the only requirement is that we consume each inner + // scope's need-check via scope.exception() before returning. The + // pending exception itself is left on the VM for the outer host + // function to observe via CHECK_UDF_EXCEPTION. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + auto abortWithPending = [&] { + self->db_->setIgnoreNextSqliteError(); + sqlite3_result_error(ctx, "", 0); + }; + if (scope.exception()) [[unlikely]] + return abortWithPending(); + + MarkedArgumentBuffer args; + args.ensureCapacity(argc); + for (int i = 0; i < argc; ++i) { + JSValue v = sqliteValueToJS(globalObject, scope, argv[i], self->useBigIntArgs_); + if (scope.exception()) [[unlikely]] + return abortWithPending(); + args.append(v); + } + + JSValue fn = self->fn_; + auto callData = JSC::getCallData(fn); + JSValue result = JSC::call(globalObject, fn, callData, jsUndefined(), args); + if (scope.exception()) [[unlikely]] + return abortWithPending(); + jsValueToSqliteResult(globalObject, ctx, result); + if (scope.exception()) [[unlikely]] + return abortWithPending(); + } + + // MUST stay a plain delete: with unfinalized statements the connection is + // zombified and this runs from the last sqlite3_finalize() — possibly + // long after the JSDatabaseSync cell was swept — so it can't touch db_ + // or any GC state. Superseded roots are released at the registration + // site instead (releaseSupersededRegistration). + static void xDestroy(void* p) { delete static_cast(p); } + + JSGlobalObject* globalObject_; + JSDatabaseSync* db_; + // Rooted by db_->m_registeredCallbacks; see the comment above the struct. + JSObject* fn_; + bool useBigIntArgs_; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// User-defined aggregate functions (DatabaseSync.prototype.aggregate) +// +// Per-invocation accumulator state lives in sqlite3_aggregate_context — a +// scratch buffer SQLite zeroes on first access and discards after xFinal. We +// store a Strong<> there so the JS accumulator value survives GC between +// xStep calls (window functions step across multiple sqlite3_step()s); the +// accumulator is per-query and cannot capture the database, so it cannot +// form the cycle the registered callbacks could. The callbacks themselves +// (start/step/result/inverse) are raw pointers rooted by the cell's +// m_registeredCallbacks, same as NodeSqliteUDF above. +// ───────────────────────────────────────────────────────────────────────────── + +struct NodeSqliteAggregate { + WTF_MAKE_TZONE_ALLOCATED_INLINE(NodeSqliteAggregate); + +public: + struct State { + JSC::Strong value; + bool initialized; + bool isWindow; + }; + + NodeSqliteAggregate(JSGlobalObject* globalObject, JSDatabaseSync* db, + JSValue start, JSObject* step, JSObject* result, JSObject* inverse, bool useBigIntArgs) + : globalObject_(globalObject) + , db_(db) + , start_(start) + , step_(step) + , result_(result) + , inverse_(inverse) + , useBigIntArgs_(useBigIntArgs) + { + } + + State* getState(sqlite3_context* ctx, TopExceptionScope& scope) + { + auto* state = static_cast(sqlite3_aggregate_context(ctx, sizeof(State))); + if (state == nullptr) return nullptr; + if (!state->initialized) { + // sqlite3_aggregate_context zero-fills on first call, so + // placement-new to bring the Strong<> to a valid empty state + // before assigning. Seed value with jsUndefined() up front so + // that if start() throws and xFinal runs afterwards, it sees a + // well-formed JSValue rather than an empty Strong handle. + new (state) State(); + state->initialized = true; + + auto& vm = getVM(globalObject_); + state->value.set(vm, jsUndefined()); + JSValue startV = start_; + if (startV.isCallable()) { + auto callData = JSC::getCallData(startV); + MarkedArgumentBuffer noArgs; + startV = JSC::call(globalObject_, startV, callData, jsNull(), noArgs); + if (scope.exception()) [[unlikely]] { + db_->setIgnoreNextSqliteError(); + sqlite3_result_error(ctx, "", 0); + return nullptr; + } + } + state->value.set(vm, startV); + } + return state; + } + + static void destroyState(sqlite3_context* ctx) + { + auto* state = static_cast(sqlite3_aggregate_context(ctx, 0)); + if (state && state->initialized) { + state->~State(); + state->initialized = false; + } + } + + void stepBase(sqlite3_context* ctx, int argc, sqlite3_value** argv, JSObject* fn) + { + auto& vm = getVM(globalObject_); + // TopExceptionScope — see the rationale on xFunc. Pending exceptions + // are deliberately left on the VM for the outer step()/exec() to + // observe via CHECK_UDF_EXCEPTION. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto abortWithPending = [&] { + db_->setIgnoreNextSqliteError(); + sqlite3_result_error(ctx, "", 0); + }; + if (scope.exception()) [[unlikely]] + return; + auto* state = getState(ctx, scope); + if (!state) return; + + MarkedArgumentBuffer args; + args.ensureCapacity(argc + 1); + args.append(state->value.get()); + for (int i = 0; i < argc; ++i) { + JSValue v = sqliteValueToJS(globalObject_, scope, argv[i], useBigIntArgs_); + if (scope.exception()) [[unlikely]] + return abortWithPending(); + args.append(v); + } + + auto callData = JSC::getCallData(fn); + JSValue ret = JSC::call(globalObject_, fn, callData, jsUndefined(), args); + if (scope.exception()) [[unlikely]] + return abortWithPending(); + state->value.set(vm, ret); + } + + void valueBase(sqlite3_context* ctx, bool isFinal) + { + auto& vm = getVM(globalObject_); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + (void)vm; + // An exception from an earlier xStep may still be pending — + // don't re-enter JS (or overwrite sqlite3_result_error with a + // NULL result) in that case; just tear down the state. + if (scope.exception()) [[unlikely]] { + if (isFinal) destroyState(ctx); + return; + } + auto* state = getState(ctx, scope); + if (!state) { + if (isFinal) destroyState(ctx); + return; + } + + if (!isFinal) { + state->isWindow = true; + } else if (state->isWindow) { + // Window aggregates emit their result via xValue; xFinal is only + // a cleanup signal and must not emit again. + destroyState(ctx); + return; + } + + JSValue result; + if (JSObject* rfn = result_) { + MarkedArgumentBuffer args; + args.append(state->value.get()); + auto callData = JSC::getCallData(rfn); + result = JSC::call(globalObject_, rfn, callData, jsNull(), args); + if (scope.exception()) [[unlikely]] { + db_->setIgnoreNextSqliteError(); + sqlite3_result_error(ctx, "", 0); + if (isFinal) destroyState(ctx); + return; + } + } else { + result = state->value.get(); + } + jsValueToSqliteResult(globalObject_, ctx, result); + if (scope.exception()) [[unlikely]] { + db_->setIgnoreNextSqliteError(); + } + if (isFinal) destroyState(ctx); + } + + static void xStep(sqlite3_context* ctx, int argc, sqlite3_value** argv) + { + auto* self = static_cast(sqlite3_user_data(ctx)); + self->stepBase(ctx, argc, argv, self->step_); + } + static void xInverse(sqlite3_context* ctx, int argc, sqlite3_value** argv) + { + auto* self = static_cast(sqlite3_user_data(ctx)); + self->stepBase(ctx, argc, argv, self->inverse_); + } + static void xFinal(sqlite3_context* ctx) + { + auto* self = static_cast(sqlite3_user_data(ctx)); + self->valueBase(ctx, true); + } + static void xValue(sqlite3_context* ctx) + { + auto* self = static_cast(sqlite3_user_data(ctx)); + self->valueBase(ctx, false); + } + // Same constraint as NodeSqliteUDF::xDestroy — may run after the cell is + // gone (zombified connection), so it must not touch db_ or GC state. + static void xDestroy(void* p) { delete static_cast(p); } + + JSGlobalObject* globalObject_; + JSDatabaseSync* db_; + // Rooted by db_->m_registeredCallbacks; see the comment above the struct. + JSValue start_; + JSObject* step_; + JSObject* result_; + JSObject* inverse_; + bool useBigIntArgs_; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Column → JSValue conversion (Node semantics: Uint8Array for BLOB, BigInt +// gated by use_big_ints, ERR_OUT_OF_RANGE if integer overflows a JS number). +// ───────────────────────────────────────────────────────────────────────────── + +static inline JSValue columnToJS(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3_stmt* stmt, int i, bool useBigInts) +{ + auto& vm = getVM(globalObject); + switch (sqlite3_column_type(stmt, i)) { + case SQLITE_INTEGER: { + int64_t v = sqlite3_column_int64(stmt, i); + if (useBigInts) { + return JSBigInt::makeHeapBigIntOrBigInt32(globalObject, v); + } + if (v > JSC::maxSafeInteger() || v < -JSC::maxSafeInteger()) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, + makeString("Value is too large to be represented as a JavaScript number: "_s, v)); + return {}; + } + return jsNumber(static_cast(v)); + } + case SQLITE_FLOAT: + return jsDoubleNumber(sqlite3_column_double(stmt, i)); + case SQLITE_TEXT: { + size_t len = sqlite3_column_bytes(stmt, i); + const unsigned char* text = sqlite3_column_text(stmt, i); + if (len == 0 || text == nullptr) return jsEmptyString(vm); + return jsString(vm, WTF::String::fromUTF8({ reinterpret_cast(text), len })); + } + case SQLITE_NULL: + return jsNull(); + case SQLITE_BLOB: { + size_t len = sqlite3_column_bytes(stmt, i); + const void* blob = sqlite3_column_blob(stmt, i); + auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), len); + RETURN_IF_EXCEPTION(scope, {}); + if (len > 0) { + memcpy(array->typedVector(), blob, len); + } + return array; + } + default: + ASSERT_NOT_REACHED(); + return jsNull(); + } +} + +// Generic (uncached) null-prototype row builder. Used when no +// JSStatementSync owner is available to hold the cached Structure, or +// when the column set is too wide for the inline-capacity fast path. +static JSValue rowToObject(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3_stmt* stmt, int numCols, bool useBigInts) +{ + auto& vm = getVM(globalObject); + JSObject* row = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, {}); + for (int i = 0; i < numCols; ++i) { + JSValue v = columnToJS(globalObject, scope, stmt, i, useBigInts); + RETURN_IF_EXCEPTION(scope, {}); + const char* name = sqlite3_column_name(stmt, i); + // Column names are user-controlled (`SELECT 1 AS "0"`); an + // index-string key must go to indexed storage, not through + // putDirect's named-property path (which asserts !parseIndex). + row->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, WTF::String::fromUTF8(name)), v); + RETURN_IF_EXCEPTION(scope, {}); + } + return row; +} + +// Fast-path row builder that reuses a precomputed null-prototype +// Structure. Every row from the same statement has identical column +// names, so instead of re-hashing each name per row we build the shape +// once (JSStatementSync::ensureRowStructure) and then place values +// directly at their known inline-offset. This is the same technique +// bun:sqlite's constructResultObject() uses and is what makes .all() +// on wide result sets competitive. +static JSValue rowToObjectCached(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* owner, int numCols, bool useBigInts) +{ + auto& vm = getVM(globalObject); + sqlite3_stmt* stmt = owner->statement(); + Structure* structure = owner->ensureRowStructure(globalObject); + if (!structure) { + // Too many columns for inline storage or pathological names — + // fall back to the generic path. + return rowToObject(globalObject, scope, stmt, numCols, useBigInts); + } + JSObject* row = JSC::constructEmptyObject(vm, structure); + const auto& offsets = owner->columnOffsets(); + // ensureRowStructure() reads sqlite3_column_count() afresh; all + // callers pass a post-step numCols, so these agree. The min() + // is a belt-and-suspenders bound so any future caller that + // passes a stale count can't walk past the offsets vector. + int limit = std::min(numCols, static_cast(offsets.size())); + for (int i = 0; i < limit; ++i) { + JSValue v = columnToJS(globalObject, scope, stmt, i, useBigInts); + RETURN_IF_EXCEPTION(scope, {}); + int8_t off = offsets[static_cast(i)]; + // Duplicate names map to the same offset, so a later column + // overwrites the earlier one — last-wins, matching Node's + // V8 Object::Set() loop and the generic rowToObject() path. + row->putDirectOffset(vm, static_cast(off), v); + } + return row; +} + +static JSValue rowToArray(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3_stmt* stmt, int numCols, bool useBigInts) +{ + auto& vm = getVM(globalObject); + JSArray* row = constructEmptyArray(globalObject, nullptr, numCols); + RETURN_IF_EXCEPTION(scope, {}); + for (int i = 0; i < numCols; ++i) { + JSValue v = columnToJS(globalObject, scope, stmt, i, useBigInts); + RETURN_IF_EXCEPTION(scope, {}); + row->putDirectIndex(globalObject, i, v); + RETURN_IF_EXCEPTION(scope, {}); + } + (void)vm; + return row; +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSDatabaseSync +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSDatabaseSync::s_info = { "DatabaseSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDatabaseSync) }; +const ClassInfo JSDatabaseSyncPrototype::s_info = { "DatabaseSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDatabaseSyncPrototype) }; +const ClassInfo JSDatabaseSyncConstructor::s_info = { "DatabaseSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDatabaseSyncConstructor) }; + +JSDatabaseSync* JSDatabaseSync::create(VM& vm, Structure* structure, WTF::String&& location, DatabaseSyncOpenConfiguration&& config) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSDatabaseSync(vm, structure); + ptr->finishCreation(vm); + ptr->m_location = std::move(location); + ptr->m_config = std::move(config); + ptr->m_enableLoadExtension = ptr->m_config.allowExtension; + return ptr; +} + +void JSDatabaseSync::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +// DatabaseSync handles are GC cells and the VM is not destructed on a normal +// exit, so an unclosed file-backed database would never reach +// sqlite3_close_v2(); Node closes them on environment teardown. +static WTF::Lock openDatabasesLock; +// Keyed by the owning VM, captured while the cell is provably alive: the exit +// walk filters on the stored pointer instead of dereferencing cells that +// another thread's heap may be sweeping. Entries are not GC roots. +static WTF::HashMap& openDatabases() +{ + static WTF::NeverDestroyed> map; + return map; +} + +static void registerOpenDatabase(JSDatabaseSync* db, JSC::VM& vm) +{ + // The destructor is what removes the raw pointer again (via + // closeInternal), so it must run before the cell's memory is reused. + static_assert(JSDatabaseSync::needsDestruction == JSC::NeedsDestruction); + WTF::Locker locker { openDatabasesLock }; + openDatabases().set(db, &vm); +} + +static void unregisterOpenDatabase(JSDatabaseSync* db) +{ + WTF::Locker locker { openDatabasesLock }; + openDatabases().remove(db); +} + +JSDatabaseSync::~JSDatabaseSync() +{ + // Reachable with a BusyScope still on the stack only when process.exit() + // was called from inside a UDF/authorizer and the heap is destructed on + // exit; closing the connection mid-sqlite3_step is a use-after-free. + if (!isBusy()) { + closeInternal(); + return; + } + // ~JSNodeSqliteSession follows record->db only while !dbGone, so it must + // be set before this cell is freelisted, and the registry must not keep a + // dangling pointer. Pure bookkeeping: neither write calls into SQLite. + for (auto& record : m_sessions) + record->dbGone = true; + unregisterOpenDatabase(this); +} + +void JSDatabaseSync::closeInternal() +{ + // Statements are not tracked here: GC order between JSDatabaseSync and + // its JSStatementSyncs is undefined during VM teardown, so holding raw + // pointers back to them would dangle. sqlite3_close_v2 tolerates + // unfinalized statements by zombifying the connection until each + // statement is independently finalized (by ~JSStatementSync()). Every + // JSStatementSync holds a strong WriteBarrier to this object, so during + // normal GC the database is kept alive while any statement is reachable; + // statements observe closure via isFinalized(). + // + // Sessions are different — the preupdate hook they install keeps a + // back-pointer into the connection, and sqlite3_close_v2 does NOT + // tear them down, so delete any that JS hasn't already closed. + if (m_db) { + deleteTrackedSessions(); + sqlite3_close_v2(m_db); + m_db = nullptr; + unregisterOpenDatabase(this); + // The connection (and with it every registered function context) + // is gone; drop the callback roots so explicitly-closed databases + // don't retain their callbacks for the rest of the cell's lifetime. + // Plain clear (no JS access), so this is safe from the destructor. + m_namedRegistrations.clear(); + Locker locker { cellLock() }; + m_registeredCallbacks.clear(); + } +} + +// Called from ExitHandler::dispatch_on_exit, on the main thread only; entries +// owned by another VM (a worker) are skipped by the stored-VM comparison +// without ever touching the foreign cell. +extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObject* globalObject) +{ + JSC::VM* mainVM = &globalObject->vm(); + WTF::Vector toClose; + { + WTF::Locker locker { openDatabasesLock }; + for (auto& entry : openDatabases()) { + if (entry.value == mainVM) + toClose.append(entry.key); + } + } + for (auto* db : toClose) { + // process.exit() inside a UDF/authorizer reaches here with + // sqlite3_step() still on the C stack; closing that connection is + // the same use-after-free a busy close() refuses. Leave it alone. + if (db->isBusy()) + continue; + // With un-finalized statements close_v2 only zombifies the connection + // and defers the WAL checkpoint to a finalize that never comes, so + // flush the WAL into the main database file explicitly. Best effort. + if (sqlite3* handle = db->connection()) + sqlite3_wal_checkpoint_v2(handle, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); + // closeInternal() re-takes openDatabasesLock to unregister, so the + // snapshot lock above must already be dropped; it also nulls m_db, + // making a later GC destructor a no-op rather than a double close. + db->closeInternal(); + } +} + +void JSDatabaseSync::deleteTrackedSessions() +{ + for (auto& record : m_sessions) { + if (record->handle) { + sqlite3session_delete(record->handle); + record->handle = nullptr; + } + record->dbGone = true; + } + m_sessions.clear(); + m_hasOrphanedSessions = false; +} + +void JSDatabaseSync::sweepOrphanedSessions() +{ + // Deferred cleanup for sessions whose JS wrapper was GC'd without + // close(): the wrapper's destructor cannot call into SQLite (it can run + // mid-sqlite3_step), so it only flags the record. Skip while busy — a + // UDF callback can re-enter exec()/prepare() while the connection is + // inside sqlite3_step and the preupdate hook may be iterating sessions. + if (!m_hasOrphanedSessions || m_busyDepth > 0) + return; + m_hasOrphanedSessions = false; + m_sessions.removeAllMatching([](auto& record) { + if (!record->wrapperGone) + return false; + if (record->handle) { + sqlite3session_delete(record->handle); + record->handle = nullptr; + } + record->dbGone = true; + return true; + }); +} + +bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) +{ + if (m_db) { + throwNodeState(globalObject, scope, "database is already open"_s); + return false; + } + + // SQLITE_OPEN_URI mirrors Node's `default_flags = SQLITE_OPEN_URI` + // (node_sqlite.cc). Strings, Uint8Arrays, and URL objects all reach + // sqlite3ParseUri verbatim (validateDatabasePath passes a URL's raw + // href through), so a `file:…?mode=ro` / `?cache=shared` query is + // honoured on any of those input types. + int flags = SQLITE_OPEN_URI | (m_config.readOnly ? SQLITE_OPEN_READONLY : (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE)); + + auto utf8 = m_location.utf8(); + sqlite3* db = nullptr; + int r = sqlite3_open_v2(utf8.data(), &db, flags, nullptr); + if (r != SQLITE_OK) { + if (db) { + throwSqliteError(globalObject, scope, db); + sqlite3_close_v2(db); + } else { + throwSqliteMessage(globalObject, scope, r, WTF::String::fromUTF8(sqlite3_errstr(r))); + } + return false; + } + + m_db = db; + ++m_openGeneration; + // Register before the fallible configuration calls below: each of their + // failure paths goes through closeInternal(), which unregisters. + registerOpenDatabase(this, globalObject->vm()); + + int v = m_config.enableDoubleQuotedStringLiterals ? 1 : 0; + sqlite3_db_config(m_db, SQLITE_DBCONFIG_DQS_DML, v, nullptr); + sqlite3_db_config(m_db, SQLITE_DBCONFIG_DQS_DDL, v, nullptr); + + v = m_config.enableForeignKeyConstraints ? 1 : 0; + if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_FKEY, v, nullptr) != SQLITE_OK) { + throwSqliteError(globalObject, scope, m_db); + closeInternal(); + return false; + } + + v = m_config.enableDefensive ? 1 : 0; + if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_DEFENSIVE, v, nullptr) != SQLITE_OK) { + throwSqliteError(globalObject, scope, m_db); + closeInternal(); + return false; + } + + for (const auto& info : kLimitMapping) { + int initial = m_config.initialLimits[static_cast(info.id)]; + if (initial >= 0) sqlite3_limit(m_db, info.id, initial); + } + + if (sqlite3_busy_timeout(m_db, m_config.timeout) != SQLITE_OK) { + throwSqliteError(globalObject, scope, m_db); + closeInternal(); + return false; + } + + if (m_config.allowExtension) { + if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, 1, nullptr) != SQLITE_OK) { + throwSqliteError(globalObject, scope, m_db); + closeInternal(); + return false; + } + } + + return true; +} + +template +void JSDatabaseSync::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_authorizer); + visitor.append(thisObject->m_limits); + // visitChildren runs on a GC thread concurrently with the mutator, and + // function()/aggregate() append to this vector — take the cell lock on + // both sides so the iteration doesn't race a reallocation. + Locker locker { thisObject->cellLock() }; + for (auto& callback : thisObject->m_registeredCallbacks) + visitor.append(callback); +} +DEFINE_VISIT_CHILDREN(JSDatabaseSync); + +size_t JSDatabaseSync::addRegisteredCallback(VM& vm, JSValue value) +{ + Locker locker { cellLock() }; + // Reuse a slot released by releaseSupersededRegistration() before growing + // the vector, so re-registering the same function name doesn't accumulate + // roots for the connection's lifetime. + for (size_t i = 0; i < m_registeredCallbacks.size(); ++i) { + if (m_registeredCallbacks[i].get().isEmpty()) { + m_registeredCallbacks[i].set(vm, this, value); + return i; + } + } + m_registeredCallbacks.append(JSC::WriteBarrier()); + m_registeredCallbacks.last().set(vm, this, value); + return m_registeredCallbacks.size() - 1; +} + +void JSDatabaseSync::releaseSupersededRegistration(const WTF::String& name, int argc) +{ + for (size_t i = 0; i < m_namedRegistrations.size(); ++i) { + auto& reg = m_namedRegistrations[i]; + // SQLite replaces registrations case-insensitively (ASCII), so match + // the same way or a re-registration under different casing would + // keep the superseded callback rooted until close(). + if (reg.argc != argc || !WTF::equalIgnoringASCIICase(reg.name, name)) + continue; + { + Locker locker { cellLock() }; + for (size_t slot : reg.slots) { + if (slot != kNoCallbackSlot && slot < m_registeredCallbacks.size()) + m_registeredCallbacks[slot].clear(); + } + } + m_namedRegistrations.removeAt(i); + return; + } +} + +void JSDatabaseSync::rememberRegistration(const WTF::String& name, int argc, const std::array& slots) +{ + m_namedRegistrations.append(NamedRegistration { name, argc, slots }); +} + +GCClient::IsoSubspace* JSDatabaseSync::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteDatabaseSync.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteDatabaseSync = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteDatabaseSync.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteDatabaseSync = std::forward(space); }); +} + +// ─── DatabaseSync prototype functions ─────────────────────────────────────── + +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncOpen); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncClose); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncExec); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncPrepare); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncLocation); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncLoadExtension); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncFunction); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncAggregate); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncCreateSession); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncEnableDefensive); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncSetAuthorizer); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncSerialize); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncDeserialize); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncCreateTagStore); +JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncDispose); +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsOpen); +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsTransaction); +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncLimits); + +#define THIS_DATABASE() \ + auto& vm = JSC::getVM(globalObject); \ + auto scope = DECLARE_THROW_SCOPE(vm); \ + JSDatabaseSync* self = dynamicDowncast(callFrame->thisValue()); \ + if (!self) [[unlikely]] { \ + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "DatabaseSync"_s)); \ + return {}; \ + } + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncOpen, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + self->open(globalObject, scope); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + if (self->isBusy()) { + // A native call on this connection is on the stack (option-getter, + // UDF, xFilter, progress, …). Closing now would null/free the + // sqlite3* out from under it — see BusyScope users below. + return throwNodeState(globalObject, scope, + "cannot close database while a statement is executing"_s); + } + self->closeInternal(); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDispose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + (void)vm; + JSDatabaseSync* self = dynamicDowncast(callFrame->thisValue()); + if (self && self->isOpen() && !self->isBusy()) { + self->closeInternal(); + } + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncExec, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + JSValue sqlVal = callFrame->argument(0); + if (!sqlVal.isString()) { + return throwNodeArgType(globalObject, scope, "sql"_s, "a string"_s); + } + auto sql = sqlVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto utf8 = sql.utf8(); + int r = sqlite3_exec(self->connection(), utf8.data(), nullptr, nullptr, nullptr); + CHECK_UDF_EXCEPTION(scope, self); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + JSValue sqlVal = callFrame->argument(0); + if (!sqlVal.isString()) { + return throwNodeArgType(globalObject, scope, "sql"_s, "a string"_s); + } + auto sql = sqlVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto utf8 = sql.utf8(); + sqlite3_stmt* stmt = nullptr; + int r = sqlite3_prepare_v2(self->connection(), utf8.data(), static_cast(utf8.length()), &stmt, nullptr); + // prepare() runs the authorizer callback (if any), which may + // throw — surface that over SQLite's generic "not authorized". + CHECK_UDF_EXCEPTION(scope, self); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + // sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == nullptr when the + // input contains no SQL (empty / whitespace / comment only). Node.js + // surfaces that as ERR_INVALID_STATE at prepare() time. + if (stmt == nullptr) { + return throwNodeState(globalObject, scope, + "The supplied SQL string contains no statements"_s); + } + // Inherit the database-level defaults (set via the constructor options), + // then let prepare()'s own options override per-statement. + const auto& cfg = self->config(); + bool readBigInts = cfg.readBigInts; + bool returnArrays = cfg.returnArrays; + bool allowBare = cfg.allowBareNamedParameters; + bool allowUnknown = cfg.allowUnknownNamedParameters; + + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + sqlite3_finalize(stmt); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"options\" argument must be an object."_s); + } + JSObject* opts = optsVal.getObject(); + auto fail = [&]() { sqlite3_finalize(stmt); return EncodedJSValue {}; }; + if (!readBoolOption(globalObject, scope, opts, "readBigInts"_s, readBigInts)) return fail(); + if (!readBoolOption(globalObject, scope, opts, "returnArrays"_s, returnArrays)) return fail(); + if (!readBoolOption(globalObject, scope, opts, "allowBareNamedParameters"_s, allowBare)) return fail(); + if (!readBoolOption(globalObject, scope, opts, "allowUnknownNamedParameters"_s, allowUnknown)) return fail(); + } + + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSStatementSyncClassStructure.get(zigGlobal); + auto* stmtObj = JSStatementSync::create(vm, structure, self, stmt); + stmtObj->setUseBigInts(readBigInts); + stmtObj->setReturnArrays(returnArrays); + stmtObj->setAllowBareNamedParams(allowBare); + stmtObj->setAllowUnknownNamedParams(allowUnknown); + return JSValue::encode(stmtObj); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncLocation, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + WTF::String dbName = "main"_s; + JSValue arg0 = callFrame->argument(0); + if (!arg0.isUndefined()) { + if (!arg0.isString()) { + return throwNodeArgType(globalObject, scope, "dbName"_s, "a string"_s); + } + dbName = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + auto utf8 = dbName.utf8(); + const char* filename = sqlite3_db_filename(self->connection(), utf8.data()); + if (filename == nullptr || filename[0] == '\0') { + return JSValue::encode(jsNull()); + } + return JSValue::encode(jsString(vm, WTF::String::fromUTF8(filename))); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSValue arg0 = callFrame->argument(0); + if (!arg0.isBoolean()) { + return throwNodeArgType(globalObject, scope, "allow"_s, "a boolean"_s); + } + bool allow = arg0.asBoolean(); + if (allow && !self->allowLoadExtension()) { + return throwNodeState(globalObject, scope, "extension loading is not allowed"_s); + } + int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, allow ? 1 : 0, nullptr); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + self->setEnableLoadExtension(allow); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncLoadExtension, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + if (!self->allowLoadExtension() || !self->enableLoadExtensionIsOn()) { + return throwNodeState(globalObject, scope, "extension loading is not allowed"_s); + } + JSValue pathVal = callFrame->argument(0); + if (!pathVal.isString()) { + return throwNodeArgType(globalObject, scope, "path"_s, "a string"_s); + } + auto path = pathVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto pathUtf8 = path.utf8(); + + WTF::CString entryUtf8; + const char* entryPtr = nullptr; + JSValue entryVal = callFrame->argument(1); + if (!entryVal.isUndefined()) { + if (!entryVal.isString()) { + return throwNodeArgType(globalObject, scope, "entryPoint"_s, "a string"_s); + } + auto entry = entryVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + entryUtf8 = entry.utf8(); + entryPtr = entryUtf8.data(); + } + + char* errmsg = nullptr; + int r = sqlite3_load_extension(self->connection(), pathUtf8.data(), entryPtr, &errmsg); + if (r != SQLITE_OK) { + WTF::String message = errmsg ? WTF::String::fromUTF8(errmsg) : WTF::String::fromUTF8(sqlite3_errstr(r)); + if (errmsg) sqlite3_free(errmsg); + Bun::throwError(globalObject, scope, ErrorCode::ERR_LOAD_SQLITE_EXTENSION, message); + return {}; + } + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncFunction, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + + JSValue nameVal = callFrame->argument(0); + if (!nameVal.isString()) { + return throwNodeArgType(globalObject, scope, "name"_s, "a string"_s); + } + auto name = nameVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // function(name, func) or function(name, options, func) + size_t fnIndex = callFrame->argumentCount() < 3 ? 1 : 2; + JSValue fnVal = callFrame->argument(fnIndex); + JSValue optsVal = fnIndex == 2 ? callFrame->argument(1) : jsUndefined(); + + bool useBigIntArgs = false; + bool varargs = false; + bool deterministic = false; + bool directOnly = false; + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + if (!readBoolOption(globalObject, scope, opts, "useBigIntArguments"_s, useBigIntArgs)) return {}; + if (!readBoolOption(globalObject, scope, opts, "varargs"_s, varargs)) return {}; + if (!readBoolOption(globalObject, scope, opts, "deterministic"_s, deterministic)) return {}; + if (!readBoolOption(globalObject, scope, opts, "directOnly"_s, directOnly)) return {}; + } + + if (!fnVal.isCallable()) { + return throwNodeArgType(globalObject, scope, "function"_s, "a function"_s); + } + JSObject* fn = fnVal.getObject(); + + int argc = -1; + if (!varargs) { + JSValue lenVal = fn->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, {}); + argc = lenVal.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + + int textRep = SQLITE_UTF8; + if (deterministic) textRep |= SQLITE_DETERMINISTIC; + if (directOnly) textRep |= SQLITE_DIRECTONLY; + + auto* udf = new NodeSqliteUDF(globalObject, self, fn, useBigIntArgs); + auto nameUtf8 = name.utf8(); + int r = sqlite3_create_function_v2(self->connection(), nameUtf8.data(), argc, textRep, + udf, NodeSqliteUDF::xFunc, nullptr, nullptr, NodeSqliteUDF::xDestroy); + if (r != SQLITE_OK) { + // SQLite owns udf once xDestroy is passed in — it invokes xDestroy + // on the failure path too (name too long / nArg out of range / + // SQLITE_BUSY), so a manual delete here would double-free. + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + // SQLite has dropped any previous (name, argc) registration, so release + // its roots, then root the new callback on the cell — the raw pointer in + // the UDF context stays valid for the registration's lifetime without + // pinning the cell. + self->releaseSupersededRegistration(name, argc); + std::array slots { JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot }; + slots[0] = self->addRegisteredCallback(vm, fn); + self->rememberRegistration(name, argc, slots); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncAggregate, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + + JSValue nameVal = callFrame->argument(0); + if (!nameVal.isString()) { + return throwNodeArgType(globalObject, scope, "name"_s, "a string"_s); + } + auto name = nameVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + + JSValue startV = opts->get(globalObject, Identifier::fromString(vm, "start"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (startV.isUndefined()) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"options.start\" argument must be a function or a primitive value."_s); + } + JSValue stepV = opts->get(globalObject, Identifier::fromString(vm, "step"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!stepV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.step"_s, "a function"_s); + } + JSValue resultV = opts->get(globalObject, Identifier::fromString(vm, "result"_s)); + RETURN_IF_EXCEPTION(scope, {}); + JSObject* resultFn = nullptr; + if (!resultV.isUndefined()) { + if (!resultV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.result"_s, "a function"_s); + } + resultFn = resultV.getObject(); + } + + bool useBigIntArgs = false; + bool varargs = false; + bool directOnly = false; + if (!readBoolOption(globalObject, scope, opts, "useBigIntArguments"_s, useBigIntArgs)) return {}; + if (!readBoolOption(globalObject, scope, opts, "varargs"_s, varargs)) return {}; + if (!readBoolOption(globalObject, scope, opts, "directOnly"_s, directOnly)) return {}; + + JSValue inverseV = opts->get(globalObject, Identifier::fromString(vm, "inverse"_s)); + RETURN_IF_EXCEPTION(scope, {}); + JSObject* inverseFn = nullptr; + if (!inverseV.isUndefined()) { + if (!inverseV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.inverse"_s, "a function"_s); + } + inverseFn = inverseV.getObject(); + } + + JSObject* stepFn = stepV.getObject(); + int argc = -1; + if (!varargs) { + JSValue lenVal = stepFn->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, {}); + // First parameter of step() is the accumulator, not a SQL argument. + argc = std::max(0, lenVal.toInt32(globalObject) - 1); + RETURN_IF_EXCEPTION(scope, {}); + if (inverseFn) { + JSValue ilenVal = inverseFn->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, {}); + argc = std::max(argc, std::max(0, ilenVal.toInt32(globalObject) - 1)); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + int textRep = SQLITE_UTF8; + if (directOnly) textRep |= SQLITE_DIRECTONLY; + + auto* agg = new NodeSqliteAggregate(globalObject, self, startV, stepFn, resultFn, inverseFn, useBigIntArgs); + auto nameUtf8 = name.utf8(); + auto xInverse = inverseFn ? NodeSqliteAggregate::xInverse : nullptr; + auto xValue = inverseFn ? NodeSqliteAggregate::xValue : nullptr; + int r = sqlite3_create_window_function(self->connection(), nameUtf8.data(), argc, textRep, agg, + NodeSqliteAggregate::xStep, NodeSqliteAggregate::xFinal, xValue, xInverse, NodeSqliteAggregate::xDestroy); + if (r != SQLITE_OK) { + // SQLite already invoked xDestroy(agg) on the failure path. + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + // SQLite has dropped any previous (name, argc) registration, so release + // its roots, then root every value the aggregate context references; the + // context itself only holds raw pointers (see NodeSqliteUDF comment). + self->releaseSupersededRegistration(name, argc); + std::array slots { JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot, JSDatabaseSync::kNoCallbackSlot }; + slots[0] = self->addRegisteredCallback(vm, startV); + slots[1] = self->addRegisteredCallback(vm, stepFn); + if (resultFn) slots[2] = self->addRegisteredCallback(vm, resultFn); + if (inverseFn) slots[3] = self->addRegisteredCallback(vm, inverseFn); + self->rememberRegistration(name, argc, slots); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateSession, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + + WTF::String table; + WTF::String dbName = "main"_s; + JSValue optsVal = callFrame->argument(0); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + JSValue tableV = opts->get(globalObject, Identifier::fromString(vm, "table"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!tableV.isUndefined()) { + if (!tableV.isString()) { + return throwNodeArgType(globalObject, scope, "options.table"_s, "a string"_s); + } + table = tableV.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + JSValue dbV = opts->get(globalObject, Identifier::fromString(vm, "db"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!dbV.isUndefined()) { + if (!dbV.isString()) { + return throwNodeArgType(globalObject, scope, "options.db"_s, "a string"_s); + } + dbName = dbV.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + auto dbNameUtf8 = dbName.utf8(); + sqlite3_session* pSession = nullptr; + int r = sqlite3session_create(self->connection(), dbNameUtf8.data(), &pSession); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + auto tableUtf8 = table.utf8(); + r = sqlite3session_attach(pSession, table.isEmpty() ? nullptr : tableUtf8.data()); + if (r != SQLITE_OK) { + sqlite3session_delete(pSession); + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + + auto record = adoptRef(*new NodeSqliteSessionRecord); + record->db = self; + record->handle = pSession; + self->trackSession(record.copyRef()); + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSNodeSqliteSessionClassStructure.get(zigGlobal); + auto* session = JSNodeSqliteSession::create(vm, structure, self, WTF::move(record)); + return JSValue::encode(session); +} + +// applyChangeset callbacks: sqlite3 needs C function pointers, so capture +// the JS callbacks in a stack-allocated context threaded through via pCtx. +struct ApplyChangesetContext { + JSGlobalObject* globalObject; + JSDatabaseSync* db; + JSObject* onConflict; + JSObject* filter; +}; + +static int applyChangesetXConflict(void* pCtx, int eConflict, sqlite3_changeset_iter*) +{ + auto* ctx = static_cast(pCtx); + if (!ctx->onConflict) return SQLITE_CHANGESET_ABORT; + auto* globalObject = ctx->globalObject; + auto& vm = getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (scope.exception()) [[unlikely]] + return SQLITE_CHANGESET_ABORT; + MarkedArgumentBuffer args; + args.append(jsNumber(eConflict)); + auto callData = JSC::getCallData(ctx->onConflict); + JSValue ret = JSC::call(globalObject, ctx->onConflict, callData, jsNull(), args); + if (scope.exception()) [[unlikely]] { + ctx->db->setIgnoreNextSqliteError(); + return SQLITE_CHANGESET_ABORT; + } + // Node returns the raw value to sqlite only when it IsInt32(); a + // non-integer (object, null, Promise, …) becomes -1, which + // sqlite3changeset_apply rejects with SQLITE_MISUSE so the caller + // sees "bad parameter or other API misuse". ToInt32 coercion would + // instead turn {} into 0 (== SQLITE_CHANGESET_OMIT) and silently + // swallow the bug. + if (ret.isInt32()) return ret.asInt32(); + if (ret.isNumber()) { + double d = ret.asNumber(); + if (std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX) + return static_cast(d); + } + return -1; +} + +static int applyChangesetXFilter(void* pCtx, const char* zTab) +{ + auto* ctx = static_cast(pCtx); + if (!ctx->filter) return 1; + auto* globalObject = ctx->globalObject; + auto& vm = getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (scope.exception()) [[unlikely]] + return 0; + MarkedArgumentBuffer args; + args.append(jsString(vm, WTF::String::fromUTF8(zTab))); + auto callData = JSC::getCallData(ctx->filter); + JSValue ret = JSC::call(globalObject, ctx->filter, callData, jsNull(), args); + if (scope.exception()) [[unlikely]] { + ctx->db->setIgnoreNextSqliteError(); + return 0; + } + bool keep = ret.toBoolean(globalObject); + return keep ? 1 : 0; +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + + auto* buf = dynamicDowncast(callFrame->argument(0)); + if (!buf) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"changeset\" argument must be a Uint8Array."_s); + } + + ApplyChangesetContext ctx { globalObject, self, nullptr, nullptr }; + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + JSValue onConflictV = opts->get(globalObject, Identifier::fromString(vm, "onConflict"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!onConflictV.isUndefined()) { + if (!onConflictV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.onConflict"_s, "a function"_s); + } + ctx.onConflict = onConflictV.getObject(); + } + JSValue filterV = opts->get(globalObject, Identifier::fromString(vm, "filter"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!filterV.isUndefined()) { + if (!filterV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.filter"_s, "a function"_s); + } + ctx.filter = filterV.getObject(); + } + } + + // The option getters above can run user JS; if one of them detached the + // input, span() below would be {nullptr, 0} and the call would "apply" + // an empty changeset and report success — reject instead (same guard as + // deserialize()). A genuinely empty changeset (no recorded changes) is + // still accepted. + if (buf->isDetached()) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_VALUE, + "The \"changeset\" argument must not be detached."_s); + } + + // sqlite3changeset_apply stores pChangeset (no copy) and streams from + // it between xFilter/xConflict invocations. Those callbacks re-enter + // JS, which could detach `buf` (e.g. `changeset.buffer.transfer()`) + // and let GC free the backing store while SQLite is still reading + // from it. Copy into an owned buffer so the lifetime is tied to this + // stack frame regardless of what JS does. Changesets are typically + // small, so the copy is cheap relative to the safety it buys. + auto span = buf->span(); + // sqlite3changeset_apply takes an `int` length and has no 64-bit + // variant; reject anything that would not survive the narrowing + // instead of letting it wrap to a small or negative count. + if (span.size() > static_cast(std::numeric_limits::max())) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, + "The \"changeset\" argument is too large for SQLite."_s); + } + WTF::Vector owned; + if (!owned.tryAppend(span)) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_MEMORY_ALLOCATION_FAILED, + "Failed to allocate memory for changeset"_s); + } + // sqlite3changeset_apply declares pChangeset as `void*` (non-const) + // for historical reasons; the buffer is not written to. + int r = sqlite3changeset_apply(self->connection(), + static_cast(owned.size()), owned.mutableSpan().data(), + applyChangesetXFilter, applyChangesetXConflict, &ctx); + CHECK_UDF_EXCEPTION(scope, self); + if (r == SQLITE_ABORT) { + // Conflict handler returned ABORT — Node.js surfaces this as + // `false` rather than throwing. + return JSValue::encode(jsBoolean(false)); + } + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + return JSValue::encode(jsBoolean(true)); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableDefensive, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSValue arg0 = callFrame->argument(0); + if (!arg0.isBoolean()) { + return throwNodeArgType(globalObject, scope, "active"_s, "a boolean"_s); + } + int enable = arg0.asBoolean() ? 1 : 0; + int out = 0; + int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_DEFENSIVE, enable, &out); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + return JSValue::encode(jsUndefined()); +} + +// sqlite3_set_authorizer() callback. Runs from inside sqlite3_prepare_* +// and sqlite3_exec() — i.e. *between* BusyScope open and close on the +// JSDatabaseSync — so the db pointer is live for its entire duration. +// Uses TopExceptionScope for the same reason xFunc does: the destructor +// of a nested ThrowScope would simulateThrow(), tripping the next +// callback's constructor under validateExceptionChecks. A thrown JS +// exception (or a non-integer / out-of-range return) becomes SQLITE_DENY +// plus setIgnoreNextSqliteError() so the outer host function surfaces +// the JS error instead of "not authorized". +static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const char* p1, const char* p2, const char* p3, const char* p4) +{ + auto* db = static_cast(userData); + auto* globalObject = db->globalObject(); + auto& vm = getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + auto* fn = db->m_authorizer.get(); + if (!fn) [[unlikely]] + return SQLITE_OK; + + auto toJS = [&](const char* s) -> JSValue { + return s ? jsString(vm, WTF::String::fromUTF8(s)) : jsNull(); + }; + + MarkedArgumentBuffer args; + args.append(jsNumber(actionCode)); + args.append(toJS(p1)); + args.append(toJS(p2)); + args.append(toJS(p3)); + args.append(toJS(p4)); + + auto callData = JSC::getCallData(fn); + JSValue result = JSC::call(globalObject, fn, callData, jsUndefined(), args); + if (scope.exception()) [[unlikely]] { + db->setIgnoreNextSqliteError(); + return SQLITE_DENY; + } + + // Node accepts only the three documented codes. Anything else is a + // TypeError (wrong type) or RangeError (integer but not in the set). + // We have to raise the JS exception from inside sqlite's C + // callback, so open a transient ThrowScope just long enough to + // place the error on the VM. After that scope's destructor has + // simulateThrow()'d, acknowledge the pending exception on the + // *outer* TopExceptionScope — otherwise its destructor's + // verifyExceptionCheckNeedIsSatisfied asserts under + // validateExceptionChecks when we unwind back into sqlite. + auto fail = [&](bool typeErr, ASCIILiteral msg) { + { + auto inner = DECLARE_THROW_SCOPE(vm); + auto* err = typeErr + ? createTypeError(globalObject, msg) + : createRangeError(globalObject, msg); + inner.throwException(globalObject, err); + inner.release(); + } + (void)scope.exception(); + db->setIgnoreNextSqliteError(); + return SQLITE_DENY; + }; + + if (!result.isInt32()) { + if (result.isNumber()) { + double d = result.asNumber(); + if (std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX) { + result = jsNumber(static_cast(d)); + } + } + if (!result.isInt32()) { + return fail(true, "Authorizer callback must return an integer authorization code"_s); + } + } + int32_t code = result.asInt32(); + if (code != SQLITE_OK && code != SQLITE_DENY && code != SQLITE_IGNORE) { + return fail(false, "Authorizer callback returned a invalid authorization code"_s); + } + return code; +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSetAuthorizer, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSValue arg0 = callFrame->argument(0); + if (arg0.isNull()) { + sqlite3_set_authorizer(self->connection(), nullptr, nullptr); + self->m_authorizer.clear(); + return JSValue::encode(jsUndefined()); + } + if (!arg0.isCallable()) { + return throwNodeArgType(globalObject, scope, "callback"_s, "a function or null"_s); + } + self->m_authorizer.set(vm, self, arg0.getObject()); + int r = sqlite3_set_authorizer(self->connection(), nodeSqliteAuthorizerCallback, self); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + JSDatabaseSync::BusyScope busy { self }; + + WTF::String dbName = "main"_s; + JSValue arg0 = callFrame->argument(0); + if (!arg0.isUndefined()) { + if (!arg0.isString()) { + return throwNodeArgType(globalObject, scope, "dbName"_s, "a string"_s); + } + dbName = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + auto dbNameUtf8 = dbName.utf8(); + + sqlite3_int64 size = 0; + unsigned char* data = sqlite3_serialize(self->connection(), dbNameUtf8.data(), &size, 0); + // For non-memdb schemas (regular :memory: or file-backed) + // sqlite3_serialize internally prepares `PRAGMA "".page_count`, + // which fires the authorizer with SQLITE_PRAGMA. Surface a thrown + // JS exception over SQLite's "not authorized" — same as + // exec()/prepare()/deserialize()/TagStore. On this path `data` is + // already null (no cleanup needed). + self->takeIgnoreNextSqliteError(); + if (scope.exception()) [[unlikely]] { + if (data) sqlite3_free(data); + return {}; + } + if (data == nullptr) { + // sqlite3_serialize returns null with size==0 for a brand-new + // empty schema whose database file hasn't been materialised yet + // (e.g. serialising an ATTACHed :memory: schema that has had no + // DDL). Node treats that as an empty Uint8Array; anything else + // is a real failure on the connection. + if (size == 0) { + auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), 0); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(array); + } + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + + size_t byteLen = static_cast(size); + auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), byteLen); + if (scope.exception()) [[unlikely]] { + sqlite3_free(data); + return {}; + } + if (byteLen > 0) memcpy(array->typedVector(), data, byteLen); + sqlite3_free(data); + return JSValue::encode(array); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + // deserialize() tears down every prepared statement on the connection + // (they all refer to schema that's about to be replaced), so refuse + // while anything is mid-execution for the same reason close() does. + if (self->isBusy()) { + return throwNodeState(globalObject, scope, "cannot deserialize database while a statement is executing"_s); + } + // …and establish our own busy scope before reading options. The + // opts.dbName [[Get]] below can re-enter JS; without this guard + // a hostile getter could db.close() and sqlite3_deserialize would + // see a null connection (no SQLITE_ENABLE_API_ARMOR → segfault on + // db->mutex). Matches the sweep in 78f8f229e7 for the other + // option-reading methods. + JSDatabaseSync::BusyScope busy { self }; + + auto* buf = dynamicDowncast(callFrame->argument(0)); + if (!buf) { + return throwNodeArgType(globalObject, scope, "buffer"_s, "a Uint8Array"_s); + } + if (buf->span().size() == 0) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_VALUE, + "The \"buffer\" argument must not be empty."_s); + } + + WTF::String dbName = "main"_s; + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + JSValue nameV = opts->get(globalObject, Identifier::fromString(vm, "dbName"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!nameV.isUndefined()) { + if (!nameV.isString()) { + return throwNodeArgType(globalObject, scope, "options.dbName"_s, "a string"_s); + } + dbName = nameV.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + } + auto dbNameUtf8 = dbName.utf8(); + + // SQLITE_DESERIALIZE_FREEONCLOSE hands ownership of the buffer to + // SQLite (freed on close) — it must therefore come from + // sqlite3_malloc64. Copy the input in case JS later mutates or + // detaches it; also required for the zombie-statement case where + // the connection outlives this call. + // + // Capture the span only AFTER the opts.dbName [[Get]] above — + // a hostile getter can buf.buffer.transfer() + GC, freeing the + // backing store that an earlier span would still point into + // (same buffer-detach UAF class applyChangeset guards against + // by copying before its callbacks). A post-detach span() is + // {nullptr, 0}, which the emptiness re-check catches. + auto span = buf->span(); + if (buf->isDetached() || span.size() == 0) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_VALUE, + "The \"buffer\" argument must not be empty."_s); + } + unsigned char* owned = static_cast(sqlite3_malloc64(span.size())); + if (!owned) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_MEMORY_ALLOCATION_FAILED, + "Failed to allocate memory for SQLite deserialize"_s); + } + memcpy(owned, span.data(), span.size()); + + // Invalidate every existing statement first — after the schema + // swap they reference tables that no longer exist. Bumping the + // open-generation makes every live JSStatementSync report + // isFinalized() without us having to track them explicitly (same + // mechanism close()+open() relies on), and Node finalizes its + // statements before deserializing too, so the bump stays ahead of + // the fallible call. We leave the underlying sqlite3_stmt* alone: + // the JS wrappers still own those handles and will + // sqlite3_finalize() them on GC, so finalizing here would make the + // wrapper double-free a dangling pointer. sqlite3_deserialize + // tolerates the outstanding stmts — they simply fail if stepped, + // which the generation check prevents. + self->bumpOpenGeneration(); + + int r = sqlite3_deserialize(self->connection(), dbNameUtf8.data(), owned, + static_cast(span.size()), static_cast(span.size()), + SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE); + // sqlite3_deserialize internally runs `ATTACH x AS ` via + // sqlite3_prepare_v2, which fires the authorizer callback with + // SQLITE_ATTACH. If that throws, surface the user's exception over + // SQLite's "not authorized" — same as exec()/prepare()/TagStore. + CHECK_UDF_EXCEPTION(scope, self); + if (r != SQLITE_OK) { + // SQLite already freed `owned` (or took ownership) on both + // success and failure paths once FREEONCLOSE is set. The + // connection itself is unchanged on failure, so existing + // sessions stay valid (Node doesn't touch them here either). + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + // The schema swap succeeded. Node leaves sessions attached here, but + // their preupdate hook would keep recording writes against the new + // database content until close() — free them like closeInternal() + // does; the wrappers observe dbGone through the shared record. + self->deleteTrackedSessions(); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateTagStore, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_DATABASE(); + REQUIRE_DB_OPEN(self); + int capacity = 1000; + JSValue arg0 = callFrame->argument(0); + if (arg0.isNumber()) { + capacity = arg0.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (capacity < 1) capacity = 1; + } + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSNodeSqliteTagStoreClassStructure.get(zigGlobal); + auto* store = JSNodeSqliteTagStore::create(vm, structure, self, static_cast(capacity)); + return JSValue::encode(store); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDatabaseSyncIsOpen, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + JSDatabaseSync* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + (void)globalObject; + return JSValue::encode(jsBoolean(self->isOpen())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDatabaseSyncIsTransaction, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSDatabaseSync* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + if (!self->isOpen()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + return JSValue::encode(jsBoolean(sqlite3_get_autocommit(self->connection()) == 0)); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDatabaseSyncLimits, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = getVM(globalObject); + JSDatabaseSync* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + // Same wrapper for the lifetime of the DatabaseSync; it stays valid + // across close()/open() and just reports ERR_INVALID_STATE while + // the connection is down. + if (auto* cached = self->m_limits.get()) return JSValue::encode(cached); + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSNodeSqliteLimitsClassStructure.get(zigGlobal); + auto* limits = JSNodeSqliteLimits::create(vm, structure, self); + self->m_limits.set(vm, self, limits); + return JSValue::encode(limits); +} + +static const HashTableValue JSDatabaseSyncPrototypeTableValues[] = { + { "open"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncOpen, 0 } }, + { "close"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncClose, 0 } }, + { "exec"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncExec, 1 } }, + { "prepare"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncPrepare, 1 } }, + { "location"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncLocation, 0 } }, + { "enableLoadExtension"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncEnableLoadExtension, 1 } }, + { "enableDefensive"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncEnableDefensive, 1 } }, + { "loadExtension"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncLoadExtension, 1 } }, + { "function"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncFunction, 2 } }, + { "aggregate"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncAggregate, 2 } }, + { "createSession"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncCreateSession, 0 } }, + { "applyChangeset"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncApplyChangeset, 1 } }, + { "setAuthorizer"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncSetAuthorizer, 1 } }, + { "serialize"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncSerialize, 0 } }, + { "deserialize"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncDeserialize, 1 } }, + { "createTagStore"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncCreateTagStore, 0 } }, + { "isOpen"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncIsOpen, nullptr } }, + { "isTransaction"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncIsTransaction, nullptr } }, + { "limits"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncLimits, nullptr } }, +}; + +void JSDatabaseSyncPrototype::finishCreation(VM& vm, JSGlobalObject* globalObject) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSDatabaseSync::info(), JSDatabaseSyncPrototypeTableValues, *this); + // Symbol.dispose — swallow errors if not open, matching Node.js. + putDirectNativeFunction(vm, globalObject, vm.propertyNames->disposeSymbol, 0, jsDatabaseSyncDispose, ImplementationVisibility::Public, NoIntrinsic, 0); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +// ─── DatabaseSync constructor ─────────────────────────────────────────────── + +static bool validateDatabasePath(JSGlobalObject* globalObject, ThrowScope& scope, JSValue pathVal, WTF::String& out) +{ + auto& vm = getVM(globalObject); + if (pathVal.isString()) { + out = pathVal.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + return true; + } + // Node.js only accepts Uint8Array (and Buffer, which subclasses it). + // Reject other TypedArrays / DataView so the error message below is + // accurate. + if (auto* view = dynamicDowncast(pathVal)) { + auto span = view->span(); + out = WTF::String::fromUTF8({ reinterpret_cast(span.data()), span.size() }); + if (out.isNull()) { + // fromUTF8 returns null for byte sequences that aren't valid + // UTF-8. Without this guard the null String would become "" + // and sqlite3_open_v2("") would silently open a private + // temporary database instead of the requested file. + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_VALUE, + "The \"path\" argument must be a Uint8Array containing a valid UTF-8 byte sequence."_s); + return false; + } + return true; + } + // URL-like object: must have href+protocol and protocol "file:" + if (pathVal.isObject()) { + JSObject* obj = pathVal.getObject(); + JSValue href = obj->get(globalObject, Identifier::fromString(vm, "href"_s)); + RETURN_IF_EXCEPTION(scope, false); + JSValue proto = obj->get(globalObject, Identifier::fromString(vm, "protocol"_s)); + RETURN_IF_EXCEPTION(scope, false); + if (href.isString() && proto.isString()) { + auto protoStr = proto.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + if (protoStr != "file:"_s) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_URL_SCHEME, "The URL must be of scheme file:"_s); + return false; + } + auto hrefStr = href.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + // Pass the full href — including any ?query — straight + // through. open() sets SQLITE_OPEN_URI (as Node does), so + // sqlite3ParseUri handles percent-decoding and honours + // ?mode=ro / ?cache=shared etc. Reducing to a plain + // filesystem path here would silently drop those, which + // is exactly what test-sqlite.js's "URI query params" + // suite checks for. This mirrors Node's + // ValidateDatabasePath, which returns the href verbatim. + if (!hrefStr.startsWith("file:"_s)) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_URL_SCHEME, "The URL must be of scheme file:"_s); + return false; + } + out = hrefStr; + return true; + } + } + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); + return false; +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSDatabaseSyncConstructor::call(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_CONSTRUCT_CALL_REQUIRED, "Cannot call constructor without `new`"_s); +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSDatabaseSyncConstructor::construct(JSGlobalObject* globalObject, CallFrame* callFrame) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* zigGlobal = defaultGlobalObject(globalObject); + + WTF::String location; + if (!validateDatabasePath(globalObject, scope, callFrame->argument(0), location)) { + return {}; + } + if (location.find('\0') != WTF::notFound) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); + return {}; + } + + DatabaseSyncOpenConfiguration config {}; + bool openImmediately = true; + + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + if (!readBoolOption(globalObject, scope, opts, "open"_s, openImmediately)) return {}; + if (!readBoolOption(globalObject, scope, opts, "readOnly"_s, config.readOnly)) return {}; + if (!readBoolOption(globalObject, scope, opts, "enableForeignKeyConstraints"_s, config.enableForeignKeyConstraints)) return {}; + if (!readBoolOption(globalObject, scope, opts, "enableDoubleQuotedStringLiterals"_s, config.enableDoubleQuotedStringLiterals)) return {}; + if (!readBoolOption(globalObject, scope, opts, "allowExtension"_s, config.allowExtension)) return {}; + if (!readBoolOption(globalObject, scope, opts, "readBigInts"_s, config.readBigInts)) return {}; + if (!readBoolOption(globalObject, scope, opts, "returnArrays"_s, config.returnArrays)) return {}; + if (!readBoolOption(globalObject, scope, opts, "allowBareNamedParameters"_s, config.allowBareNamedParameters)) return {}; + if (!readBoolOption(globalObject, scope, opts, "allowUnknownNamedParameters"_s, config.allowUnknownNamedParameters)) return {}; + if (!readBoolOption(globalObject, scope, opts, "defensive"_s, config.enableDefensive)) return {}; + + JSValue limitsV = opts->get(globalObject, Identifier::fromString(vm, "limits"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!limitsV.isUndefined()) { + if (!limitsV.isObject()) { + return throwNodeArgType(globalObject, scope, "options.limits"_s, "an object"_s); + } + JSObject* limitsObj = limitsV.getObject(); + for (const auto& info : kLimitMapping) { + JSValue v = limitsObj->get(globalObject, Identifier::fromString(vm, info.name)); + RETURN_IF_EXCEPTION(scope, {}); + if (v.isUndefined()) continue; + bool ok = v.isInt32(); + if (!ok && v.isNumber()) { + double d = v.asNumber(); + ok = std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX; + } + if (!ok) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + makeString("The \"options.limits."_s, info.name, "\" argument must be an integer."_s)); + } + int32_t iv = v.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (iv < 0) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, + makeString("The \"options.limits."_s, info.name, "\" argument must be non-negative."_s)); + } + config.initialLimits[static_cast(info.id)] = iv; + } + } + + JSValue timeoutVal = opts->get(globalObject, Identifier::fromString(vm, "timeout"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!timeoutVal.isUndefined()) { + // Node.js validates with V8's IsInt32(), i.e. a finite integral + // value within the int32 range. {timeout: Infinity} and + // out-of-range integers must throw rather than silently + // wrapping through ToInt32. + bool ok = false; + if (timeoutVal.isInt32()) { + ok = true; + } else if (timeoutVal.isNumber()) { + double d = timeoutVal.asNumber(); + ok = std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX; + } + if (!ok) { + return throwNodeArgType(globalObject, scope, "options.timeout"_s, "an integer"_s); + } + config.timeout = timeoutVal.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + auto* structure = zigGlobal->m_JSDatabaseSyncClassStructure.get(zigGlobal); + auto* db = JSDatabaseSync::create(vm, structure, std::move(location), std::move(config)); + + // Node attaches Symbol.for('sqlite-type') → 'node:sqlite' to every + // instance via the InstanceTemplate so userland can sniff the + // flavour of a DatabaseSync without an `instanceof` across realms. + auto typeSym = Identifier::fromUid(vm.symbolRegistry().symbolForKey("sqlite-type"_s)); + db->putDirect(vm, typeSym, jsString(vm, String("node:sqlite"_s)), 0); + + if (openImmediately) { + db->open(globalObject, scope); + RETURN_IF_EXCEPTION(scope, {}); + } + + return JSValue::encode(db); +} + +JSDatabaseSyncConstructor* JSDatabaseSyncConstructor::create(VM& vm, JSGlobalObject* globalObject, Structure* structure, JSObject* prototype) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSDatabaseSyncConstructor(vm, structure); + ptr->finishCreation(vm, globalObject, prototype); + return ptr; +} + +void JSDatabaseSyncConstructor::finishCreation(VM& vm, JSGlobalObject*, JSObject* prototype) +{ + Base::finishCreation(vm, 1, "DatabaseSync"_s, PropertyAdditionMode::WithoutStructureTransition); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + ASSERT(inherits(info())); +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSStatementSync +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSStatementSync::s_info = { "StatementSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSStatementSync) }; +const ClassInfo JSStatementSyncPrototype::s_info = { "StatementSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSStatementSyncPrototype) }; +const ClassInfo JSStatementSyncConstructor::s_info = { "StatementSync"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSStatementSyncConstructor) }; + +JSStatementSync* JSStatementSync::create(VM& vm, Structure* structure, JSDatabaseSync* db, sqlite3_stmt* stmt) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSStatementSync(vm, structure); + ptr->finishCreation(vm, db, stmt); + return ptr; +} + +void JSStatementSync::finishCreation(VM& vm, JSDatabaseSync* db, sqlite3_stmt* stmt) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_stmt = stmt; + m_originGeneration = db->openGeneration(); + m_database.set(vm, this, db); +} + +void JSStatementSync::finalizeStatement() +{ + if (m_stmt) { + sqlite3_finalize(m_stmt); + m_stmt = nullptr; + } +} + +JSStatementSync::~JSStatementSync() +{ + // Do NOT dereference m_database here: GC may have already destroyed + // the JSDatabaseSync, leaving the WriteBarrier pointing at freed + // memory. sqlite3_finalize is safe even if the owning connection has + // already been sqlite3_close_v2'd (it simply releases the zombie). + finalizeStatement(); +} + +sqlite3* JSStatementSync::connection() const +{ + auto* db = m_database.get(); + return db ? db->connection() : nullptr; +} + +bool JSStatementSync::isFinalized() const +{ + if (m_stmt == nullptr) return true; + auto* db = m_database.get(); + // The generation check covers both "database is closed" and + // "database was closed then re-opened" — the stmt belongs to the + // zombified old connection and must not be stepped. A raw sqlite3* + // comparison isn't sufficient here: the allocator may hand the new + // connection the same address the old one had (ABA). + return db == nullptr || !db->isOpen() || db->openGeneration() != m_originGeneration; +} + +template +void JSStatementSync::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_database); + visitor.append(thisObject->m_rowStructure); +} +DEFINE_VISIT_CHILDREN(JSStatementSync); + +void JSStatementSync::invalidateRowStructure() +{ + m_rowStructure.clear(); + m_columnOffsets.clear(); + m_rowColumnCount = -1; +} + +// Build (and cache) a null-prototype Structure whose inline slots map +// 1:1 to this statement's distinct column names. Returns nullptr when +// the column set is too wide for JSFinalObject's inline capacity — +// callers fall back to the generic rowToObject() in that case. +// +// The cache is keyed on m_resetGeneration rather than just the +// column count. sqlite3_prepare_v2 transparently re-prepares on +// SQLITE_SCHEMA, so after `ALTER TABLE … RENAME COLUMN` the same +// statement can return the *same* column count with *different* +// names — a count-only key would serve a stale {oldName: value} +// structure forever (bun:sqlite defends the same technique with a +// per-db write-version; keying on reset-generation gives the same +// correctness for the simpler cost of rebuilding once per +// run/get/all/iterate rather than once per schema change). Within +// a single .all() / .iterate() the generation is constant, so the +// hot loop still hits the cache for every row after the first. +Structure* JSStatementSync::ensureRowStructure(JSGlobalObject* globalObject) +{ + auto& vm = getVM(globalObject); + int count = sqlite3_column_count(m_stmt); + if (m_rowResetGeneration == m_resetGeneration && m_rowColumnCount == count && m_rowStructure) { + return m_rowStructure.get(); + } + invalidateRowStructure(); + m_rowColumnCount = count; + m_rowResetGeneration = m_resetGeneration; + if (count <= 0 || static_cast(count) > JSFinalObject::maxInlineCapacity) { + return nullptr; + } + + // First pass: collect distinct names in column order. A join can + // produce duplicate column names; Node's row builder iterates + // columns and calls V8 Object::Set()/CreateDataProperty() for each, + // which *overwrites* on a duplicate key — so the last occurrence + // wins. Mirror that by giving a duplicate column the same slot + // offset as the first occurrence; rowToObjectCached() writes + // columns in order, so the later putDirectOffset overwrites the + // earlier one just as the generic rowToObject()'s putDirect would. + m_columnOffsets.reserveCapacity(static_cast(count)); + WTF::Vector names; + for (int i = 0; i < count; ++i) { + const char* name = sqlite3_column_name(m_stmt, i); + if (!name || name[0] == '\0') { + // Pathological — give up on the fast path for this stmt. + m_columnOffsets.clear(); + return nullptr; + } + auto id = Identifier::fromString(vm, WTF::String::fromUTF8(name)); + // Structure::addPropertyTransition asserts !parseIndex() — + // a column aliased to "0", "1", … must go through indexed + // storage instead. Bail to the generic path, which handles + // it via putDirectMayBeIndex(). + if (parseIndex(id)) { + m_columnOffsets.clear(); + return nullptr; + } + int8_t off = -1; + for (size_t j = 0; j < names.size(); ++j) { + if (names[j] == id) { + off = static_cast(j); + break; + } + } + if (off < 0) { + off = static_cast(names.size()); + names.append(id); + } + m_columnOffsets.append(off); + } + + // StructureCache::emptyObjectStructureForPrototype requires a + // non-null prototype, but node:sqlite rows have [[Prototype]] === + // null (the tests assert it). Build the null-proto shape directly + // and let the statement's own WriteBarrier keep it alive; the + // per-property transition chain is still cached on the Structure + // itself, so subsequent statements with the same column set share + // it via transition lookup. + Structure* structure = JSFinalObject::createStructure(vm, globalObject, jsNull(), static_cast(names.size())); + for (const auto& id : names) { + PropertyOffset offset; + structure = Structure::addPropertyTransition(vm, structure, id, 0, offset); + } + m_rowStructure.set(vm, this, structure); + return structure; +} + +GCClient::IsoSubspace* JSStatementSync::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteStatementSync.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteStatementSync = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteStatementSync.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteStatementSync = std::forward(space); }); +} + +// ─── Parameter binding ────────────────────────────────────────────────────── + +bool JSStatementSync::bindValue(JSGlobalObject* globalObject, ThrowScope& scope, int index, JSValue value) +{ + int r = SQLITE_OK; + if (value.isNumber()) { + // Match Node's IsInt32() → sqlite3_bind_int fast path so that + // `typeof(?)` on a bare parameter yields 'integer' (not 'real') + // and expandedSQL shows `42`, not `42.0`. + if (value.isInt32()) { + r = sqlite3_bind_int(m_stmt, index, value.asInt32()); + } else { + r = sqlite3_bind_double(m_stmt, index, value.asNumber()); + } + } else if (value.isString()) { + auto str = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + auto utf8 = str.utf8(); + // *64: see jsValueToSqliteResult(). + r = sqlite3_bind_text64(m_stmt, index, utf8.data(), utf8.length(), SQLITE_TRANSIENT, SQLITE_UTF8); + } else if (value.isNull()) { + r = sqlite3_bind_null(m_stmt, index); + } else if (value.isBigInt()) { + int64_t iv = JSBigInt::toBigInt64(value); + // toBigInt64 truncates; detect loss by round-tripping. + JSValue roundTrip = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(iv)); + RETURN_IF_EXCEPTION(scope, false); + auto cmp = JSBigInt::compare(value, roundTrip); + if (cmp != JSBigInt::ComparisonResult::Equal) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_VALUE, "BigInt value is too large to bind"_s); + return false; + } + r = sqlite3_bind_int64(m_stmt, index, iv); + } else if (auto* view = dynamicDowncast(value)) { + auto span = view->span(); + r = sqlite3_bind_blob64(m_stmt, index, span.data(), span.size(), SQLITE_TRANSIENT); + } else { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + makeString("Provided value cannot be bound to SQLite parameter "_s, index)); + return false; + } + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, connection()); + return false; + } + return true; +} + +bool JSStatementSync::bindParams(JSGlobalObject* globalObject, ThrowScope& scope, CallFrame* callFrame) +{ + auto& vm = getVM(globalObject); + sqlite3_clear_bindings(m_stmt); + + size_t anonStart = 0; + size_t argc = callFrame->argumentCount(); + int paramCount = sqlite3_bind_parameter_count(m_stmt); + + // Named parameters: first argument is a plain object (not ArrayBufferView, not Array). + if (argc > 0) { + JSValue arg0 = callFrame->argument(0); + if (arg0.isObject() && !dynamicDowncast(arg0) && !isArray(globalObject, arg0)) { + RETURN_IF_EXCEPTION(scope, false); + JSObject* named = arg0.getObject(); + if (m_allowBareNamedParams && !m_bareNamedParams.has_value()) { + // Build into a local first so a mid-loop failure (conflicting + // prefixes for the same bare name) doesn't leave a partially + // populated map cached on the statement. + WTF::HashMap bare; + for (int i = 1; i <= paramCount; ++i) { + const char* full = sqlite3_bind_parameter_name(m_stmt, i); + if (full == nullptr || full[0] == '\0') continue; + WTF::String fullStr = WTF::String::fromUTF8(full); + WTF::String bareName = fullStr.substring(1); + auto it = bare.find(bareName); + if (it != bare.end()) { + throwNodeState(globalObject, scope, + makeString("Cannot create bare named parameter '"_s, bareName, + "' because of conflicting names '"_s, it->value, + "' and '"_s, fullStr, "'."_s)); + return false; + } + bare.add(bareName, fullStr); + } + m_bareNamedParams.emplace(std::move(bare)); + } + + PropertyNameArrayBuilder keys(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + named->getOwnPropertyNames(named, globalObject, keys, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + for (auto& key : keys) { + WTF::String keyStr = key.string(); + auto keyUtf8 = keyStr.utf8(); + int index = sqlite3_bind_parameter_index(m_stmt, keyUtf8.data()); + if (index == 0 && m_allowBareNamedParams && m_bareNamedParams.has_value()) { + auto it = m_bareNamedParams->find(keyStr); + if (it != m_bareNamedParams->end()) { + auto fullUtf8 = it->value.utf8(); + index = sqlite3_bind_parameter_index(m_stmt, fullUtf8.data()); + } + } + if (index == 0) { + if (m_allowUnknownNamedParams) continue; + throwNodeState(globalObject, scope, + makeString("Unknown named parameter '"_s, keyStr, "'"_s)); + return false; + } + JSValue v = named->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + if (!bindValue(globalObject, scope, index, v)) return false; + } + anonStart = 1; + } + RETURN_IF_EXCEPTION(scope, false); + } + + // Anonymous (positional) parameters: fill slots that don't have a + // name. SQLite reports a name for `?NNN` placeholders too ("?1", + // "?2", …) but Node treats those as positional — only `$foo` / + // `:foo` / `@foo` are skipped here. + int anonIdx = 1; + for (size_t i = anonStart; i < argc; ++i) { + while (true) { + const char* name = sqlite3_bind_parameter_name(m_stmt, anonIdx); + if (name == nullptr || name[0] == '?') break; + ++anonIdx; + } + if (!bindValue(globalObject, scope, anonIdx, callFrame->argument(i))) return false; + ++anonIdx; + } + + return true; +} + +// ─── StatementSync prototype functions ────────────────────────────────────── + +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncRun); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncGet); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncAll); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncIterate); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncColumns); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetReadBigInts); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetReturnArrays); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetAllowBareNamedParameters); +JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetAllowUnknownNamedParameters); +JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncSourceSQL); +JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncExpandedSQL); + +#define THIS_STATEMENT() \ + auto& vm = JSC::getVM(globalObject); \ + auto scope = DECLARE_THROW_SCOPE(vm); \ + JSStatementSync* self = dynamicDowncast(callFrame->thisValue()); \ + if (!self) [[unlikely]] { \ + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "StatementSync"_s)); \ + return {}; \ + } + +struct StatementResetter { + sqlite3_stmt* stmt; + ~StatementResetter() + { + if (stmt) sqlite3_reset(stmt); + } +}; + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + StatementResetter resetter { self->statement() }; + + int r = sqlite3_step(self->statement()); + while (r == SQLITE_ROW) { + r = sqlite3_step(self->statement()); + } + CHECK_UDF_EXCEPTION(scope, self->database()); + if (r != SQLITE_DONE && r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + + // Don't go through self->connection() here: a named-parameter getter + // or UDF callback may have called db.close() since REQUIRE_STMT, in + // which case the wrapper's m_db is now null and sqlite3_changes64(NULL) + // is a raw db->nChange deref (no SQLITE_ENABLE_API_ARMOR in this build). + // sqlite3_db_handle() reads the statement's own back-pointer, which + // survives zombification and is what Node's StatementSync::Run uses. + sqlite3* db = sqlite3_db_handle(self->statement()); + JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); + RETURN_IF_EXCEPTION(scope, {}); + sqlite3_int64 changes = sqlite3_changes64(db); + sqlite3_int64 rowid = sqlite3_last_insert_rowid(db); + if (self->useBigInts()) { + result->putDirect(vm, Identifier::fromString(vm, "changes"_s), JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(changes)), 0); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirect(vm, Identifier::fromString(vm, "lastInsertRowid"_s), JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(rowid)), 0); + RETURN_IF_EXCEPTION(scope, {}); + } else { + result->putDirect(vm, Identifier::fromString(vm, "changes"_s), jsNumber(static_cast(changes)), 0); + result->putDirect(vm, Identifier::fromString(vm, "lastInsertRowid"_s), jsNumber(static_cast(rowid)), 0); + } + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + StatementResetter resetter { self->statement() }; + + int r = sqlite3_step(self->statement()); + CHECK_UDF_EXCEPTION(scope, self->database()); + if (r == SQLITE_DONE) return JSValue::encode(jsUndefined()); + if (r != SQLITE_ROW) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + int numCols = sqlite3_column_count(self->statement()); + if (numCols == 0) return JSValue::encode(jsUndefined()); + JSValue row = self->returnArrays() + ? rowToArray(globalObject, scope, self->statement(), numCols, self->useBigInts()) + : rowToObjectCached(globalObject, scope, self, numCols, self->useBigInts()); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(row); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + StatementResetter resetter { self->statement() }; + + JSArray* rows = constructEmptyArray(globalObject, nullptr, 0); + RETURN_IF_EXCEPTION(scope, {}); + int r; + while ((r = sqlite3_step(self->statement())) == SQLITE_ROW) { + // Read the column count AFTER step(): sqlite3_prepare_v2's + // transparent SQLITE_SCHEMA re-prepare (e.g. SELECT * after + // ALTER TABLE … DROP COLUMN) can change it on the first + // step, and ensureRowStructure() rebuilds m_columnOffsets + // with the fresh count — a stale numCols would then index + // that Vector out-of-bounds and putDirectOffset() into a + // bogus slot. get() and the iterator already capture + // post-step; this matches them. + int numCols = sqlite3_column_count(self->statement()); + JSValue row = self->returnArrays() + ? rowToArray(globalObject, scope, self->statement(), numCols, self->useBigInts()) + : rowToObjectCached(globalObject, scope, self, numCols, self->useBigInts()); + RETURN_IF_EXCEPTION(scope, {}); + rows->push(globalObject, row); + RETURN_IF_EXCEPTION(scope, {}); + } + CHECK_UDF_EXCEPTION(scope, self->database()); + if (r != SQLITE_DONE) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } + return JSValue::encode(rows); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIterate, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + // Don't step yet — the iterator pulls rows lazily on next(). Don't + // reset on scope exit either; the cursor position belongs to the + // returned iterator until it's exhausted or return()'d. + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSStatementSyncIteratorClassStructure.get(zigGlobal); + auto* iter = JSStatementSyncIterator::create(vm, structure, self); + return JSValue::encode(iter); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncColumns, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + int numCols = sqlite3_column_count(self->statement()); + JSArray* out = constructEmptyArray(globalObject, nullptr, numCols); + RETURN_IF_EXCEPTION(scope, {}); + for (int i = 0; i < numCols; ++i) { + JSObject* col = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, {}); + auto putStr = [&](ASCIILiteral key, const char* val) { + col->putDirect(vm, Identifier::fromString(vm, key), val ? jsString(vm, WTF::String::fromUTF8(val)) : jsNull(), 0); + }; +#ifdef SQLITE_ENABLE_COLUMN_METADATA + putStr("column"_s, sqlite3_column_origin_name(self->statement(), i)); + putStr("database"_s, sqlite3_column_database_name(self->statement(), i)); +#else + putStr("column"_s, nullptr); + putStr("database"_s, nullptr); +#endif + putStr("name"_s, sqlite3_column_name(self->statement(), i)); +#ifdef SQLITE_ENABLE_COLUMN_METADATA + putStr("table"_s, sqlite3_column_table_name(self->statement(), i)); +#else + putStr("table"_s, nullptr); +#endif + putStr("type"_s, sqlite3_column_decltype(self->statement(), i)); + out->putDirectIndex(globalObject, i, col); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(out); +} + +#define DEFINE_STMT_BOOL_SETTER(fnName, setter, argName) \ + JSC_DEFINE_HOST_FUNCTION(fnName, (JSGlobalObject * globalObject, CallFrame * callFrame)) \ + { \ + THIS_STATEMENT(); \ + REQUIRE_STMT(self); \ + JSValue v = callFrame->argument(0); \ + if (!v.isBoolean()) { \ + return throwNodeArgType(globalObject, scope, argName, "a boolean"_s); \ + } \ + self->setter(v.asBoolean()); \ + return JSValue::encode(jsUndefined()); \ + } + +DEFINE_STMT_BOOL_SETTER(jsStatementSyncSetReadBigInts, setUseBigInts, "readBigInts"_s) +DEFINE_STMT_BOOL_SETTER(jsStatementSyncSetReturnArrays, setReturnArrays, "returnArrays"_s) +DEFINE_STMT_BOOL_SETTER(jsStatementSyncSetAllowBareNamedParameters, setAllowBareNamedParams, "allowBareNamedParameters"_s) +// Node names this one's argument "enabled" (not the property it +// controls) — two upstream tests assert on it. +DEFINE_STMT_BOOL_SETTER(jsStatementSyncSetAllowUnknownNamedParameters, setAllowUnknownNamedParams, "enabled"_s) + +JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncSourceSQL, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSStatementSync* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + if (self->isFinalized()) { + return throwNodeState(globalObject, scope, "statement has been finalized"_s); + } + const char* sql = sqlite3_sql(self->statement()); + return JSValue::encode(jsString(vm, WTF::String::fromUTF8(sql ? sql : ""))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncExpandedSQL, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSStatementSync* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + if (self->isFinalized()) { + return throwNodeState(globalObject, scope, "statement has been finalized"_s); + } + char* expanded = sqlite3_expanded_sql(self->statement()); + if (!expanded) { + throwSqliteMessage(globalObject, scope, SQLITE_NOMEM, "Expanded SQL text would exceed configured limits"_s); + return {}; + } + JSValue result = jsString(vm, WTF::String::fromUTF8(expanded)); + sqlite3_free(expanded); + return JSValue::encode(result); +} + +static const HashTableValue JSStatementSyncPrototypeTableValues[] = { + { "run"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncRun, 0 } }, + { "get"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncGet, 0 } }, + { "all"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncAll, 0 } }, + { "iterate"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncIterate, 0 } }, + { "columns"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncColumns, 0 } }, + { "setReadBigInts"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetReadBigInts, 1 } }, + { "setReturnArrays"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetReturnArrays, 1 } }, + { "setAllowBareNamedParameters"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetAllowBareNamedParameters, 1 } }, + { "setAllowUnknownNamedParameters"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetAllowUnknownNamedParameters, 1 } }, + { "sourceSQL"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsStatementSyncSourceSQL, nullptr } }, + { "expandedSQL"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsStatementSyncExpandedSQL, nullptr } }, +}; + +void JSStatementSyncPrototype::finishCreation(VM& vm, JSGlobalObject*) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSStatementSync::info(), JSStatementSyncPrototypeTableValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSStatementSyncConstructor::call(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSStatementSyncConstructor::construct(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSStatementSyncConstructor* JSStatementSyncConstructor::create(VM& vm, JSGlobalObject* globalObject, Structure* structure, JSObject* prototype) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSStatementSyncConstructor(vm, structure); + ptr->finishCreation(vm, globalObject, prototype); + return ptr; +} + +void JSStatementSyncConstructor::finishCreation(VM& vm, JSGlobalObject*, JSObject* prototype) +{ + Base::finishCreation(vm, 0, "StatementSync"_s, PropertyAdditionMode::WithoutStructureTransition); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + ASSERT(inherits(info())); +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSStatementSyncIterator +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSStatementSyncIterator::s_info = { "StatementSyncIterator"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSStatementSyncIterator) }; +const ClassInfo JSStatementSyncIteratorPrototype::s_info = { "StatementSyncIterator"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSStatementSyncIteratorPrototype) }; + +JSStatementSyncIterator* JSStatementSyncIterator::create(VM& vm, Structure* structure, JSStatementSync* stmt) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSStatementSyncIterator(vm, structure); + ptr->finishCreation(vm, stmt); + return ptr; +} + +void JSStatementSyncIterator::finishCreation(VM& vm, JSStatementSync* stmt) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_statement.set(vm, this, stmt); + m_capturedGeneration = stmt->resetGeneration(); +} + +template +void JSStatementSyncIterator::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_statement); +} +DEFINE_VISIT_CHILDREN(JSStatementSyncIterator); + +GCClient::IsoSubspace* JSStatementSyncIterator::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteStatementSyncIterator.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteStatementSyncIterator = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteStatementSyncIterator.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteStatementSyncIterator = std::forward(space); }); +} + +static inline JSObject* createIterResult(VM& vm, JSGlobalObject* globalObject, bool done, JSValue value) +{ + JSObject* result = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + result->putDirect(vm, vm.propertyNames->done, jsBoolean(done), 0); + result->putDirect(vm, vm.propertyNames->value, value, 0); + return result; +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorNext, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* self = dynamicDowncast(callFrame->thisValue()); + if (!self) [[unlikely]] { + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "StatementSyncIterator"_s)); + return {}; + } + // Once exhausted, next() doesn't touch the statement — so keep + // returning {done:true} regardless of whether the db has since been + // closed (matches the iterator protocol's "exhausted is permanent"). + if (self->done()) { + return JSValue::encode(createIterResult(vm, globalObject, true, jsNull())); + } + JSStatementSync* stmt = self->statement(); + if (!stmt || stmt->isFinalized()) { + return throwNodeState(globalObject, scope, "statement has been finalized"_s); + } + if (self->capturedGeneration() != stmt->resetGeneration()) { + return throwNodeState(globalObject, scope, "iterator was invalidated by calling run(), get(), all(), or iterate() on the backing statement"_s); + } + JSDatabaseSync::BusyScope busy { stmt->database() }; + + int r = sqlite3_step(stmt->statement()); + if (r != SQLITE_ROW && r != SQLITE_DONE) { + // Deliberate divergence from Node v26.3.0: Node neither resets nor + // marks the iterator done on a failed step, so catching the error and + // calling next() again silently re-yields from row 1 (SQLite + // auto-resets a halted statement). Treat a failed step as exhausting + // the iterator instead. + sqlite3_reset(stmt->statement()); + self->setDone(); + CHECK_UDF_EXCEPTION(scope, stmt->database()); + throwSqliteError(globalObject, scope, stmt->connection()); + return {}; + } + CHECK_UDF_EXCEPTION(scope, stmt->database()); + if (r == SQLITE_ROW) { + int numCols = sqlite3_column_count(stmt->statement()); + JSValue row = stmt->returnArrays() + ? rowToArray(globalObject, scope, stmt->statement(), numCols, stmt->useBigInts()) + : rowToObjectCached(globalObject, scope, stmt, numCols, stmt->useBigInts()); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(createIterResult(vm, globalObject, false, row)); + } + sqlite3_reset(stmt->statement()); + self->setDone(); + return JSValue::encode(createIterResult(vm, globalObject, true, jsNull())); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorReturn, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* self = dynamicDowncast(callFrame->thisValue()); + if (!self) [[unlikely]] { + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "StatementSyncIterator"_s)); + return {}; + } + // return() is the iterator-protocol cleanup hook (called implicitly by + // for-of's IteratorClose on break/return). Cleanup must be tolerant of + // already-closed state — throwing here would turn a benign + // `for (r of stmt.iterate()) { db.close(); break; }` into an exception. + // Matches Node, and this PR's own [Symbol.dispose]() convention. + JSStatementSync* stmt = self->statement(); + // Only reset the statement if this iterator still owns it: when a later + // iterate()/run()/get()/all() bumped the reset generation, the statement + // was already reset and may be mid-iteration under a newer iterator — + // resetting again would silently rewind that iterator's cursor. + // (Deliberate divergence: Node v26.3.0 resets unconditionally here.) + if (!self->done() && stmt && !stmt->isFinalized() + && self->capturedGeneration() == stmt->resetGeneration()) { + sqlite3_reset(stmt->statement()); + } + self->setDone(); + return JSValue::encode(createIterResult(vm, globalObject, true, jsNull())); +} + +static const HashTableValue JSStatementSyncIteratorPrototypeTableValues[] = { + { "next"_s, static_cast(PropertyAttribute::Function | PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncIteratorNext, 0 } }, + { "return"_s, static_cast(PropertyAttribute::Function | PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncIteratorReturn, 0 } }, +}; + +void JSStatementSyncIteratorPrototype::finishCreation(VM& vm, JSGlobalObject*) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSStatementSyncIterator::info(), JSStatementSyncIteratorPrototypeTableValues, *this); + // No toStringTag — Node's iterator is a plain object whose prototype + // chain ends at %IteratorPrototype% (which supplies @@iterator). +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSNodeSqliteSession +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSNodeSqliteSession::s_info = { "Session"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteSession) }; +const ClassInfo JSNodeSqliteSessionPrototype::s_info = { "Session"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteSessionPrototype) }; + +JSNodeSqliteSession* JSNodeSqliteSession::create(VM& vm, Structure* structure, JSDatabaseSync* db, Ref&& record) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSNodeSqliteSession(vm, structure); + ptr->finishCreation(vm, db, WTF::move(record)); + return ptr; +} + +void JSNodeSqliteSession::finishCreation(VM& vm, JSDatabaseSync* db, Ref&& record) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_record = WTF::move(record); + m_database.set(vm, this, db); +} + +bool JSNodeSqliteSession::isStale() const +{ + // The database frees every tracked session handle out from under the + // wrappers when it closes, is torn down, or successfully deserialize()s + // a new database — all of those mark the shared record dbGone. + auto* db = m_database.get(); + return db == nullptr || !db->isOpen() || !m_record || m_record->dbGone; +} + +void JSNodeSqliteSession::deleteSession() +{ + if (!m_record || m_record->handle == nullptr) return; + if (!m_record->dbGone) { + auto* db = m_database.get(); + db->untrackSession(m_record.get()); + sqlite3session_delete(m_record->handle); + } + // If dbGone, the database already freed the handle — don't double-free. + m_record->handle = nullptr; +} + +JSNodeSqliteSession::~JSNodeSqliteSession() +{ + // GC sweep. Never call into SQLite from here — the sweep can run inside + // an allocation made by a UDF callback while sqlite3_step() is executing + // on this very connection — and never follow m_database, because the + // sweep order between the two cells is undefined. If the database is + // already gone it freed the handle itself (record->dbGone). Otherwise + // flag the record so the database deletes the orphaned handle on its + // next entry point; record->db is safe to touch because dbGone is set + // before ~JSDatabaseSync() finishes, so !dbGone implies the cell has not + // been swept. + if (auto record = std::exchange(m_record, nullptr)) { + if (!record->dbGone && record->handle) { + record->wrapperGone = true; + record->db->noteOrphanedSession(); + } + } +} + +template +void JSNodeSqliteSession::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_database); +} +DEFINE_VISIT_CHILDREN(JSNodeSqliteSession); + +GCClient::IsoSubspace* JSNodeSqliteSession::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteSession.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteSession = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteSession.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteSession = std::forward(space); }); +} + +#define THIS_SESSION() \ + auto& vm = JSC::getVM(globalObject); \ + auto scope = DECLARE_THROW_SCOPE(vm); \ + JSNodeSqliteSession* self = dynamicDowncast(callFrame->thisValue()); \ + if (!self) [[unlikely]] { \ + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "Session"_s)); \ + return {}; \ + } + +template +static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallFrame* callFrame) +{ + THIS_SESSION(); + JSDatabaseSync* db = self->database(); + if (self->isStale()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + if (self->session() == nullptr) { + return throwNodeState(globalObject, scope, "session is not open"_s); + } + int nChangeset = 0; + void* pChangeset = nullptr; + int r = Fn(self->session(), &nChangeset, &pChangeset); + if (r != SQLITE_OK) { + if (pChangeset) sqlite3_free(pChangeset); + throwSqliteError(globalObject, scope, db->connection()); + return {}; + } + auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), static_cast(nChangeset)); + if (scope.exception()) [[unlikely]] { + sqlite3_free(pChangeset); + return {}; + } + if (nChangeset > 0) memcpy(array->typedVector(), pChangeset, static_cast(nChangeset)); + sqlite3_free(pChangeset); + return JSValue::encode(array); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionChangeset, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return sessionChangesetCommon(globalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionPatchset, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return sessionChangesetCommon(globalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_SESSION(); + if (self->isStale()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + if (self->session() == nullptr) { + return throwNodeState(globalObject, scope, "session is not open"_s); + } + self->deleteSession(); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionDispose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto* self = dynamicDowncast(callFrame->thisValue()); + (void)globalObject; + if (self) self->deleteSession(); + return JSValue::encode(jsUndefined()); +} + +static const HashTableValue JSNodeSqliteSessionPrototypeTableValues[] = { + { "changeset"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSessionChangeset, 0 } }, + { "patchset"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSessionPatchset, 0 } }, + { "close"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSessionClose, 0 } }, +}; + +void JSNodeSqliteSessionPrototype::finishCreation(VM& vm, JSGlobalObject* globalObject) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSNodeSqliteSession::info(), JSNodeSqliteSessionPrototypeTableValues, *this); + putDirectNativeFunction(vm, globalObject, vm.propertyNames->disposeSymbol, 0, jsSessionDispose, ImplementationVisibility::Public, NoIntrinsic, 0); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSNodeSqliteLimits — property-interceptor wrapper over sqlite3_limit() +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSNodeSqliteLimits::s_info = { "DatabaseSyncLimits"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteLimits) }; + +JSNodeSqliteLimits* JSNodeSqliteLimits::create(VM& vm, Structure* structure, JSDatabaseSync* db) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSNodeSqliteLimits(vm, structure); + ptr->finishCreation(vm, db); + return ptr; +} + +void JSNodeSqliteLimits::finishCreation(VM& vm, JSDatabaseSync* db) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_database.set(vm, this, db); +} + +template +void JSNodeSqliteLimits::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_database); +} +DEFINE_VISIT_CHILDREN(JSNodeSqliteLimits); + +GCClient::IsoSubspace* JSNodeSqliteLimits::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteLimits.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteLimits = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteLimits.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteLimits = std::forward(space); }); +} + +bool JSNodeSqliteLimits::getOwnPropertySlot(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +{ + auto* self = uncheckedDowncast(object); + // Only intercept the eleven known names; anything else (symbols, + // toString, unknown props) falls through to ordinary lookup so the + // object still behaves like a plain object for debugging / console. + if (!propertyName.isSymbol()) { + int id = findLimitId(propertyName.publicName()); + if (id >= 0) { + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* db = self->database(); + if (!db || !db->isOpen()) { + throwNodeState(globalObject, scope, "database is not open"_s); + return true; + } + int current = sqlite3_limit(db->connection(), id, -1); + slot.setValue(self, static_cast(PropertyAttribute::DontDelete), jsNumber(current)); + return true; + } + } + return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); +} + +bool JSNodeSqliteLimits::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +{ + auto* self = uncheckedDowncast(cell); + if (!propertyName.isSymbol()) { + int id = findLimitId(propertyName.publicName()); + if (id >= 0) { + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* db = self->database(); + if (!db || !db->isOpen()) { + throwNodeState(globalObject, scope, "database is not open"_s); + return false; + } + // Node accepts either a non-negative int32 or +Infinity + // (which resets to the compile-time maximum by passing the + // largest possible value — sqlite3_limit clamps). Reject + // everything else with Node's exact error text. + int newValue; + if (value.isNumber()) { + double d = value.asNumber(); + if (std::isinf(d) && d > 0) { + newValue = INT32_MAX; + } else if (std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX) { + newValue = static_cast(d); + if (newValue < 0) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, + "Limit value must be non-negative."_s); + return false; + } + } else { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "Limit value must be a non-negative integer or Infinity."_s); + return false; + } + } else { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "Limit value must be a non-negative integer or Infinity."_s); + return false; + } + sqlite3_limit(db->connection(), id, newValue); + return true; + } + } + return Base::put(cell, globalObject, propertyName, value, slot); +} + +void JSNodeSqliteLimits::getOwnPropertyNames(JSObject* object, JSGlobalObject* globalObject, PropertyNameArrayBuilder& names, DontEnumPropertiesMode mode) +{ + auto& vm = getVM(globalObject); + for (const auto& info : kLimitMapping) { + names.add(Identifier::fromString(vm, info.name)); + } + Base::getOwnPropertyNames(object, globalObject, names, mode); +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSNodeSqliteTagStore — db.createTagStore() +// ───────────────────────────────────────────────────────────────────────────── + +const ClassInfo JSNodeSqliteTagStore::s_info = { "SQLTagStore"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteTagStore) }; +const ClassInfo JSNodeSqliteTagStorePrototype::s_info = { "SQLTagStore"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteTagStorePrototype) }; + +JSNodeSqliteTagStore* JSNodeSqliteTagStore::create(VM& vm, Structure* structure, JSDatabaseSync* db, unsigned capacity) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSNodeSqliteTagStore(vm, structure); + ptr->finishCreation(vm, db, capacity); + return ptr; +} + +void JSNodeSqliteTagStore::finishCreation(VM& vm, JSDatabaseSync* db, unsigned capacity) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_database.set(vm, this, db); + m_capacity = capacity; +} + +void JSNodeSqliteTagStore::clear() +{ + WTF::Locker locker { cellLock() }; + m_order.clear(); +} + +template +void JSNodeSqliteTagStore::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_database); + // JSC's Riptide marker runs concurrently with the mutator, and + // prepare()/clear() can removeAt()/insert(0,…) — which may + // realloc or memmove — while this loop walks the vector. Same + // protocol as WriteBarrierList: serialise mutator-side Vector + // edits against visitation with the cell's lock. + WTF::Locker locker { thisObject->cellLock() }; + for (auto& e : thisObject->m_order) { + visitor.append(e.stmt); + } +} +DEFINE_VISIT_CHILDREN(JSNodeSqliteTagStore); + +GCClient::IsoSubspace* JSNodeSqliteTagStore::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForNodeSqliteTagStore.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForNodeSqliteTagStore = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForNodeSqliteTagStore.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForNodeSqliteTagStore = std::forward(space); }); +} + +JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, ThrowScope& scope, CallFrame* callFrame) +{ + auto& vm = getVM(globalObject); + auto* db = database(); + if (!db || !db->isOpen()) { + throwNodeState(globalObject, scope, "database is not open"_s); + return nullptr; + } + + JSValue arg0 = callFrame->argument(0); + if (!arg0.isObject() || !isArray(globalObject, arg0)) { + RETURN_IF_EXCEPTION(scope, nullptr); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "First argument must be an array of strings (template literal)."_s); + return nullptr; + } + RETURN_IF_EXCEPTION(scope, nullptr); + JSObject* parts = arg0.getObject(); + uint32_t nStrings = static_cast(toLength(globalObject, parts)); + RETURN_IF_EXCEPTION(scope, nullptr); + uint32_t nParams = callFrame->argumentCount() > 0 ? callFrame->argumentCount() - 1 : 0; + + // Join the template parts with "?" placeholders. The resulting SQL + // is also the cache key — identical tag call sites produce + // identical SQL and hit the same prepared statement. + WTF::StringBuilder sql; + for (uint32_t i = 0; i < nStrings; ++i) { + JSValue part = parts->get(globalObject, i); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!part.isString()) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "Template literal parts must be strings."_s); + return nullptr; + } + sql.append(part.toWTFString(globalObject)); + RETURN_IF_EXCEPTION(scope, nullptr); + if (i < nParams) sql.append('?'); + } + WTF::String sqlStr = sql.toString(); + + // LRU lookup: hit → move to front; evict stale entries as we go. + // m_order is walked by visitChildren() on a concurrent marker + // thread, so every mutation of the Vector (removeAt/insert, and + // the miss-branch below) is serialised under the cell lock — + // same protocol as WriteBarrierList. + JSStatementSync* stmtObj = nullptr; + { + WTF::Locker locker { cellLock() }; + for (size_t i = 0; i < m_order.size(); ++i) { + if (m_order[i].sql != sqlStr) continue; + auto* cand = m_order[i].stmt.get(); + if (!cand || cand->isFinalized()) { + m_order.removeAt(i); + break; + } + stmtObj = cand; + if (i > 0) { + Entry e = std::move(m_order[i]); + m_order.removeAt(i); + m_order.insert(0, std::move(e)); + } + break; + } + } + + if (!stmtObj) { + auto utf8 = sqlStr.utf8(); + sqlite3_stmt* stmt = nullptr; + int r = sqlite3_prepare_v2(db->connection(), utf8.data(), static_cast(utf8.length()), &stmt, nullptr); + // prepare() runs the authorizer callback (if any), which may + // throw — surface that over SQLite's generic "not authorized" + // so we don't overwrite the user's exception. Mirrors + // jsDatabaseSyncPrepare's CHECK_UDF_EXCEPTION. + db->takeIgnoreNextSqliteError(); + if (scope.exception()) [[unlikely]] { + if (stmt) sqlite3_finalize(stmt); + return nullptr; + } + if (r != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + throwSqliteError(globalObject, scope, db->connection()); + return nullptr; + } + if (!stmt) { + throwNodeState(globalObject, scope, "The supplied SQL string contains no statements"_s); + return nullptr; + } + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSStatementSyncClassStructure.get(zigGlobal); + stmtObj = JSStatementSync::create(vm, structure, db, stmt); + stmtObj->setUseBigInts(db->config().readBigInts); + stmtObj->setReturnArrays(db->config().returnArrays); + stmtObj->setAllowBareNamedParams(db->config().allowBareNamedParameters); + stmtObj->setAllowUnknownNamedParams(db->config().allowUnknownNamedParameters); + + { + WTF::Locker locker { cellLock() }; + if (m_order.size() >= m_capacity) m_order.removeLast(); + Entry e; + e.sql = sqlStr; + e.stmt.set(vm, this, stmtObj); + m_order.insert(0, std::move(e)); + } + } + + // Reset + bind positional values. Named-parameter handling is not + // meaningful for a tagged template. sqlite3_reset()'s return value + // is the *previous* step()'s error, not reset's own status — the + // reset itself always succeeds on a valid handle — so checking it + // here would spuriously re-throw a cached statement's stale error. + // StatementSync's run/get/all correctly ignore it for the same + // reason. + sqlite3_stmt* stmt = stmtObj->statement(); + sqlite3_reset(stmt); + stmtObj->bumpResetGeneration(); + sqlite3_clear_bindings(stmt); + int paramCount = sqlite3_bind_parameter_count(stmt); + for (int i = 0; i < static_cast(nParams) && i < paramCount; ++i) { + JSValue v = callFrame->argument(static_cast(i) + 1); + // Reuse StatementSync's canonical JS→SQLite bridge so BigInt + // overflow, int32 fast path, and undefined rejection stay in + // sync with stmt.run(...) — a hand-rolled copy here previously + // drifted and silently truncated 2n**64n to 0. + if (!stmtObj->bindValue(globalObject, scope, i + 1, v)) + return nullptr; + } + return stmtObj; +} + +#define THIS_TAGSTORE() \ + auto& vm = JSC::getVM(globalObject); \ + auto scope = DECLARE_THROW_SCOPE(vm); \ + JSNodeSqliteTagStore* self = dynamicDowncast(callFrame->thisValue()); \ + if (!self) [[unlikely]] { \ + scope.throwException(globalObject, createInvalidThisError(globalObject, callFrame->thisValue(), "SQLTagStore"_s)); \ + return {}; \ + } + +// Shared tag execution: prepare/reset/bind then drive the cached +// statement with the same semantics as StatementSync's run/get/all. +// No separate StatementExecutionHelper like Node's — the statement +// object already carries everything we need. + +JSC_DEFINE_HOST_FUNCTION(jsTagStoreRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_TAGSTORE(); + JSDatabaseSync::BusyScope busy { self->database() }; + JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); + RETURN_IF_EXCEPTION(scope, {}); + sqlite3_stmt* s = stmt->statement(); + int r; + while ((r = sqlite3_step(s)) == SQLITE_ROW) { + } + CHECK_UDF_EXCEPTION(scope, self->database()); + if (r != SQLITE_DONE) { + throwSqliteError(globalObject, scope, self->database()->connection()); + sqlite3_reset(s); + return {}; + } + sqlite3* conn = sqlite3_db_handle(s); + int64_t changes = sqlite3_changes64(conn); + int64_t lastId = sqlite3_last_insert_rowid(conn); + sqlite3_reset(s); + JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); + RETURN_IF_EXCEPTION(scope, {}); + JSValue changesV = stmt->useBigInts() ? JSValue(JSBigInt::makeHeapBigIntOrBigInt32(globalObject, changes)) : jsNumber(static_cast(changes)); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirect(vm, Identifier::fromString(vm, "changes"_s), changesV, 0); + JSValue lastIdV = stmt->useBigInts() ? JSValue(JSBigInt::makeHeapBigIntOrBigInt32(globalObject, lastId)) : jsNumber(static_cast(lastId)); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirect(vm, Identifier::fromString(vm, "lastInsertRowid"_s), lastIdV, 0); + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsTagStoreGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_TAGSTORE(); + JSDatabaseSync::BusyScope busy { self->database() }; + JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); + RETURN_IF_EXCEPTION(scope, {}); + sqlite3_stmt* s = stmt->statement(); + int r = sqlite3_step(s); + CHECK_UDF_EXCEPTION(scope, self->database()); + if (r == SQLITE_DONE) { + sqlite3_reset(s); + return JSValue::encode(jsUndefined()); + } + if (r != SQLITE_ROW) { + throwSqliteError(globalObject, scope, self->database()->connection()); + sqlite3_reset(s); + return {}; + } + int numCols = sqlite3_column_count(s); + JSValue row = stmt->returnArrays() + ? rowToArray(globalObject, scope, s, numCols, stmt->useBigInts()) + : rowToObjectCached(globalObject, scope, stmt, numCols, stmt->useBigInts()); + sqlite3_reset(s); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(row); +} + +JSC_DEFINE_HOST_FUNCTION(jsTagStoreAll, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_TAGSTORE(); + JSDatabaseSync::BusyScope busy { self->database() }; + JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); + RETURN_IF_EXCEPTION(scope, {}); + sqlite3_stmt* s = stmt->statement(); + JSArray* rows = constructEmptyArray(globalObject, nullptr, 0); + RETURN_IF_EXCEPTION(scope, {}); + uint32_t idx = 0; + int r; + while ((r = sqlite3_step(s)) == SQLITE_ROW) { + CHECK_UDF_EXCEPTION(scope, self->database()); + // Capture post-step — a cached statement may be transparently + // re-prepared on SQLITE_SCHEMA, changing the column count + // (see jsStatementSyncAll for the full rationale). + int numCols = sqlite3_column_count(s); + JSValue row = stmt->returnArrays() + ? rowToArray(globalObject, scope, s, numCols, stmt->useBigInts()) + : rowToObjectCached(globalObject, scope, stmt, numCols, stmt->useBigInts()); + if (scope.exception()) [[unlikely]] { + sqlite3_reset(s); + return {}; + } + rows->putDirectIndex(globalObject, idx++, row); + if (scope.exception()) [[unlikely]] { + sqlite3_reset(s); + return {}; + } + } + CHECK_UDF_EXCEPTION(scope, self->database()); + sqlite3_reset(s); + if (r != SQLITE_DONE) { + throwSqliteError(globalObject, scope, self->database()->connection()); + return {}; + } + return JSValue::encode(rows); +} + +JSC_DEFINE_HOST_FUNCTION(jsTagStoreIterate, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_TAGSTORE(); + JSDatabaseSync::BusyScope busy { self->database() }; + JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); + RETURN_IF_EXCEPTION(scope, {}); + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* structure = zigGlobal->m_JSStatementSyncIteratorClassStructure.get(zigGlobal); + auto* iter = JSStatementSyncIterator::create(vm, structure, stmt); + return JSValue::encode(iter); +} + +JSC_DEFINE_HOST_FUNCTION(jsTagStoreClear, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_TAGSTORE(); + self->clear(); + (void)scope; + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsTagStoreCapacity, (JSGlobalObject*, EncodedJSValue thisValue, PropertyName)) +{ + auto* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + return JSValue::encode(jsNumber(self->capacity())); +} +JSC_DEFINE_CUSTOM_GETTER(jsTagStoreSize, (JSGlobalObject*, EncodedJSValue thisValue, PropertyName)) +{ + auto* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + return JSValue::encode(jsNumber(self->size())); +} +JSC_DEFINE_CUSTOM_GETTER(jsTagStoreDb, (JSGlobalObject*, EncodedJSValue thisValue, PropertyName)) +{ + auto* self = dynamicDowncast(JSValue::decode(thisValue)); + if (!self) return JSValue::encode(jsUndefined()); + auto* db = self->database(); + return JSValue::encode(db ? JSValue(db) : jsUndefined()); +} + +static const HashTableValue JSNodeSqliteTagStorePrototypeTableValues[] = { + { "run"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreRun, 0 } }, + { "get"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreGet, 0 } }, + { "all"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreAll, 0 } }, + { "iterate"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreIterate, 0 } }, + { "clear"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreClear, 0 } }, + { "capacity"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreCapacity, nullptr } }, + { "size"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreSize, nullptr } }, + { "db"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreDb, nullptr } }, +}; + +void JSNodeSqliteTagStorePrototype::finishCreation(VM& vm, JSGlobalObject*) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSNodeSqliteTagStore::info(), JSNodeSqliteTagStorePrototypeTableValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Module-level exports +// ───────────────────────────────────────────────────────────────────────────── + +// backup(sourceDb, path[, options]) → Promise +// +// Node.js runs the sqlite3_backup_step loop on a libuv worker thread. Here we +// run it synchronously on the JS thread — DatabaseSync is already a fully +// synchronous API, and the source connection cannot be touched from another +// thread anyway (SQLite's default threading mode is serialized-per- +// connection). The `progress` callback still fires between each batch of +// `rate` pages so callers can observe progress; the returned Promise is +// resolved before this function returns. +JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue sourceVal = callFrame->argument(0); + if (!sourceVal.isObject()) { + return throwNodeArgType(globalObject, scope, "sourceDb"_s, "an object"_s); + } + auto* sourceDb = dynamicDowncast(sourceVal); + if (!sourceDb) { + return throwNodeArgType(globalObject, scope, "sourceDb"_s, "an object"_s); + } + if (!sourceDb->isOpen()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + JSDatabaseSync::BusyScope busy { sourceDb }; + + WTF::String destPath; + if (!validateDatabasePath(globalObject, scope, callFrame->argument(1), destPath)) return {}; + if (destPath.find('\0') != WTF::notFound) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); + } + + int rate = 100; + WTF::String sourceName = "main"_s; + WTF::String targetName = "main"_s; + JSObject* progressFn = nullptr; + + JSValue optsVal = callFrame->argument(2); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return throwNodeArgType(globalObject, scope, "options"_s, "an object"_s); + } + JSObject* opts = optsVal.getObject(); + JSValue rateV = opts->get(globalObject, Identifier::fromString(vm, "rate"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!rateV.isUndefined()) { + bool ok = rateV.isInt32(); + if (!ok && rateV.isNumber()) { + double d = rateV.asNumber(); + ok = std::isfinite(d) && std::trunc(d) == d && d >= INT32_MIN && d <= INT32_MAX; + } + if (!ok) { + return throwNodeArgType(globalObject, scope, "options.rate"_s, "an integer"_s); + } + rate = rateV.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + // sqlite3_backup_step(_, 0) copies zero pages and returns + // SQLITE_OK without advancing — on the JS thread that's an + // infinite busy-spin. Negative means "all remaining", which + // is fine. + if (rate == 0) rate = 1; + } + JSValue sourceV = opts->get(globalObject, Identifier::fromString(vm, "source"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!sourceV.isUndefined()) { + if (!sourceV.isString()) { + return throwNodeArgType(globalObject, scope, "options.source"_s, "a string"_s); + } + sourceName = sourceV.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + JSValue targetV = opts->get(globalObject, Identifier::fromString(vm, "target"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!targetV.isUndefined()) { + if (!targetV.isString()) { + return throwNodeArgType(globalObject, scope, "options.target"_s, "a string"_s); + } + targetName = targetV.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + JSValue progressV = opts->get(globalObject, Identifier::fromString(vm, "progress"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!progressV.isUndefined()) { + if (!progressV.isCallable()) { + return throwNodeArgType(globalObject, scope, "options.progress"_s, "a function"_s); + } + progressFn = progressV.getObject(); + } + } + + // All validation done — errors from here on reject the promise. We + // throw on the scope (so the ThrowScope assertion machinery is + // satisfied) then convert the pending exception into a rejected + // Promise before returning. + auto rejectWithPending = [&]() -> EncodedJSValue { + RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope))); + }; + + auto destPathUtf8 = destPath.utf8(); + sqlite3* dest = nullptr; + int r = sqlite3_open_v2(destPathUtf8.data(), &dest, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI, nullptr); + if (r != SQLITE_OK) { + if (dest) { + throwSqliteError(globalObject, scope, dest); + sqlite3_close_v2(dest); + } else { + throwSqliteMessage(globalObject, scope, r, WTF::String::fromUTF8(sqlite3_errstr(r))); + } + return rejectWithPending(); + } + + auto sourceNameUtf8 = sourceName.utf8(); + auto targetNameUtf8 = targetName.utf8(); + sqlite3_backup* backup = sqlite3_backup_init(dest, targetNameUtf8.data(), sourceDb->connection(), sourceNameUtf8.data()); + if (backup == nullptr) { + throwSqliteError(globalObject, scope, dest); + sqlite3_close_v2(dest); + return rejectWithPending(); + } + + // We run the step loop synchronously, so a locked destination would + // otherwise busy-spin at 100% CPU forever. Bound the total time spent + // waiting on BUSY/LOCKED and back off between retries; budget defaults + // to the source database's configured timeout (Node's async variant + // yields to the event loop instead, which we can't do here). + constexpr int kBusyRetrySleepMs = 25; + const int busyBudgetMs = std::max(sourceDb->config().timeout, 5000); + int busyWaitedMs = 0; + + int totalPages = 0; + while (true) { + r = sqlite3_backup_step(backup, rate); + totalPages = sqlite3_backup_pagecount(backup); + int remaining = sqlite3_backup_remaining(backup); + + if (r == SQLITE_OK && progressFn) { + JSObject* payload = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); + payload->putDirect(vm, Identifier::fromString(vm, "totalPages"_s), jsNumber(totalPages), 0); + payload->putDirect(vm, Identifier::fromString(vm, "remainingPages"_s), jsNumber(remaining), 0); + MarkedArgumentBuffer args; + args.append(payload); + auto callData = JSC::getCallData(progressFn); + JSC::call(globalObject, progressFn, callData, jsNull(), args); + if (scope.exception()) [[unlikely]] { + sqlite3_backup_finish(backup); + sqlite3_close_v2(dest); + return rejectWithPending(); + } + } + + if (r == SQLITE_DONE) break; + if (r == SQLITE_OK) { + busyWaitedMs = 0; + continue; + } + if (r == SQLITE_BUSY || r == SQLITE_LOCKED) { + if (busyWaitedMs >= busyBudgetMs) { + throwSqliteMessage(globalObject, scope, r, + "database is locked"_s); + sqlite3_backup_finish(backup); + sqlite3_close_v2(dest); + return rejectWithPending(); + } + sqlite3_sleep(kBusyRetrySleepMs); + busyWaitedMs += kBusyRetrySleepMs; + continue; + } + + // sqlite3_backup_step()'s *return value* is the authoritative + // error here. It isn't reliably mirrored onto `dest`'s + // sqlite3_errcode() until sqlite3_backup_finish() runs (and + // that also clears some codes), so asking `dest` can yield + // "not an error"/errcode 0. Node's BackupJob uses the step + // return code directly for the rejected error — do the same. + throwSqliteMessage(globalObject, scope, r, WTF::String::fromUTF8(sqlite3_errstr(r))); + sqlite3_backup_finish(backup); + sqlite3_close_v2(dest); + return rejectWithPending(); + } + + r = sqlite3_backup_finish(backup); + if (r != SQLITE_OK) { + throwSqliteMessage(globalObject, scope, r, WTF::String::fromUTF8(sqlite3_errstr(r))); + sqlite3_close_v2(dest); + return rejectWithPending(); + } + sqlite3_close_v2(dest); + + RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::resolvedPromise(globalObject, jsNumber(totalPages)))); +} + +JSValue createNodeSqliteConstants(VM& vm, JSGlobalObject* globalObject) +{ + JSObject* obj = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + auto put = [&](ASCIILiteral key, int value) { + obj->putDirect(vm, Identifier::fromString(vm, key), jsNumber(value), PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete | 0); + }; + put("SQLITE_CHANGESET_OMIT"_s, SQLITE_CHANGESET_OMIT); + put("SQLITE_CHANGESET_REPLACE"_s, SQLITE_CHANGESET_REPLACE); + put("SQLITE_CHANGESET_ABORT"_s, SQLITE_CHANGESET_ABORT); + put("SQLITE_CHANGESET_DATA"_s, SQLITE_CHANGESET_DATA); + put("SQLITE_CHANGESET_NOTFOUND"_s, SQLITE_CHANGESET_NOTFOUND); + put("SQLITE_CHANGESET_CONFLICT"_s, SQLITE_CHANGESET_CONFLICT); + put("SQLITE_CHANGESET_CONSTRAINT"_s, SQLITE_CHANGESET_CONSTRAINT); + put("SQLITE_CHANGESET_FOREIGN_KEY"_s, SQLITE_CHANGESET_FOREIGN_KEY); + + // Authorizer return codes + action codes, used by setAuthorizer(). + put("SQLITE_OK"_s, SQLITE_OK); + put("SQLITE_DENY"_s, SQLITE_DENY); + put("SQLITE_IGNORE"_s, SQLITE_IGNORE); + put("SQLITE_CREATE_INDEX"_s, SQLITE_CREATE_INDEX); + put("SQLITE_CREATE_TABLE"_s, SQLITE_CREATE_TABLE); + put("SQLITE_CREATE_TEMP_INDEX"_s, SQLITE_CREATE_TEMP_INDEX); + put("SQLITE_CREATE_TEMP_TABLE"_s, SQLITE_CREATE_TEMP_TABLE); + put("SQLITE_CREATE_TEMP_TRIGGER"_s, SQLITE_CREATE_TEMP_TRIGGER); + put("SQLITE_CREATE_TEMP_VIEW"_s, SQLITE_CREATE_TEMP_VIEW); + put("SQLITE_CREATE_TRIGGER"_s, SQLITE_CREATE_TRIGGER); + put("SQLITE_CREATE_VIEW"_s, SQLITE_CREATE_VIEW); + put("SQLITE_DELETE"_s, SQLITE_DELETE); + put("SQLITE_DROP_INDEX"_s, SQLITE_DROP_INDEX); + put("SQLITE_DROP_TABLE"_s, SQLITE_DROP_TABLE); + put("SQLITE_DROP_TEMP_INDEX"_s, SQLITE_DROP_TEMP_INDEX); + put("SQLITE_DROP_TEMP_TABLE"_s, SQLITE_DROP_TEMP_TABLE); + put("SQLITE_DROP_TEMP_TRIGGER"_s, SQLITE_DROP_TEMP_TRIGGER); + put("SQLITE_DROP_TEMP_VIEW"_s, SQLITE_DROP_TEMP_VIEW); + put("SQLITE_DROP_TRIGGER"_s, SQLITE_DROP_TRIGGER); + put("SQLITE_DROP_VIEW"_s, SQLITE_DROP_VIEW); + put("SQLITE_INSERT"_s, SQLITE_INSERT); + put("SQLITE_PRAGMA"_s, SQLITE_PRAGMA); + put("SQLITE_READ"_s, SQLITE_READ); + put("SQLITE_SELECT"_s, SQLITE_SELECT); + put("SQLITE_TRANSACTION"_s, SQLITE_TRANSACTION); + put("SQLITE_UPDATE"_s, SQLITE_UPDATE); + put("SQLITE_ATTACH"_s, SQLITE_ATTACH); + put("SQLITE_DETACH"_s, SQLITE_DETACH); + put("SQLITE_ALTER_TABLE"_s, SQLITE_ALTER_TABLE); + put("SQLITE_REINDEX"_s, SQLITE_REINDEX); + put("SQLITE_ANALYZE"_s, SQLITE_ANALYZE); + put("SQLITE_CREATE_VTABLE"_s, SQLITE_CREATE_VTABLE); + put("SQLITE_DROP_VTABLE"_s, SQLITE_DROP_VTABLE); + put("SQLITE_FUNCTION"_s, SQLITE_FUNCTION); + put("SQLITE_SAVEPOINT"_s, SQLITE_SAVEPOINT); + put("SQLITE_COPY"_s, SQLITE_COPY); + put("SQLITE_RECURSIVE"_s, SQLITE_RECURSIVE); + return obj; +} + +} // namespace Bun diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h new file mode 100644 index 000000000000..bb758574e5f1 --- /dev/null +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -0,0 +1,821 @@ +// node:sqlite — native implementation of Node.js's `node:sqlite` module. +// +// This uses the bundled sqlite3 amalgamation (sqlite3_local.h / sqlite3.c) +// on all platforms, matching Node.js which always bundles its own SQLite. +// Unlike bun:sqlite, it does not participate in macOS's LAZY_LOAD_SQLITE +// dlopen path — node:sqlite users expect Node's bundled-SQLite semantics +// (and functions like sqlite3_changes64 that older system libraries lack). +// +// Reference: https://github.com/nodejs/node/blob/main/src/node_sqlite.cc +#pragma once + +#include "root.h" +#include +#include +#include +#include +#include +#include +#include + +// Forward-declare the opaque SQLite handle types so this header does not +// pull the (large) sqlite3 amalgamation header into every translation unit +// that includes it — notably ZigGlobalObject.cpp and InternalModuleRegistry.cpp, +// which on macOS also see the system sqlite3.h via JSSQLStatement.h. +// NodeSqlite.cpp includes sqlite3_local.h directly for the full API. +extern "C" { +struct sqlite3; +struct sqlite3_stmt; +struct sqlite3_session; +} + +namespace Bun { + +class JSDatabaseSync; +class JSStatementSync; +class JSNodeSqliteSession; +class JSStatementSyncIterator; +class JSNodeSqliteLimits; +class JSNodeSqliteTagStore; + +// ───────────────────────────────────────────────────────────────────────────── +// DatabaseSync +// ───────────────────────────────────────────────────────────────────────────── + +// Must equal the number of SQLITE_LIMIT_* categories Node exposes +// (SQLITE_LIMIT_LENGTH .. SQLITE_LIMIT_TRIGGER_DEPTH). A static_assert in the +// .cpp pins SQLITE_LIMIT_LENGTH == 0 and SQLITE_LIMIT_TRIGGER_DEPTH == 10 to +// catch renumbering. +static constexpr size_t kNodeSqliteLimitCount = 11; + +// Shared bookkeeping between a DatabaseSync and one Session wrapper. The two +// are GC cells whose sweep order is undefined, so neither destructor may +// reach into the other cell; both hold a ref to this record and communicate +// through it instead. +// handle — the native session; null once freed (by either side) +// dbGone — the database freed the handle (close()/deserialize()/teardown) +// wrapperGone — the JS wrapper was swept without close(); the database +// deletes the orphaned handle on its next entry point +struct NodeSqliteSessionRecord : public WTF::RefCounted { + JSDatabaseSync* db { nullptr }; + sqlite3_session* handle { nullptr }; + bool dbGone { false }; + bool wrapperGone { false }; +}; + +struct DatabaseSyncOpenConfiguration { + bool readOnly = false; + bool enableForeignKeyConstraints = true; + bool enableDoubleQuotedStringLiterals = false; + // Node.js turns SQLITE_DBCONFIG_DEFENSIVE on by default; callers can + // disable it with {defensive: false} or db.enableDefensive(false). + bool enableDefensive = true; + bool allowExtension = false; + int timeout = 0; + // Per-limit value supplied via the constructor's {limits: {...}} + // option, applied after open(). Indexed by SQLITE_LIMIT_* id. + // -1 means "unset" — we never accept a negative limit from the user, + // so this is an unambiguous sentinel and keeps the struct trivially + // copyable (std::optional[11] would bloat every DatabaseSync). + std::array initialLimits { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 }; + // Defaults inherited by statements prepared on this connection + // (overridable per-statement via setReadBigInts() etc.). + bool readBigInts = false; + bool returnArrays = false; + bool allowBareNamedParameters = true; + bool allowUnknownNamedParameters = false; +}; + +class JSDatabaseSync final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSDatabaseSync* create(JSC::VM& vm, JSC::Structure* structure, WTF::String&& location, DatabaseSyncOpenConfiguration&& config); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSDatabaseSync(); } + ~JSDatabaseSync(); + + // Open the underlying connection. Throws on the scope if it fails or the + // database is already open. + bool open(JSC::JSGlobalObject*, JSC::ThrowScope&); + void closeInternal(); + + sqlite3* connection() const { return m_db; } + bool isOpen() const { return m_db != nullptr; } + // Bumped on every successful open(). Statements/sessions capture this + // at creation and compare instead of the raw sqlite3* — after + // close()+open() the allocator may recycle the exact same address for + // the new connection (ABA), so pointer equality isn't a sound + // "same connection" check. + unsigned openGeneration() const { return m_openGeneration; } + // deserialize() replaces the backing database without a + // close()+open() cycle, so it bumps the generation manually to + // invalidate every outstanding StatementSync/Session wrapper. We do + // NOT sqlite3_finalize() those stmts here — the JS wrappers still + // hold the raw handle and will free it themselves on GC, so a + // pre-emptive finalize would make them double-free. + void bumpOpenGeneration() { ++m_openGeneration; } + bool allowLoadExtension() const { return m_config.allowExtension; } + bool enableLoadExtensionIsOn() const { return m_enableLoadExtension; } + void setEnableLoadExtension(bool v) { m_enableLoadExtension = v; } + + const DatabaseSyncOpenConfiguration& config() const { return m_config; } + + // User-defined functions call back into JS from inside sqlite3_step(). + // If the JS callback throws, we record that here so the enclosing + // step()/exec() can propagate the JS exception instead of wrapping the + // uninformative "user-defined function raised exception" SQLite error. + bool takeIgnoreNextSqliteError() + { + bool v = m_ignoreNextSqliteError; + m_ignoreNextSqliteError = false; + return v; + } + void setIgnoreNextSqliteError() { m_ignoreNextSqliteError = true; } + + void trackSession(Ref&& record) { m_sessions.append(WTF::move(record)); } + void untrackSession(NodeSqliteSessionRecord* record) + { + m_sessions.removeFirstMatching([&](auto& r) { return r.ptr() == record; }); + } + // Free every tracked sqlite3_session* and mark its record dbGone. + // Called from closeInternal() and from deserialize() after a successful + // schema swap — Session wrappers observe "the database freed my handle" + // through the shared record, never by touching this cell. + void deleteTrackedSessions(); + // ~JSNodeSqliteSession() cannot call into SQLite (the sweep can run + // mid-sqlite3_step) — it just flags the record and this bit. The next + // BusyScope taken on this connection (every DatabaseSync, StatementSync, + // iterator, and tag-store entry point) frees the orphaned handles; + // close() and teardown sweep unconditionally via deleteTrackedSessions(). + void noteOrphanedSession() { m_hasOrphanedSessions = true; } + void sweepOrphanedSessions(); + + // setAuthorizer(cb) callback and the lazily-created limits wrapper. + // Kept as GC-traced fields on the DatabaseSync cell rather than a + // C-side Strong<> so a db → authorizer-closure → db cycle is + // collectable (Node stores the callback in an internal field on the + // wrapper object for the same reason). + JSC::WriteBarrier m_authorizer; + JSC::WriteBarrier m_limits; + + // Callbacks registered via function()/aggregate(), traced from + // visitChildren so a db → callback-closure → db cycle stays collectable. + // The sqlite-owned UDF contexts hold raw pointers to these values and + // rely on this vector for liveness (see the comment above NodeSqliteUDF + // in the .cpp). Returns the slot index; new registrations reuse slots + // released by releaseSupersededRegistration(), and the whole vector is + // dropped on close()/teardown. Releasing superseded roots happens at the + // registration site (keyed by name + SQL arg count, the identity SQLite + // replaces on), NEVER from xDestroy — with unfinalized statements the + // connection is zombified and xDestroy can run long after this cell has + // been swept. + size_t addRegisteredCallback(JSC::VM&, JSC::JSValue); + static constexpr size_t kNoCallbackSlot = SIZE_MAX; + // Clear the slots of a previous registration of (name, argc) — call only + // after sqlite3_create_*function succeeded, which is when SQLite has + // dropped the old registration. + void releaseSupersededRegistration(const WTF::String& name, int argc); + void rememberRegistration(const WTF::String& name, int argc, const std::array& slots); + + // Incremented for the duration of any native call that hands this + // connection into SQLite and may re-enter JS (option-getter, xFunc, + // xFilter, progress, …). close() rejects with ERR_INVALID_STATE while + // non-zero so a re-entrant close() can't free the sqlite3* out from + // under the in-flight C call. [Symbol.dispose] becomes a no-op. + bool isBusy() const { return m_busyDepth > 0; } + struct BusyScope { + JSDatabaseSync* db; + BusyScope(JSDatabaseSync* d) + : db(d) + { + if (db) { + // Every connection entry point takes a BusyScope, so this is + // where orphaned sessions get their deferred sweep — before + // the depth bump so the no-op fast path (flag check) still + // skips re-entrant calls from UDF/authorizer callbacks. + db->sweepOrphanedSessions(); + ++db->m_busyDepth; + } + } + ~BusyScope() + { + if (db) --db->m_busyDepth; + } + BusyScope(const BusyScope&) = delete; + BusyScope& operator=(const BusyScope&) = delete; + BusyScope(BusyScope&&) = delete; + BusyScope& operator=(BusyScope&&) = delete; + }; + +private: + JSDatabaseSync(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm); + + WTF::String m_location; + DatabaseSyncOpenConfiguration m_config {}; + sqlite3* m_db = nullptr; + unsigned m_openGeneration = 0; + unsigned m_busyDepth = 0; + // Sessions must be deleted before sqlite3_close_v2() to avoid + // use-after-free inside the preupdate hook; track them through shared + // records (not JS objects) so close() can sweep regardless of GC + // ordering. + WTF::Vector> m_sessions; + bool m_hasOrphanedSessions = false; + // GC-traced roots for function()/aggregate() callbacks; mutated and + // visited under cellLock() because visitChildren runs concurrently. + WTF::Vector> m_registeredCallbacks; + // Which slots belong to which (name, SQL arg count) registration, so a + // re-registration can release the superseded roots. Holds no JSValues; + // touched only on the JS thread. + struct NamedRegistration { + WTF::String name; + int argc; + std::array slots; + }; + WTF::Vector m_namedRegistrations; + bool m_enableLoadExtension = false; + bool m_ignoreNextSqliteError = false; +}; + +class JSDatabaseSyncPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + DECLARE_INFO; + + static JSDatabaseSyncPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSDatabaseSyncPrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSDatabaseSyncPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSDatabaseSyncPrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +class JSDatabaseSyncConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + DECLARE_INFO; + + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSDatabaseSyncConstructor* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, JSC::JSObject* prototype); + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::InternalFunctionType, StructureFlags), info()); + } + + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES call(JSC::JSGlobalObject*, JSC::CallFrame*); + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES construct(JSC::JSGlobalObject*, JSC::CallFrame*); + +private: + JSDatabaseSyncConstructor(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure, call, construct) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*, JSC::JSObject* prototype); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// StatementSync +// ───────────────────────────────────────────────────────────────────────────── + +class JSStatementSync final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSStatementSync* create(JSC::VM& vm, JSC::Structure* structure, JSDatabaseSync* db, sqlite3_stmt* stmt); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSStatementSync(); } + ~JSStatementSync(); + + sqlite3_stmt* statement() const { return m_stmt; } + sqlite3* connection() const; + JSDatabaseSync* database() const { return m_database.get(); } + // A statement is considered finalized either when it has been + // explicitly finalized or when its owning database has been closed + // (the underlying sqlite3_stmt* then points into a zombie connection + // and must not be stepped). + bool isFinalized() const; + void finalizeStatement(); + + bool useBigInts() const { return m_useBigInts; } + bool returnArrays() const { return m_returnArrays; } + bool allowBareNamedParams() const { return m_allowBareNamedParams; } + bool allowUnknownNamedParams() const { return m_allowUnknownNamedParams; } + void setUseBigInts(bool v) { m_useBigInts = v; } + void setReturnArrays(bool v) { m_returnArrays = v; } + void setAllowBareNamedParams(bool v) { m_allowBareNamedParams = v; } + void setAllowUnknownNamedParams(bool v) { m_allowUnknownNamedParams = v; } + + // Incremented whenever run()/get()/all()/iterate() resets the statement, + // so a live StatementSyncIterator can detect that its cursor position + // has been invalidated by another call on the same statement. + unsigned resetGeneration() const { return m_resetGeneration; } + void bumpResetGeneration() { ++m_resetGeneration; } + + // Bind callFrame->argument(anon_start..) to the statement using Node.js + // semantics. Returns false and throws on failure. + bool bindParams(JSC::JSGlobalObject*, JSC::ThrowScope&, JSC::CallFrame*); + // Single-value JS → sqlite3_bind_* conversion with Node's validation + // (int32 fast path, BigInt round-trip overflow check, undefined + // rejected). Public so SQLTagStore can reuse the one canonical + // JS→SQLite bridge instead of maintaining a drifted copy. + bool bindValue(JSC::JSGlobalObject*, JSC::ThrowScope&, int index, JSC::JSValue); + + JSC::WriteBarrier m_database; + std::optional> m_bareNamedParams; + + // Structure-caching fast path for result rows (mirrors bun:sqlite's + // JSSQLStatement). For queries whose column list fits in a final + // object's inline storage, we precompute one null-prototype Structure + // with a slot per distinct column name and then fill each row via + // putDirectOffset instead of running the generic put machinery per + // cell. Built lazily on the first step() that yields columns; + // invalidated when the statement is reset with a different shape. + JSC::Structure* ensureRowStructure(JSC::JSGlobalObject*); + void invalidateRowStructure(); + JSC::Structure* rowStructure() const { return m_rowStructure.get(); } + // Per-result-column index into the structure's inline slots. + // Duplicate column names share the first occurrence's slot so the + // later column overwrites it — last-wins, matching Node's V8 + // Object::Set() row builder and the generic rowToObject() fallback. + const WTF::Vector& columnOffsets() const { return m_columnOffsets; } + +private: + JSStatementSync(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM& vm, JSDatabaseSync* db, sqlite3_stmt* stmt); + + sqlite3_stmt* m_stmt = nullptr; + JSC::WriteBarrier m_rowStructure; + WTF::Vector m_columnOffsets; + int m_rowColumnCount = -1; + // Reset-generation the cached row structure was built at. Column + // *count* alone isn't a sufficient shape key: sqlite3_prepare_v2 + // transparently re-prepares on SQLITE_SCHEMA, so after an ALTER + // TABLE … RENAME COLUMN the same statement returns the same + // count with different names. Keying on reset-generation rebuilds + // once per run/get/all/iterate — still O(1) per .all() — instead + // of serving stale property names forever. + unsigned m_rowResetGeneration = 0; + // Open-generation this statement was prepared on. After db.close() + // + db.open() the JSDatabaseSync may even get the *same* sqlite3* + // back (allocator reuse — ABA), so compare the generation counter + // rather than the raw handle to let isFinalized() detect a stale + // statement instead of stepping a dead handle and reporting + // `errcode: 0 "not an error"` from the new connection. + unsigned m_originGeneration = 0; + unsigned m_resetGeneration = 0; + bool m_useBigInts : 1 = false; + bool m_returnArrays : 1 = false; + bool m_allowBareNamedParams : 1 = true; + bool m_allowUnknownNamedParams : 1 = false; +}; + +class JSStatementSyncPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + DECLARE_INFO; + + static JSStatementSyncPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSStatementSyncPrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSStatementSyncPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSStatementSyncPrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +class JSStatementSyncConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + DECLARE_INFO; + + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSStatementSyncConstructor* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, JSC::JSObject* prototype); + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::InternalFunctionType, StructureFlags), info()); + } + + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES call(JSC::JSGlobalObject*, JSC::CallFrame*); + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES construct(JSC::JSGlobalObject*, JSC::CallFrame*); + +private: + JSStatementSyncConstructor(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure, call, construct) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*, JSC::JSObject* prototype); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// StatementSyncIterator — lazy row cursor returned by iterate(). +// +// Not its own public constructor; the prototype chain is +// iter → StatementSyncIteratorPrototype → %IteratorPrototype% +// so for-of, spread, Iterator helpers all work. +// ───────────────────────────────────────────────────────────────────────────── + +class JSStatementSyncIterator final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSStatementSyncIterator* create(JSC::VM& vm, JSC::Structure* structure, JSStatementSync* stmt); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSStatementSyncIterator(); } + ~JSStatementSyncIterator() = default; + + JSStatementSync* statement() const { return m_statement.get(); } + bool done() const { return m_done; } + void setDone() { m_done = true; } + unsigned capturedGeneration() const { return m_capturedGeneration; } + + JSC::WriteBarrier m_statement; + +private: + JSStatementSyncIterator(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM& vm, JSStatementSync* stmt); + + unsigned m_capturedGeneration = 0; + bool m_done = false; +}; + +class JSStatementSyncIteratorPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + DECLARE_INFO; + + static JSStatementSyncIteratorPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSStatementSyncIteratorPrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSStatementSyncIteratorPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSStatementSyncIteratorPrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Session — thin wrapper over sqlite3_session* returned by +// DatabaseSync.prototype.createSession(). No public constructor. +// ───────────────────────────────────────────────────────────────────────────── + +class JSNodeSqliteSession final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSNodeSqliteSession* create(JSC::VM& vm, JSC::Structure* structure, JSDatabaseSync* db, Ref&& record); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSNodeSqliteSession(); } + ~JSNodeSqliteSession(); + + sqlite3_session* session() const { return m_record ? m_record->handle : nullptr; } + JSDatabaseSync* database() const { return m_database.get(); } + // True once the owning database has freed this session's handle out + // from under the wrapper — close(), close()+open(), a successful + // deserialize(), or VM teardown (record->dbGone) — or is currently + // closed. Distinct from session() == nullptr, which also covers an + // explicit session.close(). + bool isStale() const; + void deleteSession(); + + JSC::WriteBarrier m_database; + +private: + JSNodeSqliteSession(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM& vm, JSDatabaseSync* db, Ref&& record); + + RefPtr m_record; +}; + +class JSNodeSqliteSessionPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + DECLARE_INFO; + + static JSNodeSqliteSessionPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSNodeSqliteSessionPrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSNodeSqliteSessionPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSNodeSqliteSessionPrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// DatabaseSyncLimits — the object returned by `db.limits`. Reads and +// writes to its eleven named properties (length, sqlLength, …) call +// sqlite3_limit() on the owning connection. No prototype (so an +// overridden Object.prototype can't shadow a limit name). Intercepted +// via getOwnPropertySlot/put/getOwnPropertyNames rather than per-name +// accessors so the properties present as enumerable *own* data-like +// properties (Node's tests do `Object.keys(db.limits)`). +// ───────────────────────────────────────────────────────────────────────────── + +class JSNodeSqliteLimits final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetOwnPropertySlot | JSC::OverridesPut | JSC::OverridesGetOwnPropertyNames | JSC::ProhibitsPropertyCaching; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSNodeSqliteLimits* create(JSC::VM& vm, JSC::Structure* structure, JSDatabaseSync* db); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSNodeSqliteLimits(); } + ~JSNodeSqliteLimits() = default; + + JSDatabaseSync* database() const { return m_database.get(); } + + static bool getOwnPropertySlot(JSC::JSObject*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); + static bool put(JSC::JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&); + static void getOwnPropertyNames(JSC::JSObject*, JSC::JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); + + JSC::WriteBarrier m_database; + +private: + JSNodeSqliteLimits(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM& vm, JSDatabaseSync* db); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// SQLTagStore — returned by db.createTagStore(). A small LRU of +// prepared StatementSyncs keyed on the joined template-literal string, +// so sql.get`SELECT … ${x}` reuses the same compiled statement across +// calls. No public constructor. +// ───────────────────────────────────────────────────────────────────────────── + +class JSNodeSqliteTagStore final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSNodeSqliteTagStore* create(JSC::VM& vm, JSC::Structure* structure, JSDatabaseSync* db, unsigned capacity); + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSNodeSqliteTagStore(); } + ~JSNodeSqliteTagStore() = default; + + JSDatabaseSync* database() const { return m_database.get(); } + unsigned capacity() const { return m_capacity; } + unsigned size() const { return static_cast(m_order.size()); } + void clear(); + + // Build SQL from the template-tag arguments ("part0 ? part1 ? …"), + // look it up in the cache (or prepare a fresh StatementSync and + // insert it, evicting the least-recently-used entry if at capacity) + // and bind the interpolated values to the result. Returns nullptr + // and throws on any failure. + JSStatementSync* prepare(JSC::JSGlobalObject*, JSC::ThrowScope&, JSC::CallFrame*); + + JSC::WriteBarrier m_database; + +private: + JSNodeSqliteTagStore(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM& vm, JSDatabaseSync* db, unsigned capacity); + + struct Entry { + WTF::String sql; + JSC::WriteBarrier stmt; + }; + // Move-to-front LRU. O(n) is fine at the small capacities Node + // documents (default 1000, tests use 10); the structure-caching on + // StatementSync is where the real win is, this just avoids + // re-preparing the SQL. + WTF::Vector m_order; + unsigned m_capacity = 1000; +}; + +class JSNodeSqliteTagStorePrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + DECLARE_INFO; + + static JSNodeSqliteTagStorePrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSNodeSqliteTagStorePrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSNodeSqliteTagStorePrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSNodeSqliteTagStorePrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +// Module-level constants object (SQLITE_CHANGESET_* + authorizer codes). +JSC::JSValue createNodeSqliteConstants(JSC::VM&, JSC::JSGlobalObject*); + +} // namespace Bun diff --git a/src/jsc/bindings/sqlite/sqlite3.c b/src/jsc/bindings/sqlite/sqlite3.c index 30f748593ccb..332ed4f45cd0 100644 --- a/src/jsc/bindings/sqlite/sqlite3.c +++ b/src/jsc/bindings/sqlite/sqlite3.c @@ -1,7 +1,7 @@ // clang-format off /****************************************************************************** ** This file is an amalgamation of many separate C source files from SQLite -** version 3.53.0. By combining all the individual C code files into this +** version 3.53.2. By combining all the individual C code files into this ** single large file, the entire code can be compiled as a single translation ** unit. This allows many compilers to do optimizations that would not be ** possible if the files were compiled separately. Performance improvements @@ -19,7 +19,7 @@ ** separate file. This file contains only code for the core SQLite library. ** ** The content in this amalgamation comes from Fossil check-in -** 4525003a53a7fc63ca75c59b22c79608659c with changes in files: +** d6e03d8c777cfa2d35e3b60d8ec3e0187f3e with changes in files: ** ** */ @@ -469,12 +469,12 @@ extern "C" { ** [sqlite3_libversion_number()], [sqlite3_sourceid()], ** [sqlite_version()] and [sqlite_source_id()]. */ -#define SQLITE_VERSION "3.53.0" -#define SQLITE_VERSION_NUMBER 3053000 -#define SQLITE_SOURCE_ID "2026-04-09 11:41:38 4525003a53a7fc63ca75c59b22c79608659ca12f0131f52c18637f829977f20b" -#define SQLITE_SCM_BRANCH "trunk" -#define SQLITE_SCM_TAGS "release major-release version-3.53.0" -#define SQLITE_SCM_DATETIME "2026-04-09T11:41:38.498Z" +#define SQLITE_VERSION "3.53.2" +#define SQLITE_VERSION_NUMBER 3053002 +#define SQLITE_SOURCE_ID "2026-06-03 19:12:13 d6e03d8c777cfa2d35e3b60d8ec3e0187f3e9f99d8e2ee9cac695fd6fcdf1a24" +#define SQLITE_SCM_BRANCH "branch-3.53" +#define SQLITE_SCM_TAGS "release version-3.53.2" +#define SQLITE_SCM_DATETIME "2026-06-03T19:12:13.350Z" /* ** CAPI3REF: Run-Time Library Version Numbers @@ -13176,11 +13176,23 @@ SQLITE_API int sqlite3changeset_apply_v3( ** database behave as if they were declared with "ON UPDATE NO ACTION ON ** DELETE NO ACTION", even if they are actually CASCADE, RESTRICT, SET NULL ** or SET DEFAULT. +** +**
SQLITE_CHANGESETAPPLY_NOUPDATELOOP
+** Sometimes, a changeset contains two or more update statements such that +** although after applying all updates the database will contain no +** constraint violations, no single update can be applied before the others. +** The simplest example of this is a pair of UPDATEs that have "swapped" +** two column values with a UNIQUE constraint. +**

+** Usually, sqlite3changeset_apply() and similar functions work hard to try +** to find a way to apply such a changeset. However, if this flag is set, +** then all such updates are considered CONSTRAINT conflicts. */ #define SQLITE_CHANGESETAPPLY_NOSAVEPOINT 0x0001 #define SQLITE_CHANGESETAPPLY_INVERT 0x0002 #define SQLITE_CHANGESETAPPLY_IGNORENOOP 0x0004 #define SQLITE_CHANGESETAPPLY_FKNOACTION 0x0008 +#define SQLITE_CHANGESETAPPLY_NOUPDATELOOP 0x0010 /* ** CAPI3REF: Constants Passed To The Conflict Handler @@ -22450,7 +22462,15 @@ SQLITE_PRIVATE void sqlite3AlterFunctions(void); SQLITE_PRIVATE void sqlite3AlterRenameTable(Parse*, SrcList*, Token*); SQLITE_PRIVATE void sqlite3AlterRenameColumn(Parse*, SrcList*, Token*, Token*); SQLITE_PRIVATE void sqlite3AlterDropConstraint(Parse*,SrcList*,Token*,Token*); -SQLITE_PRIVATE void sqlite3AlterAddConstraint(Parse*,SrcList*,Token*,Token*,const char*,int); +SQLITE_PRIVATE void sqlite3AlterAddConstraint( + Parse *pParse, /* Parse context */ + SrcList *pSrc, /* Table to add constraint to */ + Token *pFirst, /* First token of new constraint */ + Token *pName, /* Name of new constraint. NULL if name omitted. */ + const char *zExpr, /* Text of CHECK expression */ + int nExpr, /* Size of pExpr in bytes */ + Expr *pExpr /* The parsed CHECK expression */ +); SQLITE_PRIVATE void sqlite3AlterSetNotNull(Parse*, SrcList*, Token*, Token*); SQLITE_PRIVATE i64 sqlite3GetToken(const unsigned char *, int *); SQLITE_PRIVATE void sqlite3NestedParse(Parse*, const char*, ...); @@ -32515,7 +32535,7 @@ static char *printfTempBuf(sqlite3_str *pAccum, sqlite3_int64 n){ sqlite3StrAccumSetError(pAccum, SQLITE_TOOBIG); return 0; } - z = sqlite3DbMallocRaw(pAccum->db, n); + z = sqlite3_malloc(n); if( z==0 ){ sqlite3StrAccumSetError(pAccum, SQLITE_NOMEM); } @@ -32973,11 +32993,27 @@ SQLITE_API void sqlite3_str_vappendf( szBufNeeded = MAX(e2,0)+(i64)precision+(i64)width+10; if( cThousand && e2>0 ) szBufNeeded += (e2+2)/3; - if( sqlite3StrAccumEnlargeIfNeeded(pAccum, szBufNeeded) ){ - width = length = 0; - break; + if( szBufNeeded + pAccum->nChar >= pAccum->nAlloc ){ + if( pAccum->mxAlloc==0 && pAccum->accError==0 ){ + /* Unable to allocate space in pAccum, perhaps because it + ** is coming from sqlite3_snprintf() or similar. We'll have + ** to render into temporary space and the memcpy() it over. */ + bufpt = sqlite3_malloc(szBufNeeded); + if( bufpt==0 ){ + sqlite3StrAccumSetError(pAccum, SQLITE_NOMEM); + return; + } + zExtra = bufpt; + }else if( sqlite3StrAccumEnlarge(pAccum, szBufNeeded)zText + pAccum->nChar; + } + }else{ + bufpt = pAccum->zText + pAccum->nChar; } - bufpt = zOut = pAccum->zText + pAccum->nChar; + zOut = bufpt; flag_dp = (precision>0 ?1:0) | flag_alternateform | flag_altform2; /* The sign in front of the number */ @@ -33078,14 +33114,22 @@ SQLITE_API void sqlite3_str_vappendf( } length = width; } - pAccum->nChar += length; - zOut[length] = 0; - /* Floating point conversions render directly into the output - ** buffer. Hence, don't just break out of the switch(). Bypass the - ** output buffer writing that occurs after the switch() by continuing - ** to the next character in the format string. */ - continue; + if( zExtra==0 ){ + /* The result is being rendered directory into pAccum. This + ** is the command and fast case */ + pAccum->nChar += length; + zOut[length] = 0; + continue; + }else{ + /* We were unable to render directly into pAccum because we + ** couldn't allocate sufficient memory. We need to memcpy() + ** the rendering (or some prefix thereof) into the output + ** buffer. */ + bufpt[0] = 0; + bufpt = zExtra; + break; + } } case etSIZE: if( !bArgList ){ @@ -33132,7 +33176,7 @@ SQLITE_API void sqlite3_str_vappendf( if( sqlite3StrAccumEnlargeIfNeeded(pAccum, nCopyBytes) ){ break; } - sqlite3_str_append(pAccum, + sqlite3_str_append(pAccum, &pAccum->zText[pAccum->nChar-nCopyBytes], nCopyBytes); precision -= nPrior; nPrior *= 2; @@ -33235,8 +33279,8 @@ SQLITE_API void sqlite3_str_vappendf( ** all control characters, and for backslash itself. ** For %#Q, do the same but only if there is at least ** one control character. */ - u32 nBack = 0; - u32 nCtrl = 0; + i64 nBack = 0; + i64 nCtrl = 0; for(k=0; k32))) +#define SQLITE_USE_UINT128 +#endif + /* ** Two inputs are multiplied to get a 128-bit result. Write the ** lower 64-bits of the result into *pLo, and return the high-order ** 64 bits. */ static u64 sqlite3Multiply128(u64 a, u64 b, u64 *pLo){ -#if (defined(__GNUC__) || defined(__clang__)) \ - && (defined(__x86_64__) || defined(__aarch64__) || defined(__riscv)) \ - && !defined(SQLITE_DISABLE_INTRINSIC) +#if defined(SQLITE_USE_UINT128) __uint128_t r = (__uint128_t)a * b; *pLo = (u64)r; return (u64)(r>>64); @@ -36836,9 +36885,7 @@ static u64 sqlite3Multiply128(u64 a, u64 b, u64 *pLo){ ** The lower 64 bits of A*B are discarded. */ static u64 sqlite3Multiply160(u64 a, u32 aLo, u64 b, u32 *pLo){ -#if (defined(__GNUC__) || defined(__clang__)) \ - && (defined(__x86_64__) || defined(__aarch64__) || defined(__riscv)) \ - && !defined(SQLITE_DISABLE_INTRINSIC) +#if defined(SQLITE_USE_UINT128) __uint128_t r = (__uint128_t)a * b; r += ((__uint128_t)aLo * b) >> 32; *pLo = (r>>32)&0xffffffff; @@ -36876,6 +36923,8 @@ static u64 sqlite3Multiply160(u64 a, u32 aLo, u64 b, u32 *pLo){ #endif } +#undef SQLITE_USE_UINT128 + /* ** Return a u64 with the N-th bit set. */ @@ -45280,9 +45329,9 @@ static int unixShmMap( nReqRegion = ((iRegion+nShmPerMap) / nShmPerMap) * nShmPerMap; if( pShmNode->nRegionszRegion = szRegion; @@ -45313,7 +45362,7 @@ static int unixShmMap( */ else{ static const int pgsz = 4096; - int iPg; + i64 iPg; /* Write to the last byte of each newly allocated or extended page */ assert( (nByte % pgsz)==0 ); @@ -45339,8 +45388,8 @@ static int unixShmMap( } pShmNode->apRegion = apNew; while( pShmNode->nRegionhShm>=0 ){ pMem = osMmap(0, nMap, @@ -53314,7 +53363,7 @@ static int winShmMap( if( pShmNode->nRegion<=iRegion ){ HANDLE hShared = pShmNode->hSharedShm; struct ShmRegion *apNew; /* New aRegion[] array */ - int nByte = (iRegion+1)*szRegion; /* Minimum required file size */ + i64 nByte = ((i64)iRegion+1)*(i64)szRegion; /* Minimum file size */ sqlite3_int64 sz; /* Current size of wal-index file */ pShmNode->szRegion = szRegion; @@ -53345,7 +53394,7 @@ static int winShmMap( /* Map the requested memory region into this processes address space. */ apNew = (struct ShmRegion*)sqlite3_realloc64( - pShmNode->aRegion, (iRegion+1)*sizeof(apNew[0]) + pShmNode->aRegion, ((i64)iRegion+1)*sizeof(apNew[0]) ); if( !apNew ){ rc = SQLITE_IOERR_NOMEM_BKPT; @@ -53367,15 +53416,14 @@ static int winShmMap( #elif defined(SQLITE_WIN32_HAS_ANSI) && SQLITE_WIN32_CREATEFILEMAPPINGA hMap = osCreateFileMappingA(hShared, NULL, protect, 0, nByte, NULL); #endif - - OSTRACE(("SHM-MAP-CREATE pid=%lu, region=%d, size=%d, rc=%s\n", + OSTRACE(("SHM-MAP-CREATE pid=%lu, region=%d, size=%lld, rc=%s\n", osGetCurrentProcessId(), pShmNode->nRegion, nByte, hMap ? "ok" : "failed")); if( hMap ){ - int iOffset = pShmNode->nRegion*szRegion; + i64 iOffset = pShmNode->nRegion*szRegion; int iOffsetShift = iOffset % winSysInfo.dwAllocationGranularity; pMap = osMapViewOfFile(hMap, flags, - 0, iOffset - iOffsetShift, szRegion + iOffsetShift + 0, iOffset - iOffsetShift, (i64)szRegion + iOffsetShift ); OSTRACE(("SHM-MAP-MAP pid=%lu, region=%d, offset=%d, size=%d, rc=%s\n", osGetCurrentProcessId(), pShmNode->nRegion, iOffset, @@ -53397,7 +53445,7 @@ static int winShmMap( shmpage_out: if( pShmNode->nRegion>iRegion ){ - int iOffset = iRegion*szRegion; + i64 iOffset = (i64)iRegion*(i64)szRegion; int iOffsetShift = iOffset % winSysInfo.dwAllocationGranularity; char *p = (char *)pShmNode->aRegion[iRegion].pMap; *pp = (void *)&p[iOffsetShift]; @@ -56110,10 +56158,10 @@ SQLITE_API int sqlite3_deserialize( if( rc ) goto end_deserialize; db->init.iDb = (u8)iDb; db->init.reopenMemdb = 1; - rc = sqlite3_step(pStmt); + sqlite3_step(pStmt); db->init.reopenMemdb = 0; - if( rc!=SQLITE_DONE ){ - rc = SQLITE_ERROR; + rc = sqlite3_finalize(pStmt); + if( rc!=SQLITE_OK ){ goto end_deserialize; } p = memdbFromDbSchema(db, zSchema); @@ -56134,7 +56182,6 @@ SQLITE_API int sqlite3_deserialize( } end_deserialize: - sqlite3_finalize(pStmt); if( pData && (mFlags & SQLITE_DESERIALIZE_FREEONCLOSE)!=0 ){ sqlite3_free(pData); } @@ -62126,7 +62173,7 @@ static int pager_delsuper(Pager *pPager, const char *zSuper){ if( rc!=SQLITE_OK ) goto delsuper_out; nSuperPtr = 1 + (i64)pVfs->mxPathname; assert( nSuperJournal>=0 && nSuperPtr>0 ); - zFree = sqlite3Malloc(4 + nSuperJournal + nSuperPtr + 2); + zFree = sqlite3Malloc(4 + nSuperJournal + 2 + nSuperPtr + 2); if( !zFree ){ rc = SQLITE_NOMEM_BKPT; goto delsuper_out; @@ -62387,10 +62434,10 @@ static int pager_playback(Pager *pPager, int isHot){ ** ** TODO: Technically the following is an error because it assumes that ** buffer Pager.pTmpSpace is (mxPathname+1) bytes or larger. i.e. that - ** (pPager->pageSize >= pPager->pVfs->mxPathname+1). Using os_unix.c, + ** ((pPager->pageSize+8) >= pPager->pVfs->mxPathname+1). Using os_unix.c, ** mxPathname is 512, which is the same as the minimum allowable value - ** for pageSize. - */ + ** for pageSize, and so this assumption holds. But it might not for some + ** custom VFS. */ zSuper = pPager->pTmpSpace; rc = readSuperJournal(pPager->jfd, zSuper, 1+(i64)pPager->pVfs->mxPathname); if( rc==SQLITE_OK && zSuper[0] ){ @@ -78280,7 +78327,9 @@ static int accessPayload( ** means "not yet known" (the cache is lazily populated). */ if( (pCur->curFlags & BTCF_ValidOvfl)==0 ){ - int nOvfl = (pCur->info.nPayload-pCur->info.nLocal+ovflSize-1)/ovflSize; + i64 nOvfl = pCur->info.nPayload; + testcase( nOvfl - pCur->info.nLocal + ovflSize - 1 > 0xffffffffU ); + nOvfl = (nOvfl - pCur->info.nLocal + ovflSize-1)/ovflSize; if( pCur->aOverflow==0 || nOvfl*(int)sizeof(Pgno) > sqlite3MallocSize(pCur->aOverflow) ){ @@ -78385,6 +78434,12 @@ static int accessPayload( (eOp==0 ? PAGER_GET_READONLY : 0) ); if( rc==SQLITE_OK ){ + if( eOp!=0 + && (sqlite3PagerPageRefcount(pDbPage)!=1 + || NEVER(((MemPage*)sqlite3PagerGetExtra(pDbPage))->isInit)) ){ + sqlite3PagerUnref(pDbPage); + return SQLITE_CORRUPT_PAGE(pPage); + } aPayload = sqlite3PagerGetData(pDbPage); nextPage = get4byte(aPayload); rc = copyPayload(&aPayload[offset+4], pBuf, a, eOp, pDbPage); @@ -111073,6 +111128,7 @@ static int lookupName( pExpr->op = TK_FUNCTION; pExpr->u.zToken = "coalesce"; pExpr->x.pList = pFJMatch; + pExpr->affExpr = SQLITE_AFF_DEFER; cnt = 1; goto lookupname_end; }else{ @@ -111241,6 +111297,26 @@ static int exprProbability(Expr *p){ return (int)(r*134217728.0); } +/* +** Set the EP_SubtArg property on every expression inside of +** pList. If any subexpression is actually a subquery, then +** also set the EP_SubtArg property on the first result-set +** column of that subquery. +*/ +static SQLITE_NOINLINE void resolveSetExprSubtypeArg(ExprList *pList){ + int nn, ii; + nn = pList ? pList->nExpr : 0; + for(ii=0; iia[ii].pExpr; + ExprSetProperty(pExpr, EP_SubtArg); + if( pExpr->op==TK_SELECT ){ + assert( ExprUseXSelect(pExpr) ); + assert( pExpr->x.pSelect!=0 ); + resolveSetExprSubtypeArg(pExpr->x.pSelect->pEList); + } + } +} + /* ** This routine is callback for sqlite3WalkExpr(). ** @@ -111485,10 +111561,7 @@ static int resolveExprStep(Walker *pWalker, Expr *pExpr){ if( (pDef->funcFlags & SQLITE_SUBTYPE) || ExprHasProperty(pExpr, EP_SubtArg) ){ - int ii; - for(ii=0; iia[ii].pExpr, EP_SubtArg); - } + resolveSetExprSubtypeArg(pList); } if( pDef->funcFlags & (SQLITE_FUNC_CONSTANT|SQLITE_FUNC_SLOCHNG) ){ @@ -116883,7 +116956,16 @@ static void sqlite3ExprCodeIN( CollSeq *pColl; int r3 = sqlite3GetTempReg(pParse); p = sqlite3VectorFieldSubexpr(pLeft, i); - pColl = sqlite3ExprCollSeq(pParse, p); + if( ExprUseXSelect(pExpr) ){ + Expr *pRhs = pExpr->x.pSelect->pEList->a[i].pExpr; + pColl = sqlite3BinaryCompareCollSeq(pParse, p, pRhs); + }else{ + /* If the RHS of the IN(...) expression are scalar expressions, do + ** not consider their collation sequences. The documentation says + ** "The collating sequence used for expressions of the form "x IN (y, z, + ** ...)" is the collating sequence of x.". */ + pColl = sqlite3ExprCollSeq(pParse, p); + } sqlite3VdbeAddOp3(v, OP_Column, iTab, i, r3); sqlite3VdbeAddOp4(v, OP_Ne, rLhs+i, destNotNull, r3, (void*)pColl, P4_COLLSEQ); @@ -117306,26 +117388,37 @@ static int exprCodeInlineFunction( } /* -** Expression Node callback for sqlite3ExprCanReturnSubtype(). +** Expression Node callback for sqlite3ExprCanReturnSubtype(). If +** pExpr is able to return a subtype, set pWalker->eCode and abort +** the search. If pExpr can never return a subtype, prune search. +** +** The only expressions that can return a subtype are: +** +** 1. A function +** 2. The no-op "+" operator +** 3. A CASE...END expression +** 4. A CAST() expression +** 5. A "expr COLLATE colseq" expression. ** -** Only a function call is able to return a subtype. So if the node -** is not a function call, return WRC_Prune immediately. +** For any other kind of expression, prune the search. ** -** A function call is able to return a subtype if it has the -** SQLITE_RESULT_SUBTYPE property. +** For case 1, the expression can yield a subtype if the function has +** the SQLITE_RESULT_SUBTYPE property. Functions can also return +** a subtype (via sqlite3_result_value()) if any of the arguments can +** return a subtype. ** -** Assume that every function is able to pass-through a subtype from -** one of its argument (using sqlite3_result_value()). Most functions -** are not this way, but we don't have a mechanism to distinguish those -** that are from those that are not, so assume they all work this way. -** That means that if one of its arguments is another function and that -** other function is able to return a subtype, then this function is -** able to return a subtype. +** In all cases 1 through 5, the expression might also return a subtype +** if any operand can return a subtype. */ static int exprNodeCanReturnSubtype(Walker *pWalker, Expr *pExpr){ int n; FuncDef *pDef; sqlite3 *db; + if( pExpr->op==TK_CASE || pExpr->op==TK_UPLUS + || pExpr->op==TK_COLLATE || pExpr->op==TK_CAST + ){ + return WRC_Continue; + } if( pExpr->op!=TK_FUNCTION ){ return WRC_Prune; } @@ -117335,7 +117428,7 @@ static int exprNodeCanReturnSubtype(Walker *pWalker, Expr *pExpr){ pDef = sqlite3FindFunction(db, pExpr->u.zToken, n, ENC(db), 0); if( NEVER(pDef==0) || (pDef->funcFlags & SQLITE_RESULT_SUBTYPE)!=0 ){ pWalker->eCode = 1; - return WRC_Prune; + return WRC_Abort; } return WRC_Continue; } @@ -123124,7 +123217,9 @@ SQLITE_PRIVATE void sqlite3AlterDropConstraint( if( !pTab ) return; if( pCons ){ - zArg = sqlite3MPrintf(db, "%.*Q", pCons->n, pCons->z); + char *z = sqlite3NameFromToken(db, pCons); + zArg = sqlite3MPrintf(db, "%Q", z); + sqlite3DbFree(db, z); }else{ int iCol; if( alterFindCol(pParse, pTab, pCol, &iCol) ) return; @@ -123311,19 +123406,31 @@ SQLITE_PRIVATE void sqlite3AlterAddConstraint( SrcList *pSrc, /* Table to add constraint to */ Token *pFirst, /* First token of new constraint */ Token *pName, /* Name of new constraint. NULL if name omitted. */ - const char *pExpr, /* Text of CHECK expression */ - int nExpr /* Size of pExpr in bytes */ + const char *zExpr, /* Text of CHECK expression */ + int nExpr, /* Size of pExpr in bytes */ + Expr *pExpr /* The parsed CHECK expression */ ){ Table *pTab = 0; /* Table identified by pSrc */ int iDb = 0; /* Which schema does pTab live in */ const char *zDb = 0; /* Name of the schema in which pTab lives */ const char *pCons = 0; /* Text of the constraint */ int nCons; /* Bytes of text to use from pCons[] */ + int rc; /* Result from error checking pExpr */ /* Look up the table being altered. */ assert( pSrc->nSrc==1 ); pTab = alterFindTable(pParse, pSrc, &iDb, &zDb, 1); - if( !pTab ) return; + if( !pTab ){ + sqlite3ExprDelete(pParse->db, pExpr); + return; + } + + /* Verify that the new CHECK constraint does not contain any + ** internal-use-only function. Forum post 2026-05-10T01:11:28Z + */ + rc = sqlite3ResolveSelfReference(pParse, pTab, NC_IsCheck, pExpr, 0); + sqlite3ExprDelete(pParse->db, pExpr); + if( rc ) return; /* If this new constraint has a name, check that it is not a duplicate of ** an existing constraint. It is an error if it is. */ @@ -123344,7 +123451,7 @@ SQLITE_PRIVATE void sqlite3AlterAddConstraint( sqlite3NestedParse(pParse, "SELECT sqlite_fail('constraint failed', %d) " "FROM %Q.%Q WHERE (%.*s) IS NOT TRUE", - SQLITE_CONSTRAINT, zDb, pTab->zName, nExpr, pExpr + SQLITE_CONSTRAINT, zDb, pTab->zName, nExpr, zExpr ); /* Edit the SQL for the named table. */ @@ -125506,6 +125613,16 @@ static void attachFunc( ** from sqlite3_deserialize() to close database db->init.iDb and ** reopen it as a MemDB */ Btree *pNewBt = 0; + + pNew = &db->aDb[db->init.iDb]; + assert( pNew->pBt!=0 ); + if( sqlite3BtreeTxnState(pNew->pBt)!=SQLITE_TXN_NONE + || sqlite3BtreeIsInBackup(pNew->pBt) + ){ + rc = SQLITE_BUSY; + goto attach_error; + } + pVfs = sqlite3_vfs_find("memdb"); if( pVfs==0 ) return; rc = sqlite3BtreeOpen(pVfs, "x\0", db, &pNewBt, 0, SQLITE_OPEN_MAIN_DB); @@ -125515,8 +125632,7 @@ static void attachFunc( /* Both the Btree and the new Schema were allocated successfully. ** Close the old db and update the aDb[] slot with the new memdb ** values. */ - pNew = &db->aDb[db->init.iDb]; - if( ALWAYS(pNew->pBt) ) sqlite3BtreeClose(pNew->pBt); + sqlite3BtreeClose(pNew->pBt); pNew->pBt = pNewBt; pNew->pSchema = pNewSchema; }else{ @@ -134012,9 +134128,18 @@ static void printfFunc( sqlite3StrAccumInit(&str, db, 0, 0, db->aLimit[SQLITE_LIMIT_LENGTH]); str.printfFlags = SQLITE_PRINTF_SQLFUNC; sqlite3_str_appendf(&str, zFormat, &x); - n = str.nChar; - sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n, - SQLITE_DYNAMIC); + if( str.accError==SQLITE_OK ){ + n = str.nChar; + sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n, + SQLITE_DYNAMIC); + }else{ + if( str.accError==SQLITE_NOMEM ){ + sqlite3_result_error_nomem(context); + }else{ + sqlite3_result_error_toobig(context); + } + sqlite3_str_reset(&str); + } } } @@ -135652,11 +135777,16 @@ static void sumInverse(sqlite3_context *context, int argc, sqlite3_value**argv){ assert( p->cnt>0 ); p->cnt--; if( !p->approx ){ - if( sqlite3SubInt64(&p->iSum, sqlite3_value_int64(argv[0])) ){ - p->ovrfl = 1; - p->approx = 1; + i64 x = p->iSum; + if( sqlite3SubInt64(&x, sqlite3_value_int64(argv[0]))==0 ){ + p->iSum = x; + return; } - }else if( type==SQLITE_INTEGER ){ + p->ovrfl = 1; + p->approx = 1; + kahanBabuskaNeumaierInit(p, p->iSum); + } + if( type==SQLITE_INTEGER ){ i64 iVal = sqlite3_value_int64(argv[0]); if( iVal!=SMALLEST_INT64 ){ kahanBabuskaNeumaierStepInt64(p, -iVal); @@ -136629,47 +136759,46 @@ static void percentSort(double *a, unsigned int n){ int i; /* Loop counter */ double rPivot; /* The pivot value */ - assert( n>=2 ); - if( a[0]>a[n-1] ){ - SWAP_DOUBLE(a[0],a[n-1]) - } - if( n==2 ) return; - iGt = n-1; - i = n/2; - if( a[0]>a[i] ){ - SWAP_DOUBLE(a[0],a[i]) - }else if( a[i]>a[iGt] ){ - SWAP_DOUBLE(a[i],a[iGt]) - } - if( n==3 ) return; - rPivot = a[i]; - iLt = i = 1; - do{ - if( a[i]iLt ) SWAP_DOUBLE(a[i],a[iLt]) - iLt++; - i++; - }else if( a[i]>rPivot ){ - do{ - iGt--; - }while( iGt>i && a[iGt]>rPivot ); + while( n>=2 ){ + if( a[0]>a[n-1] ){ + SWAP_DOUBLE(a[0],a[n-1]) + } + if( n==2 ) return; + iGt = n-1; + i = n/2; + if( a[0]>a[i] ){ + SWAP_DOUBLE(a[0],a[i]) + }else if( a[i]>a[iGt] ){ SWAP_DOUBLE(a[i],a[iGt]) + } + if( n==3 ) return; + rPivot = a[i]; + iLt = i = 1; + do{ + if( a[i]iLt ) SWAP_DOUBLE(a[i],a[iLt]) + iLt++; + i++; + }else if( a[i]>rPivot ){ + do{ + iGt--; + }while( iGt>i && a[iGt]>rPivot ); + SWAP_DOUBLE(a[i],a[iGt]) + }else{ + i++; + } + }while( in/2 ){ + if( n-iGt>=2 ) percentSort(a+iGt, n-iGt); + n = iLt; }else{ - i++; + if( iLt>=2 ) percentSort(a, iLt); + a += iGt; + n -= iGt; } - }while( i=2 ) percentSort(a, iLt); - if( n-iGt>=2 ) percentSort(a+iGt, n-iGt); - -/* Uncomment for testing */ -#if 0 - for(i=0; ipSrc!=0) && p->pSrc->nSrcpLimit==0 || p->pLimit->pRight==0) ){ if( pWhere->op==TK_AND ){ Expr *pRight = pWhere->pRight; @@ -156106,7 +156236,6 @@ static SQLITE_NOINLINE void existsToJoin( sqlite3TreeViewSelect(0, p, 0); } #endif - existsToJoin(pParse, p, pSubWhere); } } } @@ -156167,8 +156296,11 @@ static int selectCheckOnClausesExpr(Walker *pWalker, Expr *pExpr){ ** does not refer to a table to the right of CheckOnCtx.iJoin. */ do { SrcList *pSrc = pCtx->pSrc; + int nSrc = pSrc->nSrc; int iTab = pExpr->iTable; - if( iTab>=pSrc->a[0].iCursor && iTab<=pSrc->a[pSrc->nSrc-1].iCursor ){ + int ii; + for(ii=0; iia[ii].iCursor!=iTab; ii++){} + if( iiiJoin && iTab>pCtx->iJoin ){ sqlite3ErrorMsg(pWalker->pParse, "%s references tables to its right", @@ -165948,7 +166080,7 @@ SQLITE_PRIVATE Bitmask sqlite3WhereCodeOneLoopStart( ** by this loop in the a[0] slot and all notReady tables in a[1..] slots. ** This becomes the SrcList in the recursive call to sqlite3WhereBegin(). */ - if( pWInfo->nLevel>1 ){ + if( pWInfo->nLevel>1 || pTabItem->fg.fromExists ){ int nNotReady; /* The number of notReady tables */ SrcItem *origSrc; /* Original list of tables */ nNotReady = pWInfo->nLevel - iLevel - 1; @@ -165961,6 +166093,13 @@ SQLITE_PRIVATE Bitmask sqlite3WhereCodeOneLoopStart( for(k=1; k<=nNotReady; k++){ memcpy(&pOrTab->a[k], &origSrc[pLevel[k].iFrom], sizeof(pOrTab->a[k])); } + + /* Clear the fromExists flag on the OR-optimized table entry so that + ** the calls to sqlite3WhereEnd() do not code early-exits after the + ** first row is visited. The early exit applies to this table's + ** overall loop - including the multiple OR branches and any WHERE + ** conditions not passed to the sub-loops - not to the sub-loops. */ + pOrTab->a[0].fg.fromExists = 0; }else{ pOrTab = pWInfo->pTabList; } @@ -166204,7 +166343,7 @@ SQLITE_PRIVATE Bitmask sqlite3WhereCodeOneLoopStart( assert( pLevel->op==OP_Return ); pLevel->p2 = sqlite3VdbeCurrentAddr(v); - if( pWInfo->nLevel>1 ){ sqlite3DbFreeNN(db, pOrTab); } + if( pWInfo->pTabList!=pOrTab ){ sqlite3DbFreeNN(db, pOrTab); } if( !untestedTerms ) disableTerm(pLevel, pTerm); }else #endif /* SQLITE_OMIT_OR_OPTIMIZATION */ @@ -166361,6 +166500,7 @@ SQLITE_PRIVATE Bitmask sqlite3WhereCodeOneLoopStart( WO_EQ|WO_IN|WO_IS, 0); if( pAlt==0 ) continue; if( pAlt->wtFlags & (TERM_CODED) ) continue; + if( ExprHasProperty(pAlt->pExpr, EP_Collate) ) continue; if( (pAlt->eOperator & WO_IN) && ExprUseXSelect(pAlt->pExpr) && (pAlt->pExpr->x.pSelect->pEList->nExpr>1) @@ -167553,8 +167693,8 @@ static void exprAnalyzeOrTerm( ** 3. Not originating in the ON clause of an OUTER JOIN ** 4. The operator is not IS or else the query does not contain RIGHT JOIN ** 5. The affinities of A and B must be compatible -** 6a. Both operands use the same collating sequence OR -** 6b. The overall collating sequence is BINARY +** 6. Both operands use the same collating sequence, and they must not +** use explicit COLLATE clauses. ** If this routine returns TRUE, that means that the RHS can be substituted ** for the LHS anyplace else in the WHERE clause where the LHS column occurs. ** This is an optimization. No harm comes from returning 0. But if 1 is @@ -167562,10 +167702,9 @@ static void exprAnalyzeOrTerm( */ static int termIsEquivalence(Parse *pParse, Expr *pExpr, SrcList *pSrc){ char aff1, aff2; - CollSeq *pColl; if( !OptimizationEnabled(pParse->db, SQLITE_Transitive) ) return 0; /* (1) */ if( pExpr->op!=TK_EQ && pExpr->op!=TK_IS ) return 0; /* (2) */ - if( ExprHasProperty(pExpr, EP_OuterON) ) return 0; /* (3) */ + if( ExprHasProperty(pExpr, EP_OuterON|EP_Collate) ) return 0; /* (3) */ assert( pSrc!=0 ); if( pExpr->op==TK_IS && pSrc->nSrc>=2 @@ -167580,10 +167719,7 @@ static int termIsEquivalence(Parse *pParse, Expr *pExpr, SrcList *pSrc){ ){ return 0; /* (5) */ } - pColl = sqlite3ExprCompareCollSeq(pParse, pExpr); - if( !sqlite3IsBinary(pColl) - && !sqlite3ExprCollSeqMatch(pParse, pExpr->pLeft, pExpr->pRight) - ){ + if( !sqlite3ExprCollSeqMatch(pParse, pExpr->pLeft, pExpr->pRight) ){ return 0; /* (6) */ } return 1; @@ -167915,7 +168051,7 @@ static void exprAnalyze( /* Analyze a term that is composed of two or more subterms connected by ** an OR operator. */ - else if( pExpr->op==TK_OR ){ + else if( pExpr->op==TK_OR && !ExprHasProperty(pExpr, EP_Collate) ){ assert( pWC->op==TK_AND ); exprAnalyzeOrTerm(pSrc, pWC, idxTerm); pTerm = &pWC->a[idxTerm]; @@ -171733,7 +171869,8 @@ static int whereRangeVectorLen( idxaff = sqlite3TableColumnAffinity(pIdx->pTable, pLhs->iColumn); if( aff!=idxaff ) break; - pColl = sqlite3ExprCompareCollSeq(pParse, pTerm->pExpr); + if( ExprHasProperty(pTerm->pExpr, EP_Commuted) ) SWAP(Expr*, pRhs, pLhs); + pColl = sqlite3BinaryCompareCollSeq(pParse, pLhs, pRhs); if( pColl==0 ) break; if( sqlite3StrICmp(pColl->zName, pIdx->azColl[i+nEq]) ) break; } @@ -176129,27 +176266,11 @@ SQLITE_PRIVATE void sqlite3WhereEnd(WhereInfo *pWInfo){ } #endif /* SQLITE_DISABLE_SKIPAHEAD_DISTINCT */ } - if( pTabList->a[pLevel->iFrom].fg.fromExists - && (i==pWInfo->nLevel-1 - || pTabList->a[pWInfo->a[i+1].iFrom].fg.fromExists==0) - ){ - /* This is an EXISTS-to-JOIN optimization which is either the - ** inner-most loop, or the inner-most of a group of nested - ** EXISTS-to-JOIN optimization loops. If this loop sees a successful - ** row, it should break out of itself as well as other EXISTS-to-JOIN - ** loops in which is is directly nested. */ - int nOuter = 0; /* Nr of outer EXISTS that this one is nested within */ - while( nOutera[pLevel[-nOuter-1].iFrom].fg.fromExists ) break; - nOuter++; - } - testcase( nOuter>0 ); - sqlite3VdbeAddOp2(v, OP_Goto, 0, pLevel[-nOuter].addrBrk); - if( nOuter ){ - VdbeComment((v, "EXISTS break %d..%d", i-nOuter, i)); - }else{ - VdbeComment((v, "EXISTS break %d", i)); - } + if( pTabList->a[pLevel->iFrom].fg.fromExists ){ + /* This is an EXISTS-to-JOIN optimization loop. If this loop sees a + ** successful row, it should break out of itself. */ + sqlite3VdbeAddOp2(v, OP_Goto, 0, pLevel->addrBrk); + VdbeComment((v, "EXISTS break %d", i)); } sqlite3VdbeResolveLabel(v, pLevel->addrCont); if( pLevel->op!=OP_Noop ){ @@ -184241,9 +184362,11 @@ static YYACTIONTYPE yy_reduce( ExprList *pList = sqlite3ExprListAppend(pParse, yymsp[-3].minor.yy14, yymsp[-1].minor.yy454); yymsp[-4].minor.yy454 = sqlite3PExpr(pParse, TK_VECTOR, 0, 0); if( yymsp[-4].minor.yy454 ){ + int i; yymsp[-4].minor.yy454->x.pList = pList; - if( ALWAYS(pList->nExpr) ){ - yymsp[-4].minor.yy454->flags |= pList->a[0].pExpr->flags & EP_Propagate; + for(i=0; inExpr; i++){ + assert( pList->a[i].pExpr!=0 ); + yymsp[-4].minor.yy454->flags |= pList->a[i].pExpr->flags & EP_Propagate; } }else{ sqlite3ExprListDelete(pParse->db, pList); @@ -184354,6 +184477,7 @@ static YYACTIONTYPE yy_reduce( yymsp[-4].minor.yy454 = sqlite3PExpr(pParse, TK_BETWEEN, yymsp[-4].minor.yy454, 0); if( yymsp[-4].minor.yy454 ){ yymsp[-4].minor.yy454->x.pList = pList; + sqlite3ExprSetHeightAndFlags(pParse, yymsp[-4].minor.yy454); }else{ sqlite3ExprListDelete(pParse->db, pList); } @@ -184710,15 +184834,13 @@ static YYACTIONTYPE yy_reduce( break; case 300: /* cmd ::= ALTER TABLE fullname ADD CONSTRAINT nm CHECK LP expr RP onconf */ { - sqlite3AlterAddConstraint(pParse, yymsp[-8].minor.yy203, &yymsp[-6].minor.yy0, &yymsp[-5].minor.yy0, yymsp[-3].minor.yy0.z+1, (yymsp[-1].minor.yy0.z-yymsp[-3].minor.yy0.z-1)); + sqlite3AlterAddConstraint(pParse, yymsp[-8].minor.yy203, &yymsp[-6].minor.yy0, &yymsp[-5].minor.yy0, yymsp[-3].minor.yy0.z+1, (yymsp[-1].minor.yy0.z-yymsp[-3].minor.yy0.z-1), yymsp[-2].minor.yy454); } - yy_destructor(yypParser,219,&yymsp[-2].minor); break; case 301: /* cmd ::= ALTER TABLE fullname ADD CHECK LP expr RP onconf */ { - sqlite3AlterAddConstraint(pParse, yymsp[-6].minor.yy203, &yymsp[-4].minor.yy0, 0, yymsp[-3].minor.yy0.z+1, (yymsp[-1].minor.yy0.z-yymsp[-3].minor.yy0.z-1)); + sqlite3AlterAddConstraint(pParse, yymsp[-6].minor.yy203, &yymsp[-4].minor.yy0, 0, yymsp[-3].minor.yy0.z+1, (yymsp[-1].minor.yy0.z-yymsp[-3].minor.yy0.z-1), yymsp[-2].minor.yy454); } - yy_destructor(yypParser,219,&yymsp[-2].minor); break; case 302: /* cmd ::= create_vtab */ {sqlite3VtabFinishParse(pParse,0);} @@ -193922,6 +194044,12 @@ SQLITE_PRIVATE int sqlite3Fts3IntegrityCheck(Fts3Table *p, int *pbOk); SQLITE_EXTENSION_INIT1 #endif + +/* +** Assume any b-tree layer with more levels than this is corrupt. +*/ +#define FTS3_MAX_BTREE_HEIGHT 48 + typedef struct Fts3HashWrapper Fts3HashWrapper; struct Fts3HashWrapper { Fts3Hash hash; /* Hash table */ @@ -195638,7 +195766,11 @@ static int fts3SelectLeaf( assert( piLeaf || piLeaf2 ); fts3GetVarint32(zNode, &iHeight); - rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2); + if( iHeight>FTS3_MAX_BTREE_HEIGHT ){ + rc = FTS_CORRUPT_VTAB; + }else{ + rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2); + } assert_fts3_nc( !piLeaf2 || !piLeaf || rc!=SQLITE_OK || (*piLeaf<=*piLeaf2) ); if( rc==SQLITE_OK && iHeight>1 ){ @@ -200167,7 +200299,7 @@ static int fts3auxNextMethod(sqlite3_vtab_cursor *pCursor){ /* State 3. The integer just read is a column number. */ default: assert( eState==3 ); iCol = (int)v; - if( iCol<1 ){ + if( iCol<1 || iCol>(pFts3->nColumn+1) ){ rc = SQLITE_CORRUPT_VTAB; break; } @@ -200857,6 +200989,7 @@ static int getNextNode( assert( nKey==4 ); if( zInput[4]=='/' && zInput[5]>='0' && zInput[5]<='9' ){ nKey += 1+sqlite3Fts3ReadInt(&zInput[nKey+1], &nNear); + if( nNear>=1000000000 ) nNear = 1000000000; } } @@ -210720,7 +210853,7 @@ static int fts3ExprLHits( if( p->flag==FTS3_MATCHINFO_LHITS ){ p->aMatchinfo[iStart + iCol] = (u32)nHit; }else if( nHit ){ - p->aMatchinfo[iStart + (iCol+1)/32] |= (1 << (iCol&0x1F)); + p->aMatchinfo[iStart + iCol/32] |= (1U << (iCol&0x1F)); } } assert( *pIter==0x00 || *pIter==0x01 ); @@ -213210,7 +213343,7 @@ static void jsonAppendSqlValue( break; } case SQLITE_FLOAT: { - jsonPrintf(100, p, "%!0.15g", sqlite3_value_double(pValue)); + jsonPrintf(100, p, "%!0.17g", sqlite3_value_double(pValue)); break; } case SQLITE_INTEGER: { @@ -214524,9 +214657,10 @@ static u32 jsonbPayloadSize(const JsonParse *pParse, u32 i, u32 *pSz){ u8 x; u32 sz; u32 n; - assert( i<=pParse->nBlob ); - x = pParse->aBlob[i]>>4; - if( x<=11 ){ + if( i>=pParse->nBlob ){ + *pSz = 0; + return 0; + }else if( (x = pParse->aBlob[i]>>4)<=11 ){ sz = x; n = 1; }else if( x==12 ){ @@ -217309,11 +217443,9 @@ static void jsonGroupInverse( UNUSED_PARAMETER(argc); UNUSED_PARAMETER(argv); pStr = (JsonString*)sqlite3_aggregate_context(ctx, 0); -#ifdef NEVER /* pStr is always non-NULL since jsonArrayStep() or jsonObjectStep() will ** always have been called to initialize it */ if( NEVER(!pStr) ) return; -#endif z = pStr->zBuf; for(i=1; inUsed && ((c = z[i])!=',' || inStr || nNest); i++){ if( c=='"' ){ @@ -217342,6 +217474,13 @@ static void jsonGroupInverse( ** json_group_obj(NAME,VALUE) ** ** Return a JSON object composed of all names and values in the aggregate. +** +** Rows for which NAME is NULL do not result in a new entry. However, we +** do initially insert a "@" entry into the growing string for each null entry +** and change the first character of the string to "@" to signal that the +** string contains null entries. The "@" markers are needed in order to +** correctly process xInverse() requests. The initial "@" is converted +** back into "{" and the "@" null values are removed by jsonObjectCompute(). */ static void jsonObjectStep( sqlite3_context *ctx, @@ -217359,7 +217498,7 @@ static void jsonObjectStep( if( pStr->zBuf==0 ){ jsonStringInit(pStr, ctx); jsonAppendChar(pStr, '{'); - }else if( pStr->nUsed>1 && z!=0 ){ + }else if( pStr->nUsed>1 ){ jsonAppendChar(pStr, ','); } pStr->pCtx = ctx; @@ -217367,6 +217506,9 @@ static void jsonObjectStep( jsonAppendString(pStr, z, n); jsonAppendChar(pStr, ':'); jsonAppendSqlValue(pStr, argv[1]); + }else{ + pStr->zBuf[0] = '@'; + jsonAppendRawNZ(pStr, "@", 1); } } } @@ -217375,20 +217517,64 @@ static void jsonObjectCompute(sqlite3_context *ctx, int isFinal){ int flags = SQLITE_PTR_TO_INT(sqlite3_user_data(ctx)); pStr = (JsonString*)sqlite3_aggregate_context(ctx, 0); if( pStr ){ - jsonAppendRawNZ(pStr, "}", 2); - jsonStringTrimOneChar(pStr); + JsonString *pOgStr = pStr; + JsonString tmpStr; + jsonAppendRawNZ(pOgStr, "}", 2); /* Ensure it is zero-terminated */ + jsonStringTrimOneChar(pOgStr); /* Remove the zero terminator */ pStr->pCtx = ctx; if( pStr->eErr ){ jsonReturnString(pStr, 0, 0); return; - }else if( flags & JSON_BLOB ){ + } + if( pStr->zBuf[0]!='{' ){ + /* The string contains null entries that need to be removed */ + u64 i, j; + int inStr = 0; + if( !isFinal ){ + /* Work with a temporary copy of the string if this is not the + ** final result */ + jsonStringInit(&tmpStr, ctx); + jsonAppendRawNZ(&tmpStr, pStr->zBuf, pStr->nUsed+1); + pStr = &tmpStr; + if( pStr->eErr ){ + jsonReturnString(pStr, 0, 0); + return; + } + jsonStringTrimOneChar(pStr); /* Remove zero terminator */ + } + /* Fix up the string by changing the initial "@" flag back to + ** to "{" and removing all subsequence "@" entries, with their + ** associated comma delimeters. */ + pStr->zBuf[0] = '{'; + for(i=j=1; inUsed; i++){ + char c = pStr->zBuf[i]; + if( c=='"' ){ + inStr = !inStr; + pStr->zBuf[j++] = '"'; + }else if( c=='\\' ){ + pStr->zBuf[j++] = '\\'; + pStr->zBuf[j++] = pStr->zBuf[++i]; + }else if( c=='@' && !inStr ){ + assert( i+1nUsed ); + if( pStr->zBuf[i+1]==',' ){ + i++; + }else if( pStr->zBuf[j-1]==',' ){ + j--; + } + }else{ + pStr->zBuf[j++] = c; + } + } + pStr->zBuf[j] = 0; /* Restore zero terminator */ + pStr->nUsed = j; /* Truncate the string */ + } + if( flags & JSON_BLOB ){ jsonReturnStringAsBlob(pStr); if( isFinal ){ if( !pStr->bStatic ) sqlite3RCStrUnref(pStr->zBuf); }else{ - jsonStringTrimOneChar(pStr); + jsonStringTrimOneChar(pOgStr); } - return; }else if( isFinal ){ sqlite3_result_text(ctx, pStr->zBuf, (int)pStr->nUsed, pStr->bStatic ? SQLITE_TRANSIENT : @@ -217396,8 +217582,9 @@ static void jsonObjectCompute(sqlite3_context *ctx, int isFinal){ pStr->bStatic = 1; }else{ sqlite3_result_text(ctx, pStr->zBuf, (int)pStr->nUsed, SQLITE_TRANSIENT); - jsonStringTrimOneChar(pStr); + jsonStringTrimOneChar(pOgStr); } + if( pStr!=pOgStr ) jsonStringReset(pStr); }else if( flags & JSON_BLOB ){ static const unsigned char emptyObject = 0x0c; sqlite3_result_blob(ctx, &emptyObject, 1, SQLITE_STATIC); @@ -218249,7 +218436,7 @@ struct Rtree { u8 eCoordType; /* RTREE_COORD_REAL32 or RTREE_COORD_INT32 */ u8 nBytesPerCell; /* Bytes consumed per cell */ u8 inWrTrans; /* True if inside write transaction */ - u8 nAux; /* # of auxiliary columns in %_rowid */ + u16 nAux; /* # of auxiliary columns in %_rowid */ #ifdef SQLITE_ENABLE_GEOPOLY u8 nAuxNotNull; /* Number of initial not-null aux columns */ #endif @@ -219485,7 +219672,7 @@ static int nodeRowidIndex( ){ int ii; int nCell = NCELL(pNode); - assert( nCell<200 ); + assert( nCell<65536 && nCell>=0 ); for(ii=0; iiRTREE_MAXCELLS ){ + RTREE_IS_CORRUPT(pRtree); + return SQLITE_CORRUPT_VTAB; + } pCellData = pNode->zData + (4+pRtree->nBytesPerCell*p->iCell); while( p->iCellRTREE_MAX_AUX_COLUMN+3 ){ *pzErr = sqlite3_mprintf("%s", aErrMsg[2 + (argc>=6)]); return SQLITE_ERROR; @@ -223643,6 +223833,11 @@ static int geopolyInit( int ii; (void)pAux; + if( argc>=RTREE_MAX_AUX_COLUMN+4 ){ + *pzErr = sqlite3_mprintf("Too many columns for a geopoly table"); + return SQLITE_ERROR; + } + sqlite3_vtab_config(db, SQLITE_VTAB_CONSTRAINT_SUPPORT, 1); sqlite3_vtab_config(db, SQLITE_VTAB_INNOCUOUS); @@ -224777,7 +224972,7 @@ static void icuCaseFunc16(sqlite3_context *p, int nArg, sqlite3_value **apArg){ const UChar *zInput; /* Pointer to input string */ UChar *zOutput = 0; /* Pointer to output buffer */ int nInput; /* Size of utf-16 input string in bytes */ - int nOut; /* Size of output buffer in bytes */ + sqlite3_int64 nOut; /* Size of output buffer in bytes */ int cnt; int bToUpper; /* True for toupper(), false for tolower() */ UErrorCode status; @@ -224800,7 +224995,7 @@ static void icuCaseFunc16(sqlite3_context *p, int nArg, sqlite3_value **apArg){ } for(cnt=0; cnt<2; cnt++){ - UChar *zNew = sqlite3_realloc(zOutput, nOut); + UChar *zNew = sqlite3_realloc64(zOutput, nOut); if( zNew==0 ){ sqlite3_free(zOutput); sqlite3_result_error_nomem(p); @@ -224809,9 +225004,9 @@ static void icuCaseFunc16(sqlite3_context *p, int nArg, sqlite3_value **apArg){ zOutput = zNew; status = U_ZERO_ERROR; if( bToUpper ){ - nOut = 2*u_strToUpper(zOutput,nOut/2,zInput,nInput/2,zLocale,&status); + nOut = 2LL*u_strToUpper(zOutput,nOut/2,zInput,nInput/2,zLocale,&status); }else{ - nOut = 2*u_strToLower(zOutput,nOut/2,zInput,nInput/2,zLocale,&status); + nOut = 2LL*u_strToLower(zOutput,nOut/2,zInput,nInput/2,zLocale,&status); } if( U_SUCCESS(status) ){ @@ -232526,12 +232721,13 @@ static int dbpageFilter( pCsr->szPage = sqlite3BtreeGetPageSize(pBt); pCsr->mxPgno = sqlite3BtreeLastPage(pBt); if( idxNum & 1 ){ + i64 iPg = sqlite3_value_int64(argv[idxNum>>1]); assert( argc>(idxNum>>1) ); - pCsr->pgno = sqlite3_value_int(argv[idxNum>>1]); - if( pCsr->pgno<1 || pCsr->pgno>pCsr->mxPgno ){ + if( iPg<1 || iPg>pCsr->mxPgno ){ pCsr->pgno = 1; pCsr->mxPgno = 0; }else{ + pCsr->pgno = (Pgno)iPg; pCsr->mxPgno = pCsr->pgno; } }else{ @@ -233971,10 +234167,11 @@ static int sessionSerialLen(const u8 *a){ int n; assert( a!=0 ); e = *a; - if( e==0 || e==0xFF ) return 1; - if( e==SQLITE_NULL ) return 1; if( e==SQLITE_INTEGER || e==SQLITE_FLOAT ) return 9; - return sessionVarintGet(&a[1], &n) + 1 + n; + if( e==SQLITE_TEXT || e==SQLITE_BLOB ){ + return sessionVarintGet(&a[1], &n) + 1 + n; + } + return 1; } /* @@ -233997,17 +234194,17 @@ static unsigned int sessionChangeHash( u8 *a = aRecord; /* Used to iterate through change record */ for(i=0; inCol; i++){ - int eType = *a; int isPK = pTab->abPK[i]; if( bPkOnly && isPK==0 ) continue; - assert( eType==SQLITE_INTEGER || eType==SQLITE_FLOAT - || eType==SQLITE_TEXT || eType==SQLITE_BLOB - || eType==SQLITE_NULL || eType==0 - ); - if( isPK ){ - a++; + int eType = *a++; + + assert( eType==SQLITE_INTEGER || eType==SQLITE_FLOAT + || eType==SQLITE_TEXT || eType==SQLITE_BLOB + || eType==SQLITE_NULL || eType==0 + ); + h = sessionHashAppendType(h, eType); if( eType==SQLITE_INTEGER || eType==SQLITE_FLOAT ){ h = sessionHashAppendI64(h, sessionGetI64(a)); @@ -234877,6 +235074,16 @@ static int sessionPrepareDfltStmt( return rc; } +/* +** Finalize statement pStmt. If (*pRc) is SQLITE_OK when this function is +** called, set it to the results of the sqlite3_finalize() call. Or, if +** it is already set to an error code, leave it as is. +*/ +static void sessionFinalizeStmt(sqlite3_stmt *pStmt, int *pRc){ + int rc = sqlite3_finalize(pStmt); + if( *pRc==SQLITE_OK ) *pRc = rc; +} + /* ** Table pTab has one or more existing change-records with old.* records ** with fewer than pTab->nCol columns. This function updates all such @@ -234899,9 +235106,8 @@ static int sessionUpdateChanges(sqlite3_session *pSession, SessionTable *pTab){ } } + sessionFinalizeStmt(pStmt, &rc); pSession->rc = rc; - rc = sqlite3_finalize(pStmt); - if( pSession->rc==SQLITE_OK ) pSession->rc = rc; return pSession->rc; } @@ -235469,7 +235675,7 @@ static int sessionDiffFindNew( rc = SQLITE_NOMEM; }else{ sqlite3_stmt *pStmt; - rc = sqlite3_prepare(pSession->db, zStmt, -1, &pStmt, 0); + rc = sqlite3_prepare_v2(pSession->db, zStmt, -1, &pStmt, 0); if( rc==SQLITE_OK ){ SessionDiffCtx *pDiffCtx = (SessionDiffCtx*)pSession->hook.pCtx; pDiffCtx->pStmt = pStmt; @@ -235532,7 +235738,7 @@ static int sessionDiffFindModified( rc = SQLITE_NOMEM; }else{ sqlite3_stmt *pStmt; - rc = sqlite3_prepare(pSession->db, zStmt, -1, &pStmt, 0); + rc = sqlite3_prepare_v2(pSession->db, zStmt, -1, &pStmt, 0); if( rc==SQLITE_OK ){ SessionDiffCtx *pDiffCtx = (SessionDiffCtx*)pSession->hook.pCtx; @@ -236227,11 +236433,11 @@ static int sessionSelectStmt( ); sessionAppendStr(&cols, "tbl, ?2, stat", &rc); }else{ - #if 0 +#if 0 if( bRowid ){ sessionAppendStr(&cols, SESSIONS_ROWID, &rc); } - #endif +#endif for(i=0; iiNext+nByte)>pIn->nData ){ + if( rc==SQLITE_OK && (pIn->iNext+nByte)>pIn->nData ){ rc = SQLITE_CORRUPT_BKPT; } } @@ -237483,7 +237691,13 @@ static int sessionChangesetInvert( /* Test for EOF. */ if( (rc = sessionInputBuffer(pInput, 2)) ) goto finished_invert; - if( pInput->iNext>=pInput->nData ) break; + if( pInput->iNext+1>=pInput->nData ){ + if( pInput->iNext!=pInput->nData ){ + rc = SQLITE_CORRUPT_BKPT; + goto finished_invert; + } + break; + } eType = pInput->aData[pInput->iNext]; switch( eType ){ @@ -237679,6 +237893,7 @@ struct SessionApplyCtx { u8 bRebaseStarted; /* If table header is already in rebase */ u8 bRebase; /* True to collect rebase information */ u8 bIgnoreNoop; /* True to ignore no-op conflicts */ + u8 bNoUpdateLoop; /* No update-loop processing */ int bRowid; char *zErr; /* Error message, if any */ }; @@ -238252,7 +238467,7 @@ static int sessionConflictHandler( u8 *aBlob = &pIter->in.aData[pIter->in.iCurrent]; int nBlob = pIter->in.iNext - pIter->in.iCurrent; sessionAppendBlob(&p->constraints, aBlob, nBlob, &rc); - return SQLITE_OK; + return rc; }else if( p->bIgnoreNoop==0 || op!=SQLITE_DELETE || eType==SQLITE_CHANGESET_CONFLICT ){ @@ -238374,7 +238589,7 @@ static int sessionApplyOneOp( for(i=0; rc==SQLITE_OK && iabPK[i] || (bPatchset==0 && pOld) ){ + if( pOld && (p->abPK[i] || bPatchset==0) ){ rc = sessionBindValue(pUp, i*2+2, pOld); } if( rc==SQLITE_OK && pNew ){ @@ -238500,7 +238715,264 @@ static int sessionApplyOneWithRetry( } /* -** Retry the changes accumulated in the pApply->constraints buffer. +** Create an iterator to iterate through the retry buffer pRetry. +*/ +static int sessionRetryIterInit( + SessionBuffer *pRetry, /* Buffer to iterate through */ + int bPatchset, /* True for patchset, false for changeset */ + const char *zTab, /* Table name */ + SessionApplyCtx *pApply, /* Session apply context */ + sqlite3_changeset_iter **ppIter /* OUT: New iterator */ +){ + sqlite3_changeset_iter *pRet = 0; + int rc = SQLITE_OK; + + rc = sessionChangesetStart( + &pRet, 0, 0, pRetry->nBuf, pRetry->aBuf, pApply->bInvertConstraints, 1 + ); + if( rc==SQLITE_OK ){ + size_t nByte = 2*pApply->nCol*sizeof(sqlite3_value*); + pRet->bPatchset = bPatchset; + pRet->zTab = (char*)zTab; + pRet->nCol = pApply->nCol; + pRet->abPK = pApply->abPK; + sessionBufferGrow(&pRet->tblhdr, nByte, &rc); + pRet->apValue = (sqlite3_value**)pRet->tblhdr.aBuf; + if( rc==SQLITE_OK ){ + memset(pRet->apValue, 0, nByte); + }else{ + sqlite3changeset_finalize(pRet); + pRet = 0; + } + } + + *ppIter = pRet; + return rc; +} + +/* +** Attempt to apply all the changes in retry buffer pRetry to the database. +** Except, if parameter iSkip is greater than or equal to 0, skip change +** iSkip. +*/ +static int sessionApplyRetryBuffer( + SessionBuffer *pRetry, /* Buffer to apply changes from */ + int iSkip, /* If >=0, index of change to omit */ + sqlite3 *db, /* Database handle */ + int bPatchset, /* True for patchset, false for changeset */ + const char *zTab, /* Name of table to write to */ + SessionApplyCtx *pApply, /* Apply context */ + int(*xConflict)(void*, int, sqlite3_changeset_iter*), + void *pCtx /* First argument passed to xConflict */ +){ + int rc = SQLITE_OK; + int rc2 = SQLITE_OK; + int ii = 0; + sqlite3_changeset_iter *pIter = 0; + + assert( pApply->constraints.nBuf==0 ); + + rc = sessionRetryIterInit(pRetry, bPatchset, zTab, pApply, &pIter); + + for(ii=0; rc==SQLITE_OK && SQLITE_ROW==sqlite3changeset_next(pIter); ii++){ + if( ii!=iSkip ){ + rc = sessionApplyOneWithRetry(db, pIter, pApply, xConflict, pCtx); + } + } + + rc2 = sqlite3changeset_finalize(pIter); + if( rc==SQLITE_OK ) rc = rc2; + assert( pApply->bDeferConstraints || pApply->constraints.nBuf==0 ); + + return rc; +} + +/* +** Check if table zTab in the "main" database of db is a WITHOUT ROWID +** table. +** +** If no error occurs, return SQLITE_OK and set output variable (*pbWR) to +** true if zTab is a WITHOUT ROWID table, or false otherwise. Or, if an +** error does occur, return an SQLite error code. The final value of (*pbWR) +** is undefined in this case. +*/ +static int sessionTableIsWithoutRowid(sqlite3 *db, const char *zTab, int *pbWR){ + sqlite3_stmt *pList = 0; + char *zSql = 0; + int rc = SQLITE_OK; + + zSql = sqlite3_mprintf("PRAGMA table_list = %Q", zTab); + if( zSql==0 ){ + rc = SQLITE_NOMEM; + }else{ + rc = sqlite3_prepare_v2(db, zSql, -1, &pList, 0); + sqlite3_free(zSql); + } + + if( rc==SQLITE_OK ){ + sqlite3_step(pList); + *pbWR = sqlite3_column_int(pList, 4); + rc = sqlite3_finalize(pList); + } + + return rc; +} + +/* +** Iterator pUp points to an UPDATE change. This function deletes the +** affected row from the database and creates an INSERT statement that +** may be used to reinsert the row as it is after the UPDATE change +** has been applied. +** +** If successful, SQLITE_OK is returned and output variable (*ppInsert) +** is left pointing to a prepared INSERT statement. It is the responsibility +** of the caller to eventually free this statement using sqlite3_finalize(). +** Or, if an error occurs, an SQLite error code is returned and (*ppInsert) +** set to NULL. pApply->zErr may be set to an error message in this case. +*/ +static int sessionUpdateToDeleteInsert( + sqlite3 *db, /* Database to write to */ + const char *zTab, /* Table name */ + SessionApplyCtx *pApply, /* Apply context */ + sqlite3_changeset_iter *pUp, /* Iterator pointing to UPDATE change */ + sqlite3_stmt **ppInsert /* OUT: INSERT statement */ +){ + sqlite3_stmt *pRet = 0; /* The INSERT statement */ + sqlite3_stmt *pSelect = 0; /* SELECT to read current values of row */ + int rc = SQLITE_OK; + int bWR = 0; + + rc = sessionTableIsWithoutRowid(db, zTab, &bWR); + if( rc==SQLITE_OK ){ + char *zSelect = 0; + char *zInsert = 0; + SessionBuffer cols = {0, 0, 0}; + SessionBuffer insbind = {0, 0, 0}; + SessionBuffer pkcols = {0, 0, 0}; + SessionBuffer selbind = {0, 0, 0}; + + const char *zComma = ""; + const char *zComma2 = ""; + int ii; + for(ii=0; iinCol; ii++){ + sessionAppendStr(&cols, zComma, &rc); + sessionAppendIdent(&cols, pApply->azCol[ii], &rc); + sessionAppendStr(&insbind, zComma, &rc); + sessionAppendStr(&insbind, "?", &rc); + zComma = ", "; + + if( pApply->abPK[ii] ){ + sessionAppendStr(&pkcols, zComma2, &rc); + sessionAppendIdent(&pkcols, pApply->azCol[ii], &rc); + sessionAppendStr(&selbind, zComma2, &rc); + sessionAppendPrintf(&selbind, &rc, "?%d", ii+1); + zComma2 = ", "; + } + } + if( bWR==0 ){ + sessionAppendStr(&cols, zComma, &rc); + sessionAppendStr(&cols, SESSIONS_ROWID, &rc); + sessionAppendStr(&insbind, zComma, &rc); + sessionAppendStr(&insbind, "?", &rc); + } + + if( rc==SQLITE_OK ){ + zSelect = sqlite3_mprintf("SELECT %s FROM %Q WHERE (%s) IS (%s)", + cols.aBuf, zTab, pkcols.aBuf, selbind.aBuf + ); + if( zSelect==0 ) rc = SQLITE_NOMEM; + } + if( rc==SQLITE_OK ){ + zInsert = sqlite3_mprintf("INSERT INTO %Q(%s) VALUES(%s)", + zTab, cols.aBuf, insbind.aBuf + ); + if( zInsert==0 ) rc = SQLITE_NOMEM; + } + + if( rc==SQLITE_OK ){ + rc = sessionPrepare(db, &pSelect, &pApply->zErr, zSelect); + } + if( rc==SQLITE_OK ){ + rc = sessionPrepare(db, &pRet, &pApply->zErr, zInsert); + } + + sqlite3_free(zSelect); + sqlite3_free(zInsert); + sqlite3_free(cols.aBuf); + sqlite3_free(insbind.aBuf); + sqlite3_free(pkcols.aBuf); + sqlite3_free(selbind.aBuf); + } + + if( rc==SQLITE_OK ){ + rc = sessionBindRow( + pUp, sqlite3changeset_old, pApply->nCol, pApply->abPK, pSelect + ); + } + + if( rc==SQLITE_OK && sqlite3_step(pSelect)==SQLITE_ROW ){ + int iCol; + for(iCol=0; iColnCol; iCol++){ + sqlite3_value *pVal = pUp->apValue[iCol+pApply->nCol]; + if( pVal==0 ){ + pVal = sqlite3_column_value(pSelect, iCol); + } + rc = sqlite3_bind_value(pRet, iCol+1, pVal); + } + if( bWR==0 ){ + sqlite3_bind_int64(pRet, iCol+1, sqlite3_column_int64(pSelect, iCol)); + } + } + sessionFinalizeStmt(pSelect, &rc); + + /* Delete the row from the database. */ + if( rc==SQLITE_OK ){ + rc = sessionBindRow( + pUp, sqlite3changeset_old, pApply->nCol, pApply->abPK, pApply->pDelete + ); + sqlite3_bind_int(pApply->pDelete, pApply->nCol+1, 1); + } + if( rc==SQLITE_OK ){ + sqlite3_step(pApply->pDelete); + rc = sqlite3_reset(pApply->pDelete); + } + + if( rc!=SQLITE_OK ){ + sqlite3_finalize(pRet); + pRet = 0; + } + + *ppInsert = pRet; + return rc; +} + +/* +** Retry the changes accumulated in the pApply->constraints buffer. The +** pApply->constraints buffer contains all changes to table zTab that +** could not be applied due to SQLITE_CONSTRAINT errors. This function +** attempts to apply them as follows: +** +** 1) It runs through the buffer and attempts to retry each change, +** removing any that are successfully applied from the buffer. This +** is repeated until no further progress can be made. +** +** 2) For each UPDATE change in the buffer, try the following in a +** savepoint transaction: +** +** a) DELETE the affected row, +** b) Attempt step (1) with remaining changes, +** c) Attempt to INSERT a row equivalent to the one that would be +** created by applying this UPDATE change. +** +** If the INSERT in (c) succeeds, the savepoint is committed and all +** successfully applied changes are removed from the buffer. Step (2) +** is then repeated. +** +** 3) Once step (2) has been attempted for each UPDATE in the change, +** a final attempt is made to apply each remaining change. This time, +** if an SQLITE_CONSTRAINT error is encountered, the conflict handler +** is invoked and the user has to decide whether to omit the change +** or rollback the entire _apply() operation. */ static int sessionRetryConstraints( sqlite3 *db, @@ -238511,41 +238983,101 @@ static int sessionRetryConstraints( void *pCtx /* First argument passed to xConflict */ ){ int rc = SQLITE_OK; + int iUpdate = 0; + /* Step (1) */ while( pApply->constraints.nBuf ){ - sqlite3_changeset_iter *pIter2 = 0; SessionBuffer cons = pApply->constraints; memset(&pApply->constraints, 0, sizeof(SessionBuffer)); - rc = sessionChangesetStart( - &pIter2, 0, 0, cons.nBuf, cons.aBuf, pApply->bInvertConstraints, 1 + rc = sessionApplyRetryBuffer( + &cons, -1, db, bPatchset, zTab, pApply, xConflict, pCtx + ); + + sqlite3_free(cons.aBuf); + if( rc!=SQLITE_OK ) break; + + /* If no progress has been made this round, break out of the loop. */ + if( pApply->constraints.nBuf>=cons.nBuf ) break; + } + + /* Step (2) */ + while( rc==SQLITE_OK && pApply->constraints.nBuf && !pApply->bNoUpdateLoop ){ + SessionBuffer cons = {0, 0, 0}; + sqlite3_changeset_iter *pUp = 0; + sqlite3_stmt *pInsert = 0; + int iSkip = 0; + + rc = sessionRetryIterInit( + &pApply->constraints, bPatchset, zTab, pApply, &pUp ); if( rc==SQLITE_OK ){ - size_t nByte = 2*pApply->nCol*sizeof(sqlite3_value*); - int rc2; - pIter2->bPatchset = bPatchset; - pIter2->zTab = (char*)zTab; - pIter2->nCol = pApply->nCol; - pIter2->abPK = pApply->abPK; - sessionBufferGrow(&pIter2->tblhdr, nByte, &rc); - pIter2->apValue = (sqlite3_value**)pIter2->tblhdr.aBuf; - if( rc==SQLITE_OK ) memset(pIter2->apValue, 0, nByte); + int iThis = -1; + while( SQLITE_ROW==sqlite3changeset_next(pUp) ){ + if( pUp->op==SQLITE_UPDATE ) iThis++; + if( iThis==iUpdate ) break; + iSkip++; + } + if( iThis==iUpdate ){ + rc = sqlite3_exec(db, "SAVEPOINT update_op", 0, 0, 0); + if( rc==SQLITE_OK ){ + rc = sessionUpdateToDeleteInsert(db, zTab, pApply, pUp, &pInsert); + } + } + sqlite3changeset_finalize(pUp); + if( iThis!=iUpdate ) break; + } + + if( rc==SQLITE_OK ){ + cons = pApply->constraints; - while( rc==SQLITE_OK && SQLITE_ROW==sqlite3changeset_next(pIter2) ){ - rc = sessionApplyOneWithRetry(db, pIter2, pApply, xConflict, pCtx); + while( rc==SQLITE_OK && pApply->constraints.nBuf>0 ){ + SessionBuffer app = pApply->constraints; + memset(&pApply->constraints, 0, sizeof(SessionBuffer)); + rc = sessionApplyRetryBuffer( + &app, iSkip, db, bPatchset, zTab, pApply, xConflict, pCtx + ); + if( app.aBuf!=cons.aBuf ){ + sqlite3_free(app.aBuf); + } + if( pApply->constraints.nBuf>=app.nBuf ){ + break; + } + iSkip = -1; } + } - rc2 = sqlite3changeset_finalize(pIter2); - if( rc==SQLITE_OK ) rc = rc2; + iUpdate++; + if( rc==SQLITE_OK ){ + sqlite3_step(pInsert); + rc = sqlite3_finalize(pInsert); + if( rc==SQLITE_CONSTRAINT ){ + rc = sqlite3_exec(db, "ROLLBACK TO update_op", 0, 0, 0); + sqlite3_free(pApply->constraints.aBuf); + pApply->constraints = cons; + memset(&cons, 0, sizeof(cons)); + }else if( rc==SQLITE_OK ){ + iUpdate = 0; + } + if( rc==SQLITE_OK ){ + rc = sqlite3_exec(db, "RELEASE update_op", 0, 0, 0); + } + }else{ + sqlite3_finalize(pInsert); } - assert( pApply->bDeferConstraints || pApply->constraints.nBuf==0 ); sqlite3_free(cons.aBuf); - if( rc!=SQLITE_OK ) break; - if( pApply->constraints.nBuf>=cons.nBuf ){ - /* No progress was made on the last round. */ - pApply->bDeferConstraints = 0; - } + } + + /* Step (3) */ + if( rc==SQLITE_OK && pApply->constraints.nBuf ){ + SessionBuffer cons = pApply->constraints; + memset(&pApply->constraints, 0, sizeof(SessionBuffer)); + pApply->bDeferConstraints = 0; + rc = sessionApplyRetryBuffer( + &cons, -1, db, bPatchset, zTab, pApply, xConflict, pCtx + ); + sqlite3_free(cons.aBuf); } return rc; @@ -238599,6 +239131,7 @@ static int sessionChangesetApply( sApply.bRebase = (ppRebase && pnRebase); sApply.bInvertConstraints = !!(flags & SQLITE_CHANGESETAPPLY_INVERT); sApply.bIgnoreNoop = !!(flags & SQLITE_CHANGESETAPPLY_IGNORENOOP); + sApply.bNoUpdateLoop = !!(flags & SQLITE_CHANGESETAPPLY_NOUPDATELOOP); if( (flags & SQLITE_CHANGESETAPPLY_NOSAVEPOINT)==0 ){ rc = sqlite3_exec(db, "SAVEPOINT changeset_apply", 0, 0, 0); } @@ -240410,7 +240943,7 @@ SQLITE_API int sqlite3changegroup_change_blob( const void *pVal, int nVal ){ - sqlite3_int64 nByte = 1 + sessionVarintLen(nVal) + nVal; + sqlite3_int64 nByte = 1 + sessionVarintLen(nVal) + (i64)nVal; int rc = SQLITE_OK; SessionBuffer *pBuf = 0; @@ -250994,7 +251527,7 @@ static void fts5DataRelease(Fts5Data *pData){ static Fts5Data *fts5LeafRead(Fts5Index *p, i64 iRowid){ Fts5Data *pRet = fts5DataRead(p, iRowid); if( pRet ){ - if( pRet->nn<4 || pRet->szLeaf>pRet->nn ){ + if( pRet->szLeaf<4 || pRet->szLeaf>pRet->nn ){ FTS5_CORRUPT_ROWID(p, iRowid); fts5DataRelease(pRet); pRet = 0; @@ -252648,6 +253181,10 @@ static void fts5LeafSeek( if( nKeepn ){ + FTS5_CORRUPT_ITER(p, pIter); + return; + } assert( nKeep>=nMatch ); if( nKeep==nMatch ){ @@ -253624,8 +254161,7 @@ static void fts5PoslistFilterCallback( do { while( ieState ){ fts5BufferSafeAppendBlob(pCtx->pBuf, &pChunk[iStart], i-iStart); @@ -253774,7 +254310,7 @@ static void fts5IndexExtractColset( /* Advance pointer p until it points to pEnd or an 0x01 byte that is ** not part of a varint */ while( paiCol[i]==iCurrent ){ @@ -253871,8 +254407,11 @@ static void fts5IterSetOutputs_Col100(Fts5Iter *pIter, Fts5SegIter *pSeg){ assert( pIter->pIndex->pConfig->eDetail==FTS5_DETAIL_COLUMNS ); assert( pIter->pColset ); + assert( pIter->poslist.nSpace>=pIter->pIndex->pConfig->nCol ); - if( pSeg->iLeafOffset+pSeg->nPos>pSeg->pLeaf->szLeaf ){ + if( pSeg->iLeafOffset+pSeg->nPos>pSeg->pLeaf->szLeaf + || pSeg->nPos>pIter->pIndex->pConfig->nCol + ){ fts5IterSetOutputs_Col(pIter, pSeg); }else{ u8 *a = (u8*)&pSeg->pLeaf->p[pSeg->iLeafOffset]; @@ -255366,6 +255905,11 @@ static void fts5DoSecureDelete( }else{ iStart = fts5GetU16(&aPg[0]); } + if( iStart>nPg ){ + FTS5_CORRUPT_IDX(p); + sqlite3_free(aIdx); + return; + } iSOP = iStart + fts5GetVarint(&aPg[iStart], &iDelta); assert_nc( iSOP<=pSeg->iLeafOffset ); @@ -263242,7 +263786,7 @@ static void fts5SourceIdFunc( ){ assert( nArg==0 ); UNUSED_PARAM2(nArg, apUnused); - sqlite3_result_text(pCtx, "fts5: 2026-04-09 11:41:38 4525003a53a7fc63ca75c59b22c79608659ca12f0131f52c18637f829977f20b", -1, SQLITE_TRANSIENT); + sqlite3_result_text(pCtx, "fts5: 2026-06-03 19:12:13 d6e03d8c777cfa2d35e3b60d8ec3e0187f3e9f99d8e2ee9cac695fd6fcdf1a24", -1, SQLITE_TRANSIENT); } /* @@ -265636,8 +266180,14 @@ static int fts5PorterCreate( const char *zBase = "unicode61"; fts5_tokenizer_v2 *pV2 = 0; - if( nArg>0 ){ - zBase = azArg[0]; + while( nArg>0 ){ + if( sqlite3_stricmp(azArg[0],"porter")==0 ){ + nArg--; + azArg++; + }else{ + zBase = azArg[0]; + break; + } } pRet = (PorterTokenizer*)sqlite3_malloc64(sizeof(PorterTokenizer)); diff --git a/src/jsc/bindings/sqlite/sqlite3_local.h b/src/jsc/bindings/sqlite/sqlite3_local.h index 5c1df45f6804..64ba7a12311b 100644 --- a/src/jsc/bindings/sqlite/sqlite3_local.h +++ b/src/jsc/bindings/sqlite/sqlite3_local.h @@ -147,12 +147,12 @@ extern "C" { ** [sqlite3_libversion_number()], [sqlite3_sourceid()], ** [sqlite_version()] and [sqlite_source_id()]. */ -#define SQLITE_VERSION "3.53.0" -#define SQLITE_VERSION_NUMBER 3053000 -#define SQLITE_SOURCE_ID "2026-04-09 11:41:38 4525003a53a7fc63ca75c59b22c79608659ca12f0131f52c18637f829977f20b" -#define SQLITE_SCM_BRANCH "trunk" -#define SQLITE_SCM_TAGS "release major-release version-3.53.0" -#define SQLITE_SCM_DATETIME "2026-04-09T11:41:38.498Z" +#define SQLITE_VERSION "3.53.2" +#define SQLITE_VERSION_NUMBER 3053002 +#define SQLITE_SOURCE_ID "2026-06-03 19:12:13 d6e03d8c777cfa2d35e3b60d8ec3e0187f3e9f99d8e2ee9cac695fd6fcdf1a24" +#define SQLITE_SCM_BRANCH "branch-3.53" +#define SQLITE_SCM_TAGS "release version-3.53.2" +#define SQLITE_SCM_DATETIME "2026-06-03T19:12:13.350Z" /* ** CAPI3REF: Run-Time Library Version Numbers @@ -12854,11 +12854,23 @@ SQLITE_API int sqlite3changeset_apply_v3( ** database behave as if they were declared with "ON UPDATE NO ACTION ON ** DELETE NO ACTION", even if they are actually CASCADE, RESTRICT, SET NULL ** or SET DEFAULT. +** +**

SQLITE_CHANGESETAPPLY_NOUPDATELOOP
+** Sometimes, a changeset contains two or more update statements such that +** although after applying all updates the database will contain no +** constraint violations, no single update can be applied before the others. +** The simplest example of this is a pair of UPDATEs that have "swapped" +** two column values with a UNIQUE constraint. +**

+** Usually, sqlite3changeset_apply() and similar functions work hard to try +** to find a way to apply such a changeset. However, if this flag is set, +** then all such updates are considered CONSTRAINT conflicts. */ #define SQLITE_CHANGESETAPPLY_NOSAVEPOINT 0x0001 #define SQLITE_CHANGESETAPPLY_INVERT 0x0002 #define SQLITE_CHANGESETAPPLY_IGNORENOOP 0x0004 #define SQLITE_CHANGESETAPPLY_FKNOACTION 0x0008 +#define SQLITE_CHANGESETAPPLY_NOUPDATELOOP 0x0010 /* ** CAPI3REF: Constants Passed To The Conflict Handler diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index f14c788bd571..23b0683bcd10 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -24,6 +24,12 @@ class DOMClientIsoSubspaces { std::unique_ptr m_clientSubspaceForNapiPrototype; std::unique_ptr m_clientSubspaceForJSSQLStatement; std::unique_ptr m_clientSubspaceForJSSQLStatementConstructor; + std::unique_ptr m_clientSubspaceForNodeSqliteDatabaseSync; + std::unique_ptr m_clientSubspaceForNodeSqliteStatementSync; + std::unique_ptr m_clientSubspaceForNodeSqliteStatementSyncIterator; + std::unique_ptr m_clientSubspaceForNodeSqliteSession; + std::unique_ptr m_clientSubspaceForNodeSqliteLimits; + std::unique_ptr m_clientSubspaceForNodeSqliteTagStore; std::unique_ptr m_clientSubspaceForJSSinkConstructor; std::unique_ptr m_clientSubspaceForJSSinkController; std::unique_ptr m_clientSubspaceForJSSink; diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index c67afb40065d..d1cd47a9967d 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -24,6 +24,12 @@ class DOMIsoSubspaces { std::unique_ptr m_subspaceForNapiPrototype; std::unique_ptr m_subspaceForJSSQLStatement; std::unique_ptr m_subspaceForJSSQLStatementConstructor; + std::unique_ptr m_subspaceForNodeSqliteDatabaseSync; + std::unique_ptr m_subspaceForNodeSqliteStatementSync; + std::unique_ptr m_subspaceForNodeSqliteStatementSyncIterator; + std::unique_ptr m_subspaceForNodeSqliteSession; + std::unique_ptr m_subspaceForNodeSqliteLimits; + std::unique_ptr m_subspaceForNodeSqliteTagStore; std::unique_ptr m_subspaceForJSSinkConstructor; std::unique_ptr m_subspaceForJSSinkController; std::unique_ptr m_subspaceForJSSink; diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index 1749ab1b64f4..a85634b99b83 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -94,6 +94,7 @@ static constexpr ASCIILiteral builtinModuleNames[] = { "inspector/promises"_s, "module"_s, "net"_s, + "node:sqlite"_s, "os"_s, "path"_s, "path/posix"_s, diff --git a/src/jsc/modules/NodeSqliteModule.h b/src/jsc/modules/NodeSqliteModule.h new file mode 100644 index 000000000000..0abb545f56d4 --- /dev/null +++ b/src/jsc/modules/NodeSqliteModule.h @@ -0,0 +1,35 @@ +#include "../bindings/sqlite/NodeSqlite.h" +#include "../bindings/ZigGlobalObject.h" +#include + +namespace Bun { +JSC_DECLARE_HOST_FUNCTION(jsNodeSqliteBackup); +} + +namespace Zig { + +DEFINE_NATIVE_MODULE(NodeSqlite) +{ + INIT_NATIVE_MODULE(4); + + put(JSC::Identifier::fromString(vm, "DatabaseSync"_s), + globalObject->m_JSDatabaseSyncClassStructure.constructorInitializedOnMainThread(globalObject)); + + put(JSC::Identifier::fromString(vm, "StatementSync"_s), + globalObject->m_JSStatementSyncClassStructure.constructorInitializedOnMainThread(globalObject)); + + put(JSC::Identifier::fromString(vm, "constants"_s), + Bun::createNodeSqliteConstants(vm, globalObject)); + + // backup.length === 2 (sourceDb, path) — Node's test-sqlite-backup + // asserts it. putNativeFn hardcodes 1, so construct the function + // ourselves. + { + auto id = JSC::Identifier::fromString(vm, "backup"_s); + put(id, JSC::JSFunction::create(vm, globalObject, 2, id.string(), Bun::jsNodeSqliteBackup, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic, Bun::jsNodeSqliteBackup)); + } + + RETURN_NATIVE_MODULE(); +} + +} // namespace Zig diff --git a/src/jsc/modules/_NativeModule.h b/src/jsc/modules/_NativeModule.h index 82beb9692e08..1fe31b89135d 100644 --- a/src/jsc/modules/_NativeModule.h +++ b/src/jsc/modules/_NativeModule.h @@ -30,6 +30,7 @@ macro("bun:app"_s, BunApp) \ macro("node:buffer"_s, NodeBuffer) \ macro("node:constants"_s, NodeConstants) \ + macro("node:sqlite"_s, NodeSqlite) \ macro("node:string_decoder"_s, NodeStringDecoder) \ macro("node:util/types"_s, NodeUtilTypes) \ macro("utf-8-validate"_s, UTF8Validate) \ diff --git a/src/resolve_builtins/HardcodedModule.rs b/src/resolve_builtins/HardcodedModule.rs index 7ab280bd4432..0fbf045e0b7e 100644 --- a/src/resolve_builtins/HardcodedModule.rs +++ b/src/resolve_builtins/HardcodedModule.rs @@ -77,6 +77,8 @@ pub enum HardcodedModule { NodeReadline, #[strum(serialize = "node:readline/promises")] NodeReadlinePromises, + #[strum(serialize = "node:sqlite")] + NodeSqlite, #[strum(serialize = "node:stream")] NodeStream, #[strum(serialize = "node:stream/consumers")] @@ -235,6 +237,7 @@ bun_core::comptime_string_map! { b"node:querystring" => HardcodedModule::NodeQuerystring, b"node:readline/promises" => HardcodedModule::NodeReadlinePromises, b"node:repl" => HardcodedModule::NodeRepl, + b"node:sqlite" => HardcodedModule::NodeSqlite, b"node:stream" => HardcodedModule::NodeStream, b"node:stream/consumers" => HardcodedModule::NodeStreamConsumers, b"node:stream/iter" => HardcodedModule::NodeStreamIter, @@ -442,6 +445,7 @@ const COMMON_ALIAS_KVS: &[AliasKv] = &[ node_entry!("node:worker_threads"), node_entry!("node:zlib"), // New Node.js builtins only resolve from the prefixed one. + node_entry_only_prefix!("node:sqlite"), node_entry_only_prefix!("node:test"), // node_entry!("assert"), diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index af16e04632d0..9c8a4e58b29f 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -6,7 +6,7 @@ import path from "path"; describe.concurrent("node-module-module", () => { test("builtinModules exists", () => { expect(Array.isArray(builtinModules)).toBe(true); - expect(builtinModules).toHaveLength(76); + expect(builtinModules).toHaveLength(77); }); test("isBuiltin() works", () => { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 481df9d6707d..2eb5483a36d2 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -294,6 +294,10 @@ it("process.versions", () => { expect(process.versions).toHaveProperty("usockets"); expect(process.versions).toHaveProperty("uwebsockets"); expect(process.versions.usockets).toBe(process.versions.uwebsockets); + + // Node.js exposes the bundled SQLite version here; Bun should too. + expect(process.versions).toHaveProperty("sqlite"); + expect(process.versions.sqlite).toMatch(/^3\.\d+\.\d+$/); }); it("process.config", () => { diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts new file mode 100644 index 000000000000..bd78233f9458 --- /dev/null +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -0,0 +1,1397 @@ +import { heapStats } from "bun:jsc"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { existsSync, statSync } from "node:fs"; +import { builtinModules, isBuiltin } from "node:module"; +import path from "node:path"; +import { DatabaseSync, StatementSync, backup, constants } from "node:sqlite"; +import { pathToFileURL } from "node:url"; + +test("node:sqlite is a built-in module", () => { + expect(isBuiltin("node:sqlite")).toBe(true); + // Like node:test, node:sqlite is only available with the node: prefix. + expect(isBuiltin("sqlite")).toBe(false); + expect(builtinModules).toContain("node:sqlite"); +}); + +test("process.versions.sqlite is set", () => { + expect(typeof process.versions.sqlite).toBe("string"); + expect(process.versions.sqlite).toMatch(/^3\.\d+\.\d+$/); +}); + +describe("DatabaseSync", () => { + test("basic lifecycle", () => { + const db = new DatabaseSync(":memory:"); + expect(db.isOpen).toBe(true); + expect(db.isTransaction).toBe(false); + expect(db.exec("CREATE TABLE t (k INTEGER PRIMARY KEY, v TEXT)")).toBeUndefined(); + + const ins = db.prepare("INSERT INTO t (k, v) VALUES (?, ?)"); + expect(ins).toBeInstanceOf(StatementSync); + expect(ins.run(1, "hello")).toEqual({ changes: 1, lastInsertRowid: 1 }); + + const sel = db.prepare("SELECT * FROM t WHERE k = ?"); + expect(sel.get(1)).toEqual({ __proto__: null, k: 1, v: "hello" }); + expect(sel.all(1)).toEqual([{ __proto__: null, k: 1, v: "hello" }]); + + db.close(); + expect(db.isOpen).toBe(false); + expect(() => db.close()).toThrow(/database is not open/); + expect(() => db.exec("SELECT 1")).toThrow(/database is not open/); + }); + + test("deferred open via { open: false }", () => { + using dir = tempDir("node-sqlite-deferred", {}); + const p = path.join(String(dir), "db.sqlite"); + const db = new DatabaseSync(p, { open: false }); + expect(db.isOpen).toBe(false); + expect(() => db.exec("SELECT 1")).toThrow(/database is not open/); + db.open(); + expect(db.isOpen).toBe(true); + expect(() => db.open()).toThrow(/database is already open/); + db.close(); + }); + + test("Symbol.dispose swallows errors on closed databases", () => { + const db = new DatabaseSync(":memory:", { open: false }); + expect(() => db[Symbol.dispose]()).not.toThrow(); + expect(() => db.close()).toThrow(/database is not open/); + }); + + test("binds typed arrays as BLOBs and returns Uint8Array", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (b BLOB)"); + db.prepare("INSERT INTO t VALUES (?)").run(new Uint8Array([1, 2, 3])); + const row = db.prepare("SELECT b FROM t").get(); + expect(row.b).toBeInstanceOf(Uint8Array); + expect(row.b).toEqual(new Uint8Array([1, 2, 3])); + db.close(); + }); + + test("binds small integers with INTEGER storage class (not REAL)", () => { + const db = new DatabaseSync(":memory:"); + // Without the isInt32() fast path, 42 would bind via sqlite3_bind_double + // and typeof(?) on a bare parameter (no column affinity) returns 'real'. + expect(db.prepare("SELECT typeof(?) AS t").get(42).t).toBe("integer"); + expect(db.prepare("SELECT typeof(?) AS t").get(1.5).t).toBe("real"); + // expandedSQL reflects the bound storage class. + const stmt = db.prepare("SELECT ?"); + stmt.get(42); + expect(stmt.expandedSQL).toBe("SELECT 42"); + db.close(); + }); + + test("rejects unbindable values with ERR_INVALID_ARG_TYPE", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a, b)"); + const stmt = db.prepare("INSERT INTO t VALUES (?, ?)"); + expect(() => stmt.run(1, Symbol())).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: expect.stringMatching(/Provided value cannot be bound to SQLite parameter 2/), + }), + ); + db.close(); + }); + + test("rejects oversized BigInt with ERR_INVALID_ARG_VALUE", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a)"); + const stmt = db.prepare("INSERT INTO t VALUES (?)"); + expect(() => stmt.run(9223372036854775808n)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_VALUE", + message: expect.stringMatching(/BigInt value is too large to bind/), + }), + ); + db.close(); + }); + + test("statements are unbound on each call", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (k INTEGER PRIMARY KEY, v INTEGER)"); + const stmt = db.prepare("INSERT INTO t (k, v) VALUES (?, ?)"); + expect(stmt.run(1, 5)).toEqual({ changes: 1, lastInsertRowid: 1 }); + // In Node.js, a subsequent call with no arguments binds NULL to all + // parameters rather than re-using the previous bindings. + expect(stmt.run()).toEqual({ changes: 1, lastInsertRowid: 2 }); + expect(db.prepare("SELECT * FROM t ORDER BY k").all()).toEqual([ + { __proto__: null, k: 1, v: 5 }, + { __proto__: null, k: 2, v: null }, + ]); + db.close(); + }); + + test("StatementSync cannot be constructed directly", () => { + expect(() => new StatementSync()).toThrow(/Illegal constructor/); + }); + + test("prepare() rejects empty / comment-only SQL", () => { + const db = new DatabaseSync(":memory:"); + for (const sql of ["", " ", "-- a comment"]) { + expect(() => db.prepare(sql)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_STATE", + message: expect.stringMatching(/contains no statements/), + }), + ); + } + db.close(); + }); + + test("constructor rejects non-int32 timeout values", () => { + for (const timeout of [Infinity, -Infinity, 2 ** 32, 1.5, NaN, "100"]) { + expect(() => new DatabaseSync(":memory:", { timeout })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + // int32-range integers are accepted. + const db = new DatabaseSync(":memory:", { timeout: 1000 }); + expect(db.isOpen).toBe(true); + db.close(); + }); + + test("constructor rejects non-Uint8Array TypedArray paths", () => { + expect(() => new DatabaseSync(new Float64Array([1.5]))).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => new DatabaseSync(new Int32Array([65, 66]))).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + // Buffer (which extends Uint8Array) is accepted. + const db = new DatabaseSync(Buffer.from(":memory:")); + expect(db.isOpen).toBe(true); + db.close(); + }); + + test("constructor rejects non-UTF-8 Uint8Array paths instead of opening a temp db", () => { + // 0xff 0xfe is not valid UTF-8. Previously this would fall through to + // sqlite3_open_v2("") which opens an anonymous temporary database — + // silently swallowing the user's path. + expect(() => new DatabaseSync(Buffer.from([0x3a, 0xff, 0xfe]))).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); + }); + + test("file: URL objects pass query parameters to SQLite", () => { + // Node hands the raw href (including ?query) to sqlite3_open_v2 + // with SQLITE_OPEN_URI set, so ?mode=ro / ?cache=shared are + // honoured. A URL object must NOT be reduced to a bare + // filesystem path first (doing so would drop the query and, + // on Windows, misinterpret drive-letter handling). + using dir = tempDir("node-sqlite-uri", {}); + const dbFile = path.join(String(dir), "ro.db"); + const seed = new DatabaseSync(dbFile); + seed.exec("CREATE TABLE t(a INTEGER PRIMARY KEY)"); + seed.exec("INSERT INTO t VALUES (1)"); + seed.close(); + + const url = new URL(pathToFileURL(dbFile).href + "?mode=ro"); + const db = new DatabaseSync(url); + expect(db.prepare("SELECT a FROM t").get()).toEqual({ a: 1 }); + // Read-only came from the URI query, not from {readOnly: true} — + // if the query were stripped this insert would succeed. + expect(() => db.exec("INSERT INTO t VALUES (2)")).toThrow(expect.objectContaining({ code: "ERR_SQLITE_ERROR" })); + db.close(); + // The temporary statement above is not yet GC'd, so sqlite3_close_v2 + // left the connection in zombie mode with ro.db still open. On Windows + // that blocks tempDir's rm with EBUSY; force the finalizer. + Bun.gc(true); + }); + + test("close() is rejected while a native call is in flight (re-entrant close)", () => { + // bindParams/UDFs/xFilter/progress can re-enter JS mid-operation. + // If that JS calls db.close(), the in-flight sqlite call would see a + // freed/null sqlite3* on return. A BusyScope around each operation + // makes close() throw ERR_INVALID_STATE instead of pulling the rug. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a)"); + const stmt = db.prepare("INSERT INTO t VALUES (:a)"); + let closeErr: unknown; + const r = stmt.run({ + get a() { + try { + db.close(); + } catch (e) { + closeErr = e; + } + return 1; + }, + }); + expect(closeErr).toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(db.isOpen).toBe(true); + expect(r).toEqual({ changes: 1, lastInsertRowid: 1 }); + + // Same guard applies to option getters on function()/aggregate()/ + // createSession()/applyChangeset(). + expect(() => + db.function( + "f", + { + get varargs() { + db.close(); + return true; + }, + }, + () => 0, + ), + ).toThrow(/cannot close database/); + expect(db.isOpen).toBe(true); + + // And to xFilter inside applyChangeset (where sqlite would otherwise + // continue using a freed — not zombied — connection). + const dst = new DatabaseSync(":memory:"); + dst.exec("CREATE TABLE t (a INTEGER PRIMARY KEY)"); + db.exec("CREATE TABLE s (a INTEGER PRIMARY KEY)"); + const session = db.createSession(); + db.exec("INSERT INTO s VALUES (1)"); + const cs = session.changeset(); + let filterCloseErr: unknown; + dst.applyChangeset(cs, { + filter: () => { + try { + dst.close(); + } catch (e) { + filterCloseErr = e; + } + return false; + }, + }); + expect(filterCloseErr).toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(dst.isOpen).toBe(true); + dst.close(); + db.close(); + }); + + test("deserialize() is guarded against re-entrant close via options getter", () => { + // deserialize() checks isBusy() (refuses while something ELSE is + // in flight) but must also ESTABLISH a BusyScope before reading + // options — a hostile opts.dbName getter could otherwise close() + // the db and sqlite3_deserialize would segfault on the null + // connection (the bundled amalgamation lacks SQLITE_ENABLE_API_ARMOR). + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t(x INTEGER)"); + const buf = src.serialize(); + src.close(); + + const db = new DatabaseSync(":memory:"); + let closeErr: unknown; + db.deserialize(buf, { + get dbName() { + try { + db.close(); + } catch (e) { + closeErr = e; + } + return "main"; + }, + }); + expect(closeErr).toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(db.isOpen).toBe(true); + expect(db.prepare("SELECT name FROM sqlite_master").get().name).toBe("t"); + db.close(); + }); + + test("deserialize() rejects a buffer detached by the options getter", () => { + // The BusyScope added above blocks db.close() re-entry, but does + // nothing about the *input buffer*: if the span is captured + // before opts.dbName is read, a hostile getter can + // buf.buffer.transfer(); Bun.gc(true); + // freeing the backing store, and the later memcpy() reads freed + // memory — the deserialize() analogue of the applyChangeset + // buffer-detach UAF. The span must be (re-)captured only after + // option parsing has run. + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t(x INTEGER)"); + const buf = src.serialize(); + src.close(); + + const db = new DatabaseSync(":memory:"); + expect(() => + db.deserialize(buf, { + get dbName() { + buf.buffer.transfer(); + Bun.gc(true); + return "main"; + }, + }), + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" })); + expect(db.isOpen).toBe(true); + db.close(); + }); + + test("statements from a prior connection are finalized across close()/open()", () => { + const db = new DatabaseSync(":memory:", { open: false }); + db.open(); + const stmt = db.prepare("SELECT 1 AS v"); + expect(stmt.get().v).toBe(1); + db.close(); + db.open(); + // Statement was prepared on the *previous* (now-zombie) connection. + // Using it must report ERR_INVALID_STATE, not step against the + // zombie and then read a bogus "not an error" from the new handle. + expect(() => stmt.get()).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_STATE", + message: expect.stringMatching(/statement has been finalized/), + }), + ); + db.close(); + }); + + test("exposes changeset constants", () => { + expect(constants.SQLITE_CHANGESET_OMIT).toBe(0); + expect(constants.SQLITE_CHANGESET_REPLACE).toBe(1); + expect(constants.SQLITE_CHANGESET_ABORT).toBe(2); + }); + + test("database-level defaults flow to prepared statements", () => { + const db = new DatabaseSync(":memory:", { readBigInts: true, returnArrays: true }); + const row = db.prepare("SELECT 42 AS v").get(); + expect(row).toEqual([42n]); + // per-statement override beats the db default + const stmt = db.prepare("SELECT 42 AS v", { readBigInts: false, returnArrays: false }); + expect(stmt.get()).toEqual({ __proto__: null, v: 42 }); + db.close(); + }); +}); + +describe("DatabaseSync.prototype.function()", () => { + test("registers scalar UDFs and propagates JS exceptions", () => { + const db = new DatabaseSync(":memory:"); + db.function("double_it", x => x * 2); + expect(db.prepare("SELECT double_it(21) AS v").get().v).toBe(42); + + db.function("join_args", { varargs: true }, (...a) => a.join("-")); + expect(db.prepare("SELECT join_args('a','b','c') AS v").get().v).toBe("a-b-c"); + + // An exception thrown inside the UDF surfaces as-is, not wrapped + // in ERR_SQLITE_ERROR. + db.function("boom", () => { + throw new TypeError("kaboom"); + }); + expect(() => db.prepare("SELECT boom()").get()).toThrow( + expect.objectContaining({ name: "TypeError", message: "kaboom" }), + ); + db.close(); + }); + + test("deterministic flag permits use in generated columns", () => { + const db = new DatabaseSync(":memory:"); + db.function("square", { deterministic: true }, (x: number) => x * x); + // Deterministic UDFs are allowed in generated-column expressions. + db.exec("CREATE TABLE t (n INTEGER, sq INTEGER GENERATED ALWAYS AS (square(n)))"); + db.prepare("INSERT INTO t (n) VALUES (?)").run(7); + expect(db.prepare("SELECT sq FROM t").get().sq).toBe(49); + + db.function("rnd", { deterministic: false }, () => Math.random()); + expect(() => db.exec("CREATE TABLE u (n INTEGER, r REAL GENERATED ALWAYS AS (rnd()))")).toThrow( + /non-deterministic/, + ); + db.close(); + }); + + test("unsupported return types produce ERR_SQLITE_ERROR", () => { + const db = new DatabaseSync(":memory:"); + db.function("bad", () => ({ nope: true })); + expect(() => db.prepare("SELECT bad()").get()).toThrow( + expect.objectContaining({ + code: "ERR_SQLITE_ERROR", + message: expect.stringMatching(/cannot be converted to a SQLite value/), + }), + ); + db.function("async_bad", () => Promise.resolve(1)); + expect(() => db.prepare("SELECT async_bad()").get()).toThrow( + /Asynchronous user-defined functions are not supported/, + ); + db.close(); + }); + + test("registration failure does not double-free the UDF context", () => { + // sqlite3_create_function_v2 calls xDestroy(p) on the failure path + // (name >255 bytes → SQLITE_MISUSE); a second manual delete on our + // side would crash under ASAN. These should throw cleanly. + const db = new DatabaseSync(":memory:"); + const longName = "a".repeat(300); + expect(() => db.function(longName, () => 0)).toThrow(expect.objectContaining({ code: "ERR_SQLITE_ERROR" })); + expect(() => db.aggregate(longName, { start: 0, step: (a, n) => a + n })).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR" }), + ); + db.close(); + }); +}); + +describe("DatabaseSync.prototype.aggregate()", () => { + function setup() { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (n INTEGER); INSERT INTO t VALUES (1),(2),(3),(4)"); + return db; + } + + test("basic sum aggregate", () => { + const db = setup(); + db.aggregate("my_sum", { start: 0, step: (acc: number, n: number) => acc + n }); + expect(db.prepare("SELECT my_sum(n) AS s FROM t").get().s).toBe(10); + db.close(); + }); + + test("start as a factory function and result transform", () => { + const db = setup(); + db.aggregate("my_avg", { + start: () => [0, 0] as [number, number], + step: (acc, n: number) => [acc[0] + n, acc[1] + 1] as [number, number], + result: acc => acc[0] / acc[1], + }); + expect(db.prepare("SELECT my_avg(n) AS s FROM t").get().s).toBe(2.5); + db.close(); + }); + + test("window aggregates via inverse", () => { + const db = setup(); + db.aggregate("win_sum", { + start: 0, + step: (acc: number, n: number) => acc + n, + inverse: (acc: number, n: number) => acc - n, + }); + const rows = db.prepare("SELECT win_sum(n) OVER (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS s FROM t").all(); + expect(rows.map(r => r.s)).toEqual([3, 6, 9, 7]); + db.close(); + }); + + test("errors in step/start/result propagate to the caller", () => { + const db = setup(); + db.aggregate("step_throw", { + start: 0, + step: (_acc: number, _n: number) => { + throw new Error("step failed"); + }, + }); + expect(() => db.prepare("SELECT step_throw(n) FROM t").get()).toThrow("step failed"); + + db.aggregate("start_throw", { + start: () => { + throw new Error("start failed"); + }, + step: (_acc: number, _n: number) => 0, + }); + expect(() => db.prepare("SELECT start_throw(n) FROM t").get()).toThrow("start failed"); + db.close(); + }); +}); + +describe("StatementSync.prototype.iterate()", () => { + function setup() { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (n INTEGER); INSERT INTO t VALUES (1),(2),(3),(4)"); + return db; + } + + test("yields rows lazily and is for-of iterable", () => { + const db = setup(); + const iter = db.prepare("SELECT n FROM t ORDER BY n").iterate(); + // Inherits from %IteratorPrototype% so @@iterator returns itself. + expect(iter[Symbol.iterator]()).toBe(iter); + expect([...iter].map(r => r.n)).toEqual([1, 2, 3, 4]); + // Exhausted iterator keeps returning done. + expect(iter.next()).toEqual({ __proto__: null, done: true, value: null }); + db.close(); + }); + + test("early break resets the underlying statement", () => { + const db = setup(); + const stmt = db.prepare("SELECT n FROM t ORDER BY n"); + const seen: number[] = []; + for (const row of stmt.iterate()) { + seen.push(row.n); + if (seen.length === 2) break; + } + expect(seen).toEqual([1, 2]); + // After break, the statement is reusable from the start. + expect(stmt.all().map(r => r.n)).toEqual([1, 2, 3, 4]); + db.close(); + }); + + test("detects statement reuse while iterating", () => { + const db = setup(); + const stmt = db.prepare("SELECT n FROM t ORDER BY n"); + const iter = stmt.iterate(); + expect(iter.next().value.n).toBe(1); + // Calling run()/all()/get() on the same statement resets it, so the + // iterator's cursor position is no longer meaningful. + stmt.all(); + expect(() => iter.next()).toThrow(/iterator was invalidated/); + db.close(); + }); + + test("a failed step exhausts the iterator instead of rewinding it", () => { + const db = setup(); + db.function("boom", x => { + if (x === 2) throw new Error("boom at row 2"); + return x; + }); + // No ORDER BY: a sorter would evaluate boom() for every row during the + // first step; a plain scan evaluates it per next() so the error lands + // mid-iteration. + const stmt = db.prepare("SELECT boom(n) AS v FROM t"); + const iter = stmt.iterate(); + expect(iter.next().value.v).toBe(1); + expect(() => iter.next()).toThrow(/boom at row 2/); + // Catching the error and continuing must not silently restart from row 1. + expect(iter.next()).toEqual({ done: true, value: null }); + db.close(); + }); + + test("a stale iterator's return() does not rewind a newer iterator", () => { + const db = setup(); + const stmt = db.prepare("SELECT n FROM t ORDER BY n"); + let newer: ReturnType; + for (const _row of stmt.iterate()) { + // Starting a second iterator invalidates the one driving this loop; + // the implicit return() from `break` (IteratorClose) on the stale + // iterator must not reset the statement under the newer one. + newer = stmt.iterate(); + expect(newer.next().value.n).toBe(1); + break; + } + expect(newer!.next().value.n).toBe(2); + expect(newer!.next().value.n).toBe(3); + db.close(); + }); + + test("return() is tolerant of a finalized statement (IteratorClose on break)", () => { + const db = setup(); + const stmt = db.prepare("SELECT n FROM t ORDER BY n"); + const iter = stmt.iterate(); + // Closing the db inside the loop body finalizes the statement; the + // implicit return() from `break` (IteratorClose) must not turn that + // into an exception — cleanup should just report done. + expect(() => { + for (const _row of iter) { + db.close(); + break; + } + }).not.toThrow(); + // Explicit return() on the now-finalized iterator likewise succeeds. + expect(iter.return()).toEqual({ __proto__: null, done: true, value: null }); + }); +}); + +describe("Session / changeset", () => { + test("captures changes and applies them to another database", () => { + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); + const session = src.createSession(); + expect(Object.prototype.toString.call(session)).toBe("[object Session]"); + src.exec("INSERT INTO s VALUES (1, 'hello'), (2, 'world')"); + + const changeset = session.changeset(); + expect(changeset).toBeInstanceOf(Uint8Array); + expect(changeset.length).toBeGreaterThan(0); + const patchset = session.patchset(); + expect(patchset).toBeInstanceOf(Uint8Array); + session.close(); + expect(() => session.changeset()).toThrow(/session is not open/); + + const dst = new DatabaseSync(":memory:"); + dst.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); + expect(dst.applyChangeset(changeset)).toBe(true); + expect( + dst + .prepare("SELECT v FROM s ORDER BY id") + .all() + .map(r => r.v), + ).toEqual(["hello", "world"]); + + src.close(); + dst.close(); + }); + + test("conflict handler receives the conflict type", () => { + const src = new DatabaseSync(":memory:"); + const dst = new DatabaseSync(":memory:"); + for (const db of [src, dst]) db.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); + dst.exec("INSERT INTO s VALUES (1, 'already there')"); + + const session = src.createSession({ table: "s" }); + src.exec("INSERT INTO s VALUES (1, 'incoming')"); + const changeset = session.changeset(); + + let observed: number | undefined; + const ok = dst.applyChangeset(changeset, { + onConflict: type => { + observed = type; + return constants.SQLITE_CHANGESET_OMIT; + }, + }); + expect(ok).toBe(true); + expect(observed).toBe(constants.SQLITE_CHANGESET_CONFLICT); + // Row was omitted, original preserved. + expect(dst.prepare("SELECT v FROM s WHERE id = 1").get().v).toBe("already there"); + src.close(); + dst.close(); + }); + + test("filter callback skips tables", () => { + const src = new DatabaseSync(":memory:"); + const dst = new DatabaseSync(":memory:"); + for (const db of [src, dst]) { + db.exec("CREATE TABLE a (id INTEGER PRIMARY KEY, v TEXT)"); + db.exec("CREATE TABLE b (id INTEGER PRIMARY KEY, v TEXT)"); + } + const session = src.createSession(); + src.exec("INSERT INTO a VALUES (1, 'keep')"); + src.exec("INSERT INTO b VALUES (1, 'drop')"); + + dst.applyChangeset(session.changeset(), { + filter: table => table === "a", + }); + expect(dst.prepare("SELECT count(*) AS c FROM a").get().c).toBe(1); + expect(dst.prepare("SELECT count(*) AS c FROM b").get().c).toBe(0); + src.close(); + dst.close(); + }); + + test("applyChangeset copies the input so callbacks can't detach it mid-iteration", () => { + // sqlite3changeset_apply stores the raw pointer and streams from it + // between xFilter calls; detaching the backing ArrayBuffer there + // would free the memory sqlite is still reading. applyChangeset + // copies into an owned buffer first, so this must not crash (and the + // changes are still applied correctly). + const src = new DatabaseSync(":memory:"); + const dst = new DatabaseSync(":memory:"); + for (const db of [src, dst]) { + db.exec("CREATE TABLE a (id INTEGER PRIMARY KEY, v TEXT)"); + db.exec("CREATE TABLE b (id INTEGER PRIMARY KEY, v TEXT)"); + } + const session = src.createSession(); + src.exec("INSERT INTO a VALUES (1, 'x')"); + src.exec("INSERT INTO b VALUES (1, 'y')"); + const changeset = session.changeset(); + let detached = false; + dst.applyChangeset(changeset, { + filter: () => { + if (!detached) { + // Move the backing store to an unreferenced temp → GC-eligible. + changeset.buffer.transfer(); + detached = true; + } + Bun.gc(true); + return true; + }, + }); + expect(dst.prepare("SELECT count(*) AS c FROM a").get().c).toBe(1); + expect(dst.prepare("SELECT count(*) AS c FROM b").get().c).toBe(1); + src.close(); + dst.close(); + }); + + test("applyChangeset rejects a changeset detached by an options getter", () => { + // The option getters run before the owned-buffer copy is made; a getter + // that detaches the input must produce an error, not a silent no-op + // "successful" apply of an empty changeset. + const src = new DatabaseSync(":memory:"); + const dst = new DatabaseSync(":memory:"); + for (const db of [src, dst]) db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + const session = src.createSession(); + src.exec("INSERT INTO t VALUES (1)"); + const changeset = session.changeset(); + expect(() => + dst.applyChangeset(changeset, { + get filter() { + changeset.buffer.transfer(); + return undefined; + }, + }), + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" })); + expect(dst.prepare("SELECT count(*) AS c FROM t").get().c).toBe(0); + src.close(); + dst.close(); + }); + + test("default onConflict aborts and returns false", () => { + const src = new DatabaseSync(":memory:"); + const dst = new DatabaseSync(":memory:"); + for (const db of [src, dst]) db.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); + dst.exec("INSERT INTO s VALUES (1, 'x')"); + const session = src.createSession(); + src.exec("INSERT INTO s VALUES (1, 'y')"); + expect(dst.applyChangeset(session.changeset())).toBe(false); + src.close(); + dst.close(); + }); + + test("unclosed session is cleaned up on db.close()", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + const session = db.createSession(); + db.exec("INSERT INTO t VALUES (1)"); + db.close(); + // Session handle was freed by close(); using it now is an error but not a crash. + expect(() => session.changeset()).toThrow(/database is not open/); + }); + + test("stale session after close()+open() is rejected, not UAF'd", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + const session = db.createSession(); + db.close(); + db.open(); + // closeInternal() freed the sqlite3_session* but left the wrapper's + // pointer intact; after re-open the db is "open" again, so without the + // origin-connection check changeset()/close() would dereference freed + // memory (heap-use-after-free / double-free under ASAN). + expect(() => session.changeset()).toThrow(/database is not open/); + expect(() => session.patchset()).toThrow(/database is not open/); + expect(() => session.close()).toThrow(/database is not open/); + // Symbol.dispose swallows. + expect(() => session[Symbol.dispose]()).not.toThrow(); + db.close(); + }); +}); + +// Each backup_step with rate=1 fsyncs the destination once per page; keep +// the page count tiny so the test stays fast on slow-fsync CI filesystems. +describe("backup()", () => { + test("copies an in-memory database to a file", async () => { + using dir = tempDir("node-sqlite-backup", {}); + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)"); + src.exec("INSERT INTO t (data) VALUES ('a'), ('b'), ('c')"); + + const destPath = path.join(String(dir), "dst.db"); + let progressCalls = 0; + const pages = await backup(src, destPath, { + rate: 1, + progress: ({ totalPages, remainingPages }) => { + expect(typeof totalPages).toBe("number"); + expect(typeof remainingPages).toBe("number"); + progressCalls++; + }, + }); + expect(typeof pages).toBe("number"); + expect(progressCalls).toBeGreaterThan(0); + + const dst = new DatabaseSync(destPath); + expect(dst.prepare("SELECT count(*) AS c FROM t").get().c).toBe(3); + src.close(); + dst.close(); + // The temporary statement above is not yet GC'd, so sqlite3_close_v2 + // left dst's connection in zombie mode with the file still open. On + // Windows that blocks tempDir's rm with EBUSY; force the finalizer. + Bun.gc(true); + }); + + test("rejects with ERR_SQLITE_ERROR when the destination is unwritable", async () => { + using dir = tempDir("node-sqlite-backup-badpath", {}); + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t (x)"); + // A file inside a directory that doesn't exist — sqlite3_open_v2 will + // fail with SQLITE_CANTOPEN on every platform. Using tempDir keeps the + // path shape correct on Windows. + const bad = path.join(String(dir), "no-such-subdir", "x.db"); + await expect(backup(src, bad)).rejects.toMatchObject({ + code: "ERR_SQLITE_ERROR", + }); + src.close(); + }); + + test("progress callback exceptions reject the promise", async () => { + using dir = tempDir("node-sqlite-backup-err", {}); + const src = new DatabaseSync(":memory:"); + // Enough rows to span >1 page so the progress callback fires at least + // once before SQLITE_DONE. + src.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + src.exec("BEGIN"); + for (let i = 0; i < 1000; i++) src.prepare("INSERT INTO t DEFAULT VALUES").run(); + src.exec("COMMIT"); + await expect( + backup(src, path.join(String(dir), "dst.db"), { + rate: 1, + progress: () => { + throw new Error("nope"); + }, + }), + ).rejects.toThrow("nope"); + src.close(); + }); +}); + +describe("DatabaseSync.prototype.setAuthorizer()", () => { + test("callback receives action code + parameters and gates prepare()", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE users (id INTEGER, name TEXT)"); + const calls: unknown[][] = []; + db.setAuthorizer((action, p1, p2, p3, p4) => { + calls.push([action, p1, p2, p3, p4]); + return constants.SQLITE_OK; + }); + db.prepare("SELECT id FROM users").get(); + // One SELECT, one READ(users.id, main). Exact shape is what + // sqlite hands to the authorizer; Node surfaces it verbatim. + expect(calls).toEqual([ + [constants.SQLITE_SELECT, null, null, null, null], + [constants.SQLITE_READ, "users", "id", "main", null], + ]); + + db.setAuthorizer(() => constants.SQLITE_DENY); + expect(() => db.prepare("SELECT * FROM users")).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR", message: expect.stringMatching(/not authorized/) }), + ); + + db.setAuthorizer(null); + // Cleared — same prepare now succeeds. + expect(db.prepare("SELECT * FROM users").all()).toEqual([]); + expect(() => db.setAuthorizer(42 as any)).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + db.close(); + }); + + test("non-integer return is surfaced as a TypeError, out-of-range as RangeError", () => { + const db = new DatabaseSync(":memory:"); + db.setAuthorizer(() => "nope" as any); + expect(() => db.prepare("SELECT 1")).toThrow(TypeError); + db.setAuthorizer(() => 12345); + expect(() => db.prepare("SELECT 1")).toThrow(RangeError); + db.close(); + }); +}); + +describe("db.limits", () => { + test("named limits read/write through to sqlite3_limit and are enumerable", () => { + const db = new DatabaseSync(":memory:"); + const original = db.limits.column; + expect(typeof original).toBe("number"); + expect(original).toBeGreaterThan(0); + + db.limits.column = 10; + expect(db.limits.column).toBe(10); + db.exec("CREATE TABLE t1 (a,b,c,d,e,f,g,h,i,j)"); + expect(() => db.exec("CREATE TABLE t2 (a,b,c,d,e,f,g,h,i,j,k)")).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR" }), + ); + + db.limits.column = Infinity; + expect(db.limits.column).toBe(original); + expect(Object.keys(db.limits)).toContain("sqlLength"); + expect(() => (db.limits.column = -1)).toThrow(RangeError); + expect(() => (db.limits.column = "no" as any)).toThrow(TypeError); + db.close(); + expect(() => db.limits.column).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + }); + + test("constructor {limits} option seeds sqlite3_limit on open", () => { + const db = new DatabaseSync(":memory:", { limits: { variableNumber: 3 } }); + expect(db.limits.variableNumber).toBe(3); + expect(() => db.prepare("SELECT ?, ?, ?, ?")).toThrow(expect.objectContaining({ code: "ERR_SQLITE_ERROR" })); + db.close(); + expect(() => new DatabaseSync(":memory:", { limits: { column: -1 } })).toThrow(RangeError); + }); +}); + +describe("serialize() / deserialize()", () => { + test("round-trips schema and data, and invalidates prior statements", () => { + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t(a INTEGER PRIMARY KEY, b TEXT)"); + src.exec("INSERT INTO t VALUES (1,'hi'),(2,'there')"); + const buf = src.serialize(); + src.close(); + expect(buf).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(buf.slice(0, 15))).toBe("SQLite format 3"); + + const dst = new DatabaseSync(":memory:"); + dst.exec("CREATE TABLE old(x)"); + const stale = dst.prepare("SELECT x FROM old"); + dst.deserialize(buf); + // deserialize bumps the open-generation so the wrapper reports + // finalized rather than stepping into a vanished schema. The + // underlying sqlite3_stmt* is still owned by the wrapper — GC + // finalizes it, no double-free. + expect(() => stale.get()).toThrow(/statement has been finalized/); + expect(dst.prepare("SELECT a, b FROM t ORDER BY a").all()).toEqual([ + { a: 1, b: "hi" }, + { a: 2, b: "there" }, + ]); + dst.close(); + Bun.gc(true); + }); +}); + +describe("createTagStore()", () => { + test("caches prepared statements by template-literal shape", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + const sql = db.createTagStore(4); + expect(sql.capacity).toBe(4); + expect(sql.db).toBe(db); + expect(sql.size).toBe(0); + + expect(sql.run`INSERT INTO t (v) VALUES (${"a"})`.changes).toBe(1); + expect(sql.size).toBe(1); + // Same template, different interpolation → cache hit. + expect(sql.run`INSERT INTO t (v) VALUES (${"b"})`.changes).toBe(1); + expect(sql.size).toBe(1); + + expect(sql.get`SELECT v FROM t WHERE id = ${2}`).toEqual({ v: "b" }); + expect(sql.all`SELECT v FROM t ORDER BY id`.map(r => r.v)).toEqual(["a", "b"]); + expect([...sql.iterate`SELECT v FROM t ORDER BY id`].map(r => r.v)).toEqual(["a", "b"]); + + sql.clear(); + expect(sql.size).toBe(0); + db.close(); + }); + + test("surfaces a thrown authorizer over SQLite's 'not authorized'", () => { + // SQLTagStore's prepare() runs sqlite3_prepare_v2() which fires the + // authorizer. If the authorizer throws, the pending JS exception + // must win over the generic ERR_SQLITE_ERROR — same as + // DatabaseSync.prototype.prepare()'s CHECK_UDF_EXCEPTION path. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t(x)"); + const sql = db.createTagStore(); + db.setAuthorizer(() => { + throw new TypeError("nope from authorizer"); + }); + expect(() => sql.get`SELECT x FROM t`).toThrow( + expect.objectContaining({ + name: "TypeError", + message: "nope from authorizer", + }), + ); + db.setAuthorizer(null); + db.close(); + }); +}); + +test("deserialize() frees open sessions instead of orphaning their preupdate hook", () => { + // deserialize() bumps the open-generation to invalidate existing + // wrappers. Sessions become stale — but deleteSession() (and the + // destructor) assume "stale ⇒ closeInternal() already freed", so + // they skip sqlite3session_delete. That's only true for close()+ + // open(); deserialize() must free the tracked handles itself or + // the preupdate hook stays live on the unchanged sqlite3* and + // keeps recording writes into an unreachable change buffer. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + const session = db.createSession(); + const buf = db.serialize(); + db.deserialize(buf); + // Wrapper reports closed — the handle was freed above, not leaked. + expect(() => session.changeset()).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + // Symbol.dispose on a stale session is a silent no-op (no + // double-free of the already-deleted handle). + expect(() => session[Symbol.dispose]()).not.toThrow(); + // DB is still usable and a fresh session works. + expect(db.isOpen).toBe(true); + db.exec("INSERT INTO t VALUES (1)"); + const fresh = db.createSession(); + db.exec("INSERT INTO t VALUES (2)"); + expect(fresh.changeset().length).toBeGreaterThan(0); + fresh.close(); + db.close(); + Bun.gc(true); +}); + +describe("enableDefensive()", () => { + test("defaults on; {defensive:false} and enableDefensive() toggle it", () => { + // Defensive mode blocks PRAGMA journal_mode=OFF (among other + // things). That's the observable Node's own test uses. + const pragma = (db: any) => db.prepare("PRAGMA journal_mode").get().journal_mode; + const on = new DatabaseSync(":memory:"); + expect(pragma(on)).toBe("memory"); + on.exec("PRAGMA journal_mode=OFF"); + expect(pragma(on)).toBe("memory"); // unchanged → defensive on + on.close(); + + const off = new DatabaseSync(":memory:", { defensive: false }); + off.exec("PRAGMA journal_mode=OFF"); + expect(pragma(off)).toBe("off"); + off.enableDefensive(true); + // Can't reopen journal mode once off, so just check the call + // reaches sqlite without throwing. + off.enableDefensive(false); + off.close(); + expect(() => new DatabaseSync(":memory:").enableDefensive("nope" as any)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +}); + +describe("row-shape structure caching", () => { + test("all() results share a null-prototype structure and handle duplicate columns", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b TEXT)"); + for (let i = 0; i < 5; i++) db.prepare("INSERT INTO t VALUES (?,?)").run(i, `v${i}`); + const stmt = db.prepare("SELECT a, b FROM t ORDER BY a"); + const rows = stmt.all(); + expect(rows).toHaveLength(5); + for (const r of rows) { + expect(Object.getPrototypeOf(r)).toBe(null); + expect(Object.keys(r)).toEqual(["a", "b"]); + } + expect(rows[0]).toEqual({ a: 0, b: "v0" }); + expect(rows[4]).toEqual({ a: 4, b: "v4" }); + // Duplicate-name column collapses to a single property with + // *last*-wins semantics — Node's row builder iterates columns and + // calls V8 Object::Set()/CreateDataProperty() each time, which + // overwrites on a repeat key. The cached-offset path must agree + // with the generic putDirect() fallback, so both yield {x: 2}. + const dup = db.prepare("SELECT 1 AS x, 2 AS x").get(); + expect(Object.keys(dup)).toEqual(["x"]); + expect(dup.x).toBe(2); + db.close(); + }); + + test("picks up column renames across ALTER TABLE (structure rebuilt per reset)", () => { + // sqlite3_prepare_v2 transparently re-prepares on SQLITE_SCHEMA, + // so after ALTER TABLE … RENAME COLUMN the same `SELECT *` + // statement returns the SAME column count with DIFFERENT names. + // Keying the row-structure cache on count alone would serve the + // stale names forever; it must be rebuilt on each reset. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b INTEGER); INSERT INTO t VALUES (1, 2)"); + const stmt = db.prepare("SELECT * FROM t"); + expect(stmt.get()).toEqual({ a: 1, b: 2 }); + db.exec("ALTER TABLE t RENAME COLUMN a TO x"); + expect(stmt.get()).toEqual({ x: 1, b: 2 }); + db.close(); + }); + + test("all() reads the column count after step() re-prepares the statement", () => { + // The count variant of the rename case: DROP COLUMN between + // prepare() and .all() makes the first sqlite3_step() + // transparently re-prepare `SELECT *` with *fewer* columns. + // A pre-step column_count() would be stale; ensureRowStructure() + // rebuilds m_columnOffsets with the fresh (smaller) count, so + // looping to the stale count would index that Vector OOB and + // putDirectOffset() into a bogus slot. all() must read the + // count per row, same as get() / iterate(). + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (a, b, c); INSERT INTO t VALUES (1,2,3),(4,5,6)"); + const stmt = db.prepare("SELECT * FROM t ORDER BY a"); + db.exec("ALTER TABLE t DROP COLUMN c"); + expect(stmt.all()).toEqual([ + { a: 1, b: 2 }, + { a: 4, b: 5 }, + ]); + // Growing the count between calls must also work. + db.exec("ALTER TABLE t ADD COLUMN d INTEGER DEFAULT 9"); + expect(stmt.all()).toEqual([ + { a: 1, b: 2, d: 9 }, + { a: 4, b: 5, d: 9 }, + ]); + db.close(); + }); + + test("index-string column names go through indexed storage", () => { + // `SELECT 1 AS "0"` produces a column whose name is a canonical + // array-index string. Structure::addPropertyTransition and + // putDirect both assert !parseIndex(), so the fast path must + // bail and the fallback must use putDirectMayBeIndex(). + const db = new DatabaseSync(":memory:"); + const row = db.prepare('SELECT 7 AS "0", 8 AS one').get(); + expect(row["0"]).toBe(7); + expect(row[0]).toBe(7); + expect(row.one).toBe(8); + expect(Object.getPrototypeOf(row)).toBe(null); + db.close(); + }); +}); + +test("SQLTagStore binds via the same JS→SQLite bridge as StatementSync", () => { + // The tag store previously hand-rolled its own bind logic and + // drifted: it accepted undefined and silently wrapped oversized + // BigInts (2n**64n → 0) where stmt.run(...) throws. Both paths now + // share JSStatementSync::bindValue(). + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t(n INTEGER)"); + const sql = db.createTagStore(); + expect(() => sql.run`INSERT INTO t VALUES (${2n ** 64n})`).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); + expect(() => sql.run`INSERT INTO t VALUES (${undefined as any})`).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(db.prepare("SELECT COUNT(*) AS c FROM t").get().c).toBe(0); + db.close(); +}); + +test("authorizer constants are exposed on constants", () => { + expect(constants.SQLITE_OK).toBe(0); + expect(constants.SQLITE_DENY).toBe(1); + expect(constants.SQLITE_IGNORE).toBe(2); + expect(typeof constants.SQLITE_SELECT).toBe("number"); + expect(typeof constants.SQLITE_CREATE_TABLE).toBe("number"); +}); + +test("Symbol.for('sqlite-type') identifies a node:sqlite DatabaseSync", () => { + const db = new DatabaseSync(":memory:"); + expect(db[Symbol.for("sqlite-type")]).toBe("node:sqlite"); + db.close(); +}); + +describe("StatementSync.prototype.columns()", () => { + test("exposes origin table/column/database metadata", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); + const cols = db.prepare("SELECT id, name AS display FROM t").columns(); + expect(cols).toEqual([ + { __proto__: null, column: "id", database: "main", name: "id", table: "t", type: "INTEGER" }, + { __proto__: null, column: "name", database: "main", name: "display", table: "t", type: "TEXT" }, + ]); + // Computed expressions have no origin column/table. + const exprCols = db.prepare("SELECT 1 + 1 AS two").columns(); + expect(exprCols[0]).toEqual({ + __proto__: null, + column: null, + database: null, + name: "two", + table: null, + type: null, + }); + db.close(); + }); +}); + +// Regression: unclosed bun:sqlite databases would trigger a heap-use-after-free +// when BUN_DESTRUCT_VM_ON_EXIT=1, because Bun__closeAllSQLiteDatabasesForTermination +// closed the handle without nulling it, and the GC finalizer then closed it again. +test("unclosed sqlite database does not use-after-free on VM teardown", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + db.run('SELECT 1'); + // intentionally not closed`, + ], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Don't assert stderr is exactly empty: ASAN/debug builds emit benign + // teardown noise. The invariant is no ASAN report and a clean exit. + expect(stderr).not.toContain("heap-use-after-free"); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); +}); + +// process.exit() inside a UDF reaches ~JSDatabaseSync with a BusyScope still +// on the stack; that path must still flag its session records as dbGone or +// ~JSNodeSqliteSession writes to the already-swept database cell. +test("teardown with a busy connection and an unclosed session does not use-after-free", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(x INTEGER PRIMARY KEY)'); + db.createSession(); + db.function('die', () => process.exit(0)); + db.exec('SELECT die()');`, + ], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("heap-use-after-free"); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); +}); + +// The process-exit handler must close (or at least WAL-checkpoint) unclosed +// file-backed databases the way Node and bun:sqlite do; see +// Bun__closeAllNodeSqliteDatabasesForTermination in NodeSqlite.cpp. +test("unclosed file-backed database is closed on process exit (no WAL sidecars left)", async () => { + using dir = tempDir("node-sqlite-exit-close", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + // The printed existsSync proves the -wal the parent asserts on really + // existed while the never-closed connection was still open. + `const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync('exit.db'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (42)'); + console.log(require('node:fs').existsSync('exit.db-wal')); + // intentionally not closed`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + // stderr is drained but not asserted: ASAN/debug builds emit benign noise. + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("true\n"); + // The exit handler ran sqlite3_close_v2(): the last WAL connection + // checkpoints and unlinks both sidecars on its way out. + expect(existsSync(path.join(String(dir), "exit.db-wal"))).toBe(false); + expect(existsSync(path.join(String(dir), "exit.db-shm"))).toBe(false); + // And the checkpoint persisted the row into the main database file. + using verify = new DatabaseSync(path.join(String(dir), "exit.db")); + expect(verify.prepare("SELECT x FROM t").get()).toEqual({ x: 42 }); + expect(exitCode).toBe(0); + // The temporary statement above is not yet GC'd, so sqlite3_close_v2 + // left verify's connection in zombie mode with exit.db still open. On + // Windows that blocks tempDir's rm with EBUSY; force the finalizer. + Bun.gc(true); +}); + +// A statement that is never finalized makes sqlite3_close_v2 defer the real +// close, so the exit handler checkpoints the WAL explicitly: the data must be +// in the main database file even though the (now empty) sidecars remain. +test("exit-time WAL checkpoint runs even with a never-finalized prepared statement", async () => { + using dir = tempDir("node-sqlite-exit-zombie", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + // The printed size proves the WAL really held un-checkpointed frames + // while the statement (and connection) were still alive. + `const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync('exit.db'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('CREATE TABLE t (x INTEGER)'); + const stmt = db.prepare('INSERT INTO t VALUES (?)'); + stmt.run(42); + // stmt stays referenced and is never finalized; db is never closed. + console.log(require('node:fs').statSync('exit.db-wal').size > 0);`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("true\n"); + // SQLITE_CHECKPOINT_TRUNCATE moved every frame into exit.db. The empty + // -wal file outlives the zombified connection today, but the invariant is + // only that no un-checkpointed data is stranded in one. + const wal = path.join(String(dir), "exit.db-wal"); + expect(existsSync(wal) ? statSync(wal).size : 0).toBe(0); + using verify = new DatabaseSync(path.join(String(dir), "exit.db")); + expect(verify.prepare("SELECT x FROM t").get()).toEqual({ x: 42 }); + expect(exitCode).toBe(0); + // The temporary statement above is not yet GC'd, so sqlite3_close_v2 + // left verify's connection in zombie mode with exit.db still open. On + // Windows that blocks tempDir's rm with EBUSY; force the finalizer. + Bun.gc(true); +}); + +describe("GC lifetime", () => { + test("function()/aggregate() callbacks that capture the database do not pin it forever", () => { + // The registered callbacks are rooted on the DatabaseSync cell (not by a + // C-side Strong<>), so a db → closure → db cycle must stay collectable + // even when the database is never close()d. + const countCells = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.DatabaseSync ?? 0; + }; + const before = countCells(); + for (let i = 0; i < 50; i++) { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x INTEGER)"); + db.exec("INSERT INTO t VALUES (1), (2)"); + db.function("lookup", id => db.prepare("SELECT 1 AS v").get()!.v + id); + db.aggregate("agg", { start: 0, step: (acc, x) => acc + (db ? x : 0) }); + expect(db.prepare("SELECT lookup(1) AS v").get()!.v).toBe(2); + expect(db.prepare("SELECT agg(x) AS v FROM t").get()!.v).toBe(3); + } + // GC a few times; conservative stack scanning may keep a couple of + // stragglers alive, but with the cycle bug all 50 survive. + let delta = Infinity; + for (let i = 0; i < 10 && delta > 10; i++) { + delta = countCells() - before; + } + expect(delta).toBeLessThanOrEqual(10); + }); + + test("re-registering a function/aggregate name replaces the previous registration", () => { + // Each re-registration releases the superseded callback's roots at the + // registration site (releaseSupersededRegistration) and reuses the slots; + // the latest callback is the one SQLite invokes. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x INTEGER)"); + db.exec("INSERT INTO t VALUES (1), (2)"); + for (let i = 0; i < 500; i++) { + db.function("f", () => i); + db.aggregate("agg", { start: 0, step: (acc, _x) => acc + i }); + } + expect(db.prepare("SELECT f() AS v").get()!.v).toBe(499); + expect(db.prepare("SELECT agg(x) AS v FROM t").get()!.v).toBe(998); + // SQLite function names are case-insensitive, so a differently-cased + // re-registration replaces the same function. + db.function("Mixed", () => "old"); + db.function("MIXED", () => "new"); + expect(db.prepare("SELECT mixed() AS v").get()!.v).toBe("new"); + db.close(); + }); + + test("deferred function teardown on a zombified connection cannot unroot later registrations", () => { + // Closing with an unfinalized statement zombifies the connection, so the + // old registration's xDestroy only runs when the statement is finalized + // by GC — possibly after the database was reopened and new callbacks + // were registered. That deferred teardown must not touch the cell. + const db = new DatabaseSync(":memory:"); + db.function("f", () => 1); + let stmt: InstanceType | null = db.prepare("SELECT 1 AS v"); + expect(stmt.get()!.v).toBe(1); + db.close(); // zombie: stmt is still unfinalized + db.open(); + db.function("g", () => 42); + stmt = null; + Bun.gc(true); // finalizes the old statement → deferred xDestroy of "f" + expect(db.prepare("SELECT g() AS v").get()!.v).toBe(42); + db.close(); + }); + + test("sessions dropped without close() are reclaimed once the database is used again", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + for (let i = 0; i < 100; i++) { + db.createSession(); + } + Bun.gc(true); + // The next entry point sweeps the orphaned native sessions; the + // connection keeps working and a fresh session records normally. + db.exec("INSERT INTO t VALUES (1, 'x')"); + const fresh = db.createSession(); + db.exec("INSERT INTO t VALUES (2, 'y')"); + expect(fresh.changeset().length).toBeGreaterThan(0); + fresh.close(); + db.close(); + }); + + test("a failed deserialize() leaves existing sessions and the database untouched", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + const session = db.createSession(); + const stmt = db.prepare("SELECT COUNT(*) AS n FROM t"); + db.exec("INSERT INTO t VALUES (1, 'a')"); + + const other = new DatabaseSync(":memory:"); + other.exec("CREATE TABLE o (x INTEGER)"); + const buf = other.serialize(); + other.close(); + + // Targeting a schema that doesn't exist fails inside sqlite3_deserialize() + // before anything about the connection changes. + expect(() => db.deserialize(buf, { dbName: "nosuchschema" })).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR" }), + ); + + // Statements are finalized even on the failure path (Node finalizes its + // statements before deserializing), but the session keeps its recorded + // history because the connection was never touched. + expect(() => stmt.get()).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + expect(session.changeset().length).toBeGreaterThan(0); + expect(db.prepare("SELECT COUNT(*) AS n FROM t").get()!.n).toBe(1); + session.close(); + db.close(); + }); +}); diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index cec35c12a36d..11fcf743e67d 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -57,6 +57,8 @@ const noop = () => {}; const hasCrypto = Boolean(process.versions.openssl) && !process.env.NODE_SKIP_CRYPTO; +const hasSQLite = Boolean(process.versions.sqlite); + // Synthesize OPENSSL_VERSION_NUMBER format with the layout 0xMNN00PPSL const opensslVersionNumber = (major = 0, minor = 0, patch = 0) => { assert(major >= 0 && major <= 0xf); @@ -175,6 +177,11 @@ if (process.argv.length === 2 && installBunExposeInternalsShim(); break; } + if ((flag === "--experimental-sqlite" || flag === "--no-experimental-sqlite") && process.versions.bun) { + // node:sqlite is always available in Bun; the Node experimental gate + // does not exist, so don't re-spawn just to pass the flag through. + continue; + } if (flag === "test") { process.env.SKIP_FLAG_CHECK = "1"; break; @@ -828,6 +835,12 @@ function skipIfWorker() { } } +function skipIfSQLiteMissing() { + if (!hasSQLite) { + skip('missing SQLite'); + } +} + function getArrayBufferViews(buf) { const { buffer, byteOffset, byteLength } = buf; @@ -1097,6 +1110,7 @@ const common = { hasCrypto, hasOpenSSL, hasQuic, + hasSQLite, hasMultiLocalhost, invalidArgTypeHelper, isAlive, @@ -1131,6 +1145,7 @@ const common = { skipIfDumbTerminal, skipIfEslintMissing, skipIfInspectorDisabled, + skipIfSQLiteMissing, skipIfWorker, spawnPromisified, diff --git a/test/js/node/test/common/index.mjs b/test/js/node/test/common/index.mjs index 898e6f2d2e80..daf32c892c68 100644 --- a/test/js/node/test/common/index.mjs +++ b/test/js/node/test/common/index.mjs @@ -21,6 +21,7 @@ const { hasIntl, hasIPv6, hasMultiLocalhost, + hasSQLite, isAIX, isAlive, isDumbTerminal, @@ -52,6 +53,7 @@ const { skipIfDumbTerminal, skipIfEslintMissing, skipIfInspectorDisabled, + skipIfSQLiteMissing, spawnPromisified, } = common; @@ -77,6 +79,7 @@ export { hasIntl, hasIPv6, hasMultiLocalhost, + hasSQLite, isAIX, isAlive, isDumbTerminal, @@ -108,5 +111,6 @@ export { skipIfDumbTerminal, skipIfEslintMissing, skipIfInspectorDisabled, + skipIfSQLiteMissing, spawnPromisified, }; diff --git a/test/js/node/test/parallel/test-sqlite-aggregate-function.mjs b/test/js/node/test/parallel/test-sqlite-aggregate-function.mjs new file mode 100644 index 000000000000..5d12eeb24b6f --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-aggregate-function.mjs @@ -0,0 +1,428 @@ +import { skipIfSQLiteMissing } from '../common/index.mjs'; +import { describe, test } from 'node:test'; +skipIfSQLiteMissing(); +const { DatabaseSync } = await import('node:sqlite'); + +describe('DatabaseSync.prototype.aggregate()', () => { + describe('input validation', () => { + const db = new DatabaseSync(':memory:'); + + test('throws if options.start is not provided', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + result: (total) => total + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.start" argument must be a function or a primitive value.' + }); + }); + + test('throws if options.step is not a function', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + start: 0, + result: (total) => total + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.step" argument must be a function.' + }); + }); + + test('throws if options.useBigIntArguments is not a boolean', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + start: 0, + step: () => null, + useBigIntArguments: '' + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.useBigIntArguments" argument must be a boolean/, + }); + }); + + test('throws if options.varargs is not a boolean', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + start: 0, + step: () => null, + varargs: '' + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.varargs" argument must be a boolean/, + }); + }); + + test('throws if options.directOnly is not a boolean', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + start: 0, + step: () => null, + directOnly: '' + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.directOnly" argument must be a boolean/, + }); + }); + + test('throws if options.inverse is not a function', (t) => { + t.assert.throws(() => { + db.aggregate('sum', { + start: 0, + step: (acc, value) => acc + value, + inverse: 10 + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.inverse" argument must be a function/, + }); + }); + }); +}); + +describe('varargs', () => { + test('supports variable number of arguments when true', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: 0, + step: (_acc, _value, var1, var2, var3) => { + return var1 + var2 + var3; + }, + varargs: true, + }); + + const result = db.prepare('SELECT sum_int(value, 1, 2, 3) as total FROM data').get(); + + t.assert.deepStrictEqual(result, { __proto__: null, total: 6 }); + }); + + test('uses the max between step.length and inverse.length when false', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec(` + CREATE TABLE t3(x, y); + INSERT INTO t3 VALUES ('a', 1), + ('b', 2), + ('c', 3); + `); + + db.aggregate('sumint', { + start: 0, + step: (acc, var1) => { + return var1 + acc; + }, + inverse: (acc, var1, var2) => { + return acc - var1 - var2; + }, + varargs: false, + }); + + const result = db.prepare(` + SELECT x, sumint(y, 10) OVER ( + ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING + ) AS sum_y + FROM t3 ORDER BY x; + `).all(); + + t.assert.deepStrictEqual(result, [ + { __proto__: null, x: 'a', sum_y: 3 }, + { __proto__: null, x: 'b', sum_y: 6 }, + { __proto__: null, x: 'c', sum_y: -5 }, + ]); + + t.assert.throws(() => { + db.prepare(` + SELECT x, sumint(y) OVER ( + ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING + ) AS sum_y + FROM t3 ORDER BY x; + `); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'wrong number of arguments to function sumint()' + }); + }); + + test('throws if an incorrect number of arguments is provided when false', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.aggregate('sum_int', { + start: 0, + step: (_acc, var1, var2, var3) => { + return var1 + var2 + var3; + }, + varargs: false, + }); + + t.assert.throws(() => { + db.prepare('SELECT sum_int(1, 2, 3, 4)').get(); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'wrong number of arguments to function sum_int()' + }); + }); +}); + +describe('directOnly', () => { + test('is false by default', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.aggregate('func', { + start: 0, + step: (acc, value) => acc + value, + inverse: (acc, value) => acc - value, + }); + db.exec(` + CREATE TABLE t3(x, y); + INSERT INTO t3 VALUES ('a', 4), + ('b', 5), + ('c', 3); + `); + + db.exec(` + CREATE TRIGGER test_trigger + AFTER INSERT ON t3 + BEGIN + SELECT func(1) OVER (); + END; + `); + + // TRIGGER will work fine with the window function + db.exec('INSERT INTO t3 VALUES(\'d\', 6)'); + }); + + test('set SQLITE_DIRECT_ONLY flag when true', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.aggregate('func', { + start: 0, + step: (acc, value) => acc + value, + inverse: (acc, value) => acc - value, + directOnly: true, + }); + db.exec(` + CREATE TABLE t3(x, y); + INSERT INTO t3 VALUES ('a', 4), + ('b', 5), + ('c', 3); + `); + + db.exec(` + CREATE TRIGGER test_trigger + AFTER INSERT ON t3 + BEGIN + SELECT func(1) OVER (); + END; + `); + + t.assert.throws(() => { + db.exec('INSERT INTO t3 VALUES(\'d\', 6)'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /unsafe use of func\(\)/ + }); + }); +}); + +describe('start', () => { + test('start option as a value', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: 0, + step: (acc, value) => acc + value, + }); + + const result = db.prepare('SELECT sum_int(value) as total FROM data').get(); + + t.assert.deepStrictEqual(result, { __proto__: null, total: 6 }); + }); + + test('start option as a function', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: () => 0, + step: (acc, value) => acc + value, + }); + + const result = db.prepare('SELECT sum_int(value) as total FROM data').get(); + + t.assert.deepStrictEqual(result, { __proto__: null, total: 6 }); + }); + + test('start option can hold any js value', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: () => [], + step: (acc, value) => { + return [...acc, value]; + }, + result: (acc) => acc.join(', '), + }); + + const result = db.prepare('SELECT sum_int(value) as total FROM data').get(); + + t.assert.deepStrictEqual(result, { __proto__: null, total: '1, 2, 3' }); + }); + + test('throws if start throws an error', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('agg', { + start: () => { + throw new Error('start error'); + }, + step: () => null, + }); + + t.assert.throws(() => { + db.prepare('SELECT agg()').get(); + }, { + message: 'start error' + }); + }); +}); + +describe('step', () => { + test('throws if step throws an error', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('agg', { + start: 0, + step: () => { + throw new Error('step error'); + }, + }); + + t.assert.throws(() => { + db.prepare('SELECT agg()').get(); + }, { + message: 'step error' + }); + }); +}); + +describe('result', () => { + test('throws if result throws an error', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: 0, + step: (acc, value) => { + return acc + value; + }, + result: () => { + throw new Error('result error'); + }, + }); + t.assert.throws(() => { + db.prepare('SELECT sum_int(value) as result FROM data').get(); + }, { + message: 'result error' + }); + }); + + test('executes once when options.inverse is not present', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + const mockFn = t.mock.fn(() => 'overridden'); + db.exec('CREATE TABLE data (value INTEGER)'); + db.exec('INSERT INTO data VALUES (1), (2), (3)'); + db.aggregate('sum_int', { + start: 0, + step: (acc, value) => { + return acc + value; + }, + result: mockFn + }); + + const result = db.prepare('SELECT sum_int(value) as result FROM data').get(); + + t.assert.deepStrictEqual(result, { __proto__: null, result: 'overridden' }); + t.assert.strictEqual(mockFn.mock.calls.length, 1); + t.assert.deepStrictEqual(mockFn.mock.calls[0].arguments, [6]); + }); + + test('executes once per row when options.inverse is present', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + const mockFn = t.mock.fn((acc) => acc); + db.exec(` + CREATE TABLE t3(x, y); + INSERT INTO t3 VALUES ('a', 4), + ('b', 5), + ('c', 3); + `); + db.aggregate('sumint', { + start: 0, + step: (acc, value) => { + return acc + value; + }, + inverse: (acc, value) => { + return acc - value; + }, + result: mockFn + }); + + db.prepare(` + SELECT x, sumint(y) OVER ( + ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING + ) AS sum_y + FROM t3 ORDER BY x; + `).all(); + + t.assert.strictEqual(mockFn.mock.calls.length, 3); + t.assert.deepStrictEqual(mockFn.mock.calls[0].arguments, [9]); + t.assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [12]); + t.assert.deepStrictEqual(mockFn.mock.calls[2].arguments, [8]); + }); +}); + +test('throws an error when trying to use as windown function but didn\'t provide options.inverse', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.exec(` + CREATE TABLE t3(x, y); + INSERT INTO t3 VALUES ('a', 4), + ('b', 5), + ('c', 3); + `); + + db.aggregate('sumint', { + start: 0, + step: (total, nextValue) => total + nextValue, + }); + + t.assert.throws(() => { + db.prepare(` + SELECT x, sumint(y) OVER ( + ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING + ) AS sum_y + FROM t3 ORDER BY x; + `); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'sumint() may not be used as a window function' + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-authz.js b/test/js/node/test/parallel/test-sqlite-authz.js new file mode 100644 index 000000000000..2bf268847cd3 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-authz.js @@ -0,0 +1,278 @@ +'use strict'; + +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); + +const assert = require('node:assert'); +const { DatabaseSync, constants } = require('node:sqlite'); +const { suite, it } = require('node:test'); + +suite('DatabaseSync.prototype.setAuthorizer()', () => { + const createTestDatabase = () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE users (id INTEGER, name TEXT)'); + return db; + }; + + it('receives correct parameters for SELECT operations', (t) => { + const authorizer = t.mock.fn(() => constants.SQLITE_OK); + const db = createTestDatabase(); + + db.setAuthorizer(authorizer); + db.prepare('SELECT id FROM users').get(); + + assert.strictEqual(authorizer.mock.callCount(), 2); + const callArguments = authorizer.mock.calls.map((call) => call.arguments); + + assert.deepStrictEqual( + callArguments, + [ + [constants.SQLITE_SELECT, null, null, null, null], + [constants.SQLITE_READ, 'users', 'id', 'main', null], + ] + ); + }); + + it('receives correct parameters for INSERT operations', (t) => { + const authorizer = t.mock.fn(() => constants.SQLITE_OK); + const db = createTestDatabase(); + + db.setAuthorizer(authorizer); + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'node'); + + assert.strictEqual(authorizer.mock.callCount(), 1); + + const callArguments = authorizer.mock.calls.map((call) => call.arguments); + assert.deepStrictEqual( + callArguments, + [[constants.SQLITE_INSERT, 'users', null, 'main', null]], + ); + }); + + it('allows operations when authorizer returns SQLITE_OK', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => constants.SQLITE_OK); + + db.exec('CREATE TABLE users (id INTEGER, name TEXT)'); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); + + assert.strictEqual(tables[0].name, 'users'); + }); + + it('blocks operations when authorizer returns SQLITE_DENY', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => constants.SQLITE_DENY); + + assert.throws(() => { + db.exec('SELECT 1'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /not authorized/ + }); + }); + + it('ignores SELECT operations when authorizer returns SQLITE_IGNORE', () => { + const db = createTestDatabase(); + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice'); + + db.setAuthorizer((actionCode) => { + if (actionCode === constants.SQLITE_SELECT) { + return constants.SQLITE_IGNORE; + } + return constants.SQLITE_OK; + }); + + // SELECT should be ignored and return no results + const result = db.prepare('SELECT * FROM users').all(); + assert.deepStrictEqual(result, []); + }); + + it('ignores READ operations when authorizer returns SQLITE_IGNORE', () => { + const db = createTestDatabase(); + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice'); + + db.setAuthorizer((actionCode, arg1, arg2) => { + if (actionCode === constants.SQLITE_READ && arg1 === 'users' && arg2 === 'name') { + return constants.SQLITE_IGNORE; + } + return constants.SQLITE_OK; + }); + + // Reading the 'name' column should be ignored, returning NULL + const result = db.prepare('SELECT id, name FROM users WHERE id = 1').get(); + assert.strictEqual(result.id, 1); + assert.strictEqual(result.name, null); + }); + + it('ignores INSERT operations when authorizer returns SQLITE_IGNORE', () => { + const db = createTestDatabase(); + + db.setAuthorizer((actionCode) => { + if (actionCode === constants.SQLITE_INSERT) { + return constants.SQLITE_IGNORE; + } + return constants.SQLITE_OK; + }); + + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice'); + + // Verify no data was inserted + const count = db.prepare('SELECT COUNT(*) as count FROM users').get(); + assert.strictEqual(count.count, 0); + }); + + it('ignores UPDATE operations when authorizer returns SQLITE_IGNORE', () => { + const db = createTestDatabase(); + db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')"); + + db.setAuthorizer((actionCode) => { + if (actionCode === constants.SQLITE_UPDATE) { + return constants.SQLITE_IGNORE; + } + return constants.SQLITE_OK; + }); + + db.prepare('UPDATE users SET name = ? WHERE id = ?').run('Bob', 1); + + // Verify data was not updated + const result = db.prepare('SELECT name FROM users WHERE id = 1').get(); + assert.strictEqual(result.name, 'Alice'); + }); + + it('ignores DELETE operations when authorizer returns SQLITE_IGNORE', () => { + const db = createTestDatabase(); + db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')"); + + db.setAuthorizer(() => constants.SQLITE_IGNORE); + + db.prepare('DELETE FROM users WHERE id = ?').run(1); + + db.setAuthorizer(null); + + // Verify data was not deleted + const count = db.prepare('SELECT COUNT(*) as count FROM users').get(); + assert.strictEqual(count.count, 1); + }); + + it('rethrows error when authorizer throws error', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => { + throw new Error('Unknown error'); + }); + + assert.throws(() => { + db.exec('SELECT 1'); + }, { + message: 'Unknown error' + }); + }); + + it('throws error when authorizer returns nothing', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => { + }); + + assert.throws(() => { + db.exec('SELECT 1'); + }, { + message: 'Authorizer callback must return an integer authorization code' + }); + }); + + it('throws error when authorizer returns NaN', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => { + return '1'; + }); + + assert.throws(() => { + db.exec('SELECT 1'); + }, { + message: 'Authorizer callback must return an integer authorization code' + }); + }); + + it('throws error when authorizer returns a invalid code', () => { + const db = new DatabaseSync(':memory:'); + db.setAuthorizer(() => { + return 3; + }); + + assert.throws(() => { + db.exec('SELECT 1'); + }, { + message: 'Authorizer callback returned a invalid authorization code' + }); + }); + + it('clears authorizer when set to null', (t) => { + const authorizer = t.mock.fn(() => constants.SQLITE_OK); + const db = new DatabaseSync(':memory:'); + const statement = db.prepare('SELECT 1'); + + // Set authorizer and verify it's called + db.setAuthorizer(authorizer); + statement.run(); + assert.strictEqual(authorizer.mock.callCount(), 1); + + // Clear authorizer and verify it's no longer called + db.setAuthorizer(null); + statement.run(); + assert.strictEqual(authorizer.mock.callCount(), 1); + }); + + it('throws when callback is a string', () => { + const db = new DatabaseSync(':memory:'); + + assert.throws(() => { + db.setAuthorizer('not a function'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "callback" argument must be a function/ + }); + }); + + it('throws when callback is a number', () => { + const db = new DatabaseSync(':memory:'); + + assert.throws(() => { + db.setAuthorizer(1); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "callback" argument must be a function/ + }); + }); + + it('throws when callback is an object', () => { + const db = new DatabaseSync(':memory:'); + + assert.throws(() => { + db.setAuthorizer({}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "callback" argument must be a function/ + }); + }); + + it('throws when callback is an array', () => { + const db = new DatabaseSync(':memory:'); + + assert.throws(() => { + db.setAuthorizer([]); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "callback" argument must be a function/ + }); + }); + + it('throws when callback is undefined', () => { + const db = new DatabaseSync(':memory:'); + + assert.throws(() => { + db.setAuthorizer(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "callback" argument must be a function/ + }); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-backup.mjs b/test/js/node/test/parallel/test-sqlite-backup.mjs new file mode 100644 index 000000000000..80061ee6601d --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-backup.mjs @@ -0,0 +1,357 @@ +// Flags: --expose-gc +import { isWindows, skipIfSQLiteMissing } from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +skipIfSQLiteMissing(); +const { backup, DatabaseSync } = await import('node:sqlite'); + +const isRoot = !isWindows && process.getuid() === 0; + +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +function makeSourceDb(dbPath = ':memory:') { + const database = new DatabaseSync(dbPath); + + database.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT + `); + + const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)'); + + for (let i = 1; i <= 2; i++) { + insert.run(i, `value-${i}`); + } + + return database; +} + +describe('backup()', () => { + test('throws if the source database is not provided', (t) => { + t.assert.throws(() => { + backup(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "sourceDb" argument must be an object.' + }); + }); + + test('throws if path is not a string, URL, or Buffer', (t) => { + const database = makeSourceDb(); + + t.assert.throws(() => { + backup(database); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.' + }); + + t.assert.throws(() => { + backup(database, {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.' + }); + }); + + test('throws if the database path contains null bytes', (t) => { + const database = makeSourceDb(); + + t.assert.throws(() => { + backup(database, Buffer.from('l\0cation')); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.' + }); + + t.assert.throws(() => { + backup(database, 'l\0cation'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.' + }); + }); + + test('throws if options is not an object', (t) => { + const database = makeSourceDb(); + + t.assert.throws(() => { + backup(database, 'hello.db', 'invalid'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options" argument must be an object.' + }); + }); + + test('throws if any of provided options is invalid', (t) => { + const database = makeSourceDb(); + + t.assert.throws(() => { + backup(database, 'hello.db', { + source: 42 + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.source" argument must be a string.' + }); + + t.assert.throws(() => { + backup(database, 'hello.db', { + target: 42 + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.target" argument must be a string.' + }); + + t.assert.throws(() => { + backup(database, 'hello.db', { + rate: 'invalid' + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.rate" argument must be an integer.' + }); + + t.assert.throws(() => { + backup(database, 'hello.db', { + progress: 'invalid' + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.progress" argument must be a function.' + }); + }); +}); + +test('database backup', async (t) => { + const progressFn = t.mock.fn(); + const database = makeSourceDb(); + const destDb = nextDb(); + + await backup(database, destDb, { + rate: 1, + progress: progressFn, + }); + + const backupDb = new DatabaseSync(destDb); + const rows = backupDb.prepare('SELECT * FROM data').all(); + + // The source database has two pages - using the default page size -, + // so the progress function should be called once (the last call is not made since + // the promise resolves) + t.assert.strictEqual(progressFn.mock.calls.length, 1); + t.assert.deepStrictEqual(progressFn.mock.calls[0].arguments, [{ totalPages: 2, remainingPages: 1 }]); + t.assert.deepStrictEqual(rows, [ + { __proto__: null, key: 1, value: 'value-1' }, + { __proto__: null, key: 2, value: 'value-2' }, + ]); + + t.after(() => { + database.close(); + backupDb.close(); + }); +}); + +test('backup database using location as URL', async (t) => { + const database = makeSourceDb(); + const destDb = pathToFileURL(nextDb()); + + t.after(() => { database.close(); }); + + await backup(database, destDb); + + const backupDb = new DatabaseSync(destDb); + + t.after(() => { backupDb.close(); }); + + const rows = backupDb.prepare('SELECT * FROM data').all(); + + t.assert.deepStrictEqual(rows, [ + { __proto__: null, key: 1, value: 'value-1' }, + { __proto__: null, key: 2, value: 'value-2' }, + ]); +}); + +test('backup database using location as Buffer', async (t) => { + const database = makeSourceDb(); + const destDb = Buffer.from(nextDb()); + + t.after(() => { database.close(); }); + + await backup(database, destDb); + + const backupDb = new DatabaseSync(destDb); + + t.after(() => { backupDb.close(); }); + + const rows = backupDb.prepare('SELECT * FROM data').all(); + + t.assert.deepStrictEqual(rows, [ + { __proto__: null, key: 1, value: 'value-1' }, + { __proto__: null, key: 2, value: 'value-2' }, + ]); +}); + +test('database backup in a single call', async (t) => { + const progressFn = t.mock.fn(); + const database = makeSourceDb(); + const destDb = nextDb(); + + // Let rate to be default (100) to backup in a single call + await backup(database, destDb, { + progress: progressFn, + }); + + const backupDb = new DatabaseSync(destDb); + const rows = backupDb.prepare('SELECT * FROM data').all(); + + t.assert.strictEqual(progressFn.mock.calls.length, 0); + t.assert.deepStrictEqual(rows, [ + { __proto__: null, key: 1, value: 'value-1' }, + { __proto__: null, key: 2, value: 'value-2' }, + ]); + + t.after(() => { + database.close(); + backupDb.close(); + }); +}); + +test('throws exception when trying to start backup from a closed database', (t) => { + t.assert.throws(() => { + const database = new DatabaseSync(':memory:'); + + database.close(); + + backup(database, 'backup.db'); + }, { + code: 'ERR_INVALID_STATE', + message: 'database is not open' + }); +}); + +test('throws if URL is not file: scheme', (t) => { + const database = new DatabaseSync(':memory:'); + + t.after(() => { database.close(); }); + + t.assert.throws(() => { + backup(database, new URL('http://example.com/backup.db')); + }, { + code: 'ERR_INVALID_URL_SCHEME', + message: 'The URL must be of scheme file:', + }); +}); + +test('database backup fails when dest file is not writable', { skip: isRoot }, async (t) => { + const readonlyDestDb = nextDb(); + writeFileSync(readonlyDestDb, '', { mode: 0o444 }); + + const database = makeSourceDb(); + + await t.assert.rejects(async () => { + await backup(database, readonlyDestDb); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'attempt to write a readonly database' + }); +}); + +test('backup fails when progress function throws', async (t) => { + const database = makeSourceDb(); + const destDb = nextDb(); + + const progressFn = t.mock.fn(() => { + throw new Error('progress error'); + }); + + await t.assert.rejects(async () => { + await backup(database, destDb, { + rate: 1, + progress: progressFn, + }); + }, { + message: 'progress error' + }); +}); + +test('backup fails when source db is invalid', async (t) => { + const database = makeSourceDb(); + const destDb = nextDb(); + + await t.assert.rejects(async () => { + await backup(database, destDb, { + rate: 1, + source: 'invalid', + }); + }, { + message: 'unknown database invalid' + }); +}); + +test('backup fails when path cannot be opened', async (t) => { + const database = makeSourceDb(); + + await t.assert.rejects(async () => { + await backup(database, `${tmpdir.path}/invalid/backup.db`); + }, { + message: 'unable to open database file' + }); +}); + +test('backup has correct name and length', (t) => { + t.assert.strictEqual(backup.name, 'backup'); + t.assert.strictEqual(backup.length, 2); +}); + +test('source database is kept alive while a backup is in flight', async (t) => { + // Regression test: previously, BackupJob stored a raw DatabaseSync* and the + // source could be garbage-collected while the backup was still running, + // leading to a use-after-free when BackupJob::Finalize() dereferenced the + // stale pointer via source_->RemoveBackup(this). + const destDb = nextDb(); + + let database = makeSourceDb(); + // Insert enough rows to ensure the backup takes multiple steps. + const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)'); + for (let i = 3; i <= 500; i++) { + insert.run(i, 'A'.repeat(1024) + i); + } + + const p = backup(database, destDb, { + rate: 1, + progress() {}, + }); + // Drop the last strong JS reference to the source database. With the bug, + // the DatabaseSync could be collected here and the in-flight backup would + // later crash while accessing the freed source. + database = null; + + // Nudge the GC aggressively, but the backup must keep the source alive + // regardless. Without the fix, the source DatabaseSync would be collected + // and BackupJob::Finalize() would crash the process. + for (let i = 0; i < 5; i++) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + } + + const totalPages = await p; + t.assert.ok(totalPages > 0); + + const backupDb = new DatabaseSync(destDb); + t.after(() => { backupDb.close(); }); + const rows = backupDb.prepare('SELECT COUNT(*) AS n FROM data').get(); + t.assert.strictEqual(rows.n, 500); +}); diff --git a/test/js/node/test/parallel/test-sqlite-config.js b/test/js/node/test/parallel/test-sqlite-config.js new file mode 100644 index 000000000000..411b9d1cfaea --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-config.js @@ -0,0 +1,63 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common/index.mjs'); +const { test } = require('node:test'); +const assert = require('node:assert'); +skipIfSQLiteMissing(); +const { DatabaseSync } = require('node:sqlite'); + +function checkDefensiveMode(db) { + function journalMode() { + return db.prepare('PRAGMA journal_mode').get().journal_mode; + } + + assert.strictEqual(journalMode(), 'memory'); + db.exec('PRAGMA journal_mode=OFF'); + + switch (journalMode()) { + case 'memory': return true; // journal_mode unchanged, defensive mode must be active + case 'off': return false; // journal_mode now 'off', so defensive mode not active + default: throw new Error('unexpected journal_mode'); + } +} + +test('by default, defensive mode is on', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.strictEqual(checkDefensiveMode(db), true); +}); + +test('when passing { defensive: true } as config, defensive mode is on', (t) => { + const db = new DatabaseSync(':memory:', { + defensive: true + }); + t.assert.strictEqual(checkDefensiveMode(db), true); +}); + +test('when passing { defensive: false } as config, defensive mode is off', (t) => { + const db = new DatabaseSync(':memory:', { + defensive: false + }); + t.assert.strictEqual(checkDefensiveMode(db), false); +}); + +test('defensive mode on after calling db.enableDefensive(true)', (t) => { + const db = new DatabaseSync(':memory:'); + db.enableDefensive(true); + t.assert.strictEqual(checkDefensiveMode(db), true); +}); + +test('defensive mode off after calling db.enableDefensive(false)', (t) => { + const db = new DatabaseSync(':memory:', { + defensive: true + }); + db.enableDefensive(false); + t.assert.strictEqual(checkDefensiveMode(db), false); +}); + +test('throws if options.defensive is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync(':memory:', { defensive: 42 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.defensive" argument must be a boolean.', + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-custom-functions.js b/test/js/node/test/parallel/test-sqlite-custom-functions.js new file mode 100644 index 000000000000..6b5f974ede89 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-custom-functions.js @@ -0,0 +1,414 @@ +'use strict'; +const { skipIfSQLiteMissing, mustCall } = require('../common'); +skipIfSQLiteMissing(); +const assert = require('node:assert'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); + +suite('DatabaseSync.prototype.function()', () => { + suite('input validation', () => { + const db = new DatabaseSync(':memory:'); + + test('throws if name is not a string', () => { + assert.throws(() => { + db.function(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "name" argument must be a string/, + }); + }); + + test('throws if function is not a function', () => { + assert.throws(() => { + db.function('foo'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "function" argument must be a function/, + }); + }); + + test('throws if options is not an object', () => { + assert.throws(() => { + db.function('foo', null, () => {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be an object/, + }); + }); + + test('throws if options.useBigIntArguments is not a boolean', () => { + assert.throws(() => { + db.function('foo', { useBigIntArguments: null }, () => {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.useBigIntArguments" argument must be a boolean/, + }); + }); + + test('throws if options.varargs is not a boolean', () => { + assert.throws(() => { + db.function('foo', { varargs: null }, () => {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.varargs" argument must be a boolean/, + }); + }); + + test('throws if options.deterministic is not a boolean', () => { + assert.throws(() => { + db.function('foo', { deterministic: null }, () => {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.deterministic" argument must be a boolean/, + }); + }); + + test('throws if options.directOnly is not a boolean', () => { + assert.throws(() => { + db.function('foo', { directOnly: null }, () => {}); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.directOnly" argument must be a boolean/, + }); + }); + }); + + suite('useBigIntArguments', () => { + test('converts arguments to BigInts when true', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', { useBigIntArguments: true }, (arg) => { + value = arg; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(5) AS custom').get(); + assert.strictEqual(value, 5n); + }); + + test('uses number primitives when false', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', { useBigIntArguments: false }, (arg) => { + value = arg; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(5) AS custom').get(); + assert.strictEqual(value, 5); + }); + + test('defaults to false', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', (arg) => { + value = arg; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(5) AS custom').get(); + assert.strictEqual(value, 5); + }); + + test('throws if value cannot fit in a number', () => { + const db = new DatabaseSync(':memory:'); + const value = Number.MAX_SAFE_INTEGER + 1; + db.function('custom', (arg) => {}); + assert.throws(() => { + db.prepare(`SELECT custom(${value}) AS custom`).get(); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /Value is too large to be represented as a JavaScript number: 9007199254740992/, + }); + }); + }); + + suite('varargs', () => { + test('supports variable number of arguments when true', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', { varargs: true }, (...args) => { + value = args; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(5, 4, 3, 2, 1) AS custom').get(); + assert.deepStrictEqual(value, [5, 4, 3, 2, 1]); + }); + + test('uses function.length when false', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', { varargs: false }, (a, b, c) => { + value = [a, b, c]; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(1, 2, 3) AS custom').get(); + assert.deepStrictEqual(value, [1, 2, 3]); + }); + + test('defaults to false', () => { + const db = new DatabaseSync(':memory:'); + let value; + const r = db.function('custom', (a, b, c) => { + value = [a, b, c]; + }); + assert.strictEqual(r, undefined); + db.prepare('SELECT custom(7, 8, 9) AS custom').get(); + assert.deepStrictEqual(value, [7, 8, 9]); + }); + + test('throws if an incorrect number of arguments is provided', () => { + const db = new DatabaseSync(':memory:'); + db.function('custom', (a, b, c, d) => {}); + assert.throws(() => { + db.prepare('SELECT custom(1, 2, 3) AS custom').get(); + }, { + code: 'ERR_SQLITE_ERROR', + message: /wrong number of arguments to function custom\(\)/, + }); + }); + }); + + suite('deterministic', () => { + test('creates a deterministic function when true', () => { + const db = new DatabaseSync(':memory:'); + db.function('isDeterministic', { deterministic: true }, () => { + return 42; + }); + const r = db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (isDeterministic()) VIRTUAL + ) + `); + assert.strictEqual(r, undefined); + }); + + test('creates a non-deterministic function when false', () => { + const db = new DatabaseSync(':memory:'); + db.function('isNonDeterministic', { deterministic: false }, () => { + return 42; + }); + assert.throws(() => { + db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (isNonDeterministic()) VIRTUAL + ) + `); + }, { + code: 'ERR_SQLITE_ERROR', + message: /non-deterministic functions prohibited in generated columns/, + }); + }); + + test('deterministic defaults to false', () => { + const db = new DatabaseSync(':memory:'); + db.function('isNonDeterministic', () => { + return 42; + }); + assert.throws(() => { + db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (isNonDeterministic()) VIRTUAL + ) + `); + }, { + code: 'ERR_SQLITE_ERROR', + message: /non-deterministic functions prohibited in generated columns/, + }); + }); + }); + + suite('directOnly', () => { + test('sets SQLite direct only flag when true', () => { + const db = new DatabaseSync(':memory:'); + db.function('fn', { deterministic: true, directOnly: true }, () => { + return 42; + }); + assert.throws(() => { + db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL + ) + `); + }, { + code: 'ERR_SQLITE_ERROR', + message: /unsafe use of fn\(\)/ + }); + }); + + test('does not set SQLite direct only flag when false', () => { + const db = new DatabaseSync(':memory:'); + db.function('fn', { deterministic: true, directOnly: false }, () => { + return 42; + }); + const r = db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL + ) + `); + assert.strictEqual(r, undefined); + }); + + test('directOnly defaults to false', () => { + const db = new DatabaseSync(':memory:'); + db.function('fn', { deterministic: true }, () => { + return 42; + }); + const r = db.exec(` + CREATE TABLE t1 ( + a INTEGER PRIMARY KEY, + b INTEGER GENERATED ALWAYS AS (fn()) VIRTUAL + ) + `); + assert.strictEqual(r, undefined); + }); + }); + + suite('return types', () => { + test('supported return types', () => { + const db = new DatabaseSync(':memory:'); + db.function('retUndefined', () => {}); + db.function('retNull', () => { return null; }); + db.function('retNumber', () => { return 3; }); + db.function('retString', () => { return 'foo'; }); + db.function('retBigInt', () => { return 5n; }); + db.function('retUint8Array', () => { return new Uint8Array([1, 2, 3]); }); + db.function('retArrayBufferView', () => { + const arrayBuffer = new Uint8Array([1, 2, 3]).buffer; + return new DataView(arrayBuffer); + }); + const stmt = db.prepare(`SELECT + retUndefined() AS retUndefined, + retNull() AS retNull, + retNumber() AS retNumber, + retString() AS retString, + retBigInt() AS retBigInt, + retUint8Array() AS retUint8Array, + retArrayBufferView() AS retArrayBufferView + `); + assert.deepStrictEqual(stmt.get(), { + __proto__: null, + retUndefined: null, + retNull: null, + retNumber: 3, + retString: 'foo', + retBigInt: 5, + retUint8Array: new Uint8Array([1, 2, 3]), + retArrayBufferView: new Uint8Array([1, 2, 3]), + }); + }); + + test('throws if returned BigInt is too large for SQLite', () => { + const db = new DatabaseSync(':memory:'); + db.function('retBigInt', () => { + return BigInt(Number.MAX_SAFE_INTEGER + 1); + }); + const stmt = db.prepare('SELECT retBigInt() AS retBigInt'); + assert.throws(() => { + stmt.get(); + }, { + code: 'ERR_OUT_OF_RANGE', + }); + }); + + test('does not support Promise return values', () => { + const db = new DatabaseSync(':memory:'); + db.function('retPromise', async () => {}); + const stmt = db.prepare('SELECT retPromise() AS retPromise'); + assert.throws(() => { + stmt.get(); + }, { + code: 'ERR_SQLITE_ERROR', + message: /Asynchronous user-defined functions are not supported/, + }); + }); + + test('throws on unsupported return types', () => { + const db = new DatabaseSync(':memory:'); + db.function('retFunction', () => { + return () => {}; + }); + const stmt = db.prepare('SELECT retFunction() AS retFunction'); + assert.throws(() => { + stmt.get(); + }, { + code: 'ERR_SQLITE_ERROR', + message: /Returned JavaScript value cannot be converted to a SQLite value/, + }); + }); + }); + + suite('handles conflicting errors from SQLite and JavaScript', () => { + test('throws if value cannot fit in a number', () => { + const db = new DatabaseSync(':memory:'); + const expected = { __proto__: null, id: 5, data: 'foo' }; + db.function('custom', (arg) => {}); + db.exec('CREATE TABLE test (id NUMBER NOT NULL PRIMARY KEY, data TEXT)'); + db.prepare('INSERT INTO test (id, data) VALUES (?, ?)').run(5, 'foo'); + assert.deepStrictEqual(db.prepare('SELECT * FROM test').get(), expected); + assert.throws(() => { + db.exec(`UPDATE test SET data = CUSTOM(${Number.MAX_SAFE_INTEGER + 1})`); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /Value is too large to be represented as a JavaScript number: 9007199254740992/, + }); + assert.deepStrictEqual(db.prepare('SELECT * FROM test').get(), expected); + }); + + test('propagates JavaScript errors', () => { + const db = new DatabaseSync(':memory:'); + const expected = { __proto__: null, id: 5, data: 'foo' }; + const err = new Error('boom'); + db.function('throws', () => { + throw err; + }); + db.exec('CREATE TABLE test (id NUMBER NOT NULL PRIMARY KEY, data TEXT)'); + db.prepare('INSERT INTO test (id, data) VALUES (?, ?)').run(5, 'foo'); + assert.deepStrictEqual(db.prepare('SELECT * FROM test').get(), expected); + assert.throws(() => { + db.exec('UPDATE test SET data = THROWS()'); + }, err); + assert.deepStrictEqual(db.prepare('SELECT * FROM test').get(), expected); + }); + }); + + test('supported argument types', () => { + const db = new DatabaseSync(':memory:'); + db.function('arguments', mustCall((i, f, s, n, b) => { + assert.strictEqual(i, 5); + assert.strictEqual(f, 3.14); + assert.strictEqual(s, 'foo'); + assert.strictEqual(n, null); + assert.deepStrictEqual(b, new Uint8Array([254])); + return 42; + })); + const stmt = db.prepare( + 'SELECT arguments(5, 3.14, \'foo\', null, x\'fe\') as result' + ); + assert.deepStrictEqual(stmt.get(), { __proto__: null, result: 42 }); + }); + + test('propagates thrown errors', () => { + const db = new DatabaseSync(':memory:'); + const err = new Error('boom'); + db.function('throws', () => { + throw err; + }); + const stmt = db.prepare('SELECT throws()'); + assert.throws(() => { + stmt.get(); + }, err); + }); + + test('throws if database is not open', () => { + const db = new DatabaseSync(':memory:', { open: false }); + assert.throws(() => { + db.function('foo', () => {}); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-data-types.js b/test/js/node/test/parallel/test-sqlite-data-types.js new file mode 100644 index 000000000000..26af15a777d2 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-data-types.js @@ -0,0 +1,196 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('data binding and mapping', () => { + test('supported data types', (t) => { + const u8a = new TextEncoder().encode('a☃b☃c'); + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE types( + key INTEGER PRIMARY KEY, + int INTEGER, + double REAL, + text TEXT, + buf BLOB + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, int, double, text, buf) ' + + 'VALUES (?, ?, ?, ?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, 42, 3.14159, 'foo', u8a), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run(2, null, null, null, null), + { changes: 1, lastInsertRowid: 2 } + ); + t.assert.deepStrictEqual( + stmt.run(3, Number(8), Number(2.718), String('bar'), Buffer.from('x☃y☃')), + { changes: 1, lastInsertRowid: 3 }, + ); + t.assert.deepStrictEqual( + stmt.run(4, 99n, 0xf, '', new Uint8Array()), + { changes: 1, lastInsertRowid: 4 }, + ); + + const query = db.prepare('SELECT * FROM types WHERE key = ?'); + t.assert.deepStrictEqual(query.get(1), { + __proto__: null, + key: 1, + int: 42, + double: 3.14159, + text: 'foo', + buf: u8a, + }); + t.assert.deepStrictEqual(query.get(2), { + __proto__: null, + key: 2, + int: null, + double: null, + text: null, + buf: null, + }); + t.assert.deepStrictEqual(query.get(3), { + __proto__: null, + key: 3, + int: 8, + double: 2.718, + text: 'bar', + buf: new TextEncoder().encode('x☃y☃'), + }); + t.assert.deepStrictEqual(query.get(4), { + __proto__: null, + key: 4, + int: 99, + double: 0xf, + text: '', + buf: new Uint8Array(), + }); + }); + + test('large strings are bound correctly', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, text TEXT) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + t.assert.deepStrictEqual( + db.prepare('INSERT INTO data (key, text) VALUES (?, ?)').run(1, ''), + { changes: 1, lastInsertRowid: 1 }, + ); + + const update = db.prepare('UPDATE data SET text = ? WHERE key = 1'); + + // > 1024 bytes so `Utf8Value` uses heap storage internally. + const largeAscii = 'a'.repeat(8 * 1024); + // Force a non-one-byte string path through UTF-8 conversion. + const largeUnicode = '\u2603'.repeat(2048); + + const res = update.run(largeAscii); + t.assert.strictEqual(res.changes, 1); + + t.assert.strictEqual( + db.prepare('SELECT text FROM data WHERE key = 1').get().text, + largeAscii, + ); + + t.assert.strictEqual(update.run(largeUnicode).changes, 1); + t.assert.strictEqual( + db.prepare('SELECT text FROM data WHERE key = 1').get().text, + largeUnicode, + ); + }); + + test('unsupported data types', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + [ + undefined, + () => {}, + Symbol(), + /foo/, + Promise.resolve(), + new Map(), + new Set(), + ].forEach((val) => { + t.assert.throws(() => { + db.prepare('INSERT INTO types (key, val) VALUES (?, ?)').run(1, val); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /Provided value cannot be bound to SQLite parameter 2/, + }); + }); + + t.assert.throws(() => { + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + stmt.run({ $k: 1, $v: () => {} }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /Provided value cannot be bound to SQLite parameter 2/, + }); + }); + + test('throws when binding a BigInt that is too large', (t) => { + const max = 9223372036854775807n; // Largest 64-bit signed integer value. + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, max), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.throws(() => { + stmt.run(1, max + 1n); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: /BigInt value is too large to bind/, + }); + }); + + test('statements are unbound on each call', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run(1, 5), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run(), + { changes: 1, lastInsertRowid: 2 }, + ); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data ORDER BY key').all(), + [{ __proto__: null, key: 1, val: 5 }, { __proto__: null, key: 2, val: null }], + ); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-database-sync.js b/test/js/node/test/parallel/test-sqlite-database-sync.js new file mode 100644 index 000000000000..ac3a3c66d646 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-database-sync.js @@ -0,0 +1,525 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); +const { DatabaseSync, StatementSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('DatabaseSync() constructor', () => { + test('throws if called without new', (t) => { + t.assert.throws(() => { + DatabaseSync(); + }, { + code: 'ERR_CONSTRUCT_CALL_REQUIRED', + message: /Cannot call constructor without `new`/, + }); + }); + + test('throws if database path is not a string, Uint8Array, or URL', (t) => { + t.assert.throws(() => { + new DatabaseSync(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "path" argument must be a string, Uint8Array, or URL without null bytes/, + }); + }); + + test('throws if the database location as Buffer contains null bytes', (t) => { + t.assert.throws(() => { + new DatabaseSync(Buffer.from('l\0cation')); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.', + }); + }); + + test('throws if the database location as string contains null bytes', (t) => { + t.assert.throws(() => { + new DatabaseSync('l\0cation'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.', + }); + }); + + test('throws if options is provided but is not an object', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', null); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be an object/, + }); + }); + + test('throws if options.open is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { open: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.open" argument must be a boolean/, + }); + }); + + test('throws if options.readOnly is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { readOnly: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.readOnly" argument must be a boolean/, + }); + }); + + test('throws if options.timeout is provided but is not an integer', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { timeout: .99 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.timeout" argument must be an integer/, + }); + }); + + test('is not read-only by default', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath); + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)'); + }); + + test('is read-only if readOnly is set', (t) => { + const dbPath = nextDb(); + { + using db = new DatabaseSync(dbPath); + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)'); + } + { + using db = new DatabaseSync(dbPath, { readOnly: true }); + t.assert.throws(() => { + db.exec('CREATE TABLE bar (id INTEGER PRIMARY KEY)'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /attempt to write a readonly database/, + }); + } + }); + + test('throws if options.enableForeignKeyConstraints is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { enableForeignKeyConstraints: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.enableForeignKeyConstraints" argument must be a boolean/, + }); + }); + + test('enables foreign key constraints by default', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE foo (id INTEGER PRIMARY KEY); + CREATE TABLE bar (foo_id INTEGER REFERENCES foo(id)); + `); + t.assert.throws(() => { + db.exec('INSERT INTO bar (foo_id) VALUES (1)'); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'FOREIGN KEY constraint failed', + }); + }); + + test('allows disabling foreign key constraints', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { enableForeignKeyConstraints: false }); + db.exec(` + CREATE TABLE foo (id INTEGER PRIMARY KEY); + CREATE TABLE bar (foo_id INTEGER REFERENCES foo(id)); + `); + db.exec('INSERT INTO bar (foo_id) VALUES (1)'); + }); + + test('throws if options.enableDoubleQuotedStringLiterals is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { enableDoubleQuotedStringLiterals: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.enableDoubleQuotedStringLiterals" argument must be a boolean/, + }); + }); + + test('disables double-quoted string literals by default', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath); + t.assert.throws(() => { + db.exec('SELECT "foo";'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /no such column: "?foo"?/, + }); + }); + + test('allows enabling double-quoted string literals', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { enableDoubleQuotedStringLiterals: true }); + db.exec('SELECT "foo";'); + }); + + test('throws if options.readBigInts is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { readBigInts: 42 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.readBigInts" argument must be a boolean.', + }); + }); + + test('allows reading big integers', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { readBigInts: true }); + + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; + INSERT INTO data (key, val) VALUES (1, 42); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT val FROM data'); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42n }); + + const insert = db.prepare('INSERT INTO data (key) VALUES (?)'); + t.assert.deepStrictEqual( + insert.run(20), + { changes: 1n, lastInsertRowid: 20n }, + ); + }); + + test('throws if options.returnArrays is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { returnArrays: 42 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.returnArrays" argument must be a boolean.', + }); + }); + + test('allows returning arrays', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { returnArrays: true }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + INSERT INTO data (key, val) VALUES (2, 'two'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT key, val FROM data WHERE key = 1'); + t.assert.deepStrictEqual(query.get(), [1, 'one']); + }); + + test('throws if options.allowBareNamedParameters is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { allowBareNamedParameters: 42 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.allowBareNamedParameters" argument must be a boolean.', + }); + }); + + test('throws if bare named parameters are used when option is false', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { allowBareNamedParameters: false }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + t.assert.throws(() => { + stmt.run({ k: 2, v: 4 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter 'k'/, + }); + }); + + test('throws if options.allowUnknownNamedParameters is provided but is not a boolean', (t) => { + t.assert.throws(() => { + new DatabaseSync('foo', { allowUnknownNamedParameters: 42 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.allowUnknownNamedParameters" argument must be a boolean.', + }); + }); + + test('allows unknown named parameters', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { allowUnknownNamedParameters: true }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.deepStrictEqual( + stmt.run(params), + { changes: 1, lastInsertRowid: 1 }, + ); + }); + + test('has sqlite-type symbol property', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath); + + const sqliteTypeSymbol = Symbol.for('sqlite-type'); + t.assert.strictEqual(db[sqliteTypeSymbol], 'node:sqlite'); + }); +}); + +suite('DatabaseSync.prototype.open()', () => { + test('opens a database connection', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath, { open: false }); + + t.assert.strictEqual(db.isOpen, false); + t.assert.strictEqual(existsSync(dbPath), false); + t.assert.strictEqual(db.open(), undefined); + t.assert.strictEqual(db.isOpen, true); + t.assert.strictEqual(existsSync(dbPath), true); + }); + + test('throws if database is already open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.strictEqual(db.isOpen, false); + db.open(); + t.assert.strictEqual(db.isOpen, true); + t.assert.throws(() => { + db.open(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is already open/, + }); + t.assert.strictEqual(db.isOpen, true); + }); +}); + +suite('DatabaseSync.prototype.close()', () => { + test('closes an open database connection', (t) => { + using db = new DatabaseSync(nextDb()); + + t.assert.strictEqual(db.isOpen, true); + t.assert.strictEqual(db.close(), undefined); + t.assert.strictEqual(db.isOpen, false); + }); + + test('throws if database is not open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.strictEqual(db.isOpen, false); + t.assert.throws(() => { + db.close(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + t.assert.strictEqual(db.isOpen, false); + }); +}); + +suite('DatabaseSync.prototype.prepare()', () => { + test('returns a prepared statement', (t) => { + using db = new DatabaseSync(nextDb()); + const stmt = db.prepare('CREATE TABLE webstorage(key TEXT)'); + t.assert.ok(stmt instanceof StatementSync); + }); + + test('throws if database is not open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.throws(() => { + db.prepare(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if sql is not a string', (t) => { + using db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.prepare(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "sql" argument must be a string/, + }); + }); +}); + +suite('DatabaseSync.prototype.exec()', () => { + test('executes SQL', (t) => { + using db = new DatabaseSync(nextDb()); + const result = db.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + val INTEGER + ) STRICT; + INSERT INTO data (key, val) VALUES (1, 2); + INSERT INTO data (key, val) VALUES (8, 9); + `); + t.assert.strictEqual(result, undefined); + const stmt = db.prepare('SELECT * FROM data ORDER BY key'); + t.assert.deepStrictEqual(stmt.all(), [ + { __proto__: null, key: 1, val: 2 }, + { __proto__: null, key: 8, val: 9 }, + ]); + }); + + test('reports errors from SQLite', (t) => { + using db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.exec('CREATE TABLEEEE'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /syntax error/, + }); + }); + + test('throws if the URL does not have the file: scheme', (t) => { + t.assert.throws(() => { + new DatabaseSync(new URL('http://example.com')); + }, { + code: 'ERR_INVALID_URL_SCHEME', + message: 'The URL must be of scheme file:', + }); + }); + + test('throws if database is not open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.throws(() => { + db.exec(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if sql is not a string', (t) => { + using db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.exec(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "sql" argument must be a string/, + }); + }); +}); + +suite('DatabaseSync.prototype.isTransaction', () => { + test('correctly detects a committed transaction', (t) => { + using db = new DatabaseSync(':memory:'); + + t.assert.strictEqual(db.isTransaction, false); + db.exec('BEGIN'); + t.assert.strictEqual(db.isTransaction, true); + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)'); + t.assert.strictEqual(db.isTransaction, true); + db.exec('COMMIT'); + t.assert.strictEqual(db.isTransaction, false); + }); + + test('correctly detects a rolled back transaction', (t) => { + using db = new DatabaseSync(':memory:'); + + t.assert.strictEqual(db.isTransaction, false); + db.exec('BEGIN'); + t.assert.strictEqual(db.isTransaction, true); + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)'); + t.assert.strictEqual(db.isTransaction, true); + db.exec('ROLLBACK'); + t.assert.strictEqual(db.isTransaction, false); + }); + + test('throws if database is not open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.throws(() => { + return db.isTransaction; + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); +}); + +suite('DatabaseSync.prototype.location()', () => { + test('throws if database is not open', (t) => { + using db = new DatabaseSync(nextDb(), { open: false }); + + t.assert.throws(() => { + db.location(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if provided dbName is not string', (t) => { + using db = new DatabaseSync(nextDb()); + + t.assert.throws(() => { + db.location(null); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "dbName" argument must be a string/, + }); + }); + + test('returns null when connected to in-memory database', (t) => { + using db = new DatabaseSync(':memory:'); + t.assert.strictEqual(db.location(), null); + }); + + test('returns db path when connected to a persistent database', (t) => { + const dbPath = nextDb(); + using db = new DatabaseSync(dbPath); + t.assert.strictEqual(db.location(), dbPath); + }); + + test('returns that specific db path when attached', (t) => { + const dbPath = nextDb(); + const otherPath = nextDb(); + using db = new DatabaseSync(dbPath); + + // Adding this escape because the test with unusual chars have a single quote which breaks the query + const escapedPath = otherPath.replace("'", "''"); + db.exec(`ATTACH DATABASE '${escapedPath}' AS other`); + + t.assert.strictEqual(db.location('other'), otherPath); + }); +}); + +suite('DatabaseSync.prototype[Symbol.dispose]', () => { + test('closes an open database', (t) => { + const db = new DatabaseSync(nextDb()); + t.assert.strictEqual(db.isOpen, true); + db[Symbol.dispose](); + t.assert.strictEqual(db.isOpen, false); + }); + + test('does not throw on databases that are not open', (t) => { + const db = new DatabaseSync(nextDb(), { open: false }); + t.assert.strictEqual(db.isOpen, false); + db[Symbol.dispose](); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-limits.js b/test/js/node/test/parallel/test-sqlite-limits.js new file mode 100644 index 000000000000..1a038df05445 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-limits.js @@ -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/, + }); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-named-parameters.js b/test/js/node/test/parallel/test-sqlite-named-parameters.js new file mode 100644 index 000000000000..db8f46e6b6ce --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-named-parameters.js @@ -0,0 +1,221 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('named parameters', () => { + test('throws on unknown named parameters', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + + t.assert.throws(() => { + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + stmt.run({ $k: 1, $unknown: 1 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$unknown'/, + }); + }); + + test('bare named parameters are supported', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + stmt.run({ k: 1, v: 9 }); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').get(), + { __proto__: null, key: 1, val: 9 }, + ); + }); + + test('duplicate bare named parameters are supported', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $k)'); + stmt.run({ k: 1 }); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').get(), + { __proto__: null, key: 1, val: 1 }, + ); + }); + + test('bare named parameters throw on ambiguous names', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, @k)'); + t.assert.throws(() => { + stmt.run({ k: 1 }); + }, { + code: 'ERR_INVALID_STATE', + message: 'Cannot create bare named parameter \'k\' because of ' + + 'conflicting names \'$k\' and \'@k\'.', + }); + }); +}); + +suite('StatementSync.prototype.setAllowUnknownNamedParameters()', () => { + test('unknown named parameter support can be toggled', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + t.assert.strictEqual(stmt.setAllowUnknownNamedParameters(true), undefined); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.deepStrictEqual( + stmt.run(params), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.strictEqual(stmt.setAllowUnknownNamedParameters(false), undefined); + t.assert.throws(() => { + stmt.run(params); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$a'/, + }); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + t.assert.throws(() => { + stmt.setAllowUnknownNamedParameters(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "enabled" argument must be a boolean/, + }); + }); +}); + +suite('options.allowUnknownNamedParameters', () => { + test('unknown named parameters are allowed when input is true', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowUnknownNamedParameters: true } + ); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.deepStrictEqual( + stmt.run(params), + { changes: 1, lastInsertRowid: 1 }, + ); + }); + + test('unknown named parameters throw when input is false', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowUnknownNamedParameters: false } + ); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.throws(() => { + stmt.run(params); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$a'/, + }); + }); + + test('unknown named parameters throws error by default', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.throws(() => { + stmt.run(params); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$a'/, + }); + }); + + test('throws when option is not a boolean', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + t.assert.throws(() => { + db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowUnknownNamedParameters: 'true' } + ); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.allowUnknownNamedParameters" argument must be a boolean/, + }); + }); + + test('setAllowUnknownNamedParameters can override prepare option', (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowUnknownNamedParameters: true } + ); + const params = { $a: 1, $b: 2, $k: 42, $y: 25, $v: 84, $z: 99 }; + t.assert.deepStrictEqual( + stmt.run(params), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.strictEqual(stmt.setAllowUnknownNamedParameters(false), undefined); + t.assert.throws(() => { + stmt.run(params); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter '\$a'/, + }); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-serialize.js b/test/js/node/test/parallel/test-sqlite-serialize.js new file mode 100644 index 000000000000..77b9d9c5f483 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-serialize.js @@ -0,0 +1,305 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); + +suite('DatabaseSync.prototype.serialize()', () => { + test('returns a Uint8Array with the SQLite header', (t) => { + const db = new DatabaseSync(':memory:'); + const buf = db.serialize(); + t.assert.ok(buf instanceof Uint8Array); + t.assert.ok(buf.length > 0); + const header = new TextDecoder().decode(buf.slice(0, 15)); + t.assert.strictEqual(header, 'SQLite format 3'); + db.close(); + }); + + test('serializes an empty database', (t) => { + const db = new DatabaseSync(':memory:'); + const buf = db.serialize(); + t.assert.ok(buf instanceof Uint8Array); + t.assert.ok(buf.length > 0); + db.close(); + }); + + test('serializes a database with data', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT)'); + db.exec("INSERT INTO t VALUES (1, 'hello')"); + db.exec("INSERT INTO t VALUES (2, 'world')"); + const buf = db.serialize(); + t.assert.ok(buf.length > 0); + db.close(); + }); + + test('throws if the database is not open', (t) => { + const db = new DatabaseSync(':memory:'); + db.close(); + t.assert.throws(() => { + db.serialize(); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if dbName is not a string', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.throws(() => { + db.serialize(123); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "dbName" argument must be a string/, + }); + db.close(); + }); + + test('accepts a schema name argument', (t) => { + const db = new DatabaseSync(':memory:'); + const buf = db.serialize('main'); + t.assert.ok(buf instanceof Uint8Array); + t.assert.ok(buf.length > 0); + db.close(); + }); + + test('serializes an attached schema when dbName is provided', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec("ATTACH DATABASE ':memory:' AS aux"); + db.exec('CREATE TABLE aux.t(value TEXT)'); + db.exec("INSERT INTO aux.t VALUES ('from aux')"); + + const buf = db.serialize('aux'); + db.close(); + + const clone = new DatabaseSync(':memory:'); + clone.deserialize(buf); + + const row = clone.prepare('SELECT value FROM t').get(); + t.assert.strictEqual(row.value, 'from aux'); + clone.close(); + }); +}); + +suite('DatabaseSync.prototype.deserialize()', () => { + test('loads a serialized database', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT)'); + db1.exec("INSERT INTO t VALUES (1, 'hello')"); + db1.exec("INSERT INTO t VALUES (2, 'world')"); + const buf = db1.serialize(); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.deserialize(buf); + const rows = db2.prepare('SELECT * FROM t ORDER BY id').all(); + t.assert.strictEqual(rows.length, 2); + t.assert.strictEqual(rows[0].name, 'hello'); + t.assert.strictEqual(rows[1].name, 'world'); + db2.close(); + }); + + test('replaces existing data in the connection', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE src(val TEXT)'); + db1.exec("INSERT INTO src VALUES ('from source')"); + const buf = db1.serialize(); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.exec('CREATE TABLE old(x INTEGER)'); + db2.exec('INSERT INTO old VALUES (999)'); + db2.deserialize(buf); + + t.assert.throws(() => { + db2.prepare('SELECT * FROM old').all(); + }, /no such table: old/); + + const rows = db2.prepare('SELECT * FROM src').all(); + t.assert.strictEqual(rows.length, 1); + t.assert.strictEqual(rows[0].val, 'from source'); + db2.close(); + }); + + test('finalizes existing prepared statements before replacing the database', + (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE replacement(value TEXT)'); + db1.exec("INSERT INTO replacement VALUES ('new')"); + const buf = db1.serialize(); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.exec('CREATE TABLE original(value TEXT)'); + db2.exec("INSERT INTO original VALUES ('old')"); + const stmt = db2.prepare('SELECT value FROM original'); + + t.assert.strictEqual(stmt.get().value, 'old'); + + db2.deserialize(buf); + + t.assert.throws(() => { + stmt.get(); + }, /statement has been finalized/); + + const row = db2.prepare('SELECT value FROM replacement').get(); + t.assert.strictEqual(row.value, 'new'); + db2.close(); + }); + + test('deserialized database is writable by default', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE t(id INTEGER PRIMARY KEY)'); + const buf = db1.serialize(); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.deserialize(buf); + db2.exec('INSERT INTO t VALUES (1)'); + const rows = db2.prepare('SELECT * FROM t').all(); + t.assert.strictEqual(rows.length, 1); + db2.close(); + }); + + test('round-trip serialize then deserialize preserves data', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE t(a TEXT, b REAL, c BLOB)'); + db1.prepare('INSERT INTO t VALUES (?, ?, ?)').run( + 'text', 3.14, new Uint8Array([1, 2, 3]) + ); + const buf = db1.serialize(); + + const db2 = new DatabaseSync(':memory:'); + db2.deserialize(buf); + const row = db2.prepare('SELECT * FROM t').get(); + t.assert.strictEqual(row.a, 'text'); + t.assert.strictEqual(row.b, 3.14); + t.assert.deepStrictEqual(row.c, new Uint8Array([1, 2, 3])); + db1.close(); + db2.close(); + }); + + test('throws if the database is not open', (t) => { + const db = new DatabaseSync(':memory:'); + db.close(); + t.assert.throws(() => { + db.deserialize(new Uint8Array(0)); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if buffer argument is not a Uint8Array', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.throws(() => { + db.deserialize('not a buffer'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "buffer" argument must be a Uint8Array/, + }); + db.close(); + }); + + test('throws if buffer is empty', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.throws(() => { + db.deserialize(new Uint8Array(0)); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: /The "buffer" argument must not be empty/, + }); + db.close(); + }); + + test('throws if options is not an object', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.throws(() => { + db.deserialize(new Uint8Array(1), 'bad'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be an object/, + }); + db.close(); + }); + + test('throws if options.dbName is not a string', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.throws(() => { + db.deserialize(new Uint8Array(1), { dbName: 1 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.dbName" argument must be a string/, + }); + db.close(); + }); + + test('accepts a Buffer as input', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE t(x INTEGER)'); + db1.exec('INSERT INTO t VALUES (42)'); + const buf = Buffer.from(db1.serialize()); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.deserialize(buf); + const row = db2.prepare('SELECT * FROM t').get(); + t.assert.strictEqual(row.x, 42); + db2.close(); + }); + + test('multiple deserialize calls on the same connection', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec('CREATE TABLE a(x)'); + db1.exec("INSERT INTO a VALUES ('first')"); + const buf1 = db1.serialize(); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.exec('CREATE TABLE b(x)'); + db2.exec("INSERT INTO b VALUES ('second')"); + const buf2 = db2.serialize(); + db2.close(); + + const db3 = new DatabaseSync(':memory:'); + db3.deserialize(buf1); + t.assert.strictEqual( + db3.prepare('SELECT x FROM a').get().x, 'first' + ); + + db3.deserialize(buf2); + t.assert.throws(() => { + db3.prepare('SELECT * FROM a').all(); + }, /no such table: a/); + t.assert.strictEqual( + db3.prepare('SELECT x FROM b').get().x, 'second' + ); + db3.close(); + }); + + test('loads into an attached schema when options.dbName is provided', (t) => { + const db1 = new DatabaseSync(':memory:'); + db1.exec("ATTACH DATABASE ':memory:' AS aux"); + db1.exec('CREATE TABLE aux.t(value TEXT)'); + db1.exec("INSERT INTO aux.t VALUES ('from aux')"); + const buf = db1.serialize('aux'); + db1.close(); + + const db2 = new DatabaseSync(':memory:'); + db2.exec('CREATE TABLE main_t(value TEXT)'); + db2.exec("INSERT INTO main_t VALUES ('from main')"); + db2.exec("ATTACH DATABASE ':memory:' AS aux"); + + db2.deserialize(buf, { dbName: 'aux' }); + + t.assert.strictEqual( + db2.prepare('SELECT value FROM main_t').get().value, + 'from main', + ); + t.assert.strictEqual( + db2.prepare('SELECT value FROM aux.t').get().value, + 'from aux', + ); + db2.close(); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-session.js b/test/js/node/test/parallel/test-sqlite-session.js new file mode 100644 index 000000000000..cea34c7fe6d5 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-session.js @@ -0,0 +1,676 @@ +// Flags: --experimental-sqlite +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const { + DatabaseSync, + constants, +} = require('node:sqlite'); +const { test, suite } = require('node:test'); +const { nextDb } = require('../sqlite/next-db.js'); +const { Worker } = require('worker_threads'); +const { once } = require('events'); + +/** + * Convenience wrapper around assert.deepStrictEqual that sets a null + * prototype to the expected object. + * @returns {boolean} + */ +function deepStrictEqual(t) { + return (actual, expected, message) => { + if (Array.isArray(expected)) { + expected = expected.map((obj) => ({ ...obj, __proto__: null })); + } else if (typeof expected === 'object') { + expected = { ...expected, __proto__: null }; + } + t.assert.deepStrictEqual(actual, expected, message); + }; +} + +test('creating and applying a changeset', (t) => { + const createDataTableSql = ` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT`; + + const createDatabase = () => { + const database = new DatabaseSync(':memory:'); + database.exec(createDataTableSql); + return database; + }; + + const databaseFrom = createDatabase(); + const session = databaseFrom.createSession(); + + const select = 'SELECT * FROM data ORDER BY key'; + + const insert = databaseFrom.prepare('INSERT INTO data (key, value) VALUES (?, ?)'); + insert.run(1, 'hello'); + insert.run(2, 'world'); + + const databaseTo = createDatabase(); + + t.assert.strictEqual(databaseTo.applyChangeset(session.changeset()), true); + deepStrictEqual(t)( + databaseFrom.prepare(select).all(), + databaseTo.prepare(select).all() + ); +}); + +test('database.createSession() - closed database results in exception', (t) => { + const database = new DatabaseSync(':memory:'); + database.close(); + t.assert.throws(() => { + database.createSession(); + }, { + name: 'Error', + message: 'database is not open', + }); +}); + +test('session.changeset() - closed database results in exception', (t) => { + const database = new DatabaseSync(':memory:'); + const session = database.createSession(); + database.close(); + t.assert.throws(() => { + session.changeset(); + }, { + name: 'Error', + message: 'database is not open', + }); +}); + +test('database.applyChangeset() - closed database results in exception', (t) => { + const database = new DatabaseSync(':memory:'); + const session = database.createSession(); + const changeset = session.changeset(); + database.close(); + t.assert.throws(() => { + database.applyChangeset(changeset); + }, { + name: 'Error', + message: 'database is not open', + }); +}); + +test('database.createSession() - use table option to track specific table', (t) => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + const createData1TableSql = `CREATE TABLE data1 ( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT + `; + const createData2TableSql = `CREATE TABLE data2 ( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT + `; + database1.exec(createData1TableSql); + database1.exec(createData2TableSql); + database2.exec(createData1TableSql); + database2.exec(createData2TableSql); + + const session = database1.createSession({ + table: 'data1' + }); + const insert1 = database1.prepare('INSERT INTO data1 (key, value) VALUES (?, ?)'); + insert1.run(1, 'hello'); + insert1.run(2, 'world'); + const insert2 = database1.prepare('INSERT INTO data2 (key, value) VALUES (?, ?)'); + insert2.run(1, 'hello'); + insert2.run(2, 'world'); + const select1 = 'SELECT * FROM data1 ORDER BY key'; + const select2 = 'SELECT * FROM data2 ORDER BY key'; + t.assert.strictEqual(database2.applyChangeset(session.changeset()), true); + deepStrictEqual(t)( + database1.prepare(select1).all(), + database2.prepare(select1).all()); // data1 table should be equal + deepStrictEqual(t)(database2.prepare(select2).all(), []); // data2 should be empty in database2 + t.assert.strictEqual(database1.prepare(select2).all().length, 2); // data1 should have values in database1 +}); + +suite('conflict resolution', () => { + const createDataTableSql = `CREATE TABLE data ( + key INTEGER PRIMARY KEY, + value TEXT UNIQUE + ) STRICT`; + + const prepareConflict = () => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + database1.exec(createDataTableSql); + database2.exec(createDataTableSql); + + const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)'; + const session = database1.createSession(); + database1.prepare(insertSql).run(1, 'hello'); + database1.prepare(insertSql).run(2, 'foo'); + database2.prepare(insertSql).run(1, 'world'); + return { + database2, + changeset: session.changeset() + }; + }; + + const prepareDataConflict = () => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + database1.exec(createDataTableSql); + database2.exec(createDataTableSql); + + const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)'; + database1.prepare(insertSql).run(1, 'hello'); + database2.prepare(insertSql).run(1, 'othervalue'); + const session = database1.createSession(); + database1.prepare('UPDATE data SET value = ? WHERE key = ?').run('foo', 1); + return { + database2, + changeset: session.changeset() + }; + }; + + const prepareNotFoundConflict = () => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + database1.exec(createDataTableSql); + database2.exec(createDataTableSql); + + const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)'; + database1.prepare(insertSql).run(1, 'hello'); + const session = database1.createSession(); + database1.prepare('DELETE FROM data WHERE key = 1').run(); + return { + database2, + changeset: session.changeset() + }; + }; + + const prepareFkConflict = () => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + database1.exec(createDataTableSql); + database2.exec(createDataTableSql); + const fkTableSql = `CREATE TABLE other ( + key INTEGER PRIMARY KEY, + ref REFERENCES data(key) + )`; + database1.exec(fkTableSql); + database2.exec(fkTableSql); + + const insertDataSql = 'INSERT INTO data (key, value) VALUES (?, ?)'; + const insertOtherSql = 'INSERT INTO other (key, ref) VALUES (?, ?)'; + database1.prepare(insertDataSql).run(1, 'hello'); + database2.prepare(insertDataSql).run(1, 'hello'); + database1.prepare(insertOtherSql).run(1, 1); + database2.prepare(insertOtherSql).run(1, 1); + + database1.exec('DELETE FROM other WHERE key = 1'); // So we don't get a fk violation in database1 + const session = database1.createSession(); + database1.prepare('DELETE FROM data WHERE key = 1').run(); // Changeset with fk violation + database2.exec('PRAGMA foreign_keys = ON'); // Needs to be supported, otherwise will fail here + + return { + database2, + changeset: session.changeset() + }; + }; + + const prepareConstraintConflict = () => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + + database1.exec(createDataTableSql); + database2.exec(createDataTableSql); + + const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)'; + const session = database1.createSession(); + database1.prepare(insertSql).run(1, 'hello'); + database2.prepare(insertSql).run(2, 'hello'); // database2 already constains hello + + return { + database2, + changeset: session.changeset() + }; + }; + + test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict with default behavior (abort)', (t) => { + const { database2, changeset } = prepareConflict(); + // When changeset is aborted due to a conflict, applyChangeset should return false + t.assert.strictEqual(database2.applyChangeset(changeset), false); + deepStrictEqual(t)( + database2.prepare('SELECT value from data').all(), + [{ value: 'world' }]); // unchanged + }); + + test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict handled with SQLITE_CHANGESET_ABORT', (t) => { + const { database2, changeset } = prepareConflict(); + let conflictType = null; + const result = database2.applyChangeset(changeset, { + onConflict: (conflictType_) => { + conflictType = conflictType_; + return constants.SQLITE_CHANGESET_ABORT; + } + }); + // When changeset is aborted due to a conflict, applyChangeset should return false + t.assert.strictEqual(result, false); + t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONFLICT); + deepStrictEqual(t)( + database2.prepare('SELECT value from data').all(), + [{ value: 'world' }]); // unchanged + }); + + test('database.applyChangeset() - SQLITE_CHANGESET_DATA conflict handled with SQLITE_CHANGESET_REPLACE', (t) => { + const { database2, changeset } = prepareDataConflict(); + let conflictType = null; + const result = database2.applyChangeset(changeset, { + onConflict: (conflictType_) => { + conflictType = conflictType_; + return constants.SQLITE_CHANGESET_REPLACE; + } + }); + // Not aborted due to conflict, so should return true + t.assert.strictEqual(result, true); + t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_DATA); + deepStrictEqual(t)( + database2.prepare('SELECT value from data ORDER BY key').all(), + [{ value: 'foo' }]); // replaced + }); + + test('database.applyChangeset() - SQLITE_CHANGESET_NOTFOUND conflict with SQLITE_CHANGESET_OMIT', (t) => { + const { database2, changeset } = prepareNotFoundConflict(); + let conflictType = null; + const result = database2.applyChangeset(changeset, { + onConflict: (conflictType_) => { + conflictType = conflictType_; + return constants.SQLITE_CHANGESET_OMIT; + } + }); + // Not aborted due to conflict, so should return true + t.assert.strictEqual(result, true); + t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_NOTFOUND); + deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []); + }); + + test('database.applyChangeset() - SQLITE_CHANGESET_FOREIGN_KEY conflict', (t) => { + const { database2, changeset } = prepareFkConflict(); + let conflictType = null; + const result = database2.applyChangeset(changeset, { + onConflict: (conflictType_) => { + conflictType = conflictType_; + return constants.SQLITE_CHANGESET_OMIT; + } + }); + // Not aborted due to conflict, so should return true + t.assert.strictEqual(result, true); + t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_FOREIGN_KEY); + deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []); + }); + + test('database.applyChangeset() - SQLITE_CHANGESET_CONSTRAINT conflict', (t) => { + const { database2, changeset } = prepareConstraintConflict(); + let conflictType = null; + const result = database2.applyChangeset(changeset, { + onConflict: (conflictType_) => { + conflictType = conflictType_; + return constants.SQLITE_CHANGESET_OMIT; + } + }); + // Not aborted due to conflict, so should return true + t.assert.strictEqual(result, true); + t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONSTRAINT); + deepStrictEqual(t)(database2.prepare('SELECT key, value from data').all(), [{ key: 2, value: 'hello' }]); + }); + + test('conflict resolution handler returns invalid value', (t) => { + const invalidHandlers = [ + () => -1, + () => ({}), + () => null, + async () => constants.SQLITE_CHANGESET_ABORT, + ]; + + for (const invalidHandler of invalidHandlers) { + const { database2, changeset } = prepareConflict(); + t.assert.throws(() => { + database2.applyChangeset(changeset, { + onConflict: invalidHandler + }); + }, { + name: 'Error', + message: 'bad parameter or other API misuse', + errcode: 21, + code: 'ERR_SQLITE_ERROR' + }, `Did not throw expected exception when using invalid onConflict handler: ${invalidHandler}`); + } + }); + + test('conflict resolution handler throws', (t) => { + const { database2, changeset } = prepareConflict(); + t.assert.throws(() => { + database2.applyChangeset(changeset, { + onConflict: () => { + throw new Error('some error'); + } + }); + }, { + name: 'Error', + message: 'some error' + }); + }); +}); + +test('filter handler throws', (t) => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + const createTableSql = 'CREATE TABLE data1(key INTEGER PRIMARY KEY); CREATE TABLE data2(key INTEGER PRIMARY KEY);'; + database1.exec(createTableSql); + database2.exec(createTableSql); + + const session = database1.createSession(); + + database1.exec('INSERT INTO data1 (key) VALUES (1), (2), (3)'); + database1.exec('INSERT INTO data2 (key) VALUES (1), (2), (3), (4), (5)'); + + t.assert.throws(() => { + database2.applyChangeset(session.changeset(), { + filter: (tableName) => { + throw new Error(`Error filtering table ${tableName}`); + } + }); + }, { + name: 'Error', + message: 'Error filtering table data1' + }); +}); + +test('database.createSession() - filter changes', (t) => { + const database1 = new DatabaseSync(':memory:'); + const database2 = new DatabaseSync(':memory:'); + const createTableSql = 'CREATE TABLE data1(key INTEGER PRIMARY KEY); CREATE TABLE data2(key INTEGER PRIMARY KEY);'; + database1.exec(createTableSql); + database2.exec(createTableSql); + + const session = database1.createSession(); + + database1.exec('INSERT INTO data1 (key) VALUES (1), (2), (3)'); + database1.exec('INSERT INTO data2 (key) VALUES (1), (2), (3), (4), (5)'); + + database2.applyChangeset(session.changeset(), { + filter: (tableName) => tableName === 'data2' + }); + + const data1Rows = database2.prepare('SELECT * FROM data1').all(); + const data2Rows = database2.prepare('SELECT * FROM data2').all(); + + // Expect no rows since all changes were filtered out + t.assert.strictEqual(data1Rows.length, 0); + // Expect 5 rows since these changes were not filtered out + t.assert.strictEqual(data2Rows.length, 5); +}); + +test('database.createSession() - specify other database', (t) => { + const database = new DatabaseSync(':memory:'); + const session = database.createSession(); + const sessionMain = database.createSession({ + db: 'main' + }); + const sessionTest = database.createSession({ + db: 'test' + }); + database.exec('CREATE TABLE data (key INTEGER PRIMARY KEY)'); + database.exec('INSERT INTO data (key) VALUES (1)'); + t.assert.notStrictEqual(session.changeset().length, 0); + t.assert.notStrictEqual(sessionMain.changeset().length, 0); + // Since this session is attached to a different database, its changeset should be empty + t.assert.strictEqual(sessionTest.changeset().length, 0); +}); + +test('database.createSession() - wrong arguments', (t) => { + const database = new DatabaseSync(':memory:'); + t.assert.throws(() => { + database.createSession(null); + }, { + name: 'TypeError', + message: 'The "options" argument must be an object.' + }); + + t.assert.throws(() => { + database.createSession({ + table: 123 + }); + }, { + name: 'TypeError', + message: 'The "options.table" argument must be a string.' + }); + + t.assert.throws(() => { + database.createSession({ + db: 123 + }); + }, { + name: 'TypeError', + message: 'The "options.db" argument must be a string.' + }); +}); + +test('database.applyChangeset() - wrong arguments', (t) => { + const database = new DatabaseSync(':memory:'); + const session = database.createSession(); + t.assert.throws(() => { + database.applyChangeset(null); + }, { + name: 'TypeError', + message: 'The "changeset" argument must be a Uint8Array.' + }); + + t.assert.throws(() => { + database.applyChangeset(session.changeset(), null); + }, { + name: 'TypeError', + message: 'The "options" argument must be an object.' + }); + + t.assert.throws(() => { + database.applyChangeset(session.changeset(), { + filter: null + }, null); + }, { + name: 'TypeError', + message: 'The "options.filter" argument must be a function.' + }); + + t.assert.throws(() => { + database.applyChangeset(session.changeset(), { + onConflict: null + }, null); + }, { + name: 'TypeError', + message: 'The "options.onConflict" argument must be a function.' + }); +}); + +test('database.applyChangeset() - malformed changeset returns SQLITE_CORRUPT', { + skip: process.config.variables.node_shared_sqlite ? + 'requires the bundled SQLite session fix' : false, +}, (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE t1(a INTEGER PRIMARY KEY, b, c, d)'); + + const changeset = Buffer.from( + '540401000000743100177e0072286565286565', + 'hex'); + + t.assert.throws(() => { + database.applyChangeset(changeset); + }, { + name: 'Error', + message: 'database disk image is malformed', + errcode: 11, + code: 'ERR_SQLITE_ERROR', + }); +}); + +test('session.patchset()', (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)'); + + database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')"); + + const session = database.createSession(); + database.exec("UPDATE data SET value = 'hi' WHERE key = 1"); + + const patchset = session.patchset(); + const changeset = session.changeset(); + + t.assert.ok(patchset instanceof Uint8Array); + t.assert.ok(changeset instanceof Uint8Array); + + t.assert.deepStrictEqual(patchset, session.patchset()); + t.assert.deepStrictEqual(changeset, session.changeset()); + + t.assert.ok( + patchset.length < changeset.length, + 'expected patchset to be smaller than changeset'); +}); + +test('session.close() - using session after close throws exception', (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)'); + + database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')"); + + const session = database.createSession(); + database.exec("UPDATE data SET value = 'hi' WHERE key = 1"); + session.close(); + + database.exec("UPDATE data SET value = 'world' WHERE key = 1"); + t.assert.throws(() => { + session.changeset(); + }, { + name: 'Error', + message: 'session is not open' + }); +}); + +test('session.close() - after closing database throws exception', (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)'); + + database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')"); + + const session = database.createSession(); + database.close(); + + t.assert.throws(() => { + session.close(); + }, { + name: 'Error', + message: 'database is not open' + }); +}); + +test('session.close() - closing twice', (t) => { + const database = new DatabaseSync(':memory:'); + const session = database.createSession(); + session.close(); + + t.assert.throws(() => { + session.close(); + }, { + name: 'Error', + message: 'session is not open' + }); +}); + +test('session supports ERM', (t) => { + const database = new DatabaseSync(':memory:'); + let afterDisposeSession; + { + using session = database.createSession(); + afterDisposeSession = session; + const changeset = session.changeset(); + t.assert.ok(changeset instanceof Uint8Array); + t.assert.strictEqual(changeset.length, 0); + } + t.assert.throws(() => afterDisposeSession.changeset(), { + message: /session is not open/, + }); +}); + +test('concurrent applyChangeset with workers', { timeout: 120_000 }, async (t) => { // BUN: explicit timeout — debug-build worker spawn is slow and 10 iterations × 2 workers exceeds the 5 s done-callback default + // Before adding this test, the callbacks were stored in static variables + // this could result in a crash + // this test is a regression test for that scenario + + function modeToString(mode) { + if (mode === constants.SQLITE_CHANGESET_ABORT) return 'SQLITE_CHANGESET_ABORT'; + if (mode === constants.SQLITE_CHANGESET_OMIT) return 'SQLITE_CHANGESET_OMIT'; + } + + const dbPath = nextDb(); + const db1 = new DatabaseSync(dbPath); + const db2 = new DatabaseSync(':memory:'); + const createTable = ` + CREATE TABLE data( + key INTEGER PRIMARY KEY, + value TEXT + ) STRICT`; + db1.exec(createTable); + db2.exec(createTable); + db1.prepare('INSERT INTO data (key, value) VALUES (?, ?)').run(1, 'hello'); + db1.close(); + const session = db2.createSession(); + db2.prepare('INSERT INTO data (key, value) VALUES (?, ?)').run(1, 'world'); + const changeset = session.changeset(); // Changeset with conflict (for db1) + + const iterations = 10; + for (let i = 0; i < iterations; i++) { + const workers = []; + const expectedResults = new Map([ + [constants.SQLITE_CHANGESET_ABORT, false], + [constants.SQLITE_CHANGESET_OMIT, true]] + ); + + // Launch two workers (abort and omit modes) + for (const mode of [constants.SQLITE_CHANGESET_ABORT, constants.SQLITE_CHANGESET_OMIT]) { + const worker = new Worker(`${__dirname}/../sqlite/worker.js`, { + workerData: { + dbPath, + changeset, + mode + }, + }); + workers.push(worker); + } + + const results = await Promise.all(workers.map(async (worker) => { + const [message] = await once(worker, 'message'); + return message; + })); + + // Verify each result + for (const res of results) { + if (res.errorMessage) { + if (res.errcode === 5) { // SQLITE_BUSY + break; // ignore + } + t.assert.fail(`Worker error: ${res.error.message}`); + } + const expected = expectedResults.get(res.mode); + t.assert.strictEqual( + res.result, + expected, + `Iteration ${i}: Worker (${modeToString(res.mode)}) expected ${expected} but got ${res.result}` + ); + } + + workers.forEach((worker) => worker.terminate()); // Cleanup + } +}); diff --git a/test/js/node/test/parallel/test-sqlite-statement-sync-columns.js b/test/js/node/test/parallel/test-sqlite-statement-sync-columns.js new file mode 100644 index 000000000000..a0c3fbd74347 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-statement-sync-columns.js @@ -0,0 +1,162 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const assert = require('node:assert'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); + +suite('StatementSync.prototype.columns()', () => { + test('returns column metadata for core SQLite types', () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE test ( + col1 INTEGER, + col2 REAL, + col3 TEXT, + col4 BLOB, + col5 NULL + )`); + const stmt = db.prepare('SELECT col1, col2, col3, col4, col5 FROM test'); + assert.deepStrictEqual(stmt.columns(), [ + { + __proto__: null, + column: 'col1', + database: 'main', + name: 'col1', + table: 'test', + type: 'INTEGER', + }, + { + __proto__: null, + column: 'col2', + database: 'main', + name: 'col2', + table: 'test', + type: 'REAL', + }, + { + __proto__: null, + column: 'col3', + database: 'main', + name: 'col3', + table: 'test', + type: 'TEXT', + }, + { + __proto__: null, + column: 'col4', + database: 'main', + name: 'col4', + table: 'test', + type: 'BLOB', + }, + { + __proto__: null, + column: 'col5', + database: 'main', + name: 'col5', + table: 'test', + type: null, + }, + ]); + }); + + test('supports statements using multiple tables', () => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test1 (value1 INTEGER); + CREATE TABLE test2 (value2 INTEGER); + `); + const stmt = db.prepare('SELECT value1, value2 FROM test1, test2'); + assert.deepStrictEqual(stmt.columns(), [ + { + __proto__: null, + column: 'value1', + database: 'main', + name: 'value1', + table: 'test1', + type: 'INTEGER', + }, + { + __proto__: null, + column: 'value2', + database: 'main', + name: 'value2', + table: 'test2', + type: 'INTEGER', + }, + ]); + }); + + test('supports column aliases', () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE test (value INTEGER)`); + const stmt = db.prepare('SELECT value AS foo FROM test'); + assert.deepStrictEqual(stmt.columns(), [ + { + __proto__: null, + column: 'value', + database: 'main', + name: 'foo', + table: 'test', + type: 'INTEGER', + }, + ]); + }); + + test('supports column expressions', () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE test (value INTEGER)`); + const stmt = db.prepare('SELECT value + 1, value FROM test'); + assert.deepStrictEqual(stmt.columns(), [ + { + __proto__: null, + column: null, + database: null, + name: 'value + 1', + table: null, + type: null, + }, + { + __proto__: null, + column: 'value', + database: 'main', + name: 'value', + table: 'test', + type: 'INTEGER', + }, + ]); + }); + + test('supports subqueries', () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE test (value INTEGER)`); + const stmt = db.prepare('SELECT * FROM (SELECT * FROM test)'); + assert.deepStrictEqual(stmt.columns(), [ + { + __proto__: null, + column: 'value', + database: 'main', + name: 'value', + table: 'test', + type: 'INTEGER', + }, + ]); + }); + + test('supports statements that do not return data', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE test (value INTEGER)'); + const stmt = db.prepare('INSERT INTO test (value) VALUES (?)'); + assert.deepStrictEqual(stmt.columns(), []); + }); + + test('throws if the statement is finalized', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE test (value INTEGER)'); + const stmt = db.prepare('SELECT value FROM test'); + db.close(); + assert.throws(() => { + stmt.columns(); + }, /statement has been finalized/); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-statement-sync.js b/test/js/node/test/parallel/test-sqlite-statement-sync.js new file mode 100644 index 000000000000..aa7a3a73ae66 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-statement-sync.js @@ -0,0 +1,911 @@ +// Flags: --expose-gc +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync, StatementSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('StatementSync() constructor', () => { + test('StatementSync cannot be constructed directly', (t) => { + t.assert.throws(() => { + new StatementSync(); + }, { + code: 'ERR_ILLEGAL_CONSTRUCTOR', + message: /Illegal constructor/, + }); + }); +}); + +suite('StatementSync.prototype.get()', () => { + test('executes a query and returns undefined on no results', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.strictEqual(stmt.get(), undefined); + stmt = db.prepare('SELECT * FROM storage'); + t.assert.strictEqual(stmt.get(), undefined); + }); + + test('executes a query and returns the first result', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.strictEqual(stmt.get(), undefined); + stmt = db.prepare('INSERT INTO storage (key, val) VALUES (?, ?)'); + t.assert.strictEqual(stmt.get('key1', 'val1'), undefined); + t.assert.strictEqual(stmt.get('key2', 'val2'), undefined); + stmt = db.prepare('SELECT * FROM storage ORDER BY key'); + t.assert.deepStrictEqual(stmt.get(), { __proto__: null, key: 'key1', val: 'val1' }); + }); + + test('executes a query that returns special columns', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const stmt = db.prepare('SELECT 1 as __proto__, 2 as constructor, 3 as toString'); + t.assert.deepStrictEqual(stmt.get(), { __proto__: null, ['__proto__']: 1, constructor: 2, toString: 3 }); + }); +}); + +suite('StatementSync.prototype.all()', () => { + test('executes a query and returns an empty array on no results', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.deepStrictEqual(stmt.all(), []); + }); + + test('executes a query and returns all results', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.deepStrictEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); + stmt = db.prepare('INSERT INTO storage (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run('key1', 'val1'), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run('key2', 'val2'), + { changes: 1, lastInsertRowid: 2 }, + ); + stmt = db.prepare('SELECT * FROM storage ORDER BY key'); + t.assert.deepStrictEqual(stmt.all(), [ + { __proto__: null, key: 'key1', val: 'val1' }, + { __proto__: null, key: 'key2', val: 'val2' }, + ]); + }); +}); + +suite('StatementSync.prototype.iterate()', () => { + test('executes a query and returns an empty iterator on no results', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + const iter = stmt.iterate(); + t.assert.strictEqual(iter instanceof globalThis.Iterator, true); + t.assert.ok(iter[Symbol.iterator]); + t.assert.deepStrictEqual(iter.toArray(), []); + }); + + test('executes a query and returns all results', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); + t.assert.deepStrictEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); + stmt = db.prepare('INSERT INTO storage (key, val) VALUES (?, ?)'); + t.assert.deepStrictEqual( + stmt.run('key1', 'val1'), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.deepStrictEqual( + stmt.run('key2', 'val2'), + { changes: 1, lastInsertRowid: 2 }, + ); + + const items = [ + { __proto__: null, key: 'key1', val: 'val1' }, + { __proto__: null, key: 'key2', val: 'val2' }, + ]; + + stmt = db.prepare('SELECT * FROM storage ORDER BY key'); + t.assert.deepStrictEqual(stmt.iterate().toArray(), items); + + const itemsLoop = items.slice(); + for (const item of stmt.iterate()) { + t.assert.deepStrictEqual(item, itemsLoop.shift()); + } + }); + + test('iterator keeps the prepared statement from being collected', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test(key TEXT, val TEXT); + INSERT INTO test (key, val) VALUES ('key1', 'val1'); + INSERT INTO test (key, val) VALUES ('key2', 'val2'); + `); + // Do not keep an explicit reference to the prepared statement. + const iterator = db.prepare('SELECT * FROM test').iterate(); + const results = []; + + global.gc(); + + for (const item of iterator) { + results.push(item); + } + + t.assert.deepStrictEqual(results, [ + { __proto__: null, key: 'key1', val: 'val1' }, + { __proto__: null, key: 'key2', val: 'val2' }, + ]); + }); + + test('iterator can be exited early', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test(key TEXT, val TEXT); + INSERT INTO test (key, val) VALUES ('key1', 'val1'); + INSERT INTO test (key, val) VALUES ('key2', 'val2'); + `); + const iterator = db.prepare('SELECT * FROM test').iterate(); + const results = []; + + for (const item of iterator) { + results.push(item); + break; + } + + t.assert.deepStrictEqual(results, [ + { __proto__: null, key: 'key1', val: 'val1' }, + ]); + t.assert.deepStrictEqual( + iterator.next(), + { __proto__: null, done: true, value: null }, + ); + }); + + test('iterator is invalidated when statement is reset by get/all/run/iterate', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE test (value INTEGER NOT NULL)'); + for (let i = 0; i < 5; i++) { + db.prepare('INSERT INTO test (value) VALUES (?)').run(i); + } + const stmt = db.prepare('SELECT * FROM test'); + + // Invalidated by stmt.get() + let it = stmt.iterate(); + it.next(); + stmt.get(); + t.assert.throws(() => { it.next(); }, { + code: 'ERR_INVALID_STATE', + message: /iterator was invalidated/, + }); + + // Invalidated by stmt.all() + it = stmt.iterate(); + it.next(); + stmt.all(); + t.assert.throws(() => { it.next(); }, { + code: 'ERR_INVALID_STATE', + message: /iterator was invalidated/, + }); + + // Invalidated by stmt.run() + it = stmt.iterate(); + it.next(); + stmt.run(); + t.assert.throws(() => { it.next(); }, { + code: 'ERR_INVALID_STATE', + message: /iterator was invalidated/, + }); + + // Invalidated by a new stmt.iterate() + it = stmt.iterate(); + it.next(); + const it2 = stmt.iterate(); + t.assert.throws(() => { it.next(); }, { + code: 'ERR_INVALID_STATE', + message: /iterator was invalidated/, + }); + + // New iterator works fine + t.assert.strictEqual(it2.next().done, false); + + // Reset on a different statement does NOT invalidate this iterator + const stmt2 = db.prepare('SELECT * FROM test'); + it = stmt.iterate(); + it.next(); + stmt2.get(); + it.next(); + }); +}); + +suite('StatementSync.prototype.run()', () => { + test('executes a query and returns change metadata', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE storage(key TEXT, val TEXT); + INSERT INTO storage (key, val) VALUES ('foo', 'bar'); + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('SELECT * FROM storage'); + t.assert.deepStrictEqual(stmt.run(), { changes: 1, lastInsertRowid: 1 }); + }); + + test('SQLite throws when trying to bind too many parameters', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)'); + t.assert.throws(() => { + stmt.run(1, 2, 3); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'column index out of range', + errcode: 25, + errstr: 'column index out of range', + }); + }); + + test('SQLite defaults to NULL for unbound parameters', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)'); + t.assert.throws(() => { + stmt.run(1); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'NOT NULL constraint failed: data.val', + errcode: 1299, + errstr: 'constraint failed', + }); + }); + + test('returns correct metadata when using RETURNING', (t) => { + const db = new DatabaseSync(':memory:'); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const sql = 'INSERT INTO data (key, val) VALUES ($k, $v) RETURNING key'; + const stmt = db.prepare(sql); + t.assert.deepStrictEqual( + stmt.run({ k: 1, v: 10 }), { changes: 1, lastInsertRowid: 1 } + ); + t.assert.deepStrictEqual( + stmt.run({ k: 2, v: 20 }), { changes: 1, lastInsertRowid: 2 } + ); + t.assert.deepStrictEqual( + stmt.run({ k: 3, v: 30 }), { changes: 1, lastInsertRowid: 3 } + ); + }); + + test('SQLite defaults unbound ?NNN parameters', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?1, ?3)'); + + t.assert.throws(() => { + stmt.run(1); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'NOT NULL constraint failed: data.val', + errcode: 1299, + errstr: 'constraint failed', + }); + }); + + test('binds ?NNN params by position', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES (?1, ?2)'); + t.assert.deepStrictEqual(stmt.run(1, 2), { changes: 1, lastInsertRowid: 1 }); + }); +}); + +suite('StatementSync.prototype.sourceSQL', () => { + test('equals input SQL', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const sql = 'INSERT INTO types (key, val) VALUES ($k, $v)'; + const stmt = db.prepare(sql); + t.assert.strictEqual(stmt.sourceSQL, sql); + }); +}); + +suite('StatementSync.prototype.expandedSQL', () => { + test('equals expanded SQL', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const sql = 'INSERT INTO types (key, val) VALUES ($k, ?)'; + const expanded = 'INSERT INTO types (key, val) VALUES (\'33\', \'42\')'; + const stmt = db.prepare(sql); + t.assert.deepStrictEqual( + stmt.run({ $k: '33' }, '42'), + { changes: 1, lastInsertRowid: 33 }, + ); + t.assert.strictEqual(stmt.expandedSQL, expanded); + }); +}); + +suite('StatementSync.prototype.setReadBigInts()', () => { + test('BigInts support can be toggled', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; + INSERT INTO data (key, val) VALUES (1, 42); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT val FROM data'); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42 }); + t.assert.strictEqual(query.setReadBigInts(true), undefined); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42n }); + t.assert.strictEqual(query.setReadBigInts(false), undefined); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42 }); + + const insert = db.prepare('INSERT INTO data (key) VALUES (?)'); + t.assert.deepStrictEqual( + insert.run(10), + { changes: 1, lastInsertRowid: 10 }, + ); + t.assert.strictEqual(insert.setReadBigInts(true), undefined); + t.assert.deepStrictEqual( + insert.run(20), + { changes: 1n, lastInsertRowid: 20n }, + ); + t.assert.strictEqual(insert.setReadBigInts(false), undefined); + t.assert.deepStrictEqual( + insert.run(30), + { changes: 1, lastInsertRowid: 30 }, + ); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO types (key, val) VALUES ($k, $v)'); + t.assert.throws(() => { + stmt.setReadBigInts(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "readBigInts" argument must be a boolean/, + }); + }); + + test('BigInt is required for reading large integers', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const bad = db.prepare(`SELECT ${Number.MAX_SAFE_INTEGER} + 1`); + t.assert.throws(() => { + bad.get(); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /^Value is too large to be represented as a JavaScript number: 9007199254740992$/, + }); + const good = db.prepare(`SELECT ${Number.MAX_SAFE_INTEGER} + 1`); + good.setReadBigInts(true); + t.assert.deepStrictEqual(good.get(), { + __proto__: null, + [`${Number.MAX_SAFE_INTEGER} + 1`]: 2n ** 53n, + }); + }); +}); + +suite('StatementSync.prototype.setReturnArrays()', () => { + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('SELECT key, val FROM data'); + t.assert.throws(() => { + stmt.setReturnArrays(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "returnArrays" argument must be a boolean/, + }); + }); +}); + +suite('StatementSync.prototype.get() with array output', () => { + test('returns array row when setReturnArrays is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT key, val FROM data WHERE key = 1'); + t.assert.deepStrictEqual(query.get(), { __proto__: null, key: 1, val: 'one' }); + + query.setReturnArrays(true); + t.assert.deepStrictEqual(query.get(), [1, 'one']); + + query.setReturnArrays(false); + t.assert.deepStrictEqual(query.get(), { __proto__: null, key: 1, val: 'one' }); + }); + + test('returns array rows with BigInts when both flags are set', (t) => { + const expected = [1n, 9007199254740992n]; + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE big_data(id INTEGER, big_num INTEGER); + INSERT INTO big_data VALUES (1, 9007199254740992); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT id, big_num FROM big_data'); + query.setReturnArrays(true); + query.setReadBigInts(true); + + const row = query.get(); + t.assert.deepStrictEqual(row, expected); + }); +}); + +suite('StatementSync.prototype.all() with array output', () => { + test('returns array rows when setReturnArrays is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + INSERT INTO data (key, val) VALUES (2, 'two'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT key, val FROM data ORDER BY key'); + t.assert.deepStrictEqual(query.all(), [ + { __proto__: null, key: 1, val: 'one' }, + { __proto__: null, key: 2, val: 'two' }, + ]); + + query.setReturnArrays(true); + t.assert.deepStrictEqual(query.all(), [ + [1, 'one'], + [2, 'two'], + ]); + + query.setReturnArrays(false); + t.assert.deepStrictEqual(query.all(), [ + { __proto__: null, key: 1, val: 'one' }, + { __proto__: null, key: 2, val: 'two' }, + ]); + }); + + test('handles array rows with many columns', (t) => { + const expected = [ + 1, + 'text1', + 1.1, + new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + 5, + 'text2', + 2.2, + new Uint8Array([0xbe, 0xef, 0xca, 0xfe]), + 9, + 'text3', + ]; + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE wide_table( + col1 INTEGER, col2 TEXT, col3 REAL, col4 BLOB, col5 INTEGER, + col6 TEXT, col7 REAL, col8 BLOB, col9 INTEGER, col10 TEXT + ); + INSERT INTO wide_table VALUES ( + 1, 'text1', 1.1, X'DEADBEEF', 5, + 'text2', 2.2, X'BEEFCAFE', 9, 'text3' + ); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT * FROM wide_table'); + query.setReturnArrays(true); + + const results = query.all(); + t.assert.strictEqual(results.length, 1); + t.assert.deepStrictEqual(results[0], expected); + }); +}); + +suite('StatementSync.prototype.iterate() with array output', () => { + test('iterates array rows when setReturnArrays is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + INSERT INTO data (key, val) VALUES (2, 'two'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT key, val FROM data ORDER BY key'); + + // Test with objects first + const objectRows = []; + for (const row of query.iterate()) { + objectRows.push(row); + } + t.assert.deepStrictEqual(objectRows, [ + { __proto__: null, key: 1, val: 'one' }, + { __proto__: null, key: 2, val: 'two' }, + ]); + + // Test with arrays + query.setReturnArrays(true); + const arrayRows = []; + for (const row of query.iterate()) { + arrayRows.push(row); + } + t.assert.deepStrictEqual(arrayRows, [ + [1, 'one'], + [2, 'two'], + ]); + + // Test toArray() method + t.assert.deepStrictEqual(query.iterate().toArray(), [ + [1, 'one'], + [2, 'two'], + ]); + }); + + test('iterator can be exited early with array rows', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test(key TEXT, val TEXT); + INSERT INTO test (key, val) VALUES ('key1', 'val1'); + INSERT INTO test (key, val) VALUES ('key2', 'val2'); + `); + const stmt = db.prepare('SELECT key, val FROM test'); + stmt.setReturnArrays(true); + + const iterator = stmt.iterate(); + const results = []; + + for (const item of iterator) { + results.push(item); + break; + } + + t.assert.deepStrictEqual(results, [ + ['key1', 'val1'], + ]); + t.assert.deepStrictEqual( + iterator.next(), + { __proto__: null, done: true, value: null }, + ); + }); +}); + +suite('StatementSync.prototype.setAllowBareNamedParameters()', () => { + test('bare named parameter support can be toggled', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + t.assert.deepStrictEqual( + stmt.run({ k: 1, v: 2 }), + { changes: 1, lastInsertRowid: 1 }, + ); + t.assert.strictEqual(stmt.setAllowBareNamedParameters(false), undefined); + t.assert.throws(() => { + stmt.run({ k: 2, v: 4 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter 'k'/, + }); + t.assert.strictEqual(stmt.setAllowBareNamedParameters(true), undefined); + t.assert.deepStrictEqual( + stmt.run({ k: 3, v: 6 }), + { changes: 1, lastInsertRowid: 3 }, + ); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO data (key, val) VALUES ($k, $v)'); + t.assert.throws(() => { + stmt.setAllowBareNamedParameters(); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "allowBareNamedParameters" argument must be a boolean/, + }); + }); +}); + +suite('options.readBigInts', () => { + test('BigInts are returned when input is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; + INSERT INTO data (key, val) VALUES (1, 42); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT val FROM data', { readBigInts: true }); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42n }); + }); + + test('numbers are returned when input is false', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; + INSERT INTO data (key, val) VALUES (1, 42); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT val FROM data', { readBigInts: false }); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42 }); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + t.assert.throws(() => { + db.prepare('SELECT val FROM data', { readBigInts: 'true' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.readBigInts" argument must be a boolean/, + }); + }); + + test('setReadBigInts can override prepare option', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; + INSERT INTO data (key, val) VALUES (1, 42); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare('SELECT val FROM data', { readBigInts: true }); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42n }); + t.assert.strictEqual(query.setReadBigInts(false), undefined); + t.assert.deepStrictEqual(query.get(), { __proto__: null, val: 42 }); + }); +}); + +suite('options.returnArrays', () => { + test('arrays are returned when input is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare( + 'SELECT key, val FROM data WHERE key = 1', + { returnArrays: true } + ); + t.assert.deepStrictEqual(query.get(), [1, 'one']); + }); + + test('objects are returned when input is false', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare( + 'SELECT key, val FROM data WHERE key = 1', + { returnArrays: false } + ); + t.assert.deepStrictEqual(query.get(), { __proto__: null, key: 1, val: 'one' }); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + t.assert.throws(() => { + db.prepare('SELECT key, val FROM data', { returnArrays: 'true' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.returnArrays" argument must be a boolean/, + }); + }); + + test('setReturnArrays can override prepare option', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare( + 'SELECT key, val FROM data WHERE key = 1', + { returnArrays: true } + ); + t.assert.deepStrictEqual(query.get(), [1, 'one']); + t.assert.strictEqual(query.setReturnArrays(false), undefined); + t.assert.deepStrictEqual(query.get(), { __proto__: null, key: 1, val: 'one' }); + }); + + test('all() returns arrays when input is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + INSERT INTO data (key, val) VALUES (2, 'two'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare( + 'SELECT key, val FROM data ORDER BY key', + { returnArrays: true } + ); + t.assert.deepStrictEqual(query.all(), [ + [1, 'one'], + [2, 'two'], + ]); + }); + + test('iterate() returns arrays when input is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; + INSERT INTO data (key, val) VALUES (1, 'one'); + INSERT INTO data (key, val) VALUES (2, 'two'); + `); + t.assert.strictEqual(setup, undefined); + + const query = db.prepare( + 'SELECT key, val FROM data ORDER BY key', + { returnArrays: true } + ); + t.assert.deepStrictEqual(query.iterate().toArray(), [ + [1, 'one'], + [2, 'two'], + ]); + }); +}); + +suite('options.allowBareNamedParameters', () => { + test('bare named parameters are allowed when input is true', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowBareNamedParameters: true } + ); + t.assert.deepStrictEqual( + stmt.run({ k: 1, v: 2 }), + { changes: 1, lastInsertRowid: 1 }, + ); + }); + + test('bare named parameters throw when input is false', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowBareNamedParameters: false } + ); + t.assert.throws(() => { + stmt.run({ k: 1, v: 2 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter 'k'/, + }); + }); + + test('throws when input is not a boolean', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + t.assert.throws(() => { + db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowBareNamedParameters: 'true' } + ); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.allowBareNamedParameters" argument must be a boolean/, + }); + }); + + test('setAllowBareNamedParameters can override prepare option', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec( + 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' + ); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare( + 'INSERT INTO data (key, val) VALUES ($k, $v)', + { allowBareNamedParameters: false } + ); + t.assert.throws(() => { + stmt.run({ k: 1, v: 2 }); + }, { + code: 'ERR_INVALID_STATE', + message: /Unknown named parameter 'k'/, + }); + t.assert.strictEqual(stmt.setAllowBareNamedParameters(true), undefined); + t.assert.deepStrictEqual( + stmt.run({ k: 2, v: 4 }), + { changes: 1, lastInsertRowid: 2 }, + ); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-template-tag.js b/test/js/node/test/parallel/test-sqlite-template-tag.js new file mode 100644 index 000000000000..0c6328e33af2 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-template-tag.js @@ -0,0 +1,171 @@ +'use strict'; +// Flags: --expose-gc + +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); + +const assert = require('assert'); +const { DatabaseSync } = require('node:sqlite'); +const { test, beforeEach } = require('node:test'); + +const db = new DatabaseSync(':memory:'); +const sql = db.createTagStore(10); + +beforeEach(() => { + db.exec('DROP TABLE IF EXISTS foo'); + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY, text TEXT)'); + sql.clear(); +}); + +test('throws error if database is not open', () => { + const db = new DatabaseSync(':memory:', { open: false }); + + assert.throws(() => { + db.createTagStore(10); + }, { + code: 'ERR_INVALID_STATE', + message: 'database is not open' + }); +}); + +test('sql.run inserts data', () => { + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1); + + const count = db.prepare('SELECT COUNT(*) as count FROM foo').get().count; + assert.strictEqual(count, 3); +}); + +test('sql.get retrieves a single row', () => { + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1); + const first = sql.get`SELECT * FROM foo ORDER BY id ASC`; + assert.ok(first); + assert.strictEqual(first.text, 'bob'); + assert.strictEqual(first.id, 1); + assert.strictEqual(Object.getPrototypeOf(first), null); +}); + +test('sql.all retrieves all rows', () => { + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1); + + const all = sql.all`SELECT * FROM foo ORDER BY id ASC`; + assert.strictEqual(Array.isArray(all), true); + assert.strictEqual(all.length, 3); + for (const row of all) { + assert.strictEqual(Object.getPrototypeOf(row), null); + } + assert.deepStrictEqual(all.map((r) => r.text), ['bob', 'mac', 'alice']); +}); + +test('sql.iterate retrieves rows via iterator', () => { + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'mac'})`.changes, 1); + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'alice'})`.changes, 1); + + const iter = sql.iterate`SELECT * FROM foo ORDER BY id ASC`; + const iterRows = []; + for (const row of iter) { + assert.ok(row); + assert.strictEqual(Object.getPrototypeOf(row), null); + iterRows.push(row.text); + } + assert.deepStrictEqual(iterRows, ['bob', 'mac', 'alice']); +}); + +test('queries with no results', () => { + const none = sql.get`SELECT * FROM foo WHERE text = ${'notfound'}`; + assert.strictEqual(none, undefined); + + const empty = sql.all`SELECT * FROM foo WHERE text = ${'notfound'}`; + assert.deepStrictEqual(empty, []); + + let count = 0; + // eslint-disable-next-line no-unused-vars + for (const _ of sql.iterate`SELECT * FROM foo WHERE text = ${'notfound'}`) { + count++; + } + assert.strictEqual(count, 0); +}); + +test('TagStore capacity, size, and clear', () => { + assert.strictEqual(sql.capacity, 10); + assert.strictEqual(sql.size, 0); + + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'one'})`.changes, 1); + assert.strictEqual(sql.size, 1); + + assert.ok(sql.get`SELECT * FROM foo WHERE text = ${'one'}`); + assert.strictEqual(sql.size, 2); + + // Using the same template string shouldn't increase the size + assert.strictEqual(sql.get`SELECT * FROM foo WHERE text = ${'two'}`, undefined); + assert.strictEqual(sql.size, 2); + + assert.strictEqual(sql.all`SELECT * FROM foo`.length, 1); + assert.strictEqual(sql.size, 3); + + sql.clear(); + assert.strictEqual(sql.size, 0); + assert.strictEqual(sql.capacity, 10); +}); + +test('sql.db returns the associated DatabaseSync instance', () => { + assert.strictEqual(sql.db, db); +}); + +test('sql error messages are descriptive', () => { + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'test'})`.changes, 1); + + // Test with non-existent column + assert.throws(() => { + const result = sql.get`SELECT nonexistent_column FROM foo`; + assert.fail(`Expected error, got: ${JSON.stringify(result)}`); + }, { + code: 'ERR_SQLITE_ERROR', + message: /no such column/i, + }); + + // Test with non-existent table + assert.throws(() => { + const result = sql.get`SELECT * FROM nonexistent_table`; + assert.fail(`Expected error, got: ${JSON.stringify(result)}`); + }, { + code: 'ERR_SQLITE_ERROR', + message: /no such table/i, + }); +}); + +test('a tag store keeps the database alive by itself', () => { + const sql = new DatabaseSync(':memory:').createTagStore(); + + sql.db.exec('CREATE TABLE test (data INTEGER)'); + + global.gc(); + + // eslint-disable-next-line no-unused-expressions + sql.run`INSERT INTO test (data) VALUES (1)`; +}); + +test('tag store prevents circular reference leaks', async () => { + const { gcUntil } = require('../common/gc'); + + const before = process.memoryUsage().heapUsed; + + // Create many SQLTagStore + DatabaseSync pairs with circular references + for (let i = 0; i < 1000; i++) { + const sql = new DatabaseSync(':memory:').createTagStore(); + sql.db.exec('CREATE TABLE test (data INTEGER)'); + // eslint-disable-next-line no-void + sql.db.setAuthorizer(() => void sql.db); + } + + // GC until memory stabilizes or give up after 20 attempts + await gcUntil('tag store leak check', () => { + const after = process.memoryUsage().heapUsed; + // Memory should not grow significantly (allow 50% margin for noise) + return after < before * 1.5; + }, 20); +}); diff --git a/test/js/node/test/parallel/test-sqlite-timeout.js b/test/js/node/test/parallel/test-sqlite-timeout.js new file mode 100644 index 000000000000..ff12f152cb55 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-timeout.js @@ -0,0 +1,73 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { test } = require('node:test'); +const { once } = require('node:events'); +const { Worker } = require('node:worker_threads'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +test('waits to acquire lock', { skip: typeof Bun !== 'undefined' }, async (t) => { // BUN: Worker emits 'online' only after the eval body yields; the body blocks synchronously in sqlite3_busy_timeout, so COMMIT on the parent never runs and the lock is never released. This is a Worker 'online'-timing difference (tracked separately), not a node:sqlite bug. + const DB_PATH = nextDb(); + const conn = new DatabaseSync(DB_PATH); + t.after(() => { + try { + conn.close(); + } catch { + // Ignore. + } + }); + + conn.exec('CREATE TABLE IF NOT EXISTS data (value TEXT)'); + conn.exec('BEGIN EXCLUSIVE;'); + const worker = new Worker(` + 'use strict'; + const { DatabaseSync } = require('node:sqlite'); + const { workerData } = require('node:worker_threads'); + const conn = new DatabaseSync(workerData.database, { timeout: 30000 }); + conn.exec('SELECT * FROM data'); + conn.close(); + `, { + eval: true, + workerData: { + database: DB_PATH, + } + }); + await once(worker, 'online'); + conn.exec('COMMIT;'); + await once(worker, 'exit'); +}); + +test('throws if the lock cannot be acquired before timeout', (t) => { + const DB_PATH = nextDb(); + const conn1 = new DatabaseSync(DB_PATH); + t.after(() => { + try { + conn1.close(); + } catch { + // Ignore. + } + }); + const conn2 = new DatabaseSync(DB_PATH, { timeout: 1 }); + t.after(() => { + try { + conn2.close(); + } catch { + // Ignore. + } + }); + + conn1.exec('CREATE TABLE IF NOT EXISTS data (value TEXT)'); + conn1.exec('PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE;'); + t.assert.throws(() => { + conn2.exec('SELECT * FROM data'); + }, /database is locked/); +}); diff --git a/test/js/node/test/parallel/test-sqlite-transactions.js b/test/js/node/test/parallel/test-sqlite-transactions.js new file mode 100644 index 000000000000..50b47829aca0 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-transactions.js @@ -0,0 +1,67 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +suite('manual transactions', () => { + test('a transaction is committed', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + t.assert.deepStrictEqual( + db.prepare('BEGIN').run(), + { changes: 0, lastInsertRowid: 0 }, + ); + t.assert.deepStrictEqual( + db.prepare('INSERT INTO data (key) VALUES (100)').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('COMMIT').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 100 }], + ); + }); + + test('a transaction is rolled back', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE data( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + t.assert.deepStrictEqual( + db.prepare('BEGIN').run(), + { changes: 0, lastInsertRowid: 0 }, + ); + t.assert.deepStrictEqual( + db.prepare('INSERT INTO data (key) VALUES (100)').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual( + db.prepare('ROLLBACK').run(), + { changes: 1, lastInsertRowid: 100 }, + ); + t.assert.deepStrictEqual(db.prepare('SELECT * FROM data').all(), []); + }); +}); diff --git a/test/js/node/test/parallel/test-sqlite-typed-array-and-data-view.js b/test/js/node/test/parallel/test-sqlite-typed-array-and-data-view.js new file mode 100644 index 000000000000..71d7b181a3d7 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-typed-array-and-data-view.js @@ -0,0 +1,62 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +const arrayBuffer = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; +const TypedArrays = [ + ['Int8Array', Int8Array], + ['Uint8Array', Uint8Array], + ['Uint8ClampedArray', Uint8ClampedArray], + ['Int16Array', Int16Array], + ['Uint16Array', Uint16Array], + ['Int32Array', Int32Array], + ['Uint32Array', Uint32Array], + ['Float32Array', Float32Array], + ['Float64Array', Float64Array], + ['BigInt64Array', BigInt64Array], + ['BigUint64Array', BigUint64Array], + ['DataView', DataView], +]; + +suite('StatementSync with TypedArray/DataView', () => { + for (const [displayName, TypedArray] of TypedArrays) { + test(displayName, (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + db.exec('CREATE TABLE test (data BLOB)'); + // insert + { + const stmt = db.prepare('INSERT INTO test VALUES (?)'); + stmt.run(new TypedArray(arrayBuffer)); + } + // select all + { + const stmt = db.prepare('SELECT * FROM test'); + const row = stmt.get(); + t.assert.ok(row.data instanceof Uint8Array); + t.assert.strictEqual(row.data.length, 8); + t.assert.deepStrictEqual(row.data, new Uint8Array(arrayBuffer)); + } + // query + { + const stmt = db.prepare('SELECT * FROM test WHERE data = ?'); + const rows = stmt.all(new TypedArray(arrayBuffer)); + t.assert.strictEqual(rows.length, 1); + t.assert.ok(rows[0].data instanceof Uint8Array); + t.assert.strictEqual(rows[0].data.length, 8); + t.assert.deepStrictEqual(rows[0].data, new Uint8Array(arrayBuffer)); + } + }); + } +}); diff --git a/test/js/node/test/parallel/test-sqlite.js b/test/js/node/test/parallel/test-sqlite.js new file mode 100644 index 000000000000..e6ad6747140f --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite.js @@ -0,0 +1,340 @@ +'use strict'; +const { spawnPromisified, skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const { DatabaseSync, constants } = require('node:sqlite'); +const { suite, test } = require('node:test'); +const { pathToFileURL } = require('node:url'); +const { nextDb } = require('../sqlite/next-db.js'); + +suite('accessing the node:sqlite module', () => { + test('cannot be accessed without the node: scheme', { skip: typeof Bun !== 'undefined' }, (t) => { // BUN: require('sqlite') throws 'ResolveMessage' (MODULE_NOT_FOUND code but different message/error class); the module IS node:-only, only the error shape differs. + t.assert.throws(() => { + require('sqlite'); + }, { + code: 'MODULE_NOT_FOUND', + message: /Cannot find module 'sqlite'/, + }); + }); + + test('can be disabled with --no-experimental-sqlite flag', { skip: typeof Bun !== 'undefined' }, async (t) => { // BUN: no --no-experimental-sqlite flag; node:sqlite is always available. + const { + stdout, + stderr, + code, + signal, + } = await spawnPromisified(process.execPath, [ + '--no-experimental-sqlite', + '-e', + 'require("node:sqlite")', + ]); + + t.assert.strictEqual(stdout, ''); + t.assert.match(stderr, /No such built-in module: node:sqlite/); + t.assert.notStrictEqual(code, 0); + t.assert.strictEqual(signal, null); + }); +}); + +test('ERR_SQLITE_ERROR is thrown for errors originating from SQLite', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + const setup = db.exec(` + CREATE TABLE test( + key INTEGER PRIMARY KEY + ) STRICT; + `); + t.assert.strictEqual(setup, undefined); + const stmt = db.prepare('INSERT INTO test (key) VALUES (?)'); + t.assert.deepStrictEqual(stmt.run(1), { changes: 1, lastInsertRowid: 1 }); + t.assert.throws(() => { + stmt.run(1); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'UNIQUE constraint failed: test.key', + errcode: 1555, + errstr: 'constraint failed', + }); +}); + +test('in-memory databases are supported', (t) => { + const db1 = new DatabaseSync(':memory:'); + const db2 = new DatabaseSync(':memory:'); + const setup1 = db1.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + const setup2 = db2.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + t.assert.strictEqual(setup1, undefined); + t.assert.strictEqual(setup2, undefined); + t.assert.deepStrictEqual( + db1.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); + t.assert.deepStrictEqual( + db2.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); +}); + +test('sqlite constants are defined', (t) => { + t.assert.strictEqual(constants.SQLITE_CHANGESET_OMIT, 0); + t.assert.strictEqual(constants.SQLITE_CHANGESET_REPLACE, 1); + t.assert.strictEqual(constants.SQLITE_CHANGESET_ABORT, 2); +}); + +test('PRAGMAs are supported', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + t.assert.deepStrictEqual( + db.prepare('PRAGMA journal_mode = WAL').get(), + { __proto__: null, journal_mode: 'wal' }, + ); + t.assert.deepStrictEqual( + db.prepare('PRAGMA journal_mode').get(), + { __proto__: null, journal_mode: 'wal' }, + ); +}); + +test('Buffer is supported as the database path', (t) => { + const db = new DatabaseSync(Buffer.from(nextDb())); + t.after(() => { db.close(); }); + db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); +}); + +test('URL is supported as the database path', (t) => { + const url = pathToFileURL(nextDb()); + const db = new DatabaseSync(url); + t.after(() => { db.close(); }); + db.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); +}); + +suite('URI query params', () => { + const baseDbPath = nextDb(); + const baseDb = new DatabaseSync(baseDbPath); + baseDb.exec(` + CREATE TABLE data(key INTEGER PRIMARY KEY); + INSERT INTO data (key) VALUES (1); + `); + baseDb.close(); + + test('query params are supported with URL objects', (t) => { + const url = pathToFileURL(baseDbPath); + url.searchParams.set('mode', 'ro'); + const readOnlyDB = new DatabaseSync(url); + t.after(() => { readOnlyDB.close(); }); + + t.assert.deepStrictEqual( + readOnlyDB.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); + t.assert.throws(() => { + readOnlyDB.exec('INSERT INTO data (key) VALUES (1);'); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'attempt to write a readonly database', + }); + }); + + test('query params are supported with string', (t) => { + const url = pathToFileURL(baseDbPath); + url.searchParams.set('mode', 'ro'); + + // Ensures a valid URI passed as a string is supported + const readOnlyDB = new DatabaseSync(url.toString()); + t.after(() => { readOnlyDB.close(); }); + + t.assert.deepStrictEqual( + readOnlyDB.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); + t.assert.throws(() => { + readOnlyDB.exec('INSERT INTO data (key) VALUES (1);'); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'attempt to write a readonly database', + }); + }); + + test('query params are supported with Buffer', (t) => { + const url = pathToFileURL(baseDbPath); + url.searchParams.set('mode', 'ro'); + + // Ensures a valid URI passed as a Buffer is supported + const readOnlyDB = new DatabaseSync(Buffer.from(url.toString())); + t.after(() => { readOnlyDB.close(); }); + + t.assert.deepStrictEqual( + readOnlyDB.prepare('SELECT * FROM data').all(), + [{ __proto__: null, key: 1 }] + ); + t.assert.throws(() => { + readOnlyDB.exec('INSERT INTO data (key) VALUES (1);'); + }, { + code: 'ERR_SQLITE_ERROR', + message: 'attempt to write a readonly database', + }); + }); +}); + +suite('SQL APIs enabled at build time', () => { + test('math functions are enabled', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.deepStrictEqual( + db.prepare('SELECT PI() AS pi').get(), + { __proto__: null, pi: 3.141592653589793 }, + ); + }); + + test('percentile is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE t1 (x INTEGER); + INSERT INTO t1 (x) VALUES (1), (2), (3), (4), (5); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT percentile(x, 50) AS p50 FROM t1;').get(), + { __proto__: null, p50: 3 }, + ); + }); + + test('dbstat is enabled', (t) => { + const db = new DatabaseSync(nextDb()); + t.after(() => { db.close(); }); + db.exec(` + CREATE TABLE t1 (key INTEGER PRIMARY KEY); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM dbstat WHERE name = \'t1\'').get(), + { + __proto__: null, + mx_payload: 0, + name: 't1', + ncell: 0, + pageno: 2, + pagetype: 'leaf', + path: '/', + payload: 0, + pgoffset: 4096, + pgsize: 4096, + unused: 4088 + }, + ); + }); + + test('fts3 is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING fts3(content TEXT); + INSERT INTO t1 (content) VALUES ('hello world'); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM t1 WHERE t1 MATCH \'hello\'').all(), + [ + { __proto__: null, content: 'hello world' }, + ], + ); + }); + + test('fts3 parenthesis is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING fts3(content TEXT); + INSERT INTO t1 (content) VALUES ('hello world'); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM t1 WHERE content MATCH \'(groupedterm1 OR groupedterm2) OR hello world\'').all(), + [ + { __proto__: null, content: 'hello world' }, + ], + ); + }); + + test('fts4 is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING fts4(content TEXT); + INSERT INTO t1 (content) VALUES ('hello world'); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM t1 WHERE t1 MATCH \'hello\'').all(), + [ + { __proto__: null, content: 'hello world' }, + ], + ); + }); + + test('fts5 is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING fts5(content); + INSERT INTO t1 (content) VALUES ('hello world'); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM t1 WHERE t1 MATCH \'hello\'').all(), + [ + { __proto__: null, content: 'hello world' }, + ], + ); + }); + + test('rtree is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING rtree(id, minX, maxX, minY, maxY); + INSERT INTO t1 (id, minX, maxX, minY, maxY) VALUES (1, 0, 1, 0, 1); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT * FROM t1 WHERE minX < 0.5').all(), + [ + { __proto__: null, id: 1, minX: 0, maxX: 1, minY: 0, maxY: 1 }, + ], + ); + }); + + test('rbu is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + t.assert.deepStrictEqual( + db.prepare('SELECT sqlite_compileoption_used(\'SQLITE_ENABLE_RBU\') as rbu_enabled;').get(), + { __proto__: null, rbu_enabled: 1 }, + ); + }); + + test('geopoly is enabled', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE VIRTUAL TABLE t1 USING geopoly(a,b,c); + INSERT INTO t1(_shape) VALUES('[[0,0],[1,0],[0.5,1],[0,0]]'); + `); + + t.assert.deepStrictEqual( + db.prepare('SELECT rowid FROM t1 WHERE geopoly_contains_point(_shape, 0, 0)').get(), + { __proto__: null, rowid: 1 }, + ); + }); +}); diff --git a/test/js/node/test/sqlite/next-db.js b/test/js/node/test/sqlite/next-db.js new file mode 100644 index 000000000000..ae657325362b --- /dev/null +++ b/test/js/node/test/sqlite/next-db.js @@ -0,0 +1,14 @@ +'use strict'; +require('../common'); +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); + +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +module.exports = { nextDb }; diff --git a/test/js/node/test/sqlite/worker.js b/test/js/node/test/sqlite/worker.js new file mode 100644 index 000000000000..8d1ca2420c93 --- /dev/null +++ b/test/js/node/test/sqlite/worker.js @@ -0,0 +1,24 @@ +// This worker is used for one of the tests in test-sqlite-session.js + +'use strict'; +require('../common'); +const { parentPort, workerData } = require('worker_threads'); +const { DatabaseSync, constants } = require('node:sqlite'); +const { changeset, mode, dbPath } = workerData; + +const db = new DatabaseSync(dbPath); + +const options = {}; +if (mode !== constants.SQLITE_CHANGESET_ABORT && mode !== constants.SQLITE_CHANGESET_OMIT) { + throw new Error('Unexpected value for mode'); +} +options.onConflict = () => mode; + +try { + const result = db.applyChangeset(changeset, options); + parentPort.postMessage({ mode, result, error: null }); +} catch (error) { + parentPort.postMessage({ mode, result: null, errorMessage: error.message, errcode: error.errcode }); +} finally { + db.close(); // Just to make sure it is closed ASAP +} diff --git a/test/regression/issue/25707.test.ts b/test/regression/issue/25707.test.ts index 22d9b5444480..17fb2a2faf94 100644 --- a/test/regression/issue/25707.test.ts +++ b/test/regression/issue/25707.test.ts @@ -9,7 +9,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; test("require() of CJS file containing dynamic import of non-existent node: module does not fail at load time", async () => { using dir = tempDir("issue-25707", { // Simulates turbopack-generated chunks: a CJS module with a factory function - // containing import("node:sqlite") inside a try/catch that is never called + // containing import("node:quic") inside a try/catch that is never called // during require(). "chunk.js": ` module.exports = [ @@ -18,7 +18,7 @@ test("require() of CJS file containing dynamic import of non-existent node: modu if ("createSession" in e) { let c; try { - ({DatabaseSync: c} = await import("node:sqlite")) + ({QuicEndpoint: c} = await import("node:quic")) } catch(a) { if (null !== a && "object" == typeof a && "code" in a && "ERR_UNKNOWN_BUILTIN_MODULE" !== a.code) throw a; @@ -30,7 +30,7 @@ test("require() of CJS file containing dynamic import of non-existent node: modu ]; `, "main.js": ` - // This require() should not fail even though chunk.js contains import("node:sqlite") + // This require() should not fail even though chunk.js contains import("node:quic") const factories = require("./chunk.js"); console.log("loaded " + factories.length + " factories"); `, @@ -56,8 +56,8 @@ test("require() of CJS file with bare dynamic import of non-existent node: modul using dir = tempDir("issue-25707-bare", { "lib.js": ` module.exports = async function() { - const { DatabaseSync } = await import("node:sqlite"); - return DatabaseSync; + const { QuicEndpoint } = await import("node:quic"); + return QuicEndpoint; }; `, "main.js": ` @@ -85,7 +85,7 @@ test("dynamic import of non-existent node: module in CJS rejects at runtime with "lib.js": ` module.exports = async function() { try { - const { DatabaseSync } = await import("node:sqlite"); + await import("node:quic"); return "resolved"; } catch (e) { return "caught: " + e.code; From d8cbdba849f3f4ec2fb0ca1f14ad03be6f29ec1f Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 16:00:23 -0700 Subject: [PATCH 02/33] =?UTF-8?q?node:sqlite:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20bundled=20sqlite=20on=20macOS,=20Session/SQLTagStor?= =?UTF-8?q?e=20exports,=20Node-matching=20bind=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (blast radius): - default staticSqlite=true on darwin so bun:sqlite and node:sqlite share one library and one POSIX-lock inode map (howtocorrupt §2.2.1); setCustomSQLite() becomes a --static-sqlite=off opt-in - backup(): drop the invented 5s BUSY cap (Node retries indefinitely); correct the threading-model comment; TODO(threadpool) noted - document bun:sqlite blast radius of PREUPDATE_HOOK/session defines Tier 2 (correctness): - exit-time WAL checkpoint for bun:sqlite too (JSSQLStatement.cpp), and zero busy_timeout first so a cross-process reader can't stall exit - delete write-only m_ignoreNextSqliteError state — pending JS exception on the VM is the signal - bindValue/jsValueToSqliteResult: always sqlite3_bind_double for JS numbers (Node has no IsInt32 fast path; JSC's isInt32 is a tag-bit check) - bind detached ArrayBufferView as X'' not NULL (matches Node) - iterator return() comment: the finalized-tolerance is a divergence, not a match - enableLoadExtension(true) on {allowExtension:false}: match Node's message - export Session and SQLTagStore constructors (throw ERR_ILLEGAL_CONSTRUCTOR) - add lazy_sqlite3.h dlsym stubs for busy_timeout/wal_checkpoint_v2 Tier 3 (quality): - decode SQLite-owned bytes with fromUTF8ReplacingInvalidSequences (shared sqliteText helper) — the drift #31514 fixed for bun:sqlite - extract statementStep{Run,Get,All} shared by StatementSync and TagStore - reportExtraMemoryAllocated for JSStatementSync via SQLITE_STMTSTATUS_MEMUSED - SQLITE_PREPARE_PERSISTENT for TagStore-cached statements - flip nodejs-compat.mdx node:sqlite → 🟢 Tests: cross-module same-file, worker-owned db close on exit, GC stress, loadExtension negative paths, Session/SQLTagStore exports, non-UTF-8 TEXT, detached-view bind, JS-number-as-REAL, bun:sqlite exit-time WAL. --- docs/runtime/nodejs-compat.mdx | 2 +- docs/runtime/sqlite.mdx | 19 +- scripts/build/config.ts | 10 +- scripts/build/deps/sqlite.ts | 19 +- src/jsc/bindings/ZigGlobalObject.cpp | 6 + src/jsc/bindings/sqlite/JSSQLStatement.cpp | 7 + src/jsc/bindings/sqlite/NodeSqlite.cpp | 427 +++++++++++---------- src/jsc/bindings/sqlite/NodeSqlite.h | 81 +++- src/jsc/bindings/sqlite/lazy_sqlite3.h | 8 + src/jsc/modules/NodeSqliteModule.h | 8 +- test/js/bun/sqlite/sqlite.test.js | 37 ++ test/js/node/sqlite/node-sqlite.test.ts | 269 ++++++++++++- 12 files changed, 629 insertions(+), 264 deletions(-) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index d08cfefc4b90..38f491b8dc96 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -173,7 +173,7 @@ This page is updated regularly and reflects the latest version of Bun's compatib ### [`node:sqlite`](https://nodejs.org/api/sqlite.html) -🔴 Not implemented. +🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread). ### [`node:test`](https://nodejs.org/api/test.html) diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index ea945e9baf77..e66927760e8f 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -628,24 +628,7 @@ db.loadExtension("myext"); ``` -**macOS users** By default, macOS ships with Apple's proprietary build of SQLite, which doesn't support extensions. To use extensions, install a vanilla build of SQLite. - -```bash terminal icon="terminal" -brew install sqlite -which sqlite # get path to binary -``` - -To point `bun:sqlite` to the new build, call `Database.setCustomSQLite(path)` before creating any `Database` instances. (On other operating systems, this is a no-op.) Pass a path to the SQLite `.dylib` file, _not_ the executable. With recent versions of Homebrew this is something like `/opt/homebrew/Cellar/sqlite//libsqlite3.dylib`. - -```ts db.ts icon="/icons/typescript.svg" highlight={3} -import { Database } from "bun:sqlite"; - -Database.setCustomSQLite("/path/to/libsqlite.dylib"); - -const db = new Database(); -db.loadExtension("myext"); -``` - +Bun bundles its own SQLite build on all platforms with extension loading enabled, so `loadExtension()` works out of the box on macOS. `Database.setCustomSQLite(path)` is retained for backward compatibility but is a no-op in the default build; it only takes effect on custom builds compiled with `--static-sqlite=off`. Mixing a custom SQLite with `node:sqlite` (which always uses the bundled copy) on the same file is unsafe — see [SQLite: How To Corrupt §2.2.1](https://www.sqlite.org/howtocorrupt.html#posix_close_bug). ### `.fileControl(cmd: number, value: any)` diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 17b137772961..8ad3260b1d09 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -855,11 +855,11 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con const canary = partial.canary ?? true; const canaryRevision = canary ? "1" : "0"; - // Whether bun:sqlite links the bundled sqlite3 directly (LAZY_LOAD_SQLITE=0) - // or dlopens the system library at runtime (the macOS default). The bundled - // sqlite3.c is compiled on every platform regardless — node:sqlite always - // uses it (see scripts/build/deps/sqlite.ts). - const staticSqlite = partial.staticSqlite ?? !darwin; + // Link the bundled sqlite3 into bun:sqlite (LAZY_LOAD_SQLITE=0). Default + // true everywhere: node:sqlite requires it regardless, and two SQLite + // libraries in one process is a POSIX-lock corruption vector + // (howtocorrupt.html §2.2.1). --static-sqlite=off restores macOS dlopen. + const staticSqlite = partial.staticSqlite ?? true; // Static libatomic: on by default. Arch/Manjaro don't ship libatomic.a — // those users pass --static-libatomic=off. Not auto-detected: the link diff --git a/scripts/build/deps/sqlite.ts b/scripts/build/deps/sqlite.ts index 06e59bcfce67..ba38b1a06dcc 100644 --- a/scripts/build/deps/sqlite.ts +++ b/scripts/build/deps/sqlite.ts @@ -5,17 +5,16 @@ * sqlite3 amalgamation (single .c file). No fetch step; tracked in git. * * Always built: node:sqlite uses the bundled copy unconditionally (matching - * Node.js). bun:sqlite additionally supports dlopen()ing the system sqlite - * on macOS when staticSqlite=false (LAZY_LOAD_SQLITE=1), but NodeSqlite.cpp - * includes sqlite3_local.h directly and links against these symbols on - * every platform. + * Node.js). bun:sqlite links the same object (staticSqlite defaults true on + * every platform; --static-sqlite=off restores the macOS dlopen path but + * ships two SQLite libraries in one process — see the corruption caveat in + * config.ts). * * Bundling on macOS (previously dlopen-only there) grows the darwin binaries * by ~1.8 MB. That is the cost of node:sqlite parity: Apple's system * libsqlite3 ships without the session extension or percentile() and with * extension loading disabled, so the bundled build is required — Node.js - * bundles SQLite for the same reason. Linux/Windows already linked the - * bundled copy. + * bundles SQLite for the same reason. */ import type { Dependency } from "../source.ts"; @@ -48,7 +47,10 @@ export const sqlite: Dependency = { // node:sqlite exposes createSession/applyChangeset + columns() // metadata. Match Node.js's compile-time feature set so those // APIs work identically. PREUPDATE_HOOK is a prerequisite for the - // session extension. + // session extension. bun:sqlite links the same object, so it too + // now sees dbstat/geopoly/percentile and pays PREUPDATE_HOOK's + // codegen cost on write paths — measured to be noise on + // INSERT-OR-REPLACE / bulk-insert benches; kept for Node parity. SQLITE_ENABLE_SESSION: 1, SQLITE_ENABLE_PREUPDATE_HOOK: 1, SQLITE_ENABLE_DBSTAT_VTAB: 1, @@ -56,6 +58,9 @@ export const sqlite: Dependency = { SQLITE_ENABLE_RBU: 1, SQLITE_ENABLE_PERCENTILE: 1, }, + // The sqlite3_* API symbols are hidden by the -fvisibility=hidden + // default computeDepFlags applies to every dep object; SQLITE_API + // (default `extern`) does not override it. cflags: [ "-Wno-incompatible-pointer-types-discards-qualifiers", // Match the static CRT bun links; /U_DLL keeps sqlite from picking diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index aadfae0e471a..10fc98e50ea6 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2680,8 +2680,11 @@ void GlobalObject::finishCreation(VM& vm) auto* prototype = Bun::JSNodeSqliteSessionPrototype::create( init.vm, init.global, Bun::JSNodeSqliteSessionPrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); auto* structure = Bun::JSNodeSqliteSession::createStructure(init.vm, init.global, prototype); + auto* constructor = Bun::JSNodeSqliteSessionConstructor::create( + init.vm, init.global, Bun::JSNodeSqliteSessionConstructor::createStructure(init.vm, init.global, init.global->functionPrototype()), prototype); init.setPrototype(prototype); init.setStructure(structure); + init.setConstructor(constructor); }); m_JSNodeSqliteLimitsClassStructure.initLater( @@ -2698,8 +2701,11 @@ void GlobalObject::finishCreation(VM& vm) auto* prototype = Bun::JSNodeSqliteTagStorePrototype::create( init.vm, init.global, Bun::JSNodeSqliteTagStorePrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); auto* structure = Bun::JSNodeSqliteTagStore::createStructure(init.vm, init.global, prototype); + auto* constructor = Bun::JSNodeSqliteTagStoreConstructor::create( + init.vm, init.global, Bun::JSNodeSqliteTagStoreConstructor::createStructure(init.vm, init.global, init.global->functionPrototype()), prototype); init.setPrototype(prototype); init.setStructure(structure); + init.setConstructor(constructor); }); m_JSFFIFunctionStructure.initLater( diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 832ee947ea62..b5b5094fe559 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -280,6 +280,13 @@ extern "C" void Bun__closeAllSQLiteDatabasesForTermination() for (auto& db : dbs) { if (db->db) { + // With un-finalized statements close_v2 zombifies the connection + // and defers the WAL checkpoint to a finalize that never comes. + // Checkpoint explicitly so nothing is stranded in the -wal file; + // zero busy_timeout first so a cross-process reader can't stall + // process.exit() via TRUNCATE's busy-handler wait. + sqlite3_busy_timeout(db->db, 0); + sqlite3_wal_checkpoint_v2(db->db, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); // close_v2: with unfinalized statements still alive, plain // sqlite3_close() returns SQLITE_BUSY and leaves the connection // open, which would leak it once the pointer is nulled below. diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index b9aff384d4cb..d70836c807c7 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -87,6 +87,20 @@ namespace Bun { using namespace JSC; using namespace WebCore; +// SQLite TEXT / column names / errmsg can carry non-UTF-8 bytes (a Latin-1 +// blob CAST to TEXT, a column aliased with such a string). WTF::String:: +// fromUTF8 returns a NULL string on any invalid byte and jsString(null) then +// yields "" — the drift #31514 fixed for bun:sqlite. Decode with U+FFFD +// replacement everywhere we surface SQLite-owned bytes to JS. +static ALWAYS_INLINE WTF::String sqliteText(const char* p, size_t len) +{ + return WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast(p), len }); +} +static ALWAYS_INLINE WTF::String sqliteText(const char* p) +{ + return p ? sqliteText(p, strlen(p)) : WTF::String(); +} + // ───────────────────────────────────────────────────────────────────────────── // Error helpers (match Node.js node_sqlite.cc shapes) // ───────────────────────────────────────────────────────────────────────────── @@ -98,7 +112,7 @@ static JSObject* createNodeSqliteError(JSGlobalObject* globalObject, sqlite3* db const char* errstr = sqlite3_errstr(errcode); const char* errmsg = sqlite3_errmsg(db); auto* zigGlobal = defaultGlobalObject(globalObject); - JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, WTF::String::fromUTF8(errmsg)); + JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, sqliteText(errmsg)); error->putDirect(vm, Identifier::fromString(vm, "errcode"_s), jsNumber(errcode), 0); error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(errstr)), 0); return error; @@ -212,15 +226,10 @@ static bool readBoolOption(JSGlobalObject* globalObject, ThrowScope& scope, JSOb // After a sqlite3_step/sqlite3_exec that may have re-entered JS via a // user-defined function: if the JS callback threw, the pending exception // on the VM is the real error and any SQLITE_ERROR from sqlite is just -// the "user function raised an exception" wrapper. Propagate the JS -// exception instead. Expands to RETURN_IF_EXCEPTION so JSC's -// validateExceptionChecks records the check after each step() — a plain -// `if (scope.exception())` does not satisfy it. -#define CHECK_UDF_EXCEPTION(scope, db) \ - do { \ - if (db) (db)->takeIgnoreNextSqliteError(); \ - RETURN_IF_EXCEPTION(scope, {}); \ - } while (0) +// the "user function raised an exception" wrapper — propagate it instead. +// Node's m_ignoreNextSqliteError flag is unnecessary; the pending +// exception IS the signal. +#define CHECK_UDF_EXCEPTION(scope) RETURN_IF_EXCEPTION(scope, {}) // ───────────────────────────────────────────────────────────────────────────── // sqlite3_value* ⇄ JSValue conversions for user-defined functions and @@ -267,7 +276,7 @@ static JSValue sqliteValueToJS(JSGlobalObject* globalObject, TopExceptionScope& size_t len = sqlite3_value_bytes(value); const unsigned char* text = sqlite3_value_text(value); if (len == 0 || text == nullptr) return jsEmptyString(vm); - return jsString(vm, WTF::String::fromUTF8({ reinterpret_cast(text), len })); + return jsString(vm, sqliteText(reinterpret_cast(text), len)); } case SQLITE_NULL: return jsNull(); @@ -293,11 +302,9 @@ static void jsValueToSqliteResult(JSGlobalObject* globalObject, sqlite3_context* { if (value.isUndefinedOrNull()) { sqlite3_result_null(ctx); - } else if (value.isInt32()) { - // Match bindValue(): int32 results keep INTEGER storage class so - // `typeof(udf())` on a function returning 42 yields 'integer'. - sqlite3_result_int(ctx, value.asInt32()); } else if (value.isNumber()) { + // Match Node: always REAL. isInt32() is a tag-bit check — branching + // on it would be representation-dependent (see bindValue()). sqlite3_result_double(ctx, value.asNumber()); } else if (value.isString()) { auto str = value.toWTFString(globalObject); @@ -312,7 +319,11 @@ static void jsValueToSqliteResult(JSGlobalObject* globalObject, sqlite3_context* sqlite3_result_text64(ctx, utf8.data(), utf8.length(), SQLITE_TRANSIENT, SQLITE_UTF8); } else if (auto* view = dynamicDowncast(value)) { auto span = view->span(); - sqlite3_result_blob64(ctx, span.data(), span.size(), SQLITE_TRANSIENT); + // sqlite3_result_blob64(nullptr, 0) sets NULL, not an empty BLOB — + // Node binds a zero-length BLOB for a detached view (its + // ArrayBufferViewContents falls back to non-null stack storage), so + // hand SQLite a non-null sentinel when the vector is gone. + sqlite3_result_blob64(ctx, span.data() ? static_cast(span.data()) : "", span.size(), SQLITE_TRANSIENT); } else if (value.isBigInt()) { int64_t as_int = JSBigInt::toBigInt64(value); JSValue roundTrip = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, as_int); @@ -374,7 +385,6 @@ struct NodeSqliteUDF { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto abortWithPending = [&] { - self->db_->setIgnoreNextSqliteError(); sqlite3_result_error(ctx, "", 0); }; if (scope.exception()) [[unlikely]] @@ -469,7 +479,6 @@ struct NodeSqliteAggregate { MarkedArgumentBuffer noArgs; startV = JSC::call(globalObject_, startV, callData, jsNull(), noArgs); if (scope.exception()) [[unlikely]] { - db_->setIgnoreNextSqliteError(); sqlite3_result_error(ctx, "", 0); return nullptr; } @@ -496,7 +505,6 @@ struct NodeSqliteAggregate { // observe via CHECK_UDF_EXCEPTION. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto abortWithPending = [&] { - db_->setIgnoreNextSqliteError(); sqlite3_result_error(ctx, "", 0); }; if (scope.exception()) [[unlikely]] @@ -525,7 +533,7 @@ struct NodeSqliteAggregate { { auto& vm = getVM(globalObject_); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - (void)vm; + // An exception from an earlier xStep may still be pending — // don't re-enter JS (or overwrite sqlite3_result_error with a // NULL result) in that case; just tear down the state. @@ -555,7 +563,6 @@ struct NodeSqliteAggregate { auto callData = JSC::getCallData(rfn); result = JSC::call(globalObject_, rfn, callData, jsNull(), args); if (scope.exception()) [[unlikely]] { - db_->setIgnoreNextSqliteError(); sqlite3_result_error(ctx, "", 0); if (isFinal) destroyState(ctx); return; @@ -564,9 +571,7 @@ struct NodeSqliteAggregate { result = state->value.get(); } jsValueToSqliteResult(globalObject_, ctx, result); - if (scope.exception()) [[unlikely]] { - db_->setIgnoreNextSqliteError(); - } + (void)scope.exception(); if (isFinal) destroyState(ctx); } @@ -631,7 +636,7 @@ static inline JSValue columnToJS(JSGlobalObject* globalObject, ThrowScope& scope size_t len = sqlite3_column_bytes(stmt, i); const unsigned char* text = sqlite3_column_text(stmt, i); if (len == 0 || text == nullptr) return jsEmptyString(vm); - return jsString(vm, WTF::String::fromUTF8({ reinterpret_cast(text), len })); + return jsString(vm, sqliteText(reinterpret_cast(text), len)); } case SQLITE_NULL: return jsNull(); @@ -666,7 +671,7 @@ static JSValue rowToObject(JSGlobalObject* globalObject, ThrowScope& scope, sqli // Column names are user-controlled (`SELECT 1 AS "0"`); an // index-string key must go to indexed storage, not through // putDirect's named-property path (which asserts !parseIndex). - row->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, WTF::String::fromUTF8(name)), v); + row->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, sqliteText(name)), v); RETURN_IF_EXCEPTION(scope, {}); } return row; @@ -711,6 +716,7 @@ static JSValue rowToObjectCached(JSGlobalObject* globalObject, ThrowScope& scope static JSValue rowToArray(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3_stmt* stmt, int numCols, bool useBigInts) { auto& vm = getVM(globalObject); + (void)vm; JSArray* row = constructEmptyArray(globalObject, nullptr, numCols); RETURN_IF_EXCEPTION(scope, {}); for (int i = 0; i < numCols; ++i) { @@ -719,7 +725,6 @@ static JSValue rowToArray(JSGlobalObject* globalObject, ThrowScope& scope, sqlit row->putDirectIndex(globalObject, i, v); RETURN_IF_EXCEPTION(scope, {}); } - (void)vm; return row; } @@ -843,9 +848,15 @@ extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObje continue; // With un-finalized statements close_v2 only zombifies the connection // and defers the WAL checkpoint to a finalize that never comes, so - // flush the WAL into the main database file explicitly. Best effort. - if (sqlite3* handle = db->connection()) + // flush the WAL into the main database file explicitly. Zero + // busy_timeout first: TRUNCATE waits on readers via the connection's + // busy-handler, so a large user-set {timeout: N} plus a cross-process + // reader would otherwise stall process.exit() for up to N ms; with a + // zero handler TRUNCATE degrades to a passive checkpoint immediately. + if (sqlite3* handle = db->connection()) { + sqlite3_busy_timeout(handle, 0); sqlite3_wal_checkpoint_v2(handle, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); + } // closeInternal() re-takes openDatabasesLock to unregister, so the // snapshot lock above must already be dropped; it also nulls m_db, // making a later GC destructor a no-op rather than a double close. @@ -1110,7 +1121,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncExec, (JSGlobalObject * globalObject, Cal RETURN_IF_EXCEPTION(scope, {}); auto utf8 = sql.utf8(); int r = sqlite3_exec(self->connection(), utf8.data(), nullptr, nullptr, nullptr); - CHECK_UDF_EXCEPTION(scope, self); + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { throwSqliteError(globalObject, scope, self->connection()); return {}; @@ -1134,7 +1145,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, int r = sqlite3_prepare_v2(self->connection(), utf8.data(), static_cast(utf8.length()), &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized". - CHECK_UDF_EXCEPTION(scope, self); + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { throwSqliteError(globalObject, scope, self->connection()); return {}; @@ -1197,7 +1208,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncLocation, (JSGlobalObject * globalObject, if (filename == nullptr || filename[0] == '\0') { return JSValue::encode(jsNull()); } - return JSValue::encode(jsString(vm, WTF::String::fromUTF8(filename))); + return JSValue::encode(jsString(vm, sqliteText(filename))); } JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -1210,7 +1221,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension, (JSGlobalObject * gl } bool allow = arg0.asBoolean(); if (allow && !self->allowLoadExtension()) { - return throwNodeState(globalObject, scope, "extension loading is not allowed"_s); + return throwNodeState(globalObject, scope, + "Cannot enable extension loading because it was disabled at database creation."_s); } int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, allow ? 1 : 0, nullptr); if (r != SQLITE_OK) { @@ -1252,7 +1264,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncLoadExtension, (JSGlobalObject * globalOb char* errmsg = nullptr; int r = sqlite3_load_extension(self->connection(), pathUtf8.data(), entryPtr, &errmsg); if (r != SQLITE_OK) { - WTF::String message = errmsg ? WTF::String::fromUTF8(errmsg) : WTF::String::fromUTF8(sqlite3_errstr(r)); + WTF::String message = errmsg ? sqliteText(errmsg) : WTF::String::fromUTF8(sqlite3_errstr(r)); if (errmsg) sqlite3_free(errmsg); Bun::throwError(globalObject, scope, ErrorCode::ERR_LOAD_SQLITE_EXTENSION, message); return {}; @@ -1514,7 +1526,6 @@ static int applyChangesetXConflict(void* pCtx, int eConflict, sqlite3_changeset_ auto callData = JSC::getCallData(ctx->onConflict); JSValue ret = JSC::call(globalObject, ctx->onConflict, callData, jsNull(), args); if (scope.exception()) [[unlikely]] { - ctx->db->setIgnoreNextSqliteError(); return SQLITE_CHANGESET_ABORT; } // Node returns the raw value to sqlite only when it IsInt32(); a @@ -1542,11 +1553,10 @@ static int applyChangesetXFilter(void* pCtx, const char* zTab) if (scope.exception()) [[unlikely]] return 0; MarkedArgumentBuffer args; - args.append(jsString(vm, WTF::String::fromUTF8(zTab))); + args.append(jsString(vm, sqliteText(zTab))); auto callData = JSC::getCallData(ctx->filter); JSValue ret = JSC::call(globalObject, ctx->filter, callData, jsNull(), args); if (scope.exception()) [[unlikely]] { - ctx->db->setIgnoreNextSqliteError(); return 0; } bool keep = ret.toBoolean(globalObject); @@ -1625,7 +1635,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset, (JSGlobalObject * globalO int r = sqlite3changeset_apply(self->connection(), static_cast(owned.size()), owned.mutableSpan().data(), applyChangesetXFilter, applyChangesetXConflict, &ctx); - CHECK_UDF_EXCEPTION(scope, self); + CHECK_UDF_EXCEPTION(scope); if (r == SQLITE_ABORT) { // Conflict handler returned ABORT — Node.js surfaces this as // `false` rather than throwing. @@ -1662,9 +1672,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableDefensive, (JSGlobalObject * global // Uses TopExceptionScope for the same reason xFunc does: the destructor // of a nested ThrowScope would simulateThrow(), tripping the next // callback's constructor under validateExceptionChecks. A thrown JS -// exception (or a non-integer / out-of-range return) becomes SQLITE_DENY -// plus setIgnoreNextSqliteError() so the outer host function surfaces -// the JS error instead of "not authorized". +// exception (or a non-integer / out-of-range return) becomes SQLITE_DENY; +// the pending exception on the VM is what the outer host function's +// CHECK_UDF_EXCEPTION observes to surface it over "not authorized". static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const char* p1, const char* p2, const char* p3, const char* p4) { auto* db = static_cast(userData); @@ -1677,7 +1687,7 @@ static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const ch return SQLITE_OK; auto toJS = [&](const char* s) -> JSValue { - return s ? jsString(vm, WTF::String::fromUTF8(s)) : jsNull(); + return s ? jsString(vm, sqliteText(s)) : jsNull(); }; MarkedArgumentBuffer args; @@ -1690,7 +1700,6 @@ static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const ch auto callData = JSC::getCallData(fn); JSValue result = JSC::call(globalObject, fn, callData, jsUndefined(), args); if (scope.exception()) [[unlikely]] { - db->setIgnoreNextSqliteError(); return SQLITE_DENY; } @@ -1713,7 +1722,6 @@ static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const ch inner.release(); } (void)scope.exception(); - db->setIgnoreNextSqliteError(); return SQLITE_DENY; }; @@ -1782,7 +1790,6 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject // JS exception over SQLite's "not authorized" — same as // exec()/prepare()/deserialize()/TagStore. On this path `data` is // already null (no cleanup needed). - self->takeIgnoreNextSqliteError(); if (scope.exception()) [[unlikely]] { if (data) sqlite3_free(data); return {}; @@ -1904,7 +1911,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObje // sqlite3_prepare_v2, which fires the authorizer callback with // SQLITE_ATTACH. If that throws, surface the user's exception over // SQLite's "not authorized" — same as exec()/prepare()/TagStore. - CHECK_UDF_EXCEPTION(scope, self); + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { // SQLite already freed `owned` (or took ownership) on both // success and failure paths once FREEONCLOSE is set. The @@ -2217,6 +2224,9 @@ void JSStatementSync::finishCreation(VM& vm, JSDatabaseSync* db, sqlite3_stmt* s m_stmt = stmt; m_originGeneration = db->openGeneration(); m_database.set(vm, this, db); + m_extraMemorySize = static_cast(sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_MEMUSED, 0)); + if (m_extraMemorySize) + vm.heap.reportExtraMemoryAllocated(this, m_extraMemorySize); } void JSStatementSync::finalizeStatement() @@ -2258,6 +2268,7 @@ template void JSStatementSync::visitChildrenImpl(JSCell* cell, Visitor& visitor) { auto* thisObject = uncheckedDowncast(cell); + visitor.reportExtraMemoryVisited(thisObject->m_extraMemorySize); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); visitor.append(thisObject->m_database); @@ -2319,7 +2330,7 @@ Structure* JSStatementSync::ensureRowStructure(JSGlobalObject* globalObject) m_columnOffsets.clear(); return nullptr; } - auto id = Identifier::fromString(vm, WTF::String::fromUTF8(name)); + auto id = Identifier::fromString(vm, sqliteText(name)); // Structure::addPropertyTransition asserts !parseIndex() — // a column aliased to "0", "1", … must go through indexed // storage instead. Bail to the generic path, which handles @@ -2374,14 +2385,10 @@ bool JSStatementSync::bindValue(JSGlobalObject* globalObject, ThrowScope& scope, { int r = SQLITE_OK; if (value.isNumber()) { - // Match Node's IsInt32() → sqlite3_bind_int fast path so that - // `typeof(?)` on a bare parameter yields 'integer' (not 'real') - // and expandedSQL shows `42`, not `42.0`. - if (value.isInt32()) { - r = sqlite3_bind_int(m_stmt, index, value.asInt32()); - } else { - r = sqlite3_bind_double(m_stmt, index, value.asNumber()); - } + // Match Node: always bind_double, no IsInt32 fast path. Branching on + // JSC's isInt32() (a tag-bit check) would be representation-dependent: + // literal 42 vs Float64Array[0]=42 would get different storage classes. + r = sqlite3_bind_double(m_stmt, index, value.asNumber()); } else if (value.isString()) { auto str = value.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, false); @@ -2403,7 +2410,12 @@ bool JSStatementSync::bindValue(JSGlobalObject* globalObject, ThrowScope& scope, r = sqlite3_bind_int64(m_stmt, index, iv); } else if (auto* view = dynamicDowncast(value)) { auto span = view->span(); - r = sqlite3_bind_blob64(m_stmt, index, span.data(), span.size(), SQLITE_TRANSIENT); + // sqlite3_bind_blob64(nullptr, 0) leaves the parameter as NULL (see + // sqlite3.c:bindText's `if(zData!=0)` guard). A detached view's + // span() is {nullptr, 0}; Node binds it as a zero-length BLOB (its + // ArrayBufferViewContents falls back to non-null stack storage), so + // hand SQLite a non-null sentinel when the vector is gone. + r = sqlite3_bind_blob64(m_stmt, index, span.data() ? static_cast(span.data()) : "", span.size(), SQLITE_TRANSIENT); } else { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, makeString("Provided value cannot be bound to SQLite parameter "_s, index)); @@ -2439,7 +2451,7 @@ bool JSStatementSync::bindParams(JSGlobalObject* globalObject, ThrowScope& scope for (int i = 1; i <= paramCount; ++i) { const char* full = sqlite3_bind_parameter_name(m_stmt, i); if (full == nullptr || full[0] == '\0') continue; - WTF::String fullStr = WTF::String::fromUTF8(full); + WTF::String fullStr = sqliteText(full); WTF::String bareName = fullStr.substring(1); auto it = bare.find(bareName); if (it != bare.end()) { @@ -2532,32 +2544,28 @@ struct StatementResetter { } }; -JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) +// ─── Post-bind step drivers ───────────────────────────────────────────────── +// Shared by jsStatementSync{Run,Get,All} and jsTagStore{Run,Get,All}. Callers +// have already reset/bound the statement and hold a BusyScope; these own the +// StatementResetter and the step loop so both entry points behave identically. + +static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { - THIS_STATEMENT(); - REQUIRE_STMT(self); - BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); - if (!self->bindParams(globalObject, scope, callFrame)) return {}; StatementResetter resetter { self->statement() }; - int r = sqlite3_step(self->statement()); - while (r == SQLITE_ROW) { + while (r == SQLITE_ROW) r = sqlite3_step(self->statement()); - } - CHECK_UDF_EXCEPTION(scope, self->database()); + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_DONE && r != SQLITE_OK) { throwSqliteError(globalObject, scope, self->connection()); return {}; } - // Don't go through self->connection() here: a named-parameter getter - // or UDF callback may have called db.close() since REQUIRE_STMT, in - // which case the wrapper's m_db is now null and sqlite3_changes64(NULL) - // is a raw db->nChange deref (no SQLITE_ENABLE_API_ARMOR in this build). - // sqlite3_db_handle() reads the statement's own back-pointer, which - // survives zombification and is what Node's StatementSync::Run uses. + // or UDF callback may have called db.close() since the caller's + // liveness check, in which case the wrapper's m_db is now null and + // sqlite3_changes64(NULL) is a raw db->nChange deref. sqlite3_db_handle + // reads the statement's own back-pointer, which survives zombification + // and is what Node's StatementSync::Run uses. sqlite3* db = sqlite3_db_handle(self->statement()); JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); RETURN_IF_EXCEPTION(scope, {}); @@ -2575,18 +2583,11 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, Cal return JSValue::encode(result); } -JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { - THIS_STATEMENT(); - REQUIRE_STMT(self); - BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); - if (!self->bindParams(globalObject, scope, callFrame)) return {}; StatementResetter resetter { self->statement() }; - int r = sqlite3_step(self->statement()); - CHECK_UDF_EXCEPTION(scope, self->database()); + CHECK_UDF_EXCEPTION(scope); if (r == SQLITE_DONE) return JSValue::encode(jsUndefined()); if (r != SQLITE_ROW) { throwSqliteError(globalObject, scope, self->connection()); @@ -2601,28 +2602,19 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, Cal return JSValue::encode(row); } -JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, CallFrame* callFrame)) +static EncodedJSValue statementStepAll(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { - THIS_STATEMENT(); - REQUIRE_STMT(self); - BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); - if (!self->bindParams(globalObject, scope, callFrame)) return {}; StatementResetter resetter { self->statement() }; - JSArray* rows = constructEmptyArray(globalObject, nullptr, 0); RETURN_IF_EXCEPTION(scope, {}); int r; while ((r = sqlite3_step(self->statement())) == SQLITE_ROW) { // Read the column count AFTER step(): sqlite3_prepare_v2's // transparent SQLITE_SCHEMA re-prepare (e.g. SELECT * after - // ALTER TABLE … DROP COLUMN) can change it on the first - // step, and ensureRowStructure() rebuilds m_columnOffsets - // with the fresh count — a stale numCols would then index - // that Vector out-of-bounds and putDirectOffset() into a - // bogus slot. get() and the iterator already capture - // post-step; this matches them. + // ALTER TABLE … DROP COLUMN) can change it on the first step, + // and ensureRowStructure() rebuilds m_columnOffsets with the + // fresh count — a stale numCols would index that Vector + // out-of-bounds and putDirectOffset() into a bogus slot. int numCols = sqlite3_column_count(self->statement()); JSValue row = self->returnArrays() ? rowToArray(globalObject, scope, self->statement(), numCols, self->useBigInts()) @@ -2631,7 +2623,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, Cal rows->push(globalObject, row); RETURN_IF_EXCEPTION(scope, {}); } - CHECK_UDF_EXCEPTION(scope, self->database()); + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_DONE) { throwSqliteError(globalObject, scope, self->connection()); return {}; @@ -2639,6 +2631,39 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, Cal return JSValue::encode(rows); } +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + RELEASE_AND_RETURN(scope, statementStepRun(vm, globalObject, scope, self)); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + RELEASE_AND_RETURN(scope, statementStepGet(globalObject, scope, self)); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + BUSY_SCOPE_STMT(self); + sqlite3_reset(self->statement()); + self->bumpResetGeneration(); + if (!self->bindParams(globalObject, scope, callFrame)) return {}; + RELEASE_AND_RETURN(scope, statementStepAll(globalObject, scope, self)); +} + JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIterate, (JSGlobalObject * globalObject, CallFrame* callFrame)) { THIS_STATEMENT(); @@ -2667,7 +2692,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncColumns, (JSGlobalObject * globalObject, JSObject* col = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); RETURN_IF_EXCEPTION(scope, {}); auto putStr = [&](ASCIILiteral key, const char* val) { - col->putDirect(vm, Identifier::fromString(vm, key), val ? jsString(vm, WTF::String::fromUTF8(val)) : jsNull(), 0); + col->putDirect(vm, Identifier::fromString(vm, key), val ? jsString(vm, sqliteText(val)) : jsNull(), 0); }; #ifdef SQLITE_ENABLE_COLUMN_METADATA putStr("column"_s, sqlite3_column_origin_name(self->statement(), i)); @@ -2719,7 +2744,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncSourceSQL, (JSGlobalObject * globalObjec return throwNodeState(globalObject, scope, "statement has been finalized"_s); } const char* sql = sqlite3_sql(self->statement()); - return JSValue::encode(jsString(vm, WTF::String::fromUTF8(sql ? sql : ""))); + return JSValue::encode(jsString(vm, sqliteText(sql ? sql : ""))); } JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncExpandedSQL, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) @@ -2736,7 +2761,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncExpandedSQL, (JSGlobalObject * globalObj throwSqliteMessage(globalObject, scope, SQLITE_NOMEM, "Expanded SQL text would exceed configured limits"_s); return {}; } - JSValue result = jsString(vm, WTF::String::fromUTF8(expanded)); + JSValue result = jsString(vm, sqliteText(expanded)); sqlite3_free(expanded); return JSValue::encode(result); } @@ -2873,11 +2898,11 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorNext, (JSGlobalObject * globalOb // the iterator instead. sqlite3_reset(stmt->statement()); self->setDone(); - CHECK_UDF_EXCEPTION(scope, stmt->database()); + CHECK_UDF_EXCEPTION(scope); throwSqliteError(globalObject, scope, stmt->connection()); return {}; } - CHECK_UDF_EXCEPTION(scope, stmt->database()); + CHECK_UDF_EXCEPTION(scope); if (r == SQLITE_ROW) { int numCols = sqlite3_column_count(stmt->statement()); JSValue row = stmt->returnArrays() @@ -2904,7 +2929,10 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorReturn, (JSGlobalObject * global // for-of's IteratorClose on break/return). Cleanup must be tolerant of // already-closed state — throwing here would turn a benign // `for (r of stmt.iterate()) { db.close(); break; }` into an exception. - // Matches Node, and this PR's own [Symbol.dispose]() convention. + // Deliberate divergence: Node v26.3.0 throws ERR_INVALID_STATE on a + // finalized statement here; we treat it as a no-op so IteratorClose + // after db.close() doesn't surface a spurious error (matches this + // module's own [Symbol.dispose]() convention). JSStatementSync* stmt = self->statement(); // Only reset the statement if this iterator still owns it: when a later // iterate()/run()/get()/all() bumped the reset generation, the statement @@ -3097,6 +3125,36 @@ void JSNodeSqliteSessionPrototype::finishCreation(VM& vm, JSGlobalObject* global JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); } +const ClassInfo JSNodeSqliteSessionConstructor::s_info = { "Session"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteSessionConstructor) }; + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSNodeSqliteSessionConstructor::call(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSNodeSqliteSessionConstructor::construct(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSNodeSqliteSessionConstructor* JSNodeSqliteSessionConstructor::create(VM& vm, JSGlobalObject* globalObject, Structure* structure, JSObject* prototype) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSNodeSqliteSessionConstructor(vm, structure); + ptr->finishCreation(vm, globalObject, prototype); + return ptr; +} + +void JSNodeSqliteSessionConstructor::finishCreation(VM& vm, JSGlobalObject*, JSObject* prototype) +{ + Base::finishCreation(vm, 0, "Session"_s, PropertyAdditionMode::WithoutStructureTransition); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + ASSERT(inherits(info())); +} + // ───────────────────────────────────────────────────────────────────────────── // JSNodeSqliteLimits — property-interceptor wrapper over sqlite3_limit() // ───────────────────────────────────────────────────────────────────────────── @@ -3341,12 +3399,16 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr if (!stmtObj) { auto utf8 = sqlStr.utf8(); sqlite3_stmt* stmt = nullptr; - int r = sqlite3_prepare_v2(db->connection(), utf8.data(), static_cast(utf8.length()), &stmt, nullptr); + // SQLITE_PREPARE_PERSISTENT: TagStore-cached statements are exactly + // the "retained for a long time and probably reused many times" case + // the flag is documented for; it keeps them out of lookaside memory. + // Intentional divergence from Node (which uses prepare_v2) — the + // hint is allocator-only, not observable behavior. + int r = sqlite3_prepare_v3(db->connection(), utf8.data(), static_cast(utf8.length()), SQLITE_PREPARE_PERSISTENT, &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized" // so we don't overwrite the user's exception. Mirrors // jsDatabaseSyncPrepare's CHECK_UDF_EXCEPTION. - db->takeIgnoreNextSqliteError(); if (scope.exception()) [[unlikely]] { if (stmt) sqlite3_finalize(stmt); return nullptr; @@ -3411,10 +3473,9 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr return {}; \ } -// Shared tag execution: prepare/reset/bind then drive the cached -// statement with the same semantics as StatementSync's run/get/all. -// No separate StatementExecutionHelper like Node's — the statement -// object already carries everything we need. +// Shared tag execution: prepare/reset/bind then delegate to the same +// post-bind step drivers StatementSync uses (statementStep{Run,Get,All}), +// so both entry points behave identically and can't drift. JSC_DEFINE_HOST_FUNCTION(jsTagStoreRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) { @@ -3422,29 +3483,7 @@ JSC_DEFINE_HOST_FUNCTION(jsTagStoreRun, (JSGlobalObject * globalObject, CallFram JSDatabaseSync::BusyScope busy { self->database() }; JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); RETURN_IF_EXCEPTION(scope, {}); - sqlite3_stmt* s = stmt->statement(); - int r; - while ((r = sqlite3_step(s)) == SQLITE_ROW) { - } - CHECK_UDF_EXCEPTION(scope, self->database()); - if (r != SQLITE_DONE) { - throwSqliteError(globalObject, scope, self->database()->connection()); - sqlite3_reset(s); - return {}; - } - sqlite3* conn = sqlite3_db_handle(s); - int64_t changes = sqlite3_changes64(conn); - int64_t lastId = sqlite3_last_insert_rowid(conn); - sqlite3_reset(s); - JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); - RETURN_IF_EXCEPTION(scope, {}); - JSValue changesV = stmt->useBigInts() ? JSValue(JSBigInt::makeHeapBigIntOrBigInt32(globalObject, changes)) : jsNumber(static_cast(changes)); - RETURN_IF_EXCEPTION(scope, {}); - result->putDirect(vm, Identifier::fromString(vm, "changes"_s), changesV, 0); - JSValue lastIdV = stmt->useBigInts() ? JSValue(JSBigInt::makeHeapBigIntOrBigInt32(globalObject, lastId)) : jsNumber(static_cast(lastId)); - RETURN_IF_EXCEPTION(scope, {}); - result->putDirect(vm, Identifier::fromString(vm, "lastInsertRowid"_s), lastIdV, 0); - return JSValue::encode(result); + RELEASE_AND_RETURN(scope, statementStepRun(vm, globalObject, scope, stmt)); } JSC_DEFINE_HOST_FUNCTION(jsTagStoreGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -3453,25 +3492,7 @@ JSC_DEFINE_HOST_FUNCTION(jsTagStoreGet, (JSGlobalObject * globalObject, CallFram JSDatabaseSync::BusyScope busy { self->database() }; JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); RETURN_IF_EXCEPTION(scope, {}); - sqlite3_stmt* s = stmt->statement(); - int r = sqlite3_step(s); - CHECK_UDF_EXCEPTION(scope, self->database()); - if (r == SQLITE_DONE) { - sqlite3_reset(s); - return JSValue::encode(jsUndefined()); - } - if (r != SQLITE_ROW) { - throwSqliteError(globalObject, scope, self->database()->connection()); - sqlite3_reset(s); - return {}; - } - int numCols = sqlite3_column_count(s); - JSValue row = stmt->returnArrays() - ? rowToArray(globalObject, scope, s, numCols, stmt->useBigInts()) - : rowToObjectCached(globalObject, scope, stmt, numCols, stmt->useBigInts()); - sqlite3_reset(s); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(row); + RELEASE_AND_RETURN(scope, statementStepGet(globalObject, scope, stmt)); } JSC_DEFINE_HOST_FUNCTION(jsTagStoreAll, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -3480,37 +3501,7 @@ JSC_DEFINE_HOST_FUNCTION(jsTagStoreAll, (JSGlobalObject * globalObject, CallFram JSDatabaseSync::BusyScope busy { self->database() }; JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); RETURN_IF_EXCEPTION(scope, {}); - sqlite3_stmt* s = stmt->statement(); - JSArray* rows = constructEmptyArray(globalObject, nullptr, 0); - RETURN_IF_EXCEPTION(scope, {}); - uint32_t idx = 0; - int r; - while ((r = sqlite3_step(s)) == SQLITE_ROW) { - CHECK_UDF_EXCEPTION(scope, self->database()); - // Capture post-step — a cached statement may be transparently - // re-prepared on SQLITE_SCHEMA, changing the column count - // (see jsStatementSyncAll for the full rationale). - int numCols = sqlite3_column_count(s); - JSValue row = stmt->returnArrays() - ? rowToArray(globalObject, scope, s, numCols, stmt->useBigInts()) - : rowToObjectCached(globalObject, scope, stmt, numCols, stmt->useBigInts()); - if (scope.exception()) [[unlikely]] { - sqlite3_reset(s); - return {}; - } - rows->putDirectIndex(globalObject, idx++, row); - if (scope.exception()) [[unlikely]] { - sqlite3_reset(s); - return {}; - } - } - CHECK_UDF_EXCEPTION(scope, self->database()); - sqlite3_reset(s); - if (r != SQLITE_DONE) { - throwSqliteError(globalObject, scope, self->database()->connection()); - return {}; - } - return JSValue::encode(rows); + RELEASE_AND_RETURN(scope, statementStepAll(globalObject, scope, stmt)); } JSC_DEFINE_HOST_FUNCTION(jsTagStoreIterate, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -3571,19 +3562,51 @@ void JSNodeSqliteTagStorePrototype::finishCreation(VM& vm, JSGlobalObject*) JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); } +const ClassInfo JSNodeSqliteTagStoreConstructor::s_info = { "SQLTagStore"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteTagStoreConstructor) }; + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSNodeSqliteTagStoreConstructor::call(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSNodeSqliteTagStoreConstructor::construct(JSGlobalObject* globalObject, CallFrame*) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor"_s); +} + +JSNodeSqliteTagStoreConstructor* JSNodeSqliteTagStoreConstructor::create(VM& vm, JSGlobalObject* globalObject, Structure* structure, JSObject* prototype) +{ + auto* ptr = new (NotNull, allocateCell(vm)) JSNodeSqliteTagStoreConstructor(vm, structure); + ptr->finishCreation(vm, globalObject, prototype); + return ptr; +} + +void JSNodeSqliteTagStoreConstructor::finishCreation(VM& vm, JSGlobalObject*, JSObject* prototype) +{ + Base::finishCreation(vm, 0, "SQLTagStore"_s, PropertyAdditionMode::WithoutStructureTransition); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + ASSERT(inherits(info())); +} + // ───────────────────────────────────────────────────────────────────────────── // Module-level exports // ───────────────────────────────────────────────────────────────────────────── // backup(sourceDb, path[, options]) → Promise // -// Node.js runs the sqlite3_backup_step loop on a libuv worker thread. Here we -// run it synchronously on the JS thread — DatabaseSync is already a fully -// synchronous API, and the source connection cannot be touched from another -// thread anyway (SQLite's default threading mode is serialized-per- -// connection). The `progress` callback still fires between each batch of -// `rate` pages so callers can observe progress; the returned Promise is -// resolved before this function returns. +// Divergence from Node.js: Node runs each sqlite3_backup_step on the libuv +// threadpool, so its docs promise "the backed-up database can be used +// normally during the backup process". Bun runs the whole step loop +// synchronously on the JS thread — the returned Promise is resolved before +// this function returns and the event loop is blocked for the duration. +// TODO(node:sqlite): dispatch each step to Bun's WorkPool (webcrypto's +// PhonyWorkQueue is in-tree precedent) so this contract holds. +// +// The `progress` callback still fires between each batch of `rate` pages. JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = getVM(globalObject); @@ -3697,14 +3720,11 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal return rejectWithPending(); } - // We run the step loop synchronously, so a locked destination would - // otherwise busy-spin at 100% CPU forever. Bound the total time spent - // waiting on BUSY/LOCKED and back off between retries; budget defaults - // to the source database's configured timeout (Node's async variant - // yields to the event loop instead, which we can't do here). + // Node retries SQLITE_BUSY/LOCKED indefinitely (BackupJob just calls + // ScheduleWork() again with no timeout), so match that: no invented + // busy budget. Back off between retries so a contended destination + // doesn't busy-spin at 100% CPU. constexpr int kBusyRetrySleepMs = 25; - const int busyBudgetMs = std::max(sourceDb->config().timeout, 5000); - int busyWaitedMs = 0; int totalPages = 0; while (true) { @@ -3728,20 +3748,9 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal } if (r == SQLITE_DONE) break; - if (r == SQLITE_OK) { - busyWaitedMs = 0; - continue; - } + if (r == SQLITE_OK) continue; if (r == SQLITE_BUSY || r == SQLITE_LOCKED) { - if (busyWaitedMs >= busyBudgetMs) { - throwSqliteMessage(globalObject, scope, r, - "database is locked"_s); - sqlite3_backup_finish(backup); - sqlite3_close_v2(dest); - return rejectWithPending(); - } sqlite3_sleep(kBusyRetrySleepMs); - busyWaitedMs += kBusyRetrySleepMs; continue; } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index bb758574e5f1..c09623b65721 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -2,9 +2,12 @@ // // This uses the bundled sqlite3 amalgamation (sqlite3_local.h / sqlite3.c) // on all platforms, matching Node.js which always bundles its own SQLite. -// Unlike bun:sqlite, it does not participate in macOS's LAZY_LOAD_SQLITE -// dlopen path — node:sqlite users expect Node's bundled-SQLite semantics -// (and functions like sqlite3_changes64 that older system libraries lack). +// bun:sqlite links the same object by default (staticSqlite=true) so both +// modules share one library and one POSIX-lock inode map — two SQLite +// copies in one process is a documented corruption vector +// (howtocorrupt.html §2.2.1). A --static-sqlite=off build restores the +// macOS dlopen path for bun:sqlite; opening the same file via both APIs +// in that configuration is unsafe. // // Reference: https://github.com/nodejs/node/blob/main/src/node_sqlite.cc #pragma once @@ -139,18 +142,6 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { const DatabaseSyncOpenConfiguration& config() const { return m_config; } - // User-defined functions call back into JS from inside sqlite3_step(). - // If the JS callback throws, we record that here so the enclosing - // step()/exec() can propagate the JS exception instead of wrapping the - // uninformative "user-defined function raised exception" SQLite error. - bool takeIgnoreNextSqliteError() - { - bool v = m_ignoreNextSqliteError; - m_ignoreNextSqliteError = false; - return v; - } - void setIgnoreNextSqliteError() { m_ignoreNextSqliteError = true; } - void trackSession(Ref&& record) { m_sessions.append(WTF::move(record)); } void untrackSession(NodeSqliteSessionRecord* record) { @@ -258,7 +249,6 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { }; WTF::Vector m_namedRegistrations; bool m_enableLoadExtension = false; - bool m_ignoreNextSqliteError = false; }; class JSDatabaseSyncPrototype final : public JSC::JSNonFinalObject { @@ -412,6 +402,11 @@ class JSStatementSync final : public JSC::JSDestructibleObject { sqlite3_stmt* m_stmt = nullptr; JSC::WriteBarrier m_rowStructure; WTF::Vector m_columnOffsets; + // sqlite3_stmt native heap footprint reported to JSC's GC so + // preparing many statements applies memory pressure. Sampled once + // at creation via SQLITE_STMTSTATUS_MEMUSED (mirrors bun:sqlite's + // JSSQLStatement). + size_t m_extraMemorySize = 0; int m_rowColumnCount = -1; // Reset-generation the cached row structure was built at. Column // *count* alone isn't a sufficient shape key: sqlite3_prepare_v2 @@ -579,7 +574,9 @@ class JSStatementSyncIteratorPrototype final : public JSC::JSNonFinalObject { // ───────────────────────────────────────────────────────────────────────────── // Session — thin wrapper over sqlite3_session* returned by -// DatabaseSync.prototype.createSession(). No public constructor. +// DatabaseSync.prototype.createSession(). Exported so `instanceof Session` +// works, but the constructor throws ERR_ILLEGAL_CONSTRUCTOR (matches Node's +// IllegalConstructor template). // ───────────────────────────────────────────────────────────────────────────── class JSNodeSqliteSession final : public JSC::JSDestructibleObject { @@ -663,6 +660,31 @@ class JSNodeSqliteSessionPrototype final : public JSC::JSNonFinalObject { void finishCreation(JSC::VM&, JSC::JSGlobalObject*); }; +class JSNodeSqliteSessionConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + DECLARE_INFO; + + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSNodeSqliteSessionConstructor* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, JSC::JSObject* prototype); + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::InternalFunctionType, StructureFlags), info()); + } + + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES call(JSC::JSGlobalObject*, JSC::CallFrame*); + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES construct(JSC::JSGlobalObject*, JSC::CallFrame*); + +private: + JSNodeSqliteSessionConstructor(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure, call, construct) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*, JSC::JSObject* prototype); +}; + // ───────────────────────────────────────────────────────────────────────────── // DatabaseSyncLimits — the object returned by `db.limits`. Reads and // writes to its eleven named properties (length, sqlLength, …) call @@ -815,6 +837,31 @@ class JSNodeSqliteTagStorePrototype final : public JSC::JSNonFinalObject { void finishCreation(JSC::VM&, JSC::JSGlobalObject*); }; +class JSNodeSqliteTagStoreConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + DECLARE_INFO; + + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSNodeSqliteTagStoreConstructor* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, JSC::JSObject* prototype); + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::InternalFunctionType, StructureFlags), info()); + } + + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES call(JSC::JSGlobalObject*, JSC::CallFrame*); + static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES construct(JSC::JSGlobalObject*, JSC::CallFrame*); + +private: + JSNodeSqliteTagStoreConstructor(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure, call, construct) + { + } + void finishCreation(JSC::VM&, JSC::JSGlobalObject*, JSC::JSObject* prototype); +}; + // Module-level constants object (SQLITE_CHANGESET_* + authorizer codes). JSC::JSValue createNodeSqliteConstants(JSC::VM&, JSC::JSGlobalObject*); diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index 8e9ba2bd7432..cb4c6f712eb7 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -66,6 +66,8 @@ typedef int (*lazy_sqlite3_step_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_clear_bindings_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_column_type_type)(sqlite3_stmt*, int iCol); typedef int (*lazy_sqlite3_db_config_type)(sqlite3*, int op, ...); +typedef int (*lazy_sqlite3_busy_timeout_type)(sqlite3*, int ms); +typedef int (*lazy_sqlite3_wal_checkpoint_v2_type)(sqlite3*, const char* zDb, int eMode, int* pnLog, int* pnCkpt); typedef const char* (*lazy_sqlite3_bind_parameter_name_type)(sqlite3_stmt*, int); typedef int (*lazy_sqlite3_load_extension_type)( @@ -109,6 +111,8 @@ static lazy_sqlite3_changes_type lazy_sqlite3_changes; static lazy_sqlite3_clear_bindings_type lazy_sqlite3_clear_bindings; static lazy_sqlite3_close_v2_type lazy_sqlite3_close_v2; static lazy_sqlite3_close_type lazy_sqlite3_close; +static lazy_sqlite3_busy_timeout_type lazy_sqlite3_busy_timeout; +static lazy_sqlite3_wal_checkpoint_v2_type lazy_sqlite3_wal_checkpoint_v2; static lazy_sqlite3_file_control_type lazy_sqlite3_file_control; static lazy_sqlite3_column_blob_type lazy_sqlite3_column_blob; static lazy_sqlite3_column_bytes_type lazy_sqlite3_column_bytes; @@ -162,6 +166,8 @@ static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; #define sqlite3_clear_bindings lazy_sqlite3_clear_bindings #define sqlite3_close_v2 lazy_sqlite3_close_v2 #define sqlite3_close lazy_sqlite3_close +#define sqlite3_busy_timeout lazy_sqlite3_busy_timeout +#define sqlite3_wal_checkpoint_v2 lazy_sqlite3_wal_checkpoint_v2 #define sqlite3_file_control lazy_sqlite3_file_control #define sqlite3_column_blob lazy_sqlite3_column_blob #define sqlite3_column_bytes lazy_sqlite3_column_bytes @@ -251,6 +257,8 @@ static int lazyLoadSQLite() lazy_sqlite3_clear_bindings = (lazy_sqlite3_clear_bindings_type)dlsym(sqlite3_handle, "sqlite3_clear_bindings"); lazy_sqlite3_close_v2 = (lazy_sqlite3_close_v2_type)dlsym(sqlite3_handle, "sqlite3_close_v2"); lazy_sqlite3_close = (lazy_sqlite3_close_type)dlsym(sqlite3_handle, "sqlite3_close"); + lazy_sqlite3_busy_timeout = (lazy_sqlite3_busy_timeout_type)dlsym(sqlite3_handle, "sqlite3_busy_timeout"); + lazy_sqlite3_wal_checkpoint_v2 = (lazy_sqlite3_wal_checkpoint_v2_type)dlsym(sqlite3_handle, "sqlite3_wal_checkpoint_v2"); lazy_sqlite3_file_control = (lazy_sqlite3_file_control_type)dlsym(sqlite3_handle, "sqlite3_file_control"); lazy_sqlite3_column_blob = (lazy_sqlite3_column_blob_type)dlsym(sqlite3_handle, "sqlite3_column_blob"); lazy_sqlite3_column_bytes = (lazy_sqlite3_column_bytes_type)dlsym(sqlite3_handle, "sqlite3_column_bytes"); diff --git a/src/jsc/modules/NodeSqliteModule.h b/src/jsc/modules/NodeSqliteModule.h index 0abb545f56d4..c49e4a2057e1 100644 --- a/src/jsc/modules/NodeSqliteModule.h +++ b/src/jsc/modules/NodeSqliteModule.h @@ -10,7 +10,7 @@ namespace Zig { DEFINE_NATIVE_MODULE(NodeSqlite) { - INIT_NATIVE_MODULE(4); + INIT_NATIVE_MODULE(6); put(JSC::Identifier::fromString(vm, "DatabaseSync"_s), globalObject->m_JSDatabaseSyncClassStructure.constructorInitializedOnMainThread(globalObject)); @@ -18,6 +18,12 @@ DEFINE_NATIVE_MODULE(NodeSqlite) put(JSC::Identifier::fromString(vm, "StatementSync"_s), globalObject->m_JSStatementSyncClassStructure.constructorInitializedOnMainThread(globalObject)); + put(JSC::Identifier::fromString(vm, "Session"_s), + globalObject->m_JSNodeSqliteSessionClassStructure.constructorInitializedOnMainThread(globalObject)); + + put(JSC::Identifier::fromString(vm, "SQLTagStore"_s), + globalObject->m_JSNodeSqliteTagStoreClassStructure.constructorInitializedOnMainThread(globalObject)); + put(JSC::Identifier::fromString(vm, "constants"_s), Bun::createNodeSqliteConstants(vm, globalObject)); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index a5e39a58c76f..dd936c105c48 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1976,3 +1976,40 @@ it("keeps database handles working when many Workers open databases concurrently exitCode: 0, }); }, 30000); + +it("exit-time WAL checkpoint runs even with a never-finalized prepared statement", async () => { + // Sibling of the node:sqlite test. With un-finalized statements, close_v2 + // zombifies the connection and defers the WAL checkpoint to a finalize + // that never comes; Bun__closeAllSQLiteDatabasesForTermination now + // checkpoints explicitly first. + const dir = tempDirWithFiles("bun-sqlite-exit-zombie", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Database } = require('bun:sqlite'); + const db = new Database('exit.db'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('CREATE TABLE t (x INTEGER)'); + const stmt = db.prepare('INSERT INTO t VALUES (?)'); + stmt.run(42); + // stmt stays referenced and is never finalized; db is never closed. + console.log(require('node:fs').statSync('exit.db-wal').size > 0);`, + ], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("true\n"); + const fs = require("node:fs"); + const wal = path.join(dir, "exit.db-wal"); + // TRUNCATE moved every frame into exit.db (or the sidecar was unlinked + // by a full close). Either way, no un-checkpointed data is stranded. + expect(fs.existsSync(wal) ? fs.statSync(wal).size : 0).toBe(0); + const verify = new Database(path.join(dir, "exit.db")); + expect(verify.query("SELECT x FROM t").get().x).toBe(42); + verify.close(); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index bd78233f9458..f91137610986 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -4,7 +4,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; import { existsSync, statSync } from "node:fs"; import { builtinModules, isBuiltin } from "node:module"; import path from "node:path"; -import { DatabaseSync, StatementSync, backup, constants } from "node:sqlite"; +import { DatabaseSync, SQLTagStore, Session, StatementSync, backup, constants } from "node:sqlite"; import { pathToFileURL } from "node:url"; test("node:sqlite is a built-in module", () => { @@ -68,16 +68,57 @@ describe("DatabaseSync", () => { db.close(); }); - test("binds small integers with INTEGER storage class (not REAL)", () => { + test("binds JS numbers as REAL (matches Node) and is representation-independent", () => { + // Node v26.3.0 unconditionally uses sqlite3_bind_double for JS numbers — + // no IsInt32 fast path — so typeof(?) on a bare parameter (no column + // affinity) is 'real' and expandedSQL shows 42.0. Branching on JSC's + // tag-bit isInt32() would also give literal 42 vs Float64Array[0]=42 + // different storage classes. const db = new DatabaseSync(":memory:"); - // Without the isInt32() fast path, 42 would bind via sqlite3_bind_double - // and typeof(?) on a bare parameter (no column affinity) returns 'real'. - expect(db.prepare("SELECT typeof(?) AS t").get(42).t).toBe("integer"); + expect(db.prepare("SELECT typeof(?) AS t").get(42).t).toBe("real"); + expect(db.prepare("SELECT typeof(?) AS t").get(new Float64Array([42])[0]).t).toBe("real"); expect(db.prepare("SELECT typeof(?) AS t").get(1.5).t).toBe("real"); + // BigInt is the way to bind an INTEGER. + expect(db.prepare("SELECT typeof(?) AS t").get(42n).t).toBe("integer"); + // UDF results follow the same rule. + db.function("f", () => 42); + expect(db.prepare("SELECT typeof(f()) AS t").get().t).toBe("real"); // expandedSQL reflects the bound storage class. const stmt = db.prepare("SELECT ?"); stmt.get(42); - expect(stmt.expandedSQL).toBe("SELECT 42"); + expect(stmt.expandedSQL).toBe("SELECT 42.0"); + db.close(); + }); + + test("binds a detached ArrayBufferView as a zero-length BLOB, not NULL", () => { + // Matches Node (whose ArrayBufferViewContents falls back to non-null + // stack storage). NULL vs X'' is observable via a NOT NULL column and + // via typeof(?). + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (b BLOB NOT NULL)"); + const buf = new Uint8Array(4); + // Detach by transferring the underlying ArrayBuffer to a MessageChannel port. + structuredClone(buf.buffer, { transfer: [buf.buffer] }); + expect(buf.byteLength).toBe(0); + expect(db.prepare("SELECT typeof(?) AS t").get(buf).t).toBe("blob"); + expect(() => db.prepare("INSERT INTO t VALUES (?)").run(buf)).not.toThrow(); + expect(db.prepare("SELECT length(b) AS n FROM t").get().n).toBe(0); + db.close(); + }); + + test("decodes non-UTF-8 TEXT with replacement characters, not empty strings", () => { + // Regression: WTF::String::fromUTF8 returns null on invalid bytes and + // jsString(null) becomes "". Matches bun:sqlite (#31514) and Node. + const db = new DatabaseSync(":memory:"); + expect(db.prepare("SELECT CAST(x'4A6F73E9' AS TEXT) AS v").get().v).toBe("Jos�"); + // >64-byte variant to ensure the slow decode path is covered. + const long = db.prepare("SELECT CAST((? || x'E9') AS TEXT) AS v").get("x".repeat(80)).v; + expect(long).toBe("x".repeat(80) + "�"); + // UDF argv path (sqliteValueToJS) hits the same replacement decode. + let seen: string | undefined; + db.function("cap", v => void (seen = v)); + db.prepare("SELECT cap(CAST(x'4A6F73E9' AS TEXT))").get(); + expect(seen).toBe("Jos�"); db.close(); }); @@ -559,6 +600,7 @@ describe("StatementSync.prototype.iterate()", () => { }); test("return() is tolerant of a finalized statement (IteratorClose on break)", () => { + // Diverges from Node v26.3.0, which throws ERR_INVALID_STATE here. const db = setup(); const stmt = db.prepare("SELECT n FROM t ORDER BY n"); const iter = stmt.iterate(); @@ -1395,3 +1437,218 @@ describe("GC lifetime", () => { db.close(); }); }); + +describe("module exports", () => { + test("Session and SQLTagStore are exported and instanceof works", () => { + expect(typeof Session).toBe("function"); + expect(typeof SQLTagStore).toBe("function"); + expect(() => new Session()).toThrow(expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" })); + expect(() => new SQLTagStore()).toThrow(expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" })); + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + expect(db.createSession()).toBeInstanceOf(Session); + expect(db.createTagStore()).toBeInstanceOf(SQLTagStore); + db.close(); + }); + + test("named ESM import of Session links (spawned subprocess)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `import { Session } from "node:sqlite"; console.log(typeof Session);`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "function\n", exitCode: 0 }); + void stderr; + }); +}); + +describe("loadExtension() / enableLoadExtension()", () => { + test("loadExtension() on {allowExtension: false} throws ERR_INVALID_STATE", () => { + const db = new DatabaseSync(":memory:"); + expect(() => db.loadExtension("/nonexistent")).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_STATE", + message: expect.stringMatching(/extension loading is not allowed/), + }), + ); + db.close(); + }); + + test("enableLoadExtension(true) on {allowExtension: false} throws Node's exact message", () => { + const db = new DatabaseSync(":memory:"); + expect(() => db.enableLoadExtension(true)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_STATE", + message: expect.stringMatching(/Cannot enable extension loading because it was disabled at database creation/), + }), + ); + // enableLoadExtension(false) is always permitted. + expect(() => db.enableLoadExtension(false)).not.toThrow(); + db.close(); + }); + + test("enableLoadExtension() with no argument throws ERR_INVALID_ARG_TYPE", () => { + const db = new DatabaseSync(":memory:", { allowExtension: true }); + expect(() => db.enableLoadExtension()).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + db.close(); + }); + + test("loadExtension() after enableLoadExtension(false) throws", () => { + const db = new DatabaseSync(":memory:", { allowExtension: true }); + db.enableLoadExtension(false); + expect(() => db.loadExtension("/nonexistent")).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + db.close(); + }); + + test("loadExtension() on a nonexistent path throws ERR_LOAD_SQLITE_EXTENSION", () => { + // Exercises the sqlite3_free(errmsg) path. + const db = new DatabaseSync(":memory:", { allowExtension: true }); + db.enableLoadExtension(true); + expect(() => db.loadExtension("/bun-nonexistent-extension-path")).toThrow( + expect.objectContaining({ code: "ERR_LOAD_SQLITE_EXTENSION" }), + ); + db.close(); + }); +}); + +// bun:sqlite and node:sqlite share the bundled amalgamation (staticSqlite=true +// on every platform), so opening the same file via both is safe. This is +// platform-differential coverage: on a --static-sqlite=off build the two +// modules would use separate SQLite libraries with separate POSIX-lock inode +// maps (howtocorrupt.html §2.2.1). +test("bun:sqlite and node:sqlite can open the same on-disk file concurrently", async () => { + using dir = tempDir("node-sqlite-cross-module", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Database } = require('bun:sqlite'); + const { DatabaseSync } = require('node:sqlite'); + const bunDb = new Database('shared.db'); + bunDb.exec('PRAGMA journal_mode = WAL'); + bunDb.exec('CREATE TABLE t (x INTEGER)'); + bunDb.exec('INSERT INTO t VALUES (1)'); + const nodeDb = new DatabaseSync('shared.db'); + // node:sqlite sees bun:sqlite's committed row. + console.log('n1=' + nodeDb.prepare('SELECT x FROM t').get().x); + // Write via node:sqlite; bun:sqlite sees it. + nodeDb.exec('INSERT INTO t VALUES (2)'); + console.log('b1=' + bunDb.query('SELECT COUNT(*) c FROM t').get().c); + // Closing the bun:sqlite handle must not drop the process's fcntl locks + // out from under node:sqlite (the two-library corruption vector). + bunDb.close(); + nodeDb.exec('INSERT INTO t VALUES (3)'); + console.log('ok=' + nodeDb.prepare('PRAGMA integrity_check').get().integrity_check); + nodeDb.close();`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("n1=1\nb1=2\nok=ok\n"); + void stderr; + expect(exitCode).toBe(0); +}); + +// Worker-owned databases are closed via ~VM → lastChanceToFinalize → +// ~JSDatabaseSync — a completely different path than the main-thread exit +// sweep. Sibling of "unclosed file-backed database is closed on process exit". +test("worker-owned unclosed database is checkpointed on worker exit", async () => { + using dir = tempDir("node-sqlite-worker-exit", { + "worker.mjs": `import { DatabaseSync } from 'node:sqlite'; + const db = new DatabaseSync('exit.db'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('CREATE TABLE t (x INTEGER)'); + const stmt = db.prepare('INSERT INTO t VALUES (?)'); + stmt.run(99); + // stmt and db intentionally not closed; worker exits naturally. + postMessage('done');`, + "main.mjs": `import { Worker } from 'node:worker_threads'; + const w = new Worker('./worker.mjs'); + await new Promise((res, rej) => { + w.on('message', () => {}); // drain + w.on('error', rej); + w.on('exit', code => (code === 0 ? res() : rej(new Error('exit ' + code)))); + }); + // Verify the row landed in the main file (~JSDatabaseSync ran on + // lastChanceToFinalize) — reopen from the parent thread. + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync('exit.db'); + console.log(db.prepare('SELECT x FROM t').get().x); + db.close();`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("99\n"); + void stderr; + expect(exitCode).toBe(0); +}); + +describe("GC stress", () => { + // Interleave Bun.gc(true) with the mutations that race visitChildren, so + // a marker-vs-mutator lock miss is loud under ASAN rather than flaky. + // Debug+ASAN builds run 10-100x slower — sized for that budget. + test("re-registering db.function() while GC runs concurrently", () => { + const db = new DatabaseSync(":memory:"); + db.function("f", () => -1); + const stmt = db.prepare("SELECT f() AS v"); + for (let i = 0; i < 500; i++) { + db.function("f", () => i); + Bun.gc(true); + expect(stmt.get().v).toBe(i); + } + db.close(); + }, 30_000); + + test("TagStore LRU churn under GC pressure", () => { + const db = new DatabaseSync(":memory:"); + const sql = db.createTagStore({ capacity: 4 }); + for (let i = 0; i < 500; i++) { + // Rotate the SQL text so the LRU inserts/evicts every iteration. + const j = i % 8; + const v = sql.get(["SELECT ", ` + ${j} AS v`], i).v; + Bun.gc(true); + expect(v).toBe(i + j); + } + db.close(); + }, 30_000); + + test("aggregate step callback triggering GC between rows", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x INTEGER)"); + db.exec(`WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200) INSERT INTO t SELECT x FROM c`); + db.aggregate("gcsum", { + start: 0, + step: (acc, x) => { + Bun.gc(true); + return acc + x; + }, + }); + // The Strong<> in sqlite3_aggregate_context must survive GC between xStep calls. + expect(db.prepare("SELECT gcsum(x) AS s FROM t").get().s).toBe(20100); + db.close(); + }); + + test("session churn under GC pressure (finalizer ordering)", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + for (let i = 0; i < 500; i++) { + const s = db.createSession(); + Bun.gc(true); + s.changeset(); + // Half explicitly close, half drop — races wrapperGone/dbGone. + if (i & 1) s.close(); + } + db.close(); + }, 30_000); +}); From f327a00abc52b9d30835fb3f00220adf505c2081 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:02:35 +0000 Subject: [PATCH 03/33] [autofix.ci] apply automated fixes --- docs/runtime/sqlite.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index e66927760e8f..c0592ee2ad95 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -628,7 +628,11 @@ db.loadExtension("myext"); ``` -Bun bundles its own SQLite build on all platforms with extension loading enabled, so `loadExtension()` works out of the box on macOS. `Database.setCustomSQLite(path)` is retained for backward compatibility but is a no-op in the default build; it only takes effect on custom builds compiled with `--static-sqlite=off`. Mixing a custom SQLite with `node:sqlite` (which always uses the bundled copy) on the same file is unsafe — see [SQLite: How To Corrupt §2.2.1](https://www.sqlite.org/howtocorrupt.html#posix_close_bug). + Bun bundles its own SQLite build on all platforms with extension loading enabled, so `loadExtension()` works out of + the box on macOS. `Database.setCustomSQLite(path)` is retained for backward compatibility but is a no-op in the + default build; it only takes effect on custom builds compiled with `--static-sqlite=off`. Mixing a custom SQLite with + `node:sqlite` (which always uses the bundled copy) on the same file is unsafe — see [SQLite: How To Corrupt + §2.2.1](https://www.sqlite.org/howtocorrupt.html#posix_close_bug). ### `.fileControl(cmd: number, value: any)` From a7346280995947f18f882fe21e1bc7ef28002f4d Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 16:27:24 -0700 Subject: [PATCH 04/33] node:sqlite: dlopen the system SQLite on macOS instead of bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jarred: "Do not ship bundled sqlite. Only dynamically loaded SQLite." The two-SQLite-libraries POSIX-lock corruption vector (howtocorrupt §2.2.1) is fixed by making node:sqlite use the SAME library as bun:sqlite — the dlopen'd libsqlite3.dylib on macOS — not by bundling a second copy. - lazy_sqlite3.h: static → inline vars so JSSQLStatement.cpp and NodeSqlite.cpp share one dlopen handle / one sqlite3_lib_path; Database.setCustomSQLite() therefore also affects node:sqlite. Added ~40 dlsym stubs for the sqlite3_* / sqlite3session_* symbols NodeSqlite.cpp needs (aggregate_context, backup_*, bind_*64, changes64, column_{origin,table,database}_name, create_{function_v2,window_function}, db_{filename,handle}, errcode, exec, limit, prepare_v2, result_*, set_authorizer, sleep, sql, stmt_status, user_data, value_*, session_*, changeset_apply, libversion). Optional COLUMN_METADATA / stmt_status fall back to null-returning stubs; lazy_sqlite3_has_session records whether the loaded library has the session extension. - NodeSqlite.cpp: gate on LAZY_LOAD_SQLITE like JSSQLStatement.cpp; call lazyLoadSQLite() in open(); createSession/applyChangeset runtime-gate on lazy_sqlite3_has_session with a setCustomSQLite() hint; {allowExtension:true} throws ERR_LOAD_SQLITE_EXTENSION with the same hint when the loaded library has SQLITE_OMIT_LOAD_EXTENSION (Apple's does); sessionChangesetCommon takes a runtime function pointer (was a non-type template arg, invalid for a dlsym'd value); columns() metadata calls are always compiled in (stubbed at runtime); clear SQLITE_FCNTL_PERSIST_WAL after open so the last close unlinks -wal/-shm like Node's bundled build (Apple defaults it on); Bun__sqlite3_version() returns sqlite3_libversion() from the loaded lib. - config.ts / deps/sqlite.ts: staticSqlite defaults back to !darwin; the amalgamation is only built when staticSqlite is true. - docs: revert setCustomSQLite() to its original macOS instructions; nodejs-compat notes the dlopen caveat for loadExtension()/session on older macOS. - tests: sqliteHasSession / sqliteHasLoadExtension probes gate the affected tests; new tests assert the setCustomSQLite hint messages; cross-module test asserts both APIs report the same sqlite_version() and process.versions.sqlite; expectations.txt marks the vendored test-sqlite.js (percentile/geopoly/rbu) as failing on darwin. macOS test run (Apple libsqlite3 3.51.0, has SESSION/COLUMN_METADATA, lacks LOAD_EXTENSION/PERCENTILE/GEOPOLY/RBU): test/js/node/sqlite/ 86 pass · 4 skip · 0 fail test/js/bun/sqlite/ 91 pass · 1 skip · 0 fail --- docs/runtime/nodejs-compat.mdx | 2 +- docs/runtime/sqlite.mdx | 23 +- scripts/build/config.ts | 11 +- scripts/build/deps/sqlite.ts | 27 +- src/jsc/bindings/sqlite/NodeSqlite.cpp | 105 +++++-- src/jsc/bindings/sqlite/NodeSqlite.h | 15 +- src/jsc/bindings/sqlite/lazy_sqlite3.h | 376 +++++++++++++++++------- test/expectations.txt | 1 + test/js/node/sqlite/node-sqlite.test.ts | 219 +++++++++----- 9 files changed, 535 insertions(+), 244 deletions(-) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 38f491b8dc96..455b1c31112d 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -173,7 +173,7 @@ This page is updated regularly and reflects the latest version of Bun's compatib ### [`node:sqlite`](https://nodejs.org/api/sqlite.html) -🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread). +🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread). On macOS, Bun uses the system `libsqlite3.dylib`; `loadExtension()` (and, on older macOS releases, `createSession()`/`applyChangeset()`) require a full SQLite build — call `require("bun:sqlite").Database.setCustomSQLite(path)` before opening a database. ### [`node:test`](https://nodejs.org/api/test.html) diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index c0592ee2ad95..ea945e9baf77 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -628,11 +628,24 @@ db.loadExtension("myext"); ``` - Bun bundles its own SQLite build on all platforms with extension loading enabled, so `loadExtension()` works out of - the box on macOS. `Database.setCustomSQLite(path)` is retained for backward compatibility but is a no-op in the - default build; it only takes effect on custom builds compiled with `--static-sqlite=off`. Mixing a custom SQLite with - `node:sqlite` (which always uses the bundled copy) on the same file is unsafe — see [SQLite: How To Corrupt - §2.2.1](https://www.sqlite.org/howtocorrupt.html#posix_close_bug). +**macOS users** By default, macOS ships with Apple's proprietary build of SQLite, which doesn't support extensions. To use extensions, install a vanilla build of SQLite. + +```bash terminal icon="terminal" +brew install sqlite +which sqlite # get path to binary +``` + +To point `bun:sqlite` to the new build, call `Database.setCustomSQLite(path)` before creating any `Database` instances. (On other operating systems, this is a no-op.) Pass a path to the SQLite `.dylib` file, _not_ the executable. With recent versions of Homebrew this is something like `/opt/homebrew/Cellar/sqlite//libsqlite3.dylib`. + +```ts db.ts icon="/icons/typescript.svg" highlight={3} +import { Database } from "bun:sqlite"; + +Database.setCustomSQLite("/path/to/libsqlite.dylib"); + +const db = new Database(); +db.loadExtension("myext"); +``` + ### `.fileControl(cmd: number, value: any)` diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 8ad3260b1d09..71745640b6a3 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -855,11 +855,12 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con const canary = partial.canary ?? true; const canaryRevision = canary ? "1" : "0"; - // Link the bundled sqlite3 into bun:sqlite (LAZY_LOAD_SQLITE=0). Default - // true everywhere: node:sqlite requires it regardless, and two SQLite - // libraries in one process is a POSIX-lock corruption vector - // (howtocorrupt.html §2.2.1). --static-sqlite=off restores macOS dlopen. - const staticSqlite = partial.staticSqlite ?? true; + // Whether bun:sqlite and node:sqlite link the bundled sqlite3 directly + // (LAZY_LOAD_SQLITE=0) or dlopen the system library at runtime. macOS + // defaults to dlopen so both APIs share Apple's libsqlite3 (one library, + // one POSIX-lock inode map — howtocorrupt.html §2.2.1); Linux/Windows + // link the bundled amalgamation. + const staticSqlite = partial.staticSqlite ?? !darwin; // Static libatomic: on by default. Arch/Manjaro don't ship libatomic.a — // those users pass --static-libatomic=off. Not auto-detected: the link diff --git a/scripts/build/deps/sqlite.ts b/scripts/build/deps/sqlite.ts index ba38b1a06dcc..589da17bd95b 100644 --- a/scripts/build/deps/sqlite.ts +++ b/scripts/build/deps/sqlite.ts @@ -4,17 +4,12 @@ * Source lives IN THE BUN REPO at src/jsc/bindings/sqlite/ — it's the * sqlite3 amalgamation (single .c file). No fetch step; tracked in git. * - * Always built: node:sqlite uses the bundled copy unconditionally (matching - * Node.js). bun:sqlite links the same object (staticSqlite defaults true on - * every platform; --static-sqlite=off restores the macOS dlopen path but - * ships two SQLite libraries in one process — see the corruption caveat in - * config.ts). - * - * Bundling on macOS (previously dlopen-only there) grows the darwin binaries - * by ~1.8 MB. That is the cost of node:sqlite parity: Apple's system - * libsqlite3 ships without the session extension or percentile() and with - * extension loading disabled, so the bundled build is required — Node.js - * bundles SQLite for the same reason. + * Built when staticSqlite is true (the Linux/Windows default). On macOS + * both bun:sqlite and node:sqlite dlopen the system libsqlite3.dylib at + * runtime (LAZY_LOAD_SQLITE=1) so exactly one library is loaded per + * process — see the corruption caveat in config.ts. Apple's build lacks + * the session extension and percentile(); node:sqlite runtime-gates the + * affected APIs and points at Database.setCustomSQLite() for a full build. */ import type { Dependency } from "../source.ts"; @@ -22,7 +17,7 @@ import type { Dependency } from "../source.ts"; export const sqlite: Dependency = { name: "sqlite", - enabled: () => true, + enabled: cfg => cfg.staticSqlite, source: () => ({ kind: "in-tree", @@ -47,10 +42,7 @@ export const sqlite: Dependency = { // node:sqlite exposes createSession/applyChangeset + columns() // metadata. Match Node.js's compile-time feature set so those // APIs work identically. PREUPDATE_HOOK is a prerequisite for the - // session extension. bun:sqlite links the same object, so it too - // now sees dbstat/geopoly/percentile and pays PREUPDATE_HOOK's - // codegen cost on write paths — measured to be noise on - // INSERT-OR-REPLACE / bulk-insert benches; kept for Node parity. + // session extension. SQLITE_ENABLE_SESSION: 1, SQLITE_ENABLE_PREUPDATE_HOOK: 1, SQLITE_ENABLE_DBSTAT_VTAB: 1, @@ -58,9 +50,6 @@ export const sqlite: Dependency = { SQLITE_ENABLE_RBU: 1, SQLITE_ENABLE_PERCENTILE: 1, }, - // The sqlite3_* API symbols are hidden by the -fvisibility=hidden - // default computeDepFlags applies to every dep object; SQLITE_API - // (default `extern`) does not override it. cflags: [ "-Wno-incompatible-pointer-types-discards-qualifiers", // Match the static CRT bun links; /U_DLL keeps sqlite from picking diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index d70836c807c7..c15866617f65 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -1,13 +1,21 @@ // node:sqlite — native implementation of Node.js's `node:sqlite` module. // See header for overview. -// Always use the bundled amalgamation for node:sqlite, regardless of -// LAZY_LOAD_SQLITE — see the header comment for rationale. The session -// extension (createSession/applyChangeset) is only declared in the header -// when SQLITE_ENABLE_SESSION is defined — sqlite3.c is compiled with that -// flag via the sqlite build target (scripts/build/deps/sqlite.ts), so turn -// it on here as well to expose the prototypes. SQLITE_ENABLE_COLUMN_METADATA -// likewise gates sqlite3_column_{origin,table,database}_name. +// Use the same SQLite library bun:sqlite uses so both APIs share one POSIX- +// lock inode map (howtocorrupt.html §2.2.1). On macOS that is the dlopen'd +// system libsqlite3.dylib (LAZY_LOAD_SQLITE=1); Apple's build lacks +// sqlite3_load_extension and older releases lack the session extension, so +// those APIs runtime-gate on the dlsym result and point at +// Database.setCustomSQLite(). On Linux/Windows the bundled amalgamation is +// linked. +#ifndef LAZY_LOAD_SQLITE +#define LAZY_LOAD_SQLITE 0 +#endif + +#if LAZY_LOAD_SQLITE +#include "lazy_sqlite3.h" +#define LAZY_SQLITE_HAS_LOAD_EXTENSION() (lazy_sqlite3_load_extension != nullptr) +#else #ifndef SQLITE_ENABLE_SESSION #define SQLITE_ENABLE_SESSION 1 #endif @@ -18,6 +26,10 @@ #define SQLITE_ENABLE_COLUMN_METADATA 1 #endif #include "sqlite3_local.h" +static inline int lazyLoadSQLite() { return 0; } +static constexpr bool lazy_sqlite3_has_session = true; +#define LAZY_SQLITE_HAS_LOAD_EXTENSION() true +#endif #include "NodeSqlite.h" @@ -73,12 +85,14 @@ #define SQLITE_CHANGESET_FOREIGN_KEY 5 #endif -// process.versions.sqlite — reported from this TU (not JSSQLStatement.cpp) -// because on macOS's LAZY_LOAD_SQLITE path that file sees the *system* -// sqlite3.h and would report Apple's SDK version, whereas node:sqlite always -// links the bundled amalgamation included above. +// process.versions.sqlite — the loaded library's version on macOS (via +// dlsym'd sqlite3_libversion), the bundled amalgamation's constant elsewhere. extern "C" const char* Bun__sqlite3_version() { +#if LAZY_LOAD_SQLITE + if (lazyLoadSQLite() == 0 && lazy_sqlite3_libversion) + return lazy_sqlite3_libversion(); +#endif return SQLITE_VERSION; } @@ -906,6 +920,13 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) return false; } +#if LAZY_LOAD_SQLITE + if (lazyLoadSQLite() < 0) [[unlikely]] { + scope.throwException(globalObject, createError(globalObject, WTF::String::fromUTF8(dlerror()))); + return false; + } +#endif + // SQLITE_OPEN_URI mirrors Node's `default_flags = SQLITE_OPEN_URI` // (node_sqlite.cc). Strings, Uint8Arrays, and URL objects all reach // sqlite3ParseUri verbatim (validateDatabasePath passes a URL's raw @@ -932,6 +953,14 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) // failure paths goes through closeInternal(), which unregisters. registerOpenDatabase(this, globalObject->vm()); +#if LAZY_LOAD_SQLITE + // Apple's system libsqlite3 defaults SQLITE_FCNTL_PERSIST_WAL on; + // clear it so the last close() unlinks the -wal/-shm sidecars like + // Node.js's bundled build does. + int off = 0; + sqlite3_file_control(m_db, nullptr, SQLITE_FCNTL_PERSIST_WAL, &off); +#endif + int v = m_config.enableDoubleQuotedStringLiterals ? 1 : 0; sqlite3_db_config(m_db, SQLITE_DBCONFIG_DQS_DML, v, nullptr); sqlite3_db_config(m_db, SQLITE_DBCONFIG_DQS_DDL, v, nullptr); @@ -962,6 +991,14 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) } if (m_config.allowExtension) { + if (!LAZY_SQLITE_HAS_LOAD_EXTENSION()) [[unlikely]] { + Bun::throwError(globalObject, scope, ErrorCode::ERR_LOAD_SQLITE_EXTENSION, + "the loaded SQLite library was built with SQLITE_OMIT_LOAD_EXTENSION.\n" + "note: on macOS, install a full SQLite (e.g. `brew install sqlite`) and call " + "`require(\"bun:sqlite\").Database.setCustomSQLite(path)` before opening a database."_s); + closeInternal(); + return false; + } if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, 1, nullptr) != SQLITE_OK) { throwSqliteError(globalObject, scope, m_db); closeInternal(); @@ -1224,10 +1261,15 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension, (JSGlobalObject * gl return throwNodeState(globalObject, scope, "Cannot enable extension loading because it was disabled at database creation."_s); } - int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, allow ? 1 : 0, nullptr); - if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); - return {}; + // Apple's OMIT_LOAD_EXTENSION build rejects this db_config op; silently + // succeed for `false` (extensions were never enabled) — `true` is caught + // by the allowExtension constructor gate above. + if (LAZY_SQLITE_HAS_LOAD_EXTENSION()) { + int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, allow ? 1 : 0, nullptr); + if (r != SQLITE_OK) { + throwSqliteError(globalObject, scope, self->connection()); + return {}; + } } self->setEnableLoadExtension(allow); return JSValue::encode(jsUndefined()); @@ -1444,10 +1486,20 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncAggregate, (JSGlobalObject * globalObject return JSValue::encode(jsUndefined()); } +static EncodedJSValue throwSessionUnavailable(JSGlobalObject* globalObject, ThrowScope& scope) +{ + return Bun::throwError(globalObject, scope, ErrorCode::ERR_SQLITE_ERROR, + "the loaded SQLite library was built without SQLITE_ENABLE_SESSION.\n" + "note: on macOS, install a full SQLite (e.g. `brew install sqlite`) and call " + "`require(\"bun:sqlite\").Database.setCustomSQLite(path)` before opening a database."_s); +} + JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateSession, (JSGlobalObject * globalObject, CallFrame* callFrame)) { THIS_DATABASE(); REQUIRE_DB_OPEN(self); + if (!lazy_sqlite3_has_session) [[unlikely]] + return throwSessionUnavailable(globalObject, scope); JSDatabaseSync::BusyScope busy { self }; WTF::String table; @@ -1567,6 +1619,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset, (JSGlobalObject * globalO { THIS_DATABASE(); REQUIRE_DB_OPEN(self); + if (!lazy_sqlite3_has_session) [[unlikely]] + return throwSessionUnavailable(globalObject, scope); JSDatabaseSync::BusyScope busy { self }; auto* buf = dynamicDowncast(callFrame->argument(0)); @@ -2694,19 +2748,12 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncColumns, (JSGlobalObject * globalObject, auto putStr = [&](ASCIILiteral key, const char* val) { col->putDirect(vm, Identifier::fromString(vm, key), val ? jsString(vm, sqliteText(val)) : jsNull(), 0); }; -#ifdef SQLITE_ENABLE_COLUMN_METADATA + // On the dlopen path these are stubbed to return nullptr when the + // loaded library was built without SQLITE_ENABLE_COLUMN_METADATA. putStr("column"_s, sqlite3_column_origin_name(self->statement(), i)); putStr("database"_s, sqlite3_column_database_name(self->statement(), i)); -#else - putStr("column"_s, nullptr); - putStr("database"_s, nullptr); -#endif putStr("name"_s, sqlite3_column_name(self->statement(), i)); -#ifdef SQLITE_ENABLE_COLUMN_METADATA putStr("table"_s, sqlite3_column_table_name(self->statement(), i)); -#else - putStr("table"_s, nullptr); -#endif putStr("type"_s, sqlite3_column_decltype(self->statement(), i)); out->putDirectIndex(globalObject, i, col); RETURN_IF_EXCEPTION(scope, {}); @@ -3051,8 +3098,8 @@ GCClient::IsoSubspace* JSNodeSqliteSession::subspaceForImpl(VM& vm) return {}; \ } -template -static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallFrame* callFrame) +static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallFrame* callFrame, + int (*fn)(sqlite3_session*, int*, void**)) { THIS_SESSION(); JSDatabaseSync* db = self->database(); @@ -3064,7 +3111,7 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF } int nChangeset = 0; void* pChangeset = nullptr; - int r = Fn(self->session(), &nChangeset, &pChangeset); + int r = fn(self->session(), &nChangeset, &pChangeset); if (r != SQLITE_OK) { if (pChangeset) sqlite3_free(pChangeset); throwSqliteError(globalObject, scope, db->connection()); @@ -3082,12 +3129,12 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF JSC_DEFINE_HOST_FUNCTION(jsSessionChangeset, (JSGlobalObject * globalObject, CallFrame* callFrame)) { - return sessionChangesetCommon(globalObject, callFrame); + return sessionChangesetCommon(globalObject, callFrame, sqlite3session_changeset); } JSC_DEFINE_HOST_FUNCTION(jsSessionPatchset, (JSGlobalObject * globalObject, CallFrame* callFrame)) { - return sessionChangesetCommon(globalObject, callFrame); + return sessionChangesetCommon(globalObject, callFrame, sqlite3session_patchset); } JSC_DEFINE_HOST_FUNCTION(jsSessionClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index c09623b65721..7a66b344b3c5 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -1,13 +1,12 @@ // node:sqlite — native implementation of Node.js's `node:sqlite` module. // -// This uses the bundled sqlite3 amalgamation (sqlite3_local.h / sqlite3.c) -// on all platforms, matching Node.js which always bundles its own SQLite. -// bun:sqlite links the same object by default (staticSqlite=true) so both -// modules share one library and one POSIX-lock inode map — two SQLite -// copies in one process is a documented corruption vector -// (howtocorrupt.html §2.2.1). A --static-sqlite=off build restores the -// macOS dlopen path for bun:sqlite; opening the same file via both APIs -// in that configuration is unsafe. +// This uses the SAME sqlite3 library as bun:sqlite so exactly one copy is +// loaded per process (two copies is a POSIX-lock corruption vector, +// howtocorrupt.html §2.2.1). On macOS that is the dlopen'd system +// libsqlite3.dylib (LAZY_LOAD_SQLITE=1); features Apple omits +// (loadExtension, and the session extension on older releases) runtime- +// gate on the dlsym result and point at Database.setCustomSQLite(). On +// Linux/Windows the bundled amalgamation is linked (LAZY_LOAD_SQLITE=0). // // Reference: https://github.com/nodejs/node/blob/main/src/node_sqlite.cc #pragma once diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index cb4c6f712eb7..0be06af6f68a 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -10,16 +10,27 @@ #include #endif +// The system sqlite3.h only declares these when the library was built with +// SQLITE_ENABLE_SESSION; forward-declare the opaque handles unconditionally +// so node:sqlite can compile (their APIs are runtime-gated on dlsym below). +extern "C" { +struct sqlite3_session; +struct sqlite3_changeset_iter; +} + typedef int (*lazy_sqlite3_bind_blob_type)(sqlite3_stmt*, int, const void*, int n, void (*)(void*)); +typedef int (*lazy_sqlite3_bind_blob64_type)(sqlite3_stmt*, int, const void*, sqlite3_uint64, void (*)(void*)); typedef int (*lazy_sqlite3_bind_double_type)(sqlite3_stmt*, int, double); typedef int (*lazy_sqlite3_bind_int_type)(sqlite3_stmt*, int, int); typedef int (*lazy_sqlite3_bind_int64_type)(sqlite3_stmt*, int, sqlite3_int64); typedef int (*lazy_sqlite3_bind_null_type)(sqlite3_stmt*, int); typedef int (*lazy_sqlite3_bind_text_type)(sqlite3_stmt*, int, const char*, int, void (*)(void*)); typedef int (*lazy_sqlite3_bind_text16_type)(sqlite3_stmt*, int, const void*, int, void (*)(void*)); +typedef int (*lazy_sqlite3_bind_text64_type)(sqlite3_stmt*, int, const char*, sqlite3_uint64, void (*)(void*), unsigned char encoding); typedef int (*lazy_sqlite3_bind_parameter_count_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_bind_parameter_index_type)(sqlite3_stmt*, const char* zName); typedef int (*lazy_sqlite3_changes_type)(sqlite3*); +typedef sqlite3_int64 (*lazy_sqlite3_changes64_type)(sqlite3*); typedef int (*lazy_sqlite3_clear_bindings_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_close_v2_type)(sqlite3*); typedef int (*lazy_sqlite3_close_type)(sqlite3*); @@ -36,124 +47,186 @@ typedef int (*lazy_sqlite3_column_type_type)(sqlite3_stmt*, int iCol); typedef int (*lazy_sqlite3_column_count_type)(sqlite3_stmt* pStmt); typedef const char* (*lazy_sqlite3_column_decltype_type)(sqlite3_stmt*, int); typedef const char* (*lazy_sqlite3_column_name_type)(sqlite3_stmt*, int N); +typedef const char* (*lazy_sqlite3_column_database_name_type)(sqlite3_stmt*, int); +typedef const char* (*lazy_sqlite3_column_table_name_type)(sqlite3_stmt*, int); +typedef const char* (*lazy_sqlite3_column_origin_name_type)(sqlite3_stmt*, int); typedef const char* (*lazy_sqlite3_errmsg_type)(sqlite3*); +typedef int (*lazy_sqlite3_errcode_type)(sqlite3*); typedef int (*lazy_sqlite3_extended_errcode_type)(sqlite3*); typedef int (*lazy_sqlite3_error_offset_type)(sqlite3*); typedef int64_t (*lazy_sqlite3_memory_used_type)(); typedef const char* (*lazy_sqlite3_errstr_type)(int); typedef char* (*lazy_sqlite3_expanded_sql_type)(sqlite3_stmt* pStmt); +typedef const char* (*lazy_sqlite3_sql_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_finalize_type)(sqlite3_stmt* pStmt); typedef void (*lazy_sqlite3_free_type)(void*); typedef int (*lazy_sqlite3_get_autocommit_type)(sqlite3*); typedef int (*lazy_sqlite3_total_changes_type)(sqlite3*); -typedef int (*lazy_sqlite3_get_autocommit_type)(sqlite3*); typedef int (*lazy_sqlite3_config_type)(int, ...); -typedef int (*lazy_sqlite3_open_v2_type)(const char* filename, /* Database filename (UTF-8) */ sqlite3** ppDb, /* OUT: SQLite db handle */ int flags, /* Flags */ const char* zVfs /* Name of VFS module to use */); -typedef int (*lazy_sqlite3_prepare_v3_type)(sqlite3* db, /* Database handle */ - const char* zSql, /* SQL statement, UTF-8 encoded */ - int nByte, /* Maximum length of zSql in bytes. */ - unsigned int prepFlags, /* Zero or more SQLITE_PREPARE_ flags */ - sqlite3_stmt** ppStmt, /* OUT: Statement handle */ - const char** pzTail /* OUT: Pointer to unused portion of zSql */); -typedef int (*lazy_sqlite3_prepare16_v3_type)(sqlite3* db, /* Database handle */ - const void* zSql, /* SQL statement, UTF-16 encoded */ - int nByte, /* Maximum length of zSql in bytes. */ - unsigned int prepFlags, /* Zero or more SQLITE_PREPARE_ flags */ - sqlite3_stmt** ppStmt, /* OUT: Statement handle */ - const void** pzTail /* OUT: Pointer to unused portion of zSql */); +typedef int (*lazy_sqlite3_open_v2_type)(const char* filename, sqlite3** ppDb, int flags, const char* zVfs); +typedef int (*lazy_sqlite3_prepare_v2_type)(sqlite3* db, const char* zSql, int nByte, sqlite3_stmt** ppStmt, const char** pzTail); +typedef int (*lazy_sqlite3_prepare_v3_type)(sqlite3* db, const char* zSql, int nByte, unsigned int prepFlags, sqlite3_stmt** ppStmt, const char** pzTail); +typedef int (*lazy_sqlite3_prepare16_v3_type)(sqlite3* db, const void* zSql, int nByte, unsigned int prepFlags, sqlite3_stmt** ppStmt, const void** pzTail); typedef int (*lazy_sqlite3_reset_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_step_type)(sqlite3_stmt*); -typedef int (*lazy_sqlite3_clear_bindings_type)(sqlite3_stmt*); -typedef int (*lazy_sqlite3_column_type_type)(sqlite3_stmt*, int iCol); typedef int (*lazy_sqlite3_db_config_type)(sqlite3*, int op, ...); +typedef const char* (*lazy_sqlite3_db_filename_type)(sqlite3*, const char* zDbName); +typedef sqlite3* (*lazy_sqlite3_db_handle_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_busy_timeout_type)(sqlite3*, int ms); typedef int (*lazy_sqlite3_wal_checkpoint_v2_type)(sqlite3*, const char* zDb, int eMode, int* pnLog, int* pnCkpt); typedef const char* (*lazy_sqlite3_bind_parameter_name_type)(sqlite3_stmt*, int); - -typedef int (*lazy_sqlite3_load_extension_type)( - sqlite3* db, /* Load the extension into this database connection */ - const char* zFile, /* Name of the shared library containing extension */ - const char* zProc, /* Entry point. Derived from zFile if 0 */ - char** pzErrMsg /* Put error message here if not 0 */ -); -typedef void* (*lazy_sqlite3_libversion_type)(); +typedef int (*lazy_sqlite3_exec_type)(sqlite3*, const char* sql, int (*callback)(void*, int, char**, char**), void*, char** errmsg); +typedef int (*lazy_sqlite3_limit_type)(sqlite3*, int id, int newVal); +typedef int (*lazy_sqlite3_sleep_type)(int); +typedef int (*lazy_sqlite3_stmt_status_type)(sqlite3_stmt*, int op, int resetFlg); +typedef int (*lazy_sqlite3_load_extension_type)(sqlite3* db, const char* zFile, const char* zProc, char** pzErrMsg); +typedef const char* (*lazy_sqlite3_libversion_type)(); typedef void* (*lazy_sqlite3_malloc64_type)(sqlite3_uint64); -typedef unsigned char* (*lazy_sqlite3_serialize_type)( - sqlite3* db, /* The database connection */ - const char* zSchema, /* Which DB to serialize. ex: "main", "temp", ... */ - sqlite3_int64* piSize, /* Write size of the DB here, if not NULL */ - unsigned int mFlags /* Zero or more SQLITE_SERIALIZE_* flags */ -); -typedef int (*lazy_sqlite3_deserialize_type)( - sqlite3* db, /* The database connection */ - const char* zSchema, /* Which DB to reopen with the deserialization */ - unsigned char* pData, /* The serialized database content */ - sqlite3_int64 szDb, /* Number bytes in the deserialization */ - sqlite3_int64 szBuf, /* Total size of buffer pData[] */ - unsigned mFlags /* Zero or more SQLITE_DESERIALIZE_* flags */ -); - +typedef unsigned char* (*lazy_sqlite3_serialize_type)(sqlite3* db, const char* zSchema, sqlite3_int64* piSize, unsigned int mFlags); +typedef int (*lazy_sqlite3_deserialize_type)(sqlite3* db, const char* zSchema, unsigned char* pData, sqlite3_int64 szDb, sqlite3_int64 szBuf, unsigned mFlags); typedef int (*lazy_sqlite3_stmt_readonly_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_stmt_busy_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_compileoption_used_type)(const char* zOptName); typedef int64_t (*lazy_sqlite3_last_insert_rowid_type)(sqlite3* db); +typedef int (*lazy_sqlite3_set_authorizer_type)(sqlite3*, int (*xAuth)(void*, int, const char*, const char*, const char*, const char*), void* pUserData); +typedef int (*lazy_sqlite3_create_function_v2_type)(sqlite3* db, const char* zFunctionName, int nArg, int eTextRep, void* pApp, + void (*xFunc)(sqlite3_context*, int, sqlite3_value**), void (*xStep)(sqlite3_context*, int, sqlite3_value**), + void (*xFinal)(sqlite3_context*), void (*xDestroy)(void*)); +typedef int (*lazy_sqlite3_create_window_function_type)(sqlite3* db, const char* zFunctionName, int nArg, int eTextRep, void* pApp, + void (*xStep)(sqlite3_context*, int, sqlite3_value**), void (*xFinal)(sqlite3_context*), + void (*xValue)(sqlite3_context*), void (*xInverse)(sqlite3_context*, int, sqlite3_value**), void (*xDestroy)(void*)); +typedef void* (*lazy_sqlite3_user_data_type)(sqlite3_context*); +typedef void* (*lazy_sqlite3_aggregate_context_type)(sqlite3_context*, int nBytes); +typedef const void* (*lazy_sqlite3_value_blob_type)(sqlite3_value*); +typedef int (*lazy_sqlite3_value_bytes_type)(sqlite3_value*); +typedef double (*lazy_sqlite3_value_double_type)(sqlite3_value*); +typedef sqlite3_int64 (*lazy_sqlite3_value_int64_type)(sqlite3_value*); +typedef const unsigned char* (*lazy_sqlite3_value_text_type)(sqlite3_value*); +typedef int (*lazy_sqlite3_value_type_type)(sqlite3_value*); +typedef void (*lazy_sqlite3_result_blob64_type)(sqlite3_context*, const void*, sqlite3_uint64, void (*)(void*)); +typedef void (*lazy_sqlite3_result_double_type)(sqlite3_context*, double); +typedef void (*lazy_sqlite3_result_error_type)(sqlite3_context*, const char*, int); +typedef void (*lazy_sqlite3_result_int64_type)(sqlite3_context*, sqlite3_int64); +typedef void (*lazy_sqlite3_result_null_type)(sqlite3_context*); +typedef void (*lazy_sqlite3_result_text64_type)(sqlite3_context*, const char*, sqlite3_uint64, void (*)(void*), unsigned char encoding); +typedef sqlite3_backup* (*lazy_sqlite3_backup_init_type)(sqlite3* pDest, const char* zDestName, sqlite3* pSource, const char* zSourceName); +typedef int (*lazy_sqlite3_backup_step_type)(sqlite3_backup*, int nPage); +typedef int (*lazy_sqlite3_backup_finish_type)(sqlite3_backup*); +typedef int (*lazy_sqlite3_backup_remaining_type)(sqlite3_backup*); +typedef int (*lazy_sqlite3_backup_pagecount_type)(sqlite3_backup*); +typedef int (*lazy_sqlite3session_create_type)(sqlite3*, const char* zDb, sqlite3_session** ppSession); +typedef void (*lazy_sqlite3session_delete_type)(sqlite3_session*); +typedef int (*lazy_sqlite3session_attach_type)(sqlite3_session*, const char* zTab); +typedef int (*lazy_sqlite3session_changeset_type)(sqlite3_session*, int* pnChangeset, void** ppChangeset); +typedef int (*lazy_sqlite3session_patchset_type)(sqlite3_session*, int* pnPatchset, void** ppPatchset); +typedef int (*lazy_sqlite3changeset_apply_type)(sqlite3*, int nChangeset, void* pChangeset, + int (*xFilter)(void* pCtx, const char* zTab), int (*xConflict)(void* pCtx, int eConflict, sqlite3_changeset_iter* p), void* pCtx); -static lazy_sqlite3_bind_blob_type lazy_sqlite3_bind_blob; -static lazy_sqlite3_bind_double_type lazy_sqlite3_bind_double; -static lazy_sqlite3_bind_int_type lazy_sqlite3_bind_int; -static lazy_sqlite3_bind_int64_type lazy_sqlite3_bind_int64; -static lazy_sqlite3_bind_null_type lazy_sqlite3_bind_null; -static lazy_sqlite3_bind_parameter_count_type lazy_sqlite3_bind_parameter_count; -static lazy_sqlite3_bind_parameter_index_type lazy_sqlite3_bind_parameter_index; -static lazy_sqlite3_bind_text_type lazy_sqlite3_bind_text; -static lazy_sqlite3_bind_text16_type lazy_sqlite3_bind_text16; -static lazy_sqlite3_changes_type lazy_sqlite3_changes; -static lazy_sqlite3_clear_bindings_type lazy_sqlite3_clear_bindings; -static lazy_sqlite3_close_v2_type lazy_sqlite3_close_v2; -static lazy_sqlite3_close_type lazy_sqlite3_close; -static lazy_sqlite3_busy_timeout_type lazy_sqlite3_busy_timeout; -static lazy_sqlite3_wal_checkpoint_v2_type lazy_sqlite3_wal_checkpoint_v2; -static lazy_sqlite3_file_control_type lazy_sqlite3_file_control; -static lazy_sqlite3_column_blob_type lazy_sqlite3_column_blob; -static lazy_sqlite3_column_bytes_type lazy_sqlite3_column_bytes; -static lazy_sqlite3_column_bytes16_type lazy_sqlite3_column_bytes16; -static lazy_sqlite3_column_count_type lazy_sqlite3_column_count; -static lazy_sqlite3_column_decltype_type lazy_sqlite3_column_decltype; -static lazy_sqlite3_column_double_type lazy_sqlite3_column_double; -static lazy_sqlite3_column_int_type lazy_sqlite3_column_int; -static lazy_sqlite3_column_int64_type lazy_sqlite3_column_int64; -static lazy_sqlite3_column_name_type lazy_sqlite3_column_name; -static lazy_sqlite3_column_text_type lazy_sqlite3_column_text; -static lazy_sqlite3_column_type_type lazy_sqlite3_column_type; -static lazy_sqlite3_errmsg_type lazy_sqlite3_errmsg; -static lazy_sqlite3_errstr_type lazy_sqlite3_errstr; -static lazy_sqlite3_expanded_sql_type lazy_sqlite3_expanded_sql; -static lazy_sqlite3_finalize_type lazy_sqlite3_finalize; -static lazy_sqlite3_free_type lazy_sqlite3_free; -static lazy_sqlite3_get_autocommit_type lazy_sqlite3_get_autocommit; -static lazy_sqlite3_open_v2_type lazy_sqlite3_open_v2; -static lazy_sqlite3_prepare_v3_type lazy_sqlite3_prepare_v3; -static lazy_sqlite3_prepare16_v3_type lazy_sqlite3_prepare16_v3; -static lazy_sqlite3_reset_type lazy_sqlite3_reset; -static lazy_sqlite3_step_type lazy_sqlite3_step; -static lazy_sqlite3_db_config_type lazy_sqlite3_db_config; -static lazy_sqlite3_load_extension_type lazy_sqlite3_load_extension; -static lazy_sqlite3_malloc64_type lazy_sqlite3_malloc64; -static lazy_sqlite3_serialize_type lazy_sqlite3_serialize; -static lazy_sqlite3_deserialize_type lazy_sqlite3_deserialize; -static lazy_sqlite3_stmt_readonly_type lazy_sqlite3_stmt_readonly; -static lazy_sqlite3_stmt_busy_type lazy_sqlite3_stmt_busy; -static lazy_sqlite3_compileoption_used_type lazy_sqlite3_compileoption_used; -static lazy_sqlite3_config_type lazy_sqlite3_config; -static lazy_sqlite3_extended_result_codes_type lazy_sqlite3_extended_result_codes; -static lazy_sqlite3_extended_errcode_type lazy_sqlite3_extended_errcode; -static lazy_sqlite3_error_offset_type lazy_sqlite3_error_offset; -static lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used; -static lazy_sqlite3_bind_parameter_name_type lazy_sqlite3_bind_parameter_name; -static lazy_sqlite3_total_changes_type lazy_sqlite3_total_changes; -static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; +// C++17 inline variables: the pointers, handle, and lock are shared across +// every TU that includes this header (JSSQLStatement.cpp + NodeSqlite.cpp), +// so bun:sqlite's Database.setCustomSQLite() also affects node:sqlite and +// exactly one dlopen happens per process. +inline lazy_sqlite3_bind_blob_type lazy_sqlite3_bind_blob; +inline lazy_sqlite3_bind_blob64_type lazy_sqlite3_bind_blob64; +inline lazy_sqlite3_bind_double_type lazy_sqlite3_bind_double; +inline lazy_sqlite3_bind_int_type lazy_sqlite3_bind_int; +inline lazy_sqlite3_bind_int64_type lazy_sqlite3_bind_int64; +inline lazy_sqlite3_bind_null_type lazy_sqlite3_bind_null; +inline lazy_sqlite3_bind_parameter_count_type lazy_sqlite3_bind_parameter_count; +inline lazy_sqlite3_bind_parameter_index_type lazy_sqlite3_bind_parameter_index; +inline lazy_sqlite3_bind_text_type lazy_sqlite3_bind_text; +inline lazy_sqlite3_bind_text16_type lazy_sqlite3_bind_text16; +inline lazy_sqlite3_bind_text64_type lazy_sqlite3_bind_text64; +inline lazy_sqlite3_changes_type lazy_sqlite3_changes; +inline lazy_sqlite3_changes64_type lazy_sqlite3_changes64; +inline lazy_sqlite3_clear_bindings_type lazy_sqlite3_clear_bindings; +inline lazy_sqlite3_close_v2_type lazy_sqlite3_close_v2; +inline lazy_sqlite3_close_type lazy_sqlite3_close; +inline lazy_sqlite3_busy_timeout_type lazy_sqlite3_busy_timeout; +inline lazy_sqlite3_wal_checkpoint_v2_type lazy_sqlite3_wal_checkpoint_v2; +inline lazy_sqlite3_file_control_type lazy_sqlite3_file_control; +inline lazy_sqlite3_column_blob_type lazy_sqlite3_column_blob; +inline lazy_sqlite3_column_bytes_type lazy_sqlite3_column_bytes; +inline lazy_sqlite3_column_bytes16_type lazy_sqlite3_column_bytes16; +inline lazy_sqlite3_column_count_type lazy_sqlite3_column_count; +inline lazy_sqlite3_column_decltype_type lazy_sqlite3_column_decltype; +inline lazy_sqlite3_column_double_type lazy_sqlite3_column_double; +inline lazy_sqlite3_column_int_type lazy_sqlite3_column_int; +inline lazy_sqlite3_column_int64_type lazy_sqlite3_column_int64; +inline lazy_sqlite3_column_name_type lazy_sqlite3_column_name; +inline lazy_sqlite3_column_text_type lazy_sqlite3_column_text; +inline lazy_sqlite3_column_type_type lazy_sqlite3_column_type; +inline lazy_sqlite3_column_database_name_type lazy_sqlite3_column_database_name; +inline lazy_sqlite3_column_table_name_type lazy_sqlite3_column_table_name; +inline lazy_sqlite3_column_origin_name_type lazy_sqlite3_column_origin_name; +inline lazy_sqlite3_errmsg_type lazy_sqlite3_errmsg; +inline lazy_sqlite3_errcode_type lazy_sqlite3_errcode; +inline lazy_sqlite3_errstr_type lazy_sqlite3_errstr; +inline lazy_sqlite3_expanded_sql_type lazy_sqlite3_expanded_sql; +inline lazy_sqlite3_sql_type lazy_sqlite3_sql; +inline lazy_sqlite3_finalize_type lazy_sqlite3_finalize; +inline lazy_sqlite3_free_type lazy_sqlite3_free; +inline lazy_sqlite3_get_autocommit_type lazy_sqlite3_get_autocommit; +inline lazy_sqlite3_open_v2_type lazy_sqlite3_open_v2; +inline lazy_sqlite3_prepare_v2_type lazy_sqlite3_prepare_v2; +inline lazy_sqlite3_prepare_v3_type lazy_sqlite3_prepare_v3; +inline lazy_sqlite3_prepare16_v3_type lazy_sqlite3_prepare16_v3; +inline lazy_sqlite3_reset_type lazy_sqlite3_reset; +inline lazy_sqlite3_step_type lazy_sqlite3_step; +inline lazy_sqlite3_db_config_type lazy_sqlite3_db_config; +inline lazy_sqlite3_db_filename_type lazy_sqlite3_db_filename; +inline lazy_sqlite3_db_handle_type lazy_sqlite3_db_handle; +inline lazy_sqlite3_load_extension_type lazy_sqlite3_load_extension; +inline lazy_sqlite3_libversion_type lazy_sqlite3_libversion; +inline lazy_sqlite3_malloc64_type lazy_sqlite3_malloc64; +inline lazy_sqlite3_serialize_type lazy_sqlite3_serialize; +inline lazy_sqlite3_deserialize_type lazy_sqlite3_deserialize; +inline lazy_sqlite3_stmt_readonly_type lazy_sqlite3_stmt_readonly; +inline lazy_sqlite3_stmt_busy_type lazy_sqlite3_stmt_busy; +inline lazy_sqlite3_compileoption_used_type lazy_sqlite3_compileoption_used; +inline lazy_sqlite3_config_type lazy_sqlite3_config; +inline lazy_sqlite3_extended_result_codes_type lazy_sqlite3_extended_result_codes; +inline lazy_sqlite3_extended_errcode_type lazy_sqlite3_extended_errcode; +inline lazy_sqlite3_error_offset_type lazy_sqlite3_error_offset; +inline lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used; +inline lazy_sqlite3_bind_parameter_name_type lazy_sqlite3_bind_parameter_name; +inline lazy_sqlite3_total_changes_type lazy_sqlite3_total_changes; +inline lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; +inline lazy_sqlite3_exec_type lazy_sqlite3_exec; +inline lazy_sqlite3_limit_type lazy_sqlite3_limit; +inline lazy_sqlite3_sleep_type lazy_sqlite3_sleep; +inline lazy_sqlite3_stmt_status_type lazy_sqlite3_stmt_status; +inline lazy_sqlite3_set_authorizer_type lazy_sqlite3_set_authorizer; +inline lazy_sqlite3_create_function_v2_type lazy_sqlite3_create_function_v2; +inline lazy_sqlite3_create_window_function_type lazy_sqlite3_create_window_function; +inline lazy_sqlite3_user_data_type lazy_sqlite3_user_data; +inline lazy_sqlite3_aggregate_context_type lazy_sqlite3_aggregate_context; +inline lazy_sqlite3_value_blob_type lazy_sqlite3_value_blob; +inline lazy_sqlite3_value_bytes_type lazy_sqlite3_value_bytes; +inline lazy_sqlite3_value_double_type lazy_sqlite3_value_double; +inline lazy_sqlite3_value_int64_type lazy_sqlite3_value_int64; +inline lazy_sqlite3_value_text_type lazy_sqlite3_value_text; +inline lazy_sqlite3_value_type_type lazy_sqlite3_value_type; +inline lazy_sqlite3_result_blob64_type lazy_sqlite3_result_blob64; +inline lazy_sqlite3_result_double_type lazy_sqlite3_result_double; +inline lazy_sqlite3_result_error_type lazy_sqlite3_result_error; +inline lazy_sqlite3_result_int64_type lazy_sqlite3_result_int64; +inline lazy_sqlite3_result_null_type lazy_sqlite3_result_null; +inline lazy_sqlite3_result_text64_type lazy_sqlite3_result_text64; +inline lazy_sqlite3_backup_init_type lazy_sqlite3_backup_init; +inline lazy_sqlite3_backup_step_type lazy_sqlite3_backup_step; +inline lazy_sqlite3_backup_finish_type lazy_sqlite3_backup_finish; +inline lazy_sqlite3_backup_remaining_type lazy_sqlite3_backup_remaining; +inline lazy_sqlite3_backup_pagecount_type lazy_sqlite3_backup_pagecount; +inline lazy_sqlite3session_create_type lazy_sqlite3session_create; +inline lazy_sqlite3session_delete_type lazy_sqlite3session_delete; +inline lazy_sqlite3session_attach_type lazy_sqlite3session_attach; +inline lazy_sqlite3session_changeset_type lazy_sqlite3session_changeset; +inline lazy_sqlite3session_patchset_type lazy_sqlite3session_patchset; +inline lazy_sqlite3changeset_apply_type lazy_sqlite3changeset_apply; #define sqlite3_bind_blob lazy_sqlite3_bind_blob +#define sqlite3_bind_blob64 lazy_sqlite3_bind_blob64 #define sqlite3_bind_double lazy_sqlite3_bind_double #define sqlite3_bind_int lazy_sqlite3_bind_int #define sqlite3_bind_int64 lazy_sqlite3_bind_int64 @@ -162,7 +235,9 @@ static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; #define sqlite3_bind_parameter_index lazy_sqlite3_bind_parameter_index #define sqlite3_bind_text lazy_sqlite3_bind_text #define sqlite3_bind_text16 lazy_sqlite3_bind_text16 +#define sqlite3_bind_text64 lazy_sqlite3_bind_text64 #define sqlite3_changes lazy_sqlite3_changes +#define sqlite3_changes64 lazy_sqlite3_changes64 #define sqlite3_clear_bindings lazy_sqlite3_clear_bindings #define sqlite3_close_v2 lazy_sqlite3_close_v2 #define sqlite3_close lazy_sqlite3_close @@ -178,19 +253,28 @@ static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; #define sqlite3_column_name lazy_sqlite3_column_name #define sqlite3_column_text lazy_sqlite3_column_text #define sqlite3_column_type lazy_sqlite3_column_type +#define sqlite3_column_database_name lazy_sqlite3_column_database_name +#define sqlite3_column_table_name lazy_sqlite3_column_table_name +#define sqlite3_column_origin_name lazy_sqlite3_column_origin_name #define sqlite3_errmsg lazy_sqlite3_errmsg +#define sqlite3_errcode lazy_sqlite3_errcode #define sqlite3_errstr lazy_sqlite3_errstr #define sqlite3_expanded_sql lazy_sqlite3_expanded_sql +#define sqlite3_sql lazy_sqlite3_sql #define sqlite3_finalize lazy_sqlite3_finalize #define sqlite3_free lazy_sqlite3_free #define sqlite3_get_autocommit lazy_sqlite3_get_autocommit #define sqlite3_open_v2 lazy_sqlite3_open_v2 +#define sqlite3_prepare_v2 lazy_sqlite3_prepare_v2 #define sqlite3_prepare_v3 lazy_sqlite3_prepare_v3 #define sqlite3_prepare16_v3 lazy_sqlite3_prepare16_v3 #define sqlite3_reset lazy_sqlite3_reset #define sqlite3_step lazy_sqlite3_step #define sqlite3_db_config lazy_sqlite3_db_config +#define sqlite3_db_filename lazy_sqlite3_db_filename +#define sqlite3_db_handle lazy_sqlite3_db_handle #define sqlite3_load_extension lazy_sqlite3_load_extension +#define sqlite3_libversion lazy_sqlite3_libversion #define sqlite3_malloc64 lazy_sqlite3_malloc64 #define sqlite3_serialize lazy_sqlite3_serialize #define sqlite3_deserialize lazy_sqlite3_deserialize @@ -206,6 +290,38 @@ static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; #define sqlite3_bind_parameter_name lazy_sqlite3_bind_parameter_name #define sqlite3_total_changes lazy_sqlite3_total_changes #define sqlite3_last_insert_rowid lazy_sqlite3_last_insert_rowid +#define sqlite3_exec lazy_sqlite3_exec +#define sqlite3_limit lazy_sqlite3_limit +#define sqlite3_sleep lazy_sqlite3_sleep +#define sqlite3_stmt_status lazy_sqlite3_stmt_status +#define sqlite3_set_authorizer lazy_sqlite3_set_authorizer +#define sqlite3_create_function_v2 lazy_sqlite3_create_function_v2 +#define sqlite3_create_window_function lazy_sqlite3_create_window_function +#define sqlite3_user_data lazy_sqlite3_user_data +#define sqlite3_aggregate_context lazy_sqlite3_aggregate_context +#define sqlite3_value_blob lazy_sqlite3_value_blob +#define sqlite3_value_bytes lazy_sqlite3_value_bytes +#define sqlite3_value_double lazy_sqlite3_value_double +#define sqlite3_value_int64 lazy_sqlite3_value_int64 +#define sqlite3_value_text lazy_sqlite3_value_text +#define sqlite3_value_type lazy_sqlite3_value_type +#define sqlite3_result_blob64 lazy_sqlite3_result_blob64 +#define sqlite3_result_double lazy_sqlite3_result_double +#define sqlite3_result_error lazy_sqlite3_result_error +#define sqlite3_result_int64 lazy_sqlite3_result_int64 +#define sqlite3_result_null lazy_sqlite3_result_null +#define sqlite3_result_text64 lazy_sqlite3_result_text64 +#define sqlite3_backup_init lazy_sqlite3_backup_init +#define sqlite3_backup_step lazy_sqlite3_backup_step +#define sqlite3_backup_finish lazy_sqlite3_backup_finish +#define sqlite3_backup_remaining lazy_sqlite3_backup_remaining +#define sqlite3_backup_pagecount lazy_sqlite3_backup_pagecount +#define sqlite3session_create lazy_sqlite3session_create +#define sqlite3session_delete lazy_sqlite3session_delete +#define sqlite3session_attach lazy_sqlite3session_attach +#define sqlite3session_changeset lazy_sqlite3session_changeset +#define sqlite3session_patchset lazy_sqlite3session_patchset +#define sqlite3changeset_apply lazy_sqlite3changeset_apply #if !OS(WINDOWS) #define HMODULE void* @@ -218,17 +334,21 @@ static const char* dlerror() #endif #if OS(WINDOWS) -static const char* sqlite3_lib_path = "sqlite3.dll"; +inline const char* sqlite3_lib_path = "sqlite3.dll"; #elif OS(DARWIN) -static const char* sqlite3_lib_path = "libsqlite3.dylib"; +inline const char* sqlite3_lib_path = "libsqlite3.dylib"; #else -static const char* sqlite3_lib_path = "sqlite3"; +inline const char* sqlite3_lib_path = "sqlite3"; #endif -static HMODULE sqlite3_handle = nullptr; -static WTF::Lock sqlite3_handle_lock; +inline HMODULE sqlite3_handle = nullptr; +inline WTF::Lock sqlite3_handle_lock; +// True after dlsym found sqlite3session_create — Apple's system libsqlite3 +// is built without SQLITE_ENABLE_SESSION, so node:sqlite session/changeset +// APIs must be runtime-gated on this instead of compiled out. +inline bool lazy_sqlite3_has_session = false; -static int lazyLoadSQLite() +inline int lazyLoadSQLite() { WTF::Locker locker { sqlite3_handle_lock }; if (sqlite3_handle) @@ -245,6 +365,7 @@ static int lazyLoadSQLite() lazy_sqlite3_open_v2 = (lazy_sqlite3_open_v2_type)dlsym(sqlite3_handle, "sqlite3_open_v2"); if (!lazy_sqlite3_open_v2) return -1; lazy_sqlite3_bind_blob = (lazy_sqlite3_bind_blob_type)dlsym(sqlite3_handle, "sqlite3_bind_blob"); + lazy_sqlite3_bind_blob64 = (lazy_sqlite3_bind_blob64_type)dlsym(sqlite3_handle, "sqlite3_bind_blob64"); lazy_sqlite3_bind_double = (lazy_sqlite3_bind_double_type)dlsym(sqlite3_handle, "sqlite3_bind_double"); lazy_sqlite3_bind_int = (lazy_sqlite3_bind_int_type)dlsym(sqlite3_handle, "sqlite3_bind_int"); lazy_sqlite3_bind_int64 = (lazy_sqlite3_bind_int64_type)dlsym(sqlite3_handle, "sqlite3_bind_int64"); @@ -253,7 +374,9 @@ static int lazyLoadSQLite() lazy_sqlite3_bind_parameter_index = (lazy_sqlite3_bind_parameter_index_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_index"); lazy_sqlite3_bind_text = (lazy_sqlite3_bind_text_type)dlsym(sqlite3_handle, "sqlite3_bind_text"); lazy_sqlite3_bind_text16 = (lazy_sqlite3_bind_text16_type)dlsym(sqlite3_handle, "sqlite3_bind_text16"); + lazy_sqlite3_bind_text64 = (lazy_sqlite3_bind_text64_type)dlsym(sqlite3_handle, "sqlite3_bind_text64"); lazy_sqlite3_changes = (lazy_sqlite3_changes_type)dlsym(sqlite3_handle, "sqlite3_changes"); + lazy_sqlite3_changes64 = (lazy_sqlite3_changes64_type)dlsym(sqlite3_handle, "sqlite3_changes64"); lazy_sqlite3_clear_bindings = (lazy_sqlite3_clear_bindings_type)dlsym(sqlite3_handle, "sqlite3_clear_bindings"); lazy_sqlite3_close_v2 = (lazy_sqlite3_close_v2_type)dlsym(sqlite3_handle, "sqlite3_close_v2"); lazy_sqlite3_close = (lazy_sqlite3_close_type)dlsym(sqlite3_handle, "sqlite3_close"); @@ -270,18 +393,27 @@ static int lazyLoadSQLite() lazy_sqlite3_column_name = (lazy_sqlite3_column_name_type)dlsym(sqlite3_handle, "sqlite3_column_name"); lazy_sqlite3_column_text = (lazy_sqlite3_column_text_type)dlsym(sqlite3_handle, "sqlite3_column_text"); lazy_sqlite3_column_type = (lazy_sqlite3_column_type_type)dlsym(sqlite3_handle, "sqlite3_column_type"); + lazy_sqlite3_column_database_name = (lazy_sqlite3_column_database_name_type)dlsym(sqlite3_handle, "sqlite3_column_database_name"); + lazy_sqlite3_column_table_name = (lazy_sqlite3_column_table_name_type)dlsym(sqlite3_handle, "sqlite3_column_table_name"); + lazy_sqlite3_column_origin_name = (lazy_sqlite3_column_origin_name_type)dlsym(sqlite3_handle, "sqlite3_column_origin_name"); lazy_sqlite3_errmsg = (lazy_sqlite3_errmsg_type)dlsym(sqlite3_handle, "sqlite3_errmsg"); + lazy_sqlite3_errcode = (lazy_sqlite3_errcode_type)dlsym(sqlite3_handle, "sqlite3_errcode"); lazy_sqlite3_errstr = (lazy_sqlite3_errstr_type)dlsym(sqlite3_handle, "sqlite3_errstr"); lazy_sqlite3_expanded_sql = (lazy_sqlite3_expanded_sql_type)dlsym(sqlite3_handle, "sqlite3_expanded_sql"); + lazy_sqlite3_sql = (lazy_sqlite3_sql_type)dlsym(sqlite3_handle, "sqlite3_sql"); lazy_sqlite3_finalize = (lazy_sqlite3_finalize_type)dlsym(sqlite3_handle, "sqlite3_finalize"); lazy_sqlite3_free = (lazy_sqlite3_free_type)dlsym(sqlite3_handle, "sqlite3_free"); lazy_sqlite3_get_autocommit = (lazy_sqlite3_get_autocommit_type)dlsym(sqlite3_handle, "sqlite3_get_autocommit"); + lazy_sqlite3_prepare_v2 = (lazy_sqlite3_prepare_v2_type)dlsym(sqlite3_handle, "sqlite3_prepare_v2"); lazy_sqlite3_prepare_v3 = (lazy_sqlite3_prepare_v3_type)dlsym(sqlite3_handle, "sqlite3_prepare_v3"); lazy_sqlite3_prepare16_v3 = (lazy_sqlite3_prepare16_v3_type)dlsym(sqlite3_handle, "sqlite3_prepare16_v3"); lazy_sqlite3_reset = (lazy_sqlite3_reset_type)dlsym(sqlite3_handle, "sqlite3_reset"); lazy_sqlite3_step = (lazy_sqlite3_step_type)dlsym(sqlite3_handle, "sqlite3_step"); lazy_sqlite3_db_config = (lazy_sqlite3_db_config_type)dlsym(sqlite3_handle, "sqlite3_db_config"); + lazy_sqlite3_db_filename = (lazy_sqlite3_db_filename_type)dlsym(sqlite3_handle, "sqlite3_db_filename"); + lazy_sqlite3_db_handle = (lazy_sqlite3_db_handle_type)dlsym(sqlite3_handle, "sqlite3_db_handle"); lazy_sqlite3_load_extension = (lazy_sqlite3_load_extension_type)dlsym(sqlite3_handle, "sqlite3_load_extension"); + lazy_sqlite3_libversion = (lazy_sqlite3_libversion_type)dlsym(sqlite3_handle, "sqlite3_libversion"); lazy_sqlite3_serialize = (lazy_sqlite3_serialize_type)dlsym(sqlite3_handle, "sqlite3_serialize"); lazy_sqlite3_deserialize = (lazy_sqlite3_deserialize_type)dlsym(sqlite3_handle, "sqlite3_deserialize"); lazy_sqlite3_malloc64 = (lazy_sqlite3_malloc64_type)dlsym(sqlite3_handle, "sqlite3_malloc64"); @@ -296,6 +428,39 @@ static int lazyLoadSQLite() lazy_sqlite3_bind_parameter_name = (lazy_sqlite3_bind_parameter_name_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_name"); lazy_sqlite3_total_changes = (lazy_sqlite3_total_changes_type)dlsym(sqlite3_handle, "sqlite3_total_changes"); lazy_sqlite3_last_insert_rowid = (lazy_sqlite3_last_insert_rowid_type)dlsym(sqlite3_handle, "sqlite3_last_insert_rowid"); + lazy_sqlite3_exec = (lazy_sqlite3_exec_type)dlsym(sqlite3_handle, "sqlite3_exec"); + lazy_sqlite3_limit = (lazy_sqlite3_limit_type)dlsym(sqlite3_handle, "sqlite3_limit"); + lazy_sqlite3_sleep = (lazy_sqlite3_sleep_type)dlsym(sqlite3_handle, "sqlite3_sleep"); + lazy_sqlite3_stmt_status = (lazy_sqlite3_stmt_status_type)dlsym(sqlite3_handle, "sqlite3_stmt_status"); + lazy_sqlite3_set_authorizer = (lazy_sqlite3_set_authorizer_type)dlsym(sqlite3_handle, "sqlite3_set_authorizer"); + lazy_sqlite3_create_function_v2 = (lazy_sqlite3_create_function_v2_type)dlsym(sqlite3_handle, "sqlite3_create_function_v2"); + lazy_sqlite3_create_window_function = (lazy_sqlite3_create_window_function_type)dlsym(sqlite3_handle, "sqlite3_create_window_function"); + lazy_sqlite3_user_data = (lazy_sqlite3_user_data_type)dlsym(sqlite3_handle, "sqlite3_user_data"); + lazy_sqlite3_aggregate_context = (lazy_sqlite3_aggregate_context_type)dlsym(sqlite3_handle, "sqlite3_aggregate_context"); + lazy_sqlite3_value_blob = (lazy_sqlite3_value_blob_type)dlsym(sqlite3_handle, "sqlite3_value_blob"); + lazy_sqlite3_value_bytes = (lazy_sqlite3_value_bytes_type)dlsym(sqlite3_handle, "sqlite3_value_bytes"); + lazy_sqlite3_value_double = (lazy_sqlite3_value_double_type)dlsym(sqlite3_handle, "sqlite3_value_double"); + lazy_sqlite3_value_int64 = (lazy_sqlite3_value_int64_type)dlsym(sqlite3_handle, "sqlite3_value_int64"); + lazy_sqlite3_value_text = (lazy_sqlite3_value_text_type)dlsym(sqlite3_handle, "sqlite3_value_text"); + lazy_sqlite3_value_type = (lazy_sqlite3_value_type_type)dlsym(sqlite3_handle, "sqlite3_value_type"); + lazy_sqlite3_result_blob64 = (lazy_sqlite3_result_blob64_type)dlsym(sqlite3_handle, "sqlite3_result_blob64"); + lazy_sqlite3_result_double = (lazy_sqlite3_result_double_type)dlsym(sqlite3_handle, "sqlite3_result_double"); + lazy_sqlite3_result_error = (lazy_sqlite3_result_error_type)dlsym(sqlite3_handle, "sqlite3_result_error"); + lazy_sqlite3_result_int64 = (lazy_sqlite3_result_int64_type)dlsym(sqlite3_handle, "sqlite3_result_int64"); + lazy_sqlite3_result_null = (lazy_sqlite3_result_null_type)dlsym(sqlite3_handle, "sqlite3_result_null"); + lazy_sqlite3_result_text64 = (lazy_sqlite3_result_text64_type)dlsym(sqlite3_handle, "sqlite3_result_text64"); + lazy_sqlite3_backup_init = (lazy_sqlite3_backup_init_type)dlsym(sqlite3_handle, "sqlite3_backup_init"); + lazy_sqlite3_backup_step = (lazy_sqlite3_backup_step_type)dlsym(sqlite3_handle, "sqlite3_backup_step"); + lazy_sqlite3_backup_finish = (lazy_sqlite3_backup_finish_type)dlsym(sqlite3_handle, "sqlite3_backup_finish"); + lazy_sqlite3_backup_remaining = (lazy_sqlite3_backup_remaining_type)dlsym(sqlite3_handle, "sqlite3_backup_remaining"); + lazy_sqlite3_backup_pagecount = (lazy_sqlite3_backup_pagecount_type)dlsym(sqlite3_handle, "sqlite3_backup_pagecount"); + lazy_sqlite3session_create = (lazy_sqlite3session_create_type)dlsym(sqlite3_handle, "sqlite3session_create"); + lazy_sqlite3session_delete = (lazy_sqlite3session_delete_type)dlsym(sqlite3_handle, "sqlite3session_delete"); + lazy_sqlite3session_attach = (lazy_sqlite3session_attach_type)dlsym(sqlite3_handle, "sqlite3session_attach"); + lazy_sqlite3session_changeset = (lazy_sqlite3session_changeset_type)dlsym(sqlite3_handle, "sqlite3session_changeset"); + lazy_sqlite3session_patchset = (lazy_sqlite3session_patchset_type)dlsym(sqlite3_handle, "sqlite3session_patchset"); + lazy_sqlite3changeset_apply = (lazy_sqlite3changeset_apply_type)dlsym(sqlite3_handle, "sqlite3changeset_apply"); + lazy_sqlite3_has_session = lazy_sqlite3session_create != nullptr; if (!lazy_sqlite3_extended_result_codes) { lazy_sqlite3_extended_result_codes = [](sqlite3*, int) -> int { @@ -321,6 +486,19 @@ static int lazyLoadSQLite() }; } + // SQLITE_ENABLE_COLUMN_METADATA is optional; fall back to nullptr- + // returning stubs so callers see the same "no info" shape sqlite + // returns for expressions. + if (!lazy_sqlite3_column_database_name) { + lazy_sqlite3_column_database_name = [](sqlite3_stmt*, int) -> const char* { return nullptr; }; + lazy_sqlite3_column_table_name = [](sqlite3_stmt*, int) -> const char* { return nullptr; }; + lazy_sqlite3_column_origin_name = [](sqlite3_stmt*, int) -> const char* { return nullptr; }; + } + + if (!lazy_sqlite3_stmt_status) { + lazy_sqlite3_stmt_status = [](sqlite3_stmt*, int, int) -> int { return 0; }; + } + return 0; } diff --git a/test/expectations.txt b/test/expectations.txt index 24e09b7de2ad..3cc947477fb9 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -42,6 +42,7 @@ test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs interna # investigation; the #29854 resolved_path fix covers only the ~path-length # portion. [ DARWIN ] test/js/node/watch/fs.watch.test.ts [ FAIL ] # pre-existing leak, false-positive pass before the eval exception fix +[ DARWIN ] test/js/node/test/parallel/test-sqlite.js [ FAIL ] # Apple's system libsqlite3 lacks SQLITE_ENABLE_PERCENTILE / GEOPOLY / RBU; node:sqlite dlopens it on macOS # Tests that are flaky test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index f91137610986..c97b0112e9c0 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -7,6 +7,28 @@ import path from "node:path"; import { DatabaseSync, SQLTagStore, Session, StatementSync, backup, constants } from "node:sqlite"; import { pathToFileURL } from "node:url"; +// On macOS bun dlopens the system libsqlite3.dylib, which Apple builds +// without SQLITE_ENABLE_SESSION. createSession()/applyChangeset() throw +// with a hint to use Database.setCustomSQLite(); tests that need the +// session extension skip on such a library. +const sqliteHasSession = (() => { + try { + new DatabaseSync(":memory:").createSession(); + return true; + } catch { + return false; + } +})(); +// Apple's system libsqlite3 is built with SQLITE_OMIT_LOAD_EXTENSION. +const sqliteHasLoadExtension = (() => { + try { + new DatabaseSync(":memory:", { allowExtension: true }).close(); + return true; + } catch { + return false; + } +})(); + test("node:sqlite is a built-in module", () => { expect(isBuiltin("node:sqlite")).toBe(true); // Like node:test, node:sqlite is only available with the node: prefix. @@ -281,26 +303,28 @@ describe("DatabaseSync", () => { // And to xFilter inside applyChangeset (where sqlite would otherwise // continue using a freed — not zombied — connection). - const dst = new DatabaseSync(":memory:"); - dst.exec("CREATE TABLE t (a INTEGER PRIMARY KEY)"); - db.exec("CREATE TABLE s (a INTEGER PRIMARY KEY)"); - const session = db.createSession(); - db.exec("INSERT INTO s VALUES (1)"); - const cs = session.changeset(); - let filterCloseErr: unknown; - dst.applyChangeset(cs, { - filter: () => { - try { - dst.close(); - } catch (e) { - filterCloseErr = e; - } - return false; - }, - }); - expect(filterCloseErr).toMatchObject({ code: "ERR_INVALID_STATE" }); - expect(dst.isOpen).toBe(true); - dst.close(); + if (sqliteHasSession) { + const dst = new DatabaseSync(":memory:"); + dst.exec("CREATE TABLE t (a INTEGER PRIMARY KEY)"); + db.exec("CREATE TABLE s (a INTEGER PRIMARY KEY)"); + const session = db.createSession(); + db.exec("INSERT INTO s VALUES (1)"); + const cs = session.changeset(); + let filterCloseErr: unknown; + dst.applyChangeset(cs, { + filter: () => { + try { + dst.close(); + } catch (e) { + filterCloseErr = e; + } + return false; + }, + }); + expect(filterCloseErr).toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(dst.isOpen).toBe(true); + dst.close(); + } db.close(); }); @@ -618,7 +642,21 @@ describe("StatementSync.prototype.iterate()", () => { }); }); -describe("Session / changeset", () => { +test.skipIf(sqliteHasSession)( + "createSession() throws with a setCustomSQLite hint when the session extension is unavailable", + () => { + const db = new DatabaseSync(":memory:"); + expect(() => db.createSession()).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR", message: expect.stringMatching(/SQLITE_ENABLE_SESSION/) }), + ); + expect(() => db.applyChangeset(new Uint8Array())).toThrow( + expect.objectContaining({ code: "ERR_SQLITE_ERROR", message: expect.stringMatching(/setCustomSQLite/) }), + ); + db.close(); + }, +); + +describe.skipIf(!sqliteHasSession)("Session / changeset", () => { test("captures changes and applies them to another database", () => { const src = new DatabaseSync(":memory:"); src.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); @@ -1003,7 +1041,7 @@ describe("createTagStore()", () => { }); }); -test("deserialize() frees open sessions instead of orphaning their preupdate hook", () => { +test.skipIf(!sqliteHasSession)("deserialize() frees open sessions instead of orphaning their preupdate hook", () => { // deserialize() bumps the open-generation to invalidate existing // wrappers. Sessions become stale — but deleteSession() (and the // destructor) assume "stale ⇒ closeInternal() already freed", so @@ -1221,27 +1259,30 @@ test("unclosed sqlite database does not use-after-free on VM teardown", async () // process.exit() inside a UDF reaches ~JSDatabaseSync with a BusyScope still // on the stack; that path must still flag its session records as dbGone or // ~JSNodeSqliteSession writes to the already-swept database cell. -test("teardown with a busy connection and an unclosed session does not use-after-free", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const { DatabaseSync } = require('node:sqlite'); +test.skipIf(!sqliteHasSession)( + "teardown with a busy connection and an unclosed session does not use-after-free", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE t(x INTEGER PRIMARY KEY)'); db.createSession(); db.function('die', () => process.exit(0)); db.exec('SELECT die()');`, - ], - env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("heap-use-after-free"); - expect(stdout).toBe(""); - expect(exitCode).toBe(0); -}); + ], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("heap-use-after-free"); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); + }, +); // The process-exit handler must close (or at least WAL-checkpoint) unclosed // file-backed databases the way Node and bun:sqlite do; see @@ -1392,24 +1433,27 @@ describe("GC lifetime", () => { db.close(); }); - test("sessions dropped without close() are reclaimed once the database is used again", () => { - const db = new DatabaseSync(":memory:"); - db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); - for (let i = 0; i < 100; i++) { - db.createSession(); - } - Bun.gc(true); - // The next entry point sweeps the orphaned native sessions; the - // connection keeps working and a fresh session records normally. - db.exec("INSERT INTO t VALUES (1, 'x')"); - const fresh = db.createSession(); - db.exec("INSERT INTO t VALUES (2, 'y')"); - expect(fresh.changeset().length).toBeGreaterThan(0); - fresh.close(); - db.close(); - }); + test.skipIf(!sqliteHasSession)( + "sessions dropped without close() are reclaimed once the database is used again", + () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + for (let i = 0; i < 100; i++) { + db.createSession(); + } + Bun.gc(true); + // The next entry point sweeps the orphaned native sessions; the + // connection keeps working and a fresh session records normally. + db.exec("INSERT INTO t VALUES (1, 'x')"); + const fresh = db.createSession(); + db.exec("INSERT INTO t VALUES (2, 'y')"); + expect(fresh.changeset().length).toBeGreaterThan(0); + fresh.close(); + db.close(); + }, + ); - test("a failed deserialize() leaves existing sessions and the database untouched", () => { + test.skipIf(!sqliteHasSession)("a failed deserialize() leaves existing sessions and the database untouched", () => { const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); const session = db.createSession(); @@ -1446,7 +1490,7 @@ describe("module exports", () => { expect(() => new SQLTagStore()).toThrow(expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" })); const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); - expect(db.createSession()).toBeInstanceOf(Session); + if (sqliteHasSession) expect(db.createSession()).toBeInstanceOf(Session); expect(db.createTagStore()).toBeInstanceOf(SQLTagStore); db.close(); }); @@ -1465,6 +1509,18 @@ describe("module exports", () => { }); describe("loadExtension() / enableLoadExtension()", () => { + test.skipIf(sqliteHasLoadExtension)( + "{allowExtension: true} throws with a setCustomSQLite hint when the library was built with OMIT_LOAD_EXTENSION", + () => { + expect(() => new DatabaseSync(":memory:", { allowExtension: true })).toThrow( + expect.objectContaining({ + code: "ERR_LOAD_SQLITE_EXTENSION", + message: expect.stringMatching(/SQLITE_OMIT_LOAD_EXTENSION/), + }), + ); + }, + ); + test("loadExtension() on {allowExtension: false} throws ERR_INVALID_STATE", () => { const db = new DatabaseSync(":memory:"); expect(() => db.loadExtension("/nonexistent")).toThrow( @@ -1489,20 +1545,20 @@ describe("loadExtension() / enableLoadExtension()", () => { db.close(); }); - test("enableLoadExtension() with no argument throws ERR_INVALID_ARG_TYPE", () => { + test.skipIf(!sqliteHasLoadExtension)("enableLoadExtension() with no argument throws ERR_INVALID_ARG_TYPE", () => { const db = new DatabaseSync(":memory:", { allowExtension: true }); expect(() => db.enableLoadExtension()).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); db.close(); }); - test("loadExtension() after enableLoadExtension(false) throws", () => { + test.skipIf(!sqliteHasLoadExtension)("loadExtension() after enableLoadExtension(false) throws", () => { const db = new DatabaseSync(":memory:", { allowExtension: true }); db.enableLoadExtension(false); expect(() => db.loadExtension("/nonexistent")).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); db.close(); }); - test("loadExtension() on a nonexistent path throws ERR_LOAD_SQLITE_EXTENSION", () => { + test.skipIf(!sqliteHasLoadExtension)("loadExtension() on a nonexistent path throws ERR_LOAD_SQLITE_EXTENSION", () => { // Exercises the sqlite3_free(errmsg) path. const db = new DatabaseSync(":memory:", { allowExtension: true }); db.enableLoadExtension(true); @@ -1513,12 +1569,12 @@ describe("loadExtension() / enableLoadExtension()", () => { }); }); -// bun:sqlite and node:sqlite share the bundled amalgamation (staticSqlite=true -// on every platform), so opening the same file via both is safe. This is -// platform-differential coverage: on a --static-sqlite=off build the two -// modules would use separate SQLite libraries with separate POSIX-lock inode -// maps (howtocorrupt.html §2.2.1). -test("bun:sqlite and node:sqlite can open the same on-disk file concurrently", async () => { +// bun:sqlite and node:sqlite share ONE sqlite3 library (dlopen'd on macOS, +// linked on Linux/Windows) — two libraries in one process is a POSIX-lock +// corruption vector (howtocorrupt.html §2.2.1). Assert both modules report +// the same sqlite_version() and that closing one module's handle does not +// drop the other's fcntl locks. +test("bun:sqlite and node:sqlite share one SQLite library", async () => { using dir = tempDir("node-sqlite-cross-module", {}); await using proc = Bun.spawn({ cmd: [ @@ -1531,6 +1587,9 @@ test("bun:sqlite and node:sqlite can open the same on-disk file concurrently", a bunDb.exec('CREATE TABLE t (x INTEGER)'); bunDb.exec('INSERT INTO t VALUES (1)'); const nodeDb = new DatabaseSync('shared.db'); + const bv = bunDb.query('SELECT sqlite_version() v').get().v; + const nv = nodeDb.prepare('SELECT sqlite_version() v').get().v; + console.log('same=' + (bv === nv && bv === process.versions.sqlite)); // node:sqlite sees bun:sqlite's committed row. console.log('n1=' + nodeDb.prepare('SELECT x FROM t').get().x); // Write via node:sqlite; bun:sqlite sees it. @@ -1549,7 +1608,7 @@ test("bun:sqlite and node:sqlite can open the same on-disk file concurrently", a stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("n1=1\nb1=2\nok=ok\n"); + expect(stdout).toBe("same=true\nn1=1\nb1=2\nok=ok\n"); void stderr; expect(exitCode).toBe(0); }); @@ -1639,16 +1698,20 @@ describe("GC stress", () => { db.close(); }); - test("session churn under GC pressure (finalizer ordering)", () => { - const db = new DatabaseSync(":memory:"); - db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); - for (let i = 0; i < 500; i++) { - const s = db.createSession(); - Bun.gc(true); - s.changeset(); - // Half explicitly close, half drop — races wrapperGone/dbGone. - if (i & 1) s.close(); - } - db.close(); - }, 30_000); + test.skipIf(!sqliteHasSession)( + "session churn under GC pressure (finalizer ordering)", + () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + for (let i = 0; i < 500; i++) { + const s = db.createSession(); + Bun.gc(true); + s.changeset(); + // Half explicitly close, half drop — races wrapperGone/dbGone. + if (i & 1) s.close(); + } + db.close(); + }, + 30_000, + ); }); From abb6f09a337d64f484ff6230ed41c67262a6b3bb Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 16:33:19 -0700 Subject: [PATCH 05/33] test: pass createTagStore capacity as a number so the LRU-churn test actually evicts --- test/js/node/sqlite/node-sqlite.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index c97b0112e9c0..639f9082f89d 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1671,7 +1671,7 @@ describe("GC stress", () => { test("TagStore LRU churn under GC pressure", () => { const db = new DatabaseSync(":memory:"); - const sql = db.createTagStore({ capacity: 4 }); + const sql = db.createTagStore(4); for (let i = 0; i < 500; i++) { // Rotate the SQL text so the LRU inserts/evicts every iteration. const j = i % 8; From 0e07202e86bd4d501b6495952614cc220eea51ae Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 7 Jul 2026 16:47:09 -0700 Subject: [PATCH 06/33] node:sqlite: run the shared SQLite one-time config before either module's first open bun:sqlite and node:sqlite now share one SQLite library (dlopen'd on macOS, the bundled amalgamation elsewhere), but only bun:sqlite's open path ran the process-global sqlite3_config() calls (SQLITE_CONFIG_LOOKASIDE off, SQLITE_CONFIG_MALLOC -> fastMalloc). sqlite3_config() is SQLITE_MISUSE once the library is initialized, and the first sqlite3_open_v2() initializes it, so node:sqlite opening a database before bun:sqlite in the same process left bun:sqlite unable to install its allocator -- a hard assertion in debug builds, on every platform. The existing cross-module test happens to open bun:sqlite first, so it could never see this. Give the one-time config external linkage (extern "C" Bun__initializeSQLite, following the file's Bun__* convention and forward-declarable on every build configuration -- lazy_sqlite3.h is only included on the dlopen path) and call it from every open site in both modules. Add a regression test for the node:sqlite-first ordering. Also replace the whole-file `[ DARWIN ] test-sqlite.js [ FAIL ]` expectation with `{ skip }` on the three subtests Apple's system libsqlite3 actually lacks. Each is gated on its own sqlite_compileoption_used() probe of the loaded library (never the platform), so a custom SQLite with any subset of geopoly/rbu/percentile skips exactly the right ones, and the rest of that file -- verified passing against the dlopen'd library, including dbstat -- keeps running on macOS. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 11 +++++--- src/jsc/bindings/sqlite/NodeSqlite.cpp | 12 +++++++++ test/expectations.txt | 1 - test/js/node/sqlite/node-sqlite.test.ts | 31 ++++++++++++++++++++++ test/js/node/test/parallel/test-sqlite.js | 20 +++++++++++--- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index b5b5094fe559..50c6796216ec 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -133,7 +133,10 @@ class AutoDestructingSQLiteStatement { } }; -static void initializeSQLite() +// One-time sqlite3_config() calls. Must run before the FIRST sqlite3_open_v2 +// from EITHER bun:sqlite or node:sqlite (they share one library, and config +// is SQLITE_MISUSE after init). extern "C" for the cross-TU forward-declare. +extern "C" void Bun__initializeSQLite() { static std::once_flag onceFlag; std::call_once(onceFlag, [] { @@ -1209,7 +1212,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetCustomSQLite, (JSC::JSGlobalObject * l } #endif - initializeSQLite(); + Bun__initializeSQLite(); RELEASE_AND_RETURN(scope, JSValue::encode(JSC::jsBoolean(true))); } @@ -1262,7 +1265,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic return {}; } #endif - initializeSQLite(); + Bun__initializeSQLite(); size_t byteLength = array->byteLength(); void* ptr = array->vector(); @@ -1737,7 +1740,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje return {}; } #endif - initializeSQLite(); + Bun__initializeSQLite(); auto topExceptionScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); String path = pathValue.toWTFString(lexicalGlobalObject); diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index c15866617f65..74259c84f291 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -85,6 +85,11 @@ static constexpr bool lazy_sqlite3_has_session = true; #define SQLITE_CHANGESET_FOREIGN_KEY 5 #endif +// One-time process-global sqlite3_config() (defined in JSSQLStatement.cpp). +// Forward-declared here rather than in lazy_sqlite3.h because that header is +// only included on the dlopen path, and this must be visible on every build. +extern "C" void Bun__initializeSQLite(); + // process.versions.sqlite — the loaded library's version on macOS (via // dlsym'd sqlite3_libversion), the bundled amalgamation's constant elsewhere. extern "C" const char* Bun__sqlite3_version() @@ -927,6 +932,10 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) } #endif + // Must run before the first sqlite3_open_v2 in the process, from either + // module; see the definition in JSSQLStatement.cpp. + Bun__initializeSQLite(); + // SQLITE_OPEN_URI mirrors Node's `default_flags = SQLITE_OPEN_URI` // (node_sqlite.cc). Strings, Uint8Arrays, and URL objects all reach // sqlite3ParseUri verbatim (validateDatabasePath passes a URL's raw @@ -3747,6 +3756,9 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal auto destPathUtf8 = destPath.utf8(); sqlite3* dest = nullptr; + // The source db is already open (so this can never be the process's first + // open), but keep the "config before any open" invariant local and free. + Bun__initializeSQLite(); int r = sqlite3_open_v2(destPathUtf8.data(), &dest, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI, nullptr); if (r != SQLITE_OK) { if (dest) { diff --git a/test/expectations.txt b/test/expectations.txt index 3cc947477fb9..24e09b7de2ad 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -42,7 +42,6 @@ test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs interna # investigation; the #29854 resolved_path fix covers only the ~path-length # portion. [ DARWIN ] test/js/node/watch/fs.watch.test.ts [ FAIL ] # pre-existing leak, false-positive pass before the eval exception fix -[ DARWIN ] test/js/node/test/parallel/test-sqlite.js [ FAIL ] # Apple's system libsqlite3 lacks SQLITE_ENABLE_PERCENTILE / GEOPOLY / RBU; node:sqlite dlopens it on macOS # Tests that are flaky test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 639f9082f89d..bb047c810098 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1613,6 +1613,37 @@ test("bun:sqlite and node:sqlite share one SQLite library", async () => { expect(exitCode).toBe(0); }); +// The reverse ordering of the test above: node:sqlite opening FIRST used to +// leave sqlite3 initialized before bun:sqlite's sqlite3_config() calls ran, +// which is SQLITE_MISUSE (a hard debug assertion). See Bun__initializeSQLite. +test("bun:sqlite still initializes correctly when node:sqlite opens a database first", async () => { + using dir = tempDir("node-sqlite-init-order", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { DatabaseSync } = require('node:sqlite'); + const nodeDb = new DatabaseSync('order.db'); + nodeDb.exec('CREATE TABLE t (x INTEGER)'); + nodeDb.exec('INSERT INTO t VALUES (1)'); + const { Database } = require('bun:sqlite'); + const bunDb = new Database('order.db'); + bunDb.run('INSERT INTO t VALUES (2)'); + console.log('n=' + nodeDb.prepare('SELECT COUNT(*) c FROM t').get().c); + bunDb.close(); + nodeDb.close();`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr is drained but not pinned: ASAN/debug builds emit benign noise. + expect({ stdout, exitCode }).toEqual({ stdout: "n=2\n", exitCode: 0 }); + void stderr; +}); + // Worker-owned databases are closed via ~VM → lastChanceToFinalize → // ~JSDatabaseSync — a completely different path than the main-thread exit // sweep. Sibling of "unclosed file-backed database is closed on process exit". diff --git a/test/js/node/test/parallel/test-sqlite.js b/test/js/node/test/parallel/test-sqlite.js index e6ad6747140f..d1afe54512d2 100644 --- a/test/js/node/test/parallel/test-sqlite.js +++ b/test/js/node/test/parallel/test-sqlite.js @@ -6,6 +6,20 @@ const { suite, test } = require('node:test'); const { pathToFileURL } = require('node:url'); const { nextDb } = require('../sqlite/next-db.js'); +// BUN: on macOS bun dlopens the system libsqlite3, which Apple builds without +// geopoly, rbu, and percentile. Probe the LOADED library, per option (a custom +// SQLite may have any subset); a probe failure means "assume present". +const missingSQLiteOption = typeof Bun === 'undefined' ? () => false : (option) => { + const probe = new DatabaseSync(':memory:'); + try { + return probe.prepare(`SELECT sqlite_compileoption_used('${option}') AS v`).get().v === 0; + } catch { + return false; + } finally { + probe.close(); + } +}; + suite('accessing the node:sqlite module', () => { test('cannot be accessed without the node: scheme', { skip: typeof Bun !== 'undefined' }, (t) => { // BUN: require('sqlite') throws 'ResolveMessage' (MODULE_NOT_FOUND code but different message/error class); the module IS node:-only, only the error shape differs. t.assert.throws(() => { @@ -204,7 +218,7 @@ suite('SQL APIs enabled at build time', () => { ); }); - test('percentile is enabled', (t) => { + test('percentile is enabled', { skip: missingSQLiteOption('SQLITE_ENABLE_PERCENTILE') }, (t) => { // BUN: not in Apple's libsqlite3; see missingSQLiteOption above. const db = new DatabaseSync(':memory:'); db.exec(` CREATE TABLE t1 (x INTEGER); @@ -317,7 +331,7 @@ suite('SQL APIs enabled at build time', () => { ); }); - test('rbu is enabled', (t) => { + test('rbu is enabled', { skip: missingSQLiteOption('SQLITE_ENABLE_RBU') }, (t) => { // BUN: not in Apple's libsqlite3; see missingSQLiteOption above. const db = new DatabaseSync(':memory:'); t.assert.deepStrictEqual( db.prepare('SELECT sqlite_compileoption_used(\'SQLITE_ENABLE_RBU\') as rbu_enabled;').get(), @@ -325,7 +339,7 @@ suite('SQL APIs enabled at build time', () => { ); }); - test('geopoly is enabled', (t) => { + test('geopoly is enabled', { skip: missingSQLiteOption('SQLITE_ENABLE_GEOPOLY') }, (t) => { // BUN: not in Apple's libsqlite3; see missingSQLiteOption above. const db = new DatabaseSync(':memory:'); db.exec(` CREATE VIRTUAL TABLE t1 USING geopoly(a,b,c); From 525f6482d6ccda4238534a750c1c3c7a187907d6 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 18:17:35 -0700 Subject: [PATCH 07/33] process.versions.sqlite: don't force-dlopen the system SQLite Report the loaded library's version only when a library has already been loaded; otherwise fall back to the header constant. Forcing a dlopen from process.versions defeated Database.setCustomSQLite() for anyone whose imports read process.versions before opening a database. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 7 ++++--- test/js/node/sqlite/node-sqlite.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 74259c84f291..ae8c363ba1eb 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -90,12 +90,13 @@ static constexpr bool lazy_sqlite3_has_session = true; // only included on the dlopen path, and this must be visible on every build. extern "C" void Bun__initializeSQLite(); -// process.versions.sqlite — the loaded library's version on macOS (via -// dlsym'd sqlite3_libversion), the bundled amalgamation's constant elsewhere. +// process.versions.sqlite — the loaded library's version if a library has +// been loaded, else the header constant. Never triggers a dlopen: reading +// process.versions must not defeat Database.setCustomSQLite(). extern "C" const char* Bun__sqlite3_version() { #if LAZY_LOAD_SQLITE - if (lazyLoadSQLite() == 0 && lazy_sqlite3_libversion) + if (sqlite3_handle && lazy_sqlite3_libversion) return lazy_sqlite3_libversion(); #endif return SQLITE_VERSION; diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index bb047c810098..29e5798d0d45 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1746,3 +1746,27 @@ describe("GC stress", () => { 30_000, ); }); + +// process.versions.sqlite must not force-dlopen the system SQLite: that +// would defeat setCustomSQLite() for anyone whose imports read +// process.versions before opening a database. +test.skipIf(process.platform !== "darwin")("reading process.versions does not defeat setCustomSQLite", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const _ = process.versions.sqlite; + const { Database } = require("bun:sqlite"); + Database.setCustomSQLite("/usr/lib/libsqlite3.dylib"); + console.log("ok"); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("already loaded"); + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); +}); From 6d37274c22670c46ebeeb9c874dbe44da5e156ae Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 20:20:11 -0700 Subject: [PATCH 08/33] node:sqlite: align with Node v26 semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop SQLTagStore from module exports (no Node counterpart; class stays reachable via db.createTagStore()) - prepare("") / whitespace / comment-only SQL returns a StatementSync whose accessors throw ERR_INVALID_STATE instead of throwing at prepare() - drop Session.prototype[Symbol.toStringTag] - give db.limits an ObjectTemplate-shaped proto ({} → Object.prototype) instead of null - allow re-entrant db.close() from bind/option getters — sqlite3_close_v2 zombifies while stmts are outstanding; option-reading paths re-check the connection before the sqlite call (Node segfaults there); bindParams re-checks after the getter and matches the ERR_SQLITE_ERROR errcode 7 Node emits --- src/jsc/bindings/ZigGlobalObject.cpp | 10 +- src/jsc/bindings/sqlite/NodeSqlite.cpp | 56 +++++---- src/jsc/bindings/sqlite/NodeSqlite.h | 8 +- src/jsc/modules/NodeSqliteModule.h | 5 +- test/js/node/sqlite/node-sqlite.test.ts | 159 ++++++++++++------------ 5 files changed, 118 insertions(+), 120 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 10fc98e50ea6..79ff4d3ecd09 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2689,10 +2689,12 @@ void GlobalObject::finishCreation(VM& vm) m_JSNodeSqliteLimitsClassStructure.initLater( [](LazyClassStructure::Initializer& init) { - // Null prototype: Node's DatabaseSyncLimits is an ObjectTemplate - // with only the named-property handler, so Object.prototype is - // NOT on its chain and can't shadow a limit name. - auto* structure = Bun::JSNodeSqliteLimits::createStructure(init.vm, init.global, JSC::jsNull()); + // Node's DatabaseSyncLimits is a V8 ObjectTemplate: instances get a + // per-template prototype whose own [[Prototype]] is Object.prototype. + // Match the observable chain (limits → {} → Object.prototype). + auto* prototype = JSC::constructEmptyObject(init.global, init.global->objectPrototype()); + auto* structure = Bun::JSNodeSqliteLimits::createStructure(init.vm, init.global, prototype); + init.setPrototype(prototype); init.setStructure(structure); }); diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index ae8c363ba1eb..b0ff36eac125 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -1133,13 +1133,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncClose, (JSGlobalObject * globalObject, Ca { THIS_DATABASE(); REQUIRE_DB_OPEN(self); - if (self->isBusy()) { - // A native call on this connection is on the stack (option-getter, - // UDF, xFilter, progress, …). Closing now would null/free the - // sqlite3* out from under it — see BusyScope users below. - return throwNodeState(globalObject, scope, - "cannot close database while a statement is executing"_s); - } + // Node allows re-entrant close(); sqlite3_close_v2 zombifies while any + // stmt is outstanding, and option-reading paths REQUIRE_DB_OPEN again + // before the sqlite call. self->closeInternal(); return JSValue::encode(jsUndefined()); } @@ -1149,7 +1145,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDispose, (JSGlobalObject * globalObject, auto& vm = JSC::getVM(globalObject); (void)vm; JSDatabaseSync* self = dynamicDowncast(callFrame->thisValue()); - if (self && self->isOpen() && !self->isBusy()) { + if (self && self->isOpen()) { self->closeInternal(); } return JSValue::encode(jsUndefined()); @@ -1197,13 +1193,10 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, throwSqliteError(globalObject, scope, self->connection()); return {}; } - // sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == nullptr when the - // input contains no SQL (empty / whitespace / comment only). Node.js - // surfaces that as ERR_INVALID_STATE at prepare() time. - if (stmt == nullptr) { - return throwNodeState(globalObject, scope, - "The supplied SQL string contains no statements"_s); - } + // sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == nullptr for empty / + // comment-only input — Node returns a StatementSync whose accessors throw + // ERR_INVALID_STATE "statement has been finalized" via REQUIRE_STMT. + // // Inherit the database-level defaults (set via the constructor options), // then let prepare()'s own options override per-statement. const auto& cfg = self->config(); @@ -1374,6 +1367,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncFunction, (JSGlobalObject * globalObject, if (deterministic) textRep |= SQLITE_DETERMINISTIC; if (directOnly) textRep |= SQLITE_DIRECTONLY; + // An options getter above may have re-entered close(); re-check before + // handing SQLite the connection (Node segfaults here — Bun throws). + REQUIRE_DB_OPEN(self); auto* udf = new NodeSqliteUDF(globalObject, self, fn, useBigIntArgs); auto nameUtf8 = name.utf8(); int r = sqlite3_create_function_v2(self->connection(), nameUtf8.data(), argc, textRep, @@ -1472,6 +1468,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncAggregate, (JSGlobalObject * globalObject int textRep = SQLITE_UTF8; if (directOnly) textRep |= SQLITE_DIRECTONLY; + // An options getter above may have re-entered close(). + REQUIRE_DB_OPEN(self); auto* agg = new NodeSqliteAggregate(globalObject, self, startV, stepFn, resultFn, inverseFn, useBigIntArgs); auto nameUtf8 = name.utf8(); auto xInverse = inverseFn ? NodeSqliteAggregate::xInverse : nullptr; @@ -1540,6 +1538,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateSession, (JSGlobalObject * globalOb } } + // An options getter above may have re-entered close(). + REQUIRE_DB_OPEN(self); auto dbNameUtf8 = dbName.utf8(); sqlite3_session* pSession = nullptr; int r = sqlite3session_create(self->connection(), dbNameUtf8.data(), &pSession); @@ -1695,7 +1695,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset, (JSGlobalObject * globalO "Failed to allocate memory for changeset"_s); } // sqlite3changeset_apply declares pChangeset as `void*` (non-const) - // for historical reasons; the buffer is not written to. + // for historical reasons; the buffer is not written to. An options + // getter above may have re-entered close(); re-check first. + REQUIRE_DB_OPEN(self); int r = sqlite3changeset_apply(self->connection(), static_cast(owned.size()), owned.mutableSpan().data(), applyChangesetXFilter, applyChangesetXConflict, &ctx); @@ -1890,16 +1892,10 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObje REQUIRE_DB_OPEN(self); // deserialize() tears down every prepared statement on the connection // (they all refer to schema that's about to be replaced), so refuse - // while anything is mid-execution for the same reason close() does. + // while anything is mid-execution. if (self->isBusy()) { return throwNodeState(globalObject, scope, "cannot deserialize database while a statement is executing"_s); } - // …and establish our own busy scope before reading options. The - // opts.dbName [[Get]] below can re-enter JS; without this guard - // a hostile getter could db.close() and sqlite3_deserialize would - // see a null connection (no SQLITE_ENABLE_API_ARMOR → segfault on - // db->mutex). Matches the sweep in 78f8f229e7 for the other - // option-reading methods. JSDatabaseSync::BusyScope busy { self }; auto* buf = dynamicDowncast(callFrame->argument(0)); @@ -1930,6 +1926,10 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObje } auto dbNameUtf8 = dbName.utf8(); + // The opts.dbName [[Get]] above may have re-entered close(); re-check + // before handing SQLite the connection (Node segfaults here). + REQUIRE_DB_OPEN(self); + // SQLITE_DESERIALIZE_FREEONCLOSE hands ownership of the buffer to // SQLite (freed on close) — it must therefore come from // sqlite3_malloc64. Copy the input in case JS later mutates or @@ -2288,7 +2288,7 @@ void JSStatementSync::finishCreation(VM& vm, JSDatabaseSync* db, sqlite3_stmt* s m_stmt = stmt; m_originGeneration = db->openGeneration(); m_database.set(vm, this, db); - m_extraMemorySize = static_cast(sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_MEMUSED, 0)); + m_extraMemorySize = stmt ? static_cast(sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_MEMUSED, 0)) : 0; if (m_extraMemorySize) vm.heap.reportExtraMemoryAllocated(this, m_extraMemorySize); } @@ -2552,6 +2552,13 @@ bool JSStatementSync::bindParams(JSGlobalObject* globalObject, ThrowScope& scope } JSValue v = named->get(globalObject, key); RETURN_IF_EXCEPTION(scope, false); + // The getter may have re-entered close(); Node finalizes stmts + // there and throws ERR_SQLITE_ERROR (errcode 7 via + // sqlite3_errmsg(NULL)) — match that path. + if (isFinalized()) [[unlikely]] { + throwSqliteError(globalObject, scope, connection()); + return false; + } if (!bindValue(globalObject, scope, index, v)) return false; } anonStart = 1; @@ -3179,7 +3186,6 @@ void JSNodeSqliteSessionPrototype::finishCreation(VM& vm, JSGlobalObject* global Base::finishCreation(vm); reifyStaticProperties(vm, JSNodeSqliteSession::info(), JSNodeSqliteSessionPrototypeTableValues, *this); putDirectNativeFunction(vm, globalObject, vm.propertyNames->disposeSymbol, 0, jsSessionDispose, ImplementationVisibility::Public, NoIntrinsic, 0); - JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); } const ClassInfo JSNodeSqliteSessionConstructor::s_info = { "Session"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSNodeSqliteSessionConstructor) }; diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index 7a66b344b3c5..9aef837fda4b 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -186,11 +186,9 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { void releaseSupersededRegistration(const WTF::String& name, int argc); void rememberRegistration(const WTF::String& name, int argc, const std::array& slots); - // Incremented for the duration of any native call that hands this - // connection into SQLite and may re-enter JS (option-getter, xFunc, - // xFilter, progress, …). close() rejects with ERR_INVALID_STATE while - // non-zero so a re-entrant close() can't free the sqlite3* out from - // under the in-flight C call. [Symbol.dispose] becomes a no-op. + // Incremented for the duration of any native call that may re-enter JS. + // deserialize()/process-exit close consult it; close() itself does not + // (Node compat — sqlite3_close_v2 zombifies while stmts are outstanding). bool isBusy() const { return m_busyDepth > 0; } struct BusyScope { JSDatabaseSync* db; diff --git a/src/jsc/modules/NodeSqliteModule.h b/src/jsc/modules/NodeSqliteModule.h index c49e4a2057e1..d6083d4496ff 100644 --- a/src/jsc/modules/NodeSqliteModule.h +++ b/src/jsc/modules/NodeSqliteModule.h @@ -10,7 +10,7 @@ namespace Zig { DEFINE_NATIVE_MODULE(NodeSqlite) { - INIT_NATIVE_MODULE(6); + INIT_NATIVE_MODULE(5); put(JSC::Identifier::fromString(vm, "DatabaseSync"_s), globalObject->m_JSDatabaseSyncClassStructure.constructorInitializedOnMainThread(globalObject)); @@ -21,9 +21,6 @@ DEFINE_NATIVE_MODULE(NodeSqlite) put(JSC::Identifier::fromString(vm, "Session"_s), globalObject->m_JSNodeSqliteSessionClassStructure.constructorInitializedOnMainThread(globalObject)); - put(JSC::Identifier::fromString(vm, "SQLTagStore"_s), - globalObject->m_JSNodeSqliteTagStoreClassStructure.constructorInitializedOnMainThread(globalObject)); - put(JSC::Identifier::fromString(vm, "constants"_s), Bun::createNodeSqliteConstants(vm, globalObject)); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 29e5798d0d45..b4ddb366cab3 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -4,7 +4,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; import { existsSync, statSync } from "node:fs"; import { builtinModules, isBuiltin } from "node:module"; import path from "node:path"; -import { DatabaseSync, SQLTagStore, Session, StatementSync, backup, constants } from "node:sqlite"; +import { DatabaseSync, Session, StatementSync, backup, constants } from "node:sqlite"; import { pathToFileURL } from "node:url"; // On macOS bun dlopens the system libsqlite3.dylib, which Apple builds @@ -189,15 +189,19 @@ describe("DatabaseSync", () => { expect(() => new StatementSync()).toThrow(/Illegal constructor/); }); - test("prepare() rejects empty / comment-only SQL", () => { + test("prepare() with empty / comment-only SQL returns a finalized StatementSync", () => { const db = new DatabaseSync(":memory:"); - for (const sql of ["", " ", "-- a comment"]) { - expect(() => db.prepare(sql)).toThrow( - expect.objectContaining({ - code: "ERR_INVALID_STATE", - message: expect.stringMatching(/contains no statements/), - }), - ); + for (const sql of ["", " ", "-- a comment", "/* block */"]) { + const stmt = db.prepare(sql); + expect(stmt).toBeInstanceOf(StatementSync); + for (const fn of [() => stmt.run(), () => stmt.get(), () => stmt.all(), () => stmt.iterate()]) { + expect(fn).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_STATE", + message: "statement has been finalized", + }), + ); + } } db.close(); }); @@ -262,31 +266,41 @@ describe("DatabaseSync", () => { Bun.gc(true); }); - test("close() is rejected while a native call is in flight (re-entrant close)", () => { - // bindParams/UDFs/xFilter/progress can re-enter JS mid-operation. - // If that JS calls db.close(), the in-flight sqlite call would see a - // freed/null sqlite3* on return. A BusyScope around each operation - // makes close() throw ERR_INVALID_STATE instead of pulling the rug. + test("close() re-entered from a bind-parameter getter succeeds; the outer run() fails", () => { + // Node lets close() succeed even when re-entered mid-operation (bind + // getters, option getters, UDFs). sqlite3_close_v2 zombifies the + // connection while any stmt is outstanding, so the in-flight bind sees + // a valid handle; the subsequent step() fails on the zombie and Node + // reports errcode 7 (sqlite3_errmsg(NULL) → "out of memory"). const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (a)"); const stmt = db.prepare("INSERT INTO t VALUES (:a)"); let closeErr: unknown; - const r = stmt.run({ - get a() { - try { - db.close(); - } catch (e) { - closeErr = e; - } - return 1; - }, - }); - expect(closeErr).toMatchObject({ code: "ERR_INVALID_STATE" }); - expect(db.isOpen).toBe(true); - expect(r).toEqual({ changes: 1, lastInsertRowid: 1 }); + let runErr: unknown; + try { + stmt.run({ + get a() { + try { + db.close(); + } catch (e) { + closeErr = e; + } + return 1; + }, + }); + } catch (e) { + runErr = e; + } + expect(closeErr).toBeUndefined(); + expect(db.isOpen).toBe(false); + expect(runErr).toMatchObject({ code: "ERR_SQLITE_ERROR" }); + }); - // Same guard applies to option getters on function()/aggregate()/ - // createSession()/applyChangeset(). + test("close() re-entered from an options getter closes; the outer call throws 'not open'", () => { + // Node segfaults on this pattern (function()/aggregate()/createSession()/ + // deserialize() all pass connection() straight to sqlite after option + // reading with no re-check). Bun re-checks and throws instead. + const db = new DatabaseSync(":memory:"); expect(() => db.function( "f", @@ -298,42 +312,15 @@ describe("DatabaseSync", () => { }, () => 0, ), - ).toThrow(/cannot close database/); - expect(db.isOpen).toBe(true); - - // And to xFilter inside applyChangeset (where sqlite would otherwise - // continue using a freed — not zombied — connection). - if (sqliteHasSession) { - const dst = new DatabaseSync(":memory:"); - dst.exec("CREATE TABLE t (a INTEGER PRIMARY KEY)"); - db.exec("CREATE TABLE s (a INTEGER PRIMARY KEY)"); - const session = db.createSession(); - db.exec("INSERT INTO s VALUES (1)"); - const cs = session.changeset(); - let filterCloseErr: unknown; - dst.applyChangeset(cs, { - filter: () => { - try { - dst.close(); - } catch (e) { - filterCloseErr = e; - } - return false; - }, - }); - expect(filterCloseErr).toMatchObject({ code: "ERR_INVALID_STATE" }); - expect(dst.isOpen).toBe(true); - dst.close(); - } - db.close(); + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE", message: "database is not open" })); + expect(db.isOpen).toBe(false); }); - test("deserialize() is guarded against re-entrant close via options getter", () => { - // deserialize() checks isBusy() (refuses while something ELSE is - // in flight) but must also ESTABLISH a BusyScope before reading - // options — a hostile opts.dbName getter could otherwise close() - // the db and sqlite3_deserialize would segfault on the null - // connection (the bundled amalgamation lacks SQLITE_ENABLE_API_ARMOR). + test("deserialize() re-checks the connection after a hostile opts.dbName getter closes it", () => { + // A hostile opts.dbName getter can db.close() before + // sqlite3_deserialize is called. Without a re-check the null + // connection would segfault (the bundled amalgamation lacks + // SQLITE_ENABLE_API_ARMOR); Node segfaults on this pattern. const src = new DatabaseSync(":memory:"); src.exec("CREATE TABLE t(x INTEGER)"); const buf = src.serialize(); @@ -341,20 +328,20 @@ describe("DatabaseSync", () => { const db = new DatabaseSync(":memory:"); let closeErr: unknown; - db.deserialize(buf, { - get dbName() { - try { - db.close(); - } catch (e) { - closeErr = e; - } - return "main"; - }, - }); - expect(closeErr).toMatchObject({ code: "ERR_INVALID_STATE" }); - expect(db.isOpen).toBe(true); - expect(db.prepare("SELECT name FROM sqlite_master").get().name).toBe("t"); - db.close(); + expect(() => + db.deserialize(buf, { + get dbName() { + try { + db.close(); + } catch (e) { + closeErr = e; + } + return "main"; + }, + }), + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE", message: "database is not open" })); + expect(closeErr).toBeUndefined(); + expect(db.isOpen).toBe(false); }); test("deserialize() rejects a buffer detached by the options getter", () => { @@ -661,7 +648,9 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { const src = new DatabaseSync(":memory:"); src.exec("CREATE TABLE s (id INTEGER PRIMARY KEY, v TEXT)"); const session = src.createSession(); - expect(Object.prototype.toString.call(session)).toBe("[object Session]"); + expect(Object.prototype.toString.call(session)).toBe("[object Object]"); + expect(session[Symbol.toStringTag]).toBeUndefined(); + expect(session.constructor.name).toBe("Session"); src.exec("INSERT INTO s VALUES (1, 'hello'), (2, 'world')"); const changeset = session.changeset(); @@ -1483,15 +1472,21 @@ describe("GC lifetime", () => { }); describe("module exports", () => { - test("Session and SQLTagStore are exported and instanceof works", () => { + test("Session is exported and instanceof works; SQLTagStore is not exported", () => { expect(typeof Session).toBe("function"); - expect(typeof SQLTagStore).toBe("function"); expect(() => new Session()).toThrow(expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" })); - expect(() => new SQLTagStore()).toThrow(expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" })); + // SQLTagStore is Bun-internal (createTagStore()) — not a Node export. + expect(Object.keys(require("node:sqlite")).sort()).toEqual([ + "DatabaseSync", + "Session", + "StatementSync", + "backup", + "constants", + ]); const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); if (sqliteHasSession) expect(db.createSession()).toBeInstanceOf(Session); - expect(db.createTagStore()).toBeInstanceOf(SQLTagStore); + expect(db.createTagStore().constructor.name).toBe("SQLTagStore"); db.close(); }); From a805a900f9b9c0f843a9bdb48b1a916df5a4b145 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 21:20:56 -0700 Subject: [PATCH 09/33] test(sqlite): assert -wal is checkpointed on worker exit; use module-scope fs import --- test/js/bun/sqlite/sqlite.test.js | 5 ++--- test/js/node/sqlite/node-sqlite.test.ts | 8 +++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index dd936c105c48..82da8301a780 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { constants, Database, SQLiteError } from "bun:sqlite"; import { describe, expect, it } from "bun:test"; -import { readdirSync, readFileSync, realpathSync, writeFileSync } from "fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs"; import { bunEnv, bunExe, isMacOS, isMacOSVersionAtLeast, isWindows, tempDirWithFiles } from "harness"; import { tmpdir } from "os"; import path from "path"; @@ -2003,11 +2003,10 @@ it("exit-time WAL checkpoint runs even with a never-finalized prepared statement }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout).toBe("true\n"); - const fs = require("node:fs"); const wal = path.join(dir, "exit.db-wal"); // TRUNCATE moved every frame into exit.db (or the sidecar was unlinked // by a full close). Either way, no un-checkpointed data is stranded. - expect(fs.existsSync(wal) ? fs.statSync(wal).size : 0).toBe(0); + expect(existsSync(wal) ? statSync(wal).size : 0).toBe(0); const verify = new Database(path.join(dir, "exit.db")); expect(verify.query("SELECT x FROM t").get().x).toBe(42); verify.close(); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index b4ddb366cab3..0a62e0c6970a 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1653,14 +1653,16 @@ test("worker-owned unclosed database is checkpointed on worker exit", async () = // stmt and db intentionally not closed; worker exits naturally. postMessage('done');`, "main.mjs": `import { Worker } from 'node:worker_threads'; + import { existsSync, statSync } from 'node:fs'; const w = new Worker('./worker.mjs'); await new Promise((res, rej) => { w.on('message', () => {}); // drain w.on('error', rej); w.on('exit', code => (code === 0 ? res() : rej(new Error('exit ' + code)))); }); - // Verify the row landed in the main file (~JSDatabaseSync ran on - // lastChanceToFinalize) — reopen from the parent thread. + // ~JSDatabaseSync on lastChanceToFinalize checkpointed: the -wal is + // gone or empty. Checked before the reopen below touches the sidecars. + console.log(existsSync('exit.db-wal') ? statSync('exit.db-wal').size : 0); const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync('exit.db'); console.log(db.prepare('SELECT x FROM t').get().x); @@ -1674,7 +1676,7 @@ test("worker-owned unclosed database is checkpointed on worker exit", async () = stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("99\n"); + expect(stdout).toBe("0\n99\n"); void stderr; expect(exitCode).toBe(0); }); From 099026577b08430d219db0472687bc3a1134212f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:53:42 +0000 Subject: [PATCH 10/33] node:sqlite: fix UDF reentrancy crashes and Array-first-arg Node parity Keep UDF/aggregate callbacks rooted while a step() is on the stack. closeInternal() cleared m_registeredCallbacks unconditionally; with close() no longer refusing while busy, a UDF calling db.close() mid-scan unrooted every registered callback while the zombified connection still invoked them, so the next invocation dereferenced a freed JSFunction (ASAN heap-use-after-free in getCallData). Refuse run()/get()/all()/iterate() on a statement whose own sqlite3_step is on the C stack. sqlite3_reset on a running VDBE corrupts it and segfaults (Node v26.3.0 crashes on this shape too). Tracked with a per-statement SteppingScope; sqlite3_stmt_busy() is too broad (also true for a parked iterator, which is safe to reset). Treat an Array first argument as a named-parameter object like Node does (IsObject() && !IsArrayBufferView()); the extra !isArray() clause made stmt.run([...]) diverge on both the error code and the allowUnknownNamedParameters path. Add a sqlite3_changes64 dlopen fallback for macOS 12 (system lib 3.36.0 lacks it), and pass -1 to sqlite3_prepare_v2/v3 instead of narrowing the UTF-8 length to int. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 59 +++++++++++++---- src/jsc/bindings/sqlite/NodeSqlite.h | 17 +++++ src/jsc/bindings/sqlite/lazy_sqlite3.h | 8 +++ test/js/node/sqlite/node-sqlite.test.ts | 85 +++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index b0ff36eac125..29d0c17d04cb 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -179,6 +179,16 @@ static EncodedJSValue throwNodeState(JSGlobalObject* globalObject, ThrowScope& s } \ } while (0) +// run()/get()/all()/iterate() call sqlite3_reset before stepping. If a UDF +// invoked from this statement's own step() re-enters that path, +// sqlite3_reset corrupts the running VDBE and sqlite3_step segfaults (Node +// v26.3.0 crashes here too). Refuse before touching the handle. +#define REQUIRE_STMT_IDLE(self) \ + do { \ + if ((self)->isStepping()) [[unlikely]] \ + return throwNodeState(globalObject, scope, "statement is currently executing"_s); \ + } while (0) + // Pin the owning database for the duration of a StatementSync call that // may re-enter JS (bindParams getters, UDFs, aggregate callbacks). Must // follow REQUIRE_STMT so database() is known live. @@ -836,13 +846,18 @@ void JSDatabaseSync::closeInternal() sqlite3_close_v2(m_db); m_db = nullptr; unregisterOpenDatabase(this); - // The connection (and with it every registered function context) - // is gone; drop the callback roots so explicitly-closed databases - // don't retain their callbacks for the rest of the cell's lifetime. - // Plain clear (no JS access), so this is safe from the destructor. - m_namedRegistrations.clear(); - Locker locker { cellLock() }; - m_registeredCallbacks.clear(); + // Drop the callback roots so an explicitly-closed database doesn't + // retain them for the rest of the cell's lifetime — but only when + // no step() is on the stack. With a live step() close_v2 only + // zombified the connection: UDF/aggregate contexts (which hold + // raw JSObject* rooted by m_registeredCallbacks) are still + // registered and will be invoked again, so clearing now would + // leave those pointers dangling for GC to collect mid-scan. + if (!isBusy()) { + m_namedRegistrations.clear(); + Locker locker { cellLock() }; + m_registeredCallbacks.clear(); + } } } @@ -1185,7 +1200,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, RETURN_IF_EXCEPTION(scope, {}); auto utf8 = sql.utf8(); sqlite3_stmt* stmt = nullptr; - int r = sqlite3_prepare_v2(self->connection(), utf8.data(), static_cast(utf8.length()), &stmt, nullptr); + // utf8.data() is NUL-terminated (CString); -1 lets SQLite compute the + // length and avoids narrowing a size_t into int. + int r = sqlite3_prepare_v2(self->connection(), utf8.data(), -1, &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized". CHECK_UDF_EXCEPTION(scope); @@ -2501,11 +2518,13 @@ bool JSStatementSync::bindParams(JSGlobalObject* globalObject, ThrowScope& scope size_t argc = callFrame->argumentCount(); int paramCount = sqlite3_bind_parameter_count(m_stmt); - // Named parameters: first argument is a plain object (not ArrayBufferView, not Array). + // Named parameters: Node treats any non-ArrayBufferView object in the + // first slot as a named-params bag (V8's IsObject() && !IsArrayBufferView()). + // Arrays are NOT special-cased — Node walks their own-enumerable keys + // ("0", "1", …) through the named-param path. if (argc > 0) { JSValue arg0 = callFrame->argument(0); - if (arg0.isObject() && !dynamicDowncast(arg0) && !isArray(globalObject, arg0)) { - RETURN_IF_EXCEPTION(scope, false); + if (arg0.isObject() && !dynamicDowncast(arg0)) { JSObject* named = arg0.getObject(); if (m_allowBareNamedParams && !m_bareNamedParams.has_value()) { // Build into a local first so a mid-loop failure (conflicting @@ -2623,6 +2642,7 @@ struct StatementResetter { static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { StatementResetter resetter { self->statement() }; + JSStatementSync::SteppingScope stepping { self }; int r = sqlite3_step(self->statement()); while (r == SQLITE_ROW) r = sqlite3_step(self->statement()); @@ -2657,6 +2677,7 @@ static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, Thr static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { StatementResetter resetter { self->statement() }; + JSStatementSync::SteppingScope stepping { self }; int r = sqlite3_step(self->statement()); CHECK_UDF_EXCEPTION(scope); if (r == SQLITE_DONE) return JSValue::encode(jsUndefined()); @@ -2676,6 +2697,7 @@ static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope& static EncodedJSValue statementStepAll(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self) { StatementResetter resetter { self->statement() }; + JSStatementSync::SteppingScope stepping { self }; JSArray* rows = constructEmptyArray(globalObject, nullptr, 0); RETURN_IF_EXCEPTION(scope, {}); int r; @@ -2706,6 +2728,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, Cal { THIS_STATEMENT(); REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); sqlite3_reset(self->statement()); self->bumpResetGeneration(); @@ -2717,6 +2740,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, Cal { THIS_STATEMENT(); REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); sqlite3_reset(self->statement()); self->bumpResetGeneration(); @@ -2728,6 +2752,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, Cal { THIS_STATEMENT(); REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); sqlite3_reset(self->statement()); self->bumpResetGeneration(); @@ -2739,6 +2764,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIterate, (JSGlobalObject * globalObject, { THIS_STATEMENT(); REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); sqlite3_reset(self->statement()); self->bumpResetGeneration(); @@ -2952,6 +2978,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorNext, (JSGlobalObject * globalOb return throwNodeState(globalObject, scope, "iterator was invalidated by calling run(), get(), all(), or iterate() on the backing statement"_s); } JSDatabaseSync::BusyScope busy { stmt->database() }; + JSStatementSync::SteppingScope stepping { stmt }; int r = sqlite3_step(stmt->statement()); if (r != SQLITE_ROW && r != SQLITE_DONE) { @@ -3467,7 +3494,7 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr // the flag is documented for; it keeps them out of lookaside memory. // Intentional divergence from Node (which uses prepare_v2) — the // hint is allocator-only, not observable behavior. - int r = sqlite3_prepare_v3(db->connection(), utf8.data(), static_cast(utf8.length()), SQLITE_PREPARE_PERSISTENT, &stmt, nullptr); + int r = sqlite3_prepare_v3(db->connection(), utf8.data(), -1, SQLITE_PREPARE_PERSISTENT, &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized" // so we don't overwrite the user's exception. Mirrors @@ -3503,6 +3530,14 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr } } + // A UDF re-entering the same tagged template hits the cached statement + // whose step() is on the C stack; resetting it would segfault the VDBE + // (see REQUIRE_STMT_IDLE). + if (stmtObj->isStepping()) [[unlikely]] { + throwNodeState(globalObject, scope, "statement is currently executing"_s); + return nullptr; + } + // Reset + bind positional values. Named-parameter handling is not // meaningful for a tagged template. sqlite3_reset()'s return value // is the *previous* step()'s error, not reset's own status — the diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index 9aef837fda4b..13936cbad391 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -361,6 +361,22 @@ class JSStatementSync final : public JSC::JSDestructibleObject { unsigned resetGeneration() const { return m_resetGeneration; } void bumpResetGeneration() { ++m_resetGeneration; } + // True while a sqlite3_step on this statement is on the C stack. A UDF + // that re-enters run()/get()/all()/iterate() on the same statement would + // sqlite3_reset a running VDBE and segfault; REQUIRE_STMT_IDLE checks + // this. Not sqlite3_stmt_busy(): that also reports a parked iterator + // (stepped, yielded SQLITE_ROW, returned to JS), which is safe to reset. + bool isStepping() const { return m_steppingDepth > 0; } + struct SteppingScope { + JSStatementSync* stmt; + explicit SteppingScope(JSStatementSync* s) + : stmt(s) + { + ++stmt->m_steppingDepth; + } + ~SteppingScope() { --stmt->m_steppingDepth; } + }; + // Bind callFrame->argument(anon_start..) to the statement using Node.js // semantics. Returns false and throws on failure. bool bindParams(JSC::JSGlobalObject*, JSC::ThrowScope&, JSC::CallFrame*); @@ -421,6 +437,7 @@ class JSStatementSync final : public JSC::JSDestructibleObject { // `errcode: 0 "not an error"` from the new connection. unsigned m_originGeneration = 0; unsigned m_resetGeneration = 0; + unsigned m_steppingDepth = 0; bool m_useBigInts : 1 = false; bool m_returnArrays : 1 = false; bool m_allowBareNamedParams : 1 = true; diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index 0be06af6f68a..e3692eacdc8e 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -499,6 +499,14 @@ inline int lazyLoadSQLite() lazy_sqlite3_stmt_status = [](sqlite3_stmt*, int, int) -> int { return 0; }; } + // sqlite3_changes64 was added in 3.37.0; macOS 12 ships 3.36.0. The + // 32-bit variant has been in the ABI since 3.0.0. + if (!lazy_sqlite3_changes64) { + lazy_sqlite3_changes64 = [](sqlite3* db) -> sqlite3_int64 { + return static_cast(lazy_sqlite3_changes(db)); + }; + } + return 0; } diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 0a62e0c6970a..9ce89a579549 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -189,6 +189,24 @@ describe("DatabaseSync", () => { expect(() => new StatementSync()).toThrow(/Illegal constructor/); }); + test("an Array first argument is treated as a named-parameter object", () => { + // Node's test is IsObject() && !IsArrayBufferView(); Arrays are not + // special-cased. Their own-enumerable keys ("0", "1", …) go through the + // named-parameter path, so the default behaviour is ERR_INVALID_STATE + // for the unknown name, and allowUnknownNamedParameters makes it a no-op. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x)"); + const s1 = db.prepare("INSERT INTO t VALUES (?)"); + expect(() => s1.run([99])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_STATE", message: "Unknown named parameter '0'" }), + ); + const s2 = db.prepare("INSERT INTO t VALUES (?)"); + s2.setAllowUnknownNamedParameters(true); + expect(s2.run([99])).toEqual({ changes: 1, lastInsertRowid: 1 }); + expect(db.prepare("SELECT x FROM t").all()).toEqual([{ x: null }]); + db.close(); + }); + test("prepare() with empty / comment-only SQL returns a finalized StatementSync", () => { const db = new DatabaseSync(":memory:"); for (const sql of ["", " ", "-- a comment", "/* block */"]) { @@ -471,6 +489,73 @@ describe("DatabaseSync.prototype.function()", () => { ); db.close(); }); + + test("re-entering a statement from its own UDF throws instead of crashing", () => { + // sqlite3_reset on a VDBE that is mid-sqlite3_step corrupts the running + // state; Node v26.3.0 segfaults on this shape. Bun refuses with + // ERR_INVALID_STATE via isStepping() before touching the handle. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x)"); + let stmt: StatementSync; + db.function("reenter", () => { + try { + stmt.run(); + return "ran"; + } catch (e: any) { + return e.code; + } + }); + stmt = db.prepare("INSERT INTO t VALUES (reenter())"); + stmt.run(); + expect(db.prepare("SELECT x FROM t").all()).toEqual([{ x: "ERR_INVALID_STATE" }]); + // get()/all()/iterate() on the same statement are guarded the same way. + let caught: string[] = []; + db.function("reenter2", () => { + for (const fn of ["run", "get", "all", "iterate"] as const) { + try { + (stmt2[fn] as () => void)(); + caught.push("ran"); + } catch (e: any) { + caught.push(e.code); + } + } + return null; + }); + const stmt2 = db.prepare("SELECT reenter2()"); + stmt2.get(); + expect(caught).toEqual(["ERR_INVALID_STATE", "ERR_INVALID_STATE", "ERR_INVALID_STATE", "ERR_INVALID_STATE"]); + db.close(); + }); + + test("closing the database from a UDF keeps the callbacks rooted until the scan completes", async () => { + // closeInternal() clears m_registeredCallbacks; with close() no longer + // refusing while busy, doing so mid-step would unroot every UDF callback + // while the zombified connection still invokes them. Force system malloc + // so ASAN surfaces the use-after-free if the guard regresses. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x)"); + for (let i = 0; i < 30; i++) db.prepare("INSERT INTO t VALUES (?)").run(i); + let n = 0; + db.function("gcer", v => { Bun.gc(true); return v; }); + db.function("target", v => { if (++n === 3) db.close(); return v; }); + const rows = db.prepare("SELECT target(x) AS t, gcer(x) AS g FROM t").all(); + console.log(JSON.stringify({ rows: rows.length, calls: n, isOpen: db.isOpen })); + `, + ], + env: { ...bunEnv, Malloc: "1" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("AddressSanitizer"); + expect(stdout.trim()).toBe(JSON.stringify({ rows: 30, calls: 30, isOpen: false })); + expect(exitCode).toBe(0); + }); }); describe("DatabaseSync.prototype.aggregate()", () => { From 6cafa3d233095e45e495f68fbc894705e3cfbfd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:23:47 +0000 Subject: [PATCH 11/33] node:sqlite: guard iterator next() against stepping its own statement A UDF invoked from an iterator's sqlite3_step that calls next() on the same iterator re-enters sqlite3_step on a running VDBE (hang/UB). Check isStepping() in next() before stepping, same as run()/get()/all()/iterate(). Also assert the UDF-close regression test via {stdout, exitCode} instead of a stderr substring. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 3 +++ test/js/node/sqlite/node-sqlite.test.ts | 27 ++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 29d0c17d04cb..513a6eddecf6 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -2977,6 +2977,9 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorNext, (JSGlobalObject * globalOb if (self->capturedGeneration() != stmt->resetGeneration()) { return throwNodeState(globalObject, scope, "iterator was invalidated by calling run(), get(), all(), or iterate() on the backing statement"_s); } + if (stmt->isStepping()) [[unlikely]] { + return throwNodeState(globalObject, scope, "statement is currently executing"_s); + } JSDatabaseSync::BusyScope busy { stmt->database() }; JSStatementSync::SteppingScope stepping { stmt }; diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 9ce89a579549..2e43312c471a 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -524,6 +524,23 @@ describe("DatabaseSync.prototype.function()", () => { const stmt2 = db.prepare("SELECT reenter2()"); stmt2.get(); expect(caught).toEqual(["ERR_INVALID_STATE", "ERR_INVALID_STATE", "ERR_INVALID_STATE", "ERR_INVALID_STATE"]); + // Iterator next() on a statement whose step() is on the stack is the + // same re-entry path without a reset. + db.exec("INSERT INTO t VALUES ('a'),('b')"); + let iterCaught; + db.function("reenter3", () => { + try { + it.next(); + return "stepped"; + } catch (e: any) { + iterCaught = e.code; + return e.code; + } + }); + const it = db.prepare("SELECT reenter3() AS r FROM t").iterate(); + expect(it.next().value).toEqual({ r: "ERR_INVALID_STATE" }); + expect(iterCaught).toBe("ERR_INVALID_STATE"); + it.return(); db.close(); }); @@ -552,9 +569,13 @@ describe("DatabaseSync.prototype.function()", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("AddressSanitizer"); - expect(stdout.trim()).toBe(JSON.stringify({ rows: 30, calls: 30, isOpen: false })); - expect(exitCode).toBe(0); + // On regression ASAN aborts the process, so stdout/exitCode are the + // fail condition; stderr is captured so the diff is informative. + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ rows: 30, calls: 30, isOpen: false }), + stderr: expect.any(String), + exitCode: 0, + }); }); }); From efff7e7219d806a48bfb45cbdc4c7314c8ba0f36 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:35:14 +0000 Subject: [PATCH 12/33] node:sqlite: install isOpen/isTransaction/limits/sourceSQL/expandedSQL as own-instance accessors Node installs these via InstanceTemplate()->SetAccessorProperty (DontDelete), so they are own enumerable properties: Object.keys(db) lists them and {...db} copies them. Move them (and SQLTagStore's capacity/db/size) out of the prototype HashTableValue arrays and into each class's finishCreation via putDirectCustomAccessor, so the property descriptor matches Node's {get, set: undefined, enumerable: true, configurable: false}. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 43 +++++++++++++++++-------- test/js/node/sqlite/node-sqlite.test.ts | 22 +++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 513a6eddecf6..8e7ffc2c84af 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -165,6 +165,17 @@ static EncodedJSValue throwNodeState(JSGlobalObject* globalObject, ThrowScope& s return {}; } +// Node.js installs these getters via InstanceTemplate()->SetAccessorProperty +// (DontDelete), so they are OWN properties — Object.keys(db) lists them and +// {...db} copies them. Install in each finishCreation rather than on the +// prototype; subsequent instances follow the cached structure transitions. +static ALWAYS_INLINE void putNodeInstanceGetter(VM& vm, JSObject* target, ASCIILiteral name, JSC::GetValueFunc getter) +{ + target->putDirectCustomAccessor(vm, Identifier::fromString(vm, name), + CustomGetterSetter::create(vm, getter, nullptr), + PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete); +} + #define REQUIRE_DB_OPEN(db) \ do { \ if ((db)->connection() == nullptr) { \ @@ -776,10 +787,17 @@ JSDatabaseSync* JSDatabaseSync::create(VM& vm, Structure* structure, WTF::String return ptr; } +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsOpen); +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsTransaction); +JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncLimits); + void JSDatabaseSync::finishCreation(VM& vm) { Base::finishCreation(vm); ASSERT(inherits(info())); + putNodeInstanceGetter(vm, this, "isOpen"_s, jsDatabaseSyncIsOpen); + putNodeInstanceGetter(vm, this, "isTransaction"_s, jsDatabaseSyncIsTransaction); + putNodeInstanceGetter(vm, this, "limits"_s, jsDatabaseSyncLimits); } // DatabaseSync handles are GC cells and the VM is not destructed on a normal @@ -1123,9 +1141,6 @@ JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncSerialize); JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncDeserialize); JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncCreateTagStore); JSC_DECLARE_HOST_FUNCTION(jsDatabaseSyncDispose); -JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsOpen); -JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncIsTransaction); -JSC_DECLARE_CUSTOM_GETTER(jsDatabaseSyncLimits); #define THIS_DATABASE() \ auto& vm = JSC::getVM(globalObject); \ @@ -2079,9 +2094,6 @@ static const HashTableValue JSDatabaseSyncPrototypeTableValues[] = { { "serialize"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncSerialize, 0 } }, { "deserialize"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncDeserialize, 1 } }, { "createTagStore"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsDatabaseSyncCreateTagStore, 0 } }, - { "isOpen"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncIsOpen, nullptr } }, - { "isTransaction"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncIsTransaction, nullptr } }, - { "limits"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDatabaseSyncLimits, nullptr } }, }; void JSDatabaseSyncPrototype::finishCreation(VM& vm, JSGlobalObject* globalObject) @@ -2298,10 +2310,15 @@ JSStatementSync* JSStatementSync::create(VM& vm, Structure* structure, JSDatabas return ptr; } +JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncSourceSQL); +JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncExpandedSQL); + void JSStatementSync::finishCreation(VM& vm, JSDatabaseSync* db, sqlite3_stmt* stmt) { Base::finishCreation(vm); ASSERT(inherits(info())); + putNodeInstanceGetter(vm, this, "sourceSQL"_s, jsStatementSyncSourceSQL); + putNodeInstanceGetter(vm, this, "expandedSQL"_s, jsStatementSyncExpandedSQL); m_stmt = stmt; m_originGeneration = db->openGeneration(); m_database.set(vm, this, db); @@ -2614,8 +2631,6 @@ JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetReadBigInts); JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetReturnArrays); JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetAllowBareNamedParameters); JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetAllowUnknownNamedParameters); -JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncSourceSQL); -JSC_DECLARE_CUSTOM_GETTER(jsStatementSyncExpandedSQL); #define THIS_STATEMENT() \ auto& vm = JSC::getVM(globalObject); \ @@ -2866,8 +2881,6 @@ static const HashTableValue JSStatementSyncPrototypeTableValues[] = { { "setReturnArrays"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetReturnArrays, 1 } }, { "setAllowBareNamedParameters"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetAllowBareNamedParameters, 1 } }, { "setAllowUnknownNamedParameters"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetAllowUnknownNamedParameters, 1 } }, - { "sourceSQL"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsStatementSyncSourceSQL, nullptr } }, - { "expandedSQL"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsStatementSyncExpandedSQL, nullptr } }, }; void JSStatementSyncPrototype::finishCreation(VM& vm, JSGlobalObject*) @@ -3381,10 +3394,17 @@ JSNodeSqliteTagStore* JSNodeSqliteTagStore::create(VM& vm, Structure* structure, return ptr; } +JSC_DECLARE_CUSTOM_GETTER(jsTagStoreCapacity); +JSC_DECLARE_CUSTOM_GETTER(jsTagStoreDb); +JSC_DECLARE_CUSTOM_GETTER(jsTagStoreSize); + void JSNodeSqliteTagStore::finishCreation(VM& vm, JSDatabaseSync* db, unsigned capacity) { Base::finishCreation(vm); ASSERT(inherits(info())); + putNodeInstanceGetter(vm, this, "capacity"_s, jsTagStoreCapacity); + putNodeInstanceGetter(vm, this, "db"_s, jsTagStoreDb); + putNodeInstanceGetter(vm, this, "size"_s, jsTagStoreSize); m_database.set(vm, this, db); m_capacity = capacity; } @@ -3651,9 +3671,6 @@ static const HashTableValue JSNodeSqliteTagStorePrototypeTableValues[] = { { "all"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreAll, 0 } }, { "iterate"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreIterate, 0 } }, { "clear"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsTagStoreClear, 0 } }, - { "capacity"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreCapacity, nullptr } }, - { "size"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreSize, nullptr } }, - { "db"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsTagStoreDb, nullptr } }, }; void JSNodeSqliteTagStorePrototype::finishCreation(VM& vm, JSGlobalObject*) diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 2e43312c471a..4ee5b9f6c839 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -189,6 +189,28 @@ describe("DatabaseSync", () => { expect(() => new StatementSync()).toThrow(/Illegal constructor/); }); + test("isOpen/isTransaction/limits/sourceSQL/expandedSQL are own accessor properties", () => { + // Node installs these via InstanceTemplate()->SetAccessorProperty + // (DontDelete), so Object.keys() lists them and {...obj} copies them. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x)"); + const stmt = db.prepare("SELECT 1"); + const tag = db.createTagStore(); + expect(Object.keys(db)).toEqual(["isOpen", "isTransaction", "limits"]); + expect(Object.keys(stmt)).toEqual(["sourceSQL", "expandedSQL"]); + expect(Object.keys(tag)).toEqual(["capacity", "db", "size"]); + const desc = Object.getOwnPropertyDescriptor(db, "isOpen")!; + expect({ hasGet: typeof desc.get, set: desc.set, enumerable: desc.enumerable, configurable: desc.configurable }).toEqual({ + hasGet: "function", + set: undefined, + enumerable: true, + configurable: false, + }); + expect(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(db), "isOpen")).toBeUndefined(); + expect(Object.keys({ ...db })).toEqual(["isOpen", "isTransaction", "limits"]); + db.close(); + }); + test("an Array first argument is treated as a named-parameter object", () => { // Node's test is IsObject() && !IsArrayBufferView(); Arrays are not // special-cased. Their own-enumerable keys ("0", "1", …) go through the From 32ccb44cef9592ba94d318f97494310e72d12b95 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:37:16 +0000 Subject: [PATCH 13/33] [autofix.ci] apply automated fixes --- test/js/node/sqlite/node-sqlite.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 4ee5b9f6c839..d9f8c8914ec0 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -200,7 +200,12 @@ describe("DatabaseSync", () => { expect(Object.keys(stmt)).toEqual(["sourceSQL", "expandedSQL"]); expect(Object.keys(tag)).toEqual(["capacity", "db", "size"]); const desc = Object.getOwnPropertyDescriptor(db, "isOpen")!; - expect({ hasGet: typeof desc.get, set: desc.set, enumerable: desc.enumerable, configurable: desc.configurable }).toEqual({ + expect({ + hasGet: typeof desc.get, + set: desc.set, + enumerable: desc.enumerable, + configurable: desc.configurable, + }).toEqual({ hasGet: "function", set: undefined, enumerable: true, From 2e42058ad4348d77671b38ea7028c934271ea2a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:51:06 +0000 Subject: [PATCH 14/33] node:sqlite: defer sqlite3_close_v2 while a BusyScope is on the stack Re-entrant close() from an authorizer callback during sqlite3_prepare_v2 freed the sqlite3* under the parser: the authorizer fires from sqlite3StartTable before sqlite3GetVdbe, so db->pVdbe is NULL, connectionIsBusy() returns 0, and sqlite3_close_v2 proceeds to free instead of zombify. closeInternal() now stashes the handle in m_deferredClose when isBusy() and the outermost BusyScope runs finishDeferredClose() on unwind. Session/callback-root teardown waits with it (UDF contexts hold raw JSObject* rooted by m_registeredCallbacks). Re-check the source database is open in backup() after option parsing, matching the sibling option-reading methods: a hostile rate/source/ target/progress getter could close() the source and hand sqlite3_backup_init a null pSrcDb. Correct the JSNodeSqliteLimits class comment: the prototype chain is limits -> {} -> Object.prototype (not null-prototype); shadowing is prevented by own-slot interception, not by the chain. Halve the aggregate-step GC stress test's row count; 200 full GCs under debug+ASAN in the full suite was grazing the 5s timeout. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 67 +++++++++++++++++-------- src/jsc/bindings/sqlite/NodeSqlite.h | 26 +++++++--- test/js/node/sqlite/node-sqlite.test.ts | 53 ++++++++++++++++++- 3 files changed, 117 insertions(+), 29 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 8e7ffc2c84af..d987c2260727 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -855,28 +855,48 @@ void JSDatabaseSync::closeInternal() // JSStatementSync holds a strong WriteBarrier to this object, so during // normal GC the database is kept alive while any statement is reachable; // statements observe closure via isFinalized(). - // - // Sessions are different — the preupdate hook they install keeps a - // back-pointer into the connection, and sqlite3_close_v2 does NOT - // tear them down, so delete any that JS hasn't already closed. - if (m_db) { - deleteTrackedSessions(); - sqlite3_close_v2(m_db); + if (!m_db) return; + + // A BusyScope is on the stack (re-entrant close from an option getter / + // UDF / authorizer). From a UDF close_v2 only zombifies, but from an + // authorizer during prepare() no Vdbe exists yet and close_v2 frees the + // sqlite3* while sqlite3Prepare is still holding it. Mark closed now + // (m_db == nullptr), stash the handle, and let the outermost BusyScope + // run the teardown. UDF contexts hold raw JSObject* rooted by + // m_registeredCallbacks, so that clear must wait too. + if (isBusy()) { + m_deferredClose = m_db; m_db = nullptr; - unregisterOpenDatabase(this); - // Drop the callback roots so an explicitly-closed database doesn't - // retain them for the rest of the cell's lifetime — but only when - // no step() is on the stack. With a live step() close_v2 only - // zombified the connection: UDF/aggregate contexts (which hold - // raw JSObject* rooted by m_registeredCallbacks) are still - // registered and will be invoked again, so clearing now would - // leave those pointers dangling for GC to collect mid-scan. - if (!isBusy()) { - m_namedRegistrations.clear(); - Locker locker { cellLock() }; - m_registeredCallbacks.clear(); - } + return; } + + // Sessions must go before close_v2: the preupdate hook they install + // keeps a back-pointer into the connection, and close_v2 does NOT + // tear them down. + deleteTrackedSessions(); + sqlite3_close_v2(m_db); + m_db = nullptr; + unregisterOpenDatabase(this); + m_namedRegistrations.clear(); + Locker locker { cellLock() }; + m_registeredCallbacks.clear(); +} + +void JSDatabaseSync::finishDeferredClose() +{ + ASSERT(!isBusy()); + sqlite3* handle = m_deferredClose; + m_deferredClose = nullptr; + if (!handle) return; + deleteTrackedSessions(); + sqlite3_close_v2(handle); + // If open() ran after the deferred close but before this unwind, m_db is + // the new connection — keep its registration and roots. + if (m_db) return; + unregisterOpenDatabase(this); + m_namedRegistrations.clear(); + Locker locker { cellLock() }; + m_registeredCallbacks.clear(); } // Called from ExitHandler::dispatch_on_exit, on the main thread only; entries @@ -3808,6 +3828,13 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal } } + // An options getter (rate/source/target/progress, or href/protocol on a + // URL-like path) may have re-entered close(); sqlite3_backup_init + // dereferences pSrcDb->mutex with no API-armor guard. + if (!sourceDb->isOpen()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + // All validation done — errors from here on reject the promise. We // throw on the scope (so the ThrowScope assertion machinery is // satisfied) then convert the pending exception into a rejected diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index 13936cbad391..d5fadd7c77ad 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -187,8 +187,12 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { void rememberRegistration(const WTF::String& name, int argc, const std::array& slots); // Incremented for the duration of any native call that may re-enter JS. - // deserialize()/process-exit close consult it; close() itself does not - // (Node compat — sqlite3_close_v2 zombifies while stmts are outstanding). + // close()/deserialize()/process-exit consult it. Node permits re-entrant + // close(), so closeInternal() runs when busy but defers sqlite3_close_v2 + // itself until the outermost BusyScope unwinds: while a statement is + // outstanding close_v2 only zombifies, but from an authorizer callback + // during prepare() no Vdbe exists yet and close_v2 would free the handle + // under the parser's feet. bool isBusy() const { return m_busyDepth > 0; } struct BusyScope { JSDatabaseSync* db; @@ -206,13 +210,16 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { } ~BusyScope() { - if (db) --db->m_busyDepth; + if (!db) return; + if (--db->m_busyDepth == 0 && db->m_deferredClose) [[unlikely]] + db->finishDeferredClose(); } BusyScope(const BusyScope&) = delete; BusyScope& operator=(const BusyScope&) = delete; BusyScope(BusyScope&&) = delete; BusyScope& operator=(BusyScope&&) = delete; }; + void finishDeferredClose(); private: JSDatabaseSync(JSC::VM& vm, JSC::Structure* structure) @@ -225,6 +232,9 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { WTF::String m_location; DatabaseSyncOpenConfiguration m_config {}; sqlite3* m_db = nullptr; + // Handle whose sqlite3_close_v2 was deferred by a re-entrant close() + // until the outermost BusyScope unwinds; see finishDeferredClose(). + sqlite3* m_deferredClose = nullptr; unsigned m_openGeneration = 0; unsigned m_busyDepth = 0; // Sessions must be deleted before sqlite3_close_v2() to avoid @@ -702,11 +712,13 @@ class JSNodeSqliteSessionConstructor final : public JSC::InternalFunction { // ───────────────────────────────────────────────────────────────────────────── // DatabaseSyncLimits — the object returned by `db.limits`. Reads and // writes to its eleven named properties (length, sqlLength, …) call -// sqlite3_limit() on the owning connection. No prototype (so an -// overridden Object.prototype can't shadow a limit name). Intercepted -// via getOwnPropertySlot/put/getOwnPropertyNames rather than per-name +// sqlite3_limit() on the owning connection. Intercepted via +// getOwnPropertySlot/put/getOwnPropertyNames rather than per-name // accessors so the properties present as enumerable *own* data-like -// properties (Node's tests do `Object.keys(db.limits)`). +// properties (Node's tests do `Object.keys(db.limits)`); interception at +// own-slot level means an overridden Object.prototype cannot shadow a +// limit name even though the prototype chain reaches Object.prototype +// (limits → {} → Object.prototype) to match Node's observable chain. // ───────────────────────────────────────────────────────────────────────────── class JSNodeSqliteLimits final : public JSC::JSDestructibleObject { diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index d9f8c8914ec0..fa800a168b74 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -604,6 +604,36 @@ describe("DatabaseSync.prototype.function()", () => { exitCode: 0, }); }); + + test("closing the database from an authorizer during the first prepare() defers sqlite3_close_v2", async () => { + // Authorizer fires from inside sqlite3_prepare_v2 before a Vdbe exists, + // so sqlite3_close_v2 would free (not zombify) the handle under the + // parser's feet. The close is deferred until the BusyScope unwinds; on + // regression ASAN reports heap-use-after-free in sqlite3AuthCheck and + // the process aborts. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(":memory:"); + let err; + db.setAuthorizer(() => { try { db.close(); } catch (e) { err = e.code; } return 0; }); + const stmt = db.prepare("CREATE TABLE t(x)"); + console.log(JSON.stringify({ isOpen: db.isOpen, err, haveStmt: !!stmt })); + `, + ], + env: { ...bunEnv, Malloc: "1" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ isOpen: false, err: "ERR_INVALID_STATE", haveStmt: true }), + stderr: expect.any(String), + exitCode: 0, + }); + }); }); describe("DatabaseSync.prototype.aggregate()", () => { @@ -954,6 +984,25 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { // Each backup_step with rate=1 fsyncs the destination once per page; keep // the page count tiny so the test stays fast on slow-fsync CI filesystems. describe("backup()", () => { + test("re-checks the source is open after reading options", () => { + // sqlite3_backup_init dereferences pSrcDb->mutex with no API-armor + // guard; a hostile getter that closes the source would hand it a + // nullptr. Matches the post-option-parse REQUIRE_DB_OPEN on + // function()/aggregate()/createSession()/applyChangeset()/deserialize(). + using dir = tempDir("node-sqlite-backup-recheck", {}); + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t (x)"); + expect(() => + backup(src, path.join(String(dir), "dst.db"), { + get rate() { + src.close(); + return 1; + }, + }), + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE", message: "database is not open" })); + expect(src.isOpen).toBe(false); + }); + test("copies an in-memory database to a file", async () => { using dir = tempDir("node-sqlite-backup", {}); const src = new DatabaseSync(":memory:"); @@ -1846,7 +1895,7 @@ describe("GC stress", () => { test("aggregate step callback triggering GC between rows", () => { const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (x INTEGER)"); - db.exec(`WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200) INSERT INTO t SELECT x FROM c`); + db.exec(`WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 100) INSERT INTO t SELECT x FROM c`); db.aggregate("gcsum", { start: 0, step: (acc, x) => { @@ -1855,7 +1904,7 @@ describe("GC stress", () => { }, }); // The Strong<> in sqlite3_aggregate_context must survive GC between xStep calls. - expect(db.prepare("SELECT gcsum(x) AS s FROM t").get().s).toBe(20100); + expect(db.prepare("SELECT gcsum(x) AS s FROM t").get().s).toBe(5050); db.close(); }); From 72ccccc4f48b58825a1b078d39143273d9a24444 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:54:41 +0000 Subject: [PATCH 15/33] node:sqlite: skip iterator return()'s sqlite3_reset while stepping; delete SteppingScope copy/move return() is the remaining sqlite3_reset entry point the isStepping() sweep missed: a UDF re-entering it.return() on its own iterator passes every ownership check (nothing bumped resetGeneration) and would reset a running VDBE. Skip the reset when isStepping(), matching return()'s tolerant contract; done is still marked so the next next() stops. Iterator next() already has the guard (6cafa3d233). Delete SteppingScope's copy/move constructors/assignment so an accidental copy cannot double-decrement m_steppingDepth (mirrors BusyScope). --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 4 +++- src/jsc/bindings/sqlite/NodeSqlite.h | 4 ++++ test/js/node/sqlite/node-sqlite.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index d987c2260727..3229c0ce2007 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -3066,7 +3066,9 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorReturn, (JSGlobalObject * global // was already reset and may be mid-iteration under a newer iterator — // resetting again would silently rewind that iterator's cursor. // (Deliberate divergence: Node v26.3.0 resets unconditionally here.) - if (!self->done() && stmt && !stmt->isFinalized() + // isStepping() — this iterator's own sqlite3_step is on the C stack (a + // UDF re-entered return()); sqlite3_reset on a running VDBE is misuse. + if (!self->done() && stmt && !stmt->isFinalized() && !stmt->isStepping() && self->capturedGeneration() == stmt->resetGeneration()) { sqlite3_reset(stmt->statement()); } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index d5fadd7c77ad..fdb4f036d45e 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -385,6 +385,10 @@ class JSStatementSync final : public JSC::JSDestructibleObject { ++stmt->m_steppingDepth; } ~SteppingScope() { --stmt->m_steppingDepth; } + SteppingScope(const SteppingScope&) = delete; + SteppingScope& operator=(const SteppingScope&) = delete; + SteppingScope(SteppingScope&&) = delete; + SteppingScope& operator=(SteppingScope&&) = delete; }; // Bind callFrame->argument(anon_start..) to the statement using Node.js diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index fa800a168b74..c0a48b3939fe 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -568,6 +568,15 @@ describe("DatabaseSync.prototype.function()", () => { expect(it.next().value).toEqual({ r: "ERR_INVALID_STATE" }); expect(iterCaught).toBe("ERR_INVALID_STATE"); it.return(); + // Iterator return() while stepping skips the sqlite3_reset (tolerant) + // and just marks done; the outer next() still yields the in-flight row. + db.function("reenter4", () => { + it2.return(); + return "r"; + }); + const it2 = db.prepare("SELECT reenter4() AS r FROM t").iterate(); + expect(it2.next()).toEqual({ done: false, value: { r: "r" } }); + expect(it2.next()).toEqual({ done: true, value: null }); db.close(); }); From e61e668172b29ad4b150524bcaad1cf5f77d49f7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 8 Jul 2026 10:29:16 -0700 Subject: [PATCH 16/33] node:sqlite: report return-code-authoritative SQLite errors from the return code Fixes the three remaining CI failures. darwin test-sqlite-session.js: sqlite3changeset_apply(), the sqlite3session_* APIs, sqlite3_deserialize(), sqlite3_db_config(), sqlite3_busy_timeout(), sqlite3_set_authorizer(), and both sqlite3_create_*function variants all report failure in their RETURN code and (verified in the amalgamation source) never write it onto the db handle. Building the thrown error from the handle therefore produced a useless `errcode: 0, "not an error"`. It only shows on the macOS dlopen path because the bundled 3.53.2 happens to also mirror the code onto the handle; the return code is the invariant on both. A shared throwSqliteReturnCodeError(db, r) now uses `r`, and prefers the handle's richer extended code + errmsg only when its primary code AGREES with `r` -- so a stale prior error or a benign SQLITE_ROW left on the handle can never be reported in place of the real failure, and the bundled path is unchanged. All 14 return-code-authoritative call sites are converted; the 14 handle-authoritative ones (prepare/step/exec/open/backup) are untouched. The vendored session tests assert exactly the codes this produces (errcode 21 "bad parameter or other API misuse", errcode 11 "database disk image is malformed"); the file goes from 22 pass / 3 fail to 25 pass on darwin. Windows node-sqlite.test.ts (all three shards, one as an illegal instruction): the two UDF-root regression tests spawn a child with `Malloc: "1"` to force bmalloc's SystemHeap so ASAN catches a regression. WebKit stubs that heap out on Windows as RELEASE_BASSERT_NOT_REACHED(), so the child traps in pas_system_heap_malloc during JSC initialization -- 3 ms in, before any test code runs, deterministically (symbolized from the shard's own PDB: the faulting instruction is the compiled-in `brk #1`). The tests still run on Windows; they just no longer set an env var the platform's allocator cannot honor. darwin test-sqlite-serialize.js: some Apple libsqlite3 builds return a 0-byte image from sqlite3_serialize() for a PRISTINE, never-written :memory: database, where the bundled build emits the 4096-byte header page. Only the three pristine-db subtests are affected (every populated-db one passes), so they skip on a runtime probe of the loaded library rather than on the platform -- an Apple build (or a Database.setCustomSQLite() one) that materialises the header runs all three. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 53 ++++++++++++------- test/js/node/sqlite/node-sqlite.test.ts | 12 +++-- .../test/parallel/test-sqlite-serialize.js | 21 ++++++-- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 3229c0ce2007..3ae0be0d6ae8 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -154,6 +154,21 @@ static void throwSqliteMessage(JSGlobalObject* globalObject, ThrowScope& scope, scope.throwException(globalObject, error); } +// The session extension, sqlite3_deserialize, sqlite3_db_config, and friends +// report failure only in their RETURN code, so `r` is truth. Use the handle's +// richer extended code/message only when its primary code AGREES with `r`. +static void throwSqliteReturnCodeError(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3* db, int r) +{ + // A stale prior error or a benign SQLITE_ROW/DONE left on the handle + // never matches `r`, so neither can ever be reported in place of it. + int onHandle = db ? sqlite3_extended_errcode(db) : SQLITE_OK; + if ((onHandle & 0xff) == (r & 0xff)) { + throwSqliteError(globalObject, scope, db); + return; + } + throwSqliteMessage(globalObject, scope, r, sqliteText(sqlite3_errstr(r))); +} + // Node's THROW_ERR_INVALID_STATE(...) emits the message verbatim; Bun's // generic helper prepends "Invalid state: ". Several upstream tests // (test-sqlite-session.js, test-sqlite-template-tag.js, …) assert the @@ -1029,15 +1044,15 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) sqlite3_db_config(m_db, SQLITE_DBCONFIG_DQS_DDL, v, nullptr); v = m_config.enableForeignKeyConstraints ? 1 : 0; - if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_FKEY, v, nullptr) != SQLITE_OK) { - throwSqliteError(globalObject, scope, m_db); + if (int r = sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_FKEY, v, nullptr); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); closeInternal(); return false; } v = m_config.enableDefensive ? 1 : 0; - if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_DEFENSIVE, v, nullptr) != SQLITE_OK) { - throwSqliteError(globalObject, scope, m_db); + if (int r = sqlite3_db_config(m_db, SQLITE_DBCONFIG_DEFENSIVE, v, nullptr); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); closeInternal(); return false; } @@ -1047,8 +1062,8 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) if (initial >= 0) sqlite3_limit(m_db, info.id, initial); } - if (sqlite3_busy_timeout(m_db, m_config.timeout) != SQLITE_OK) { - throwSqliteError(globalObject, scope, m_db); + if (int r = sqlite3_busy_timeout(m_db, m_config.timeout); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); closeInternal(); return false; } @@ -1062,8 +1077,8 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) closeInternal(); return false; } - if (sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, 1, nullptr) != SQLITE_OK) { - throwSqliteError(globalObject, scope, m_db); + if (int r = sqlite3_db_config(m_db, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, 1, nullptr); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); closeInternal(); return false; } @@ -1322,7 +1337,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableLoadExtension, (JSGlobalObject * gl if (LAZY_SQLITE_HAS_LOAD_EXTENSION()) { int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, allow ? 1 : 0, nullptr); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } } @@ -1430,7 +1445,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncFunction, (JSGlobalObject * globalObject, // SQLite owns udf once xDestroy is passed in — it invokes xDestroy // on the failure path too (name too long / nArg out of range / // SQLITE_BUSY), so a manual delete here would double-free. - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } // SQLite has dropped any previous (name, argc) registration, so release @@ -1530,7 +1545,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncAggregate, (JSGlobalObject * globalObject NodeSqliteAggregate::xStep, NodeSqliteAggregate::xFinal, xValue, xInverse, NodeSqliteAggregate::xDestroy); if (r != SQLITE_OK) { // SQLite already invoked xDestroy(agg) on the failure path. - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } // SQLite has dropped any previous (name, argc) registration, so release @@ -1596,14 +1611,14 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateSession, (JSGlobalObject * globalOb sqlite3_session* pSession = nullptr; int r = sqlite3session_create(self->connection(), dbNameUtf8.data(), &pSession); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } auto tableUtf8 = table.utf8(); r = sqlite3session_attach(pSession, table.isEmpty() ? nullptr : tableUtf8.data()); if (r != SQLITE_OK) { sqlite3session_delete(pSession); - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } @@ -1760,7 +1775,9 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncApplyChangeset, (JSGlobalObject * globalO return JSValue::encode(jsBoolean(false)); } if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + // An invalid conflict-handler return is SQLITE_MISUSE and a malformed + // changeset is SQLITE_CORRUPT; the vendored tests assert both exactly. + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } return JSValue::encode(jsBoolean(true)); @@ -1778,7 +1795,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncEnableDefensive, (JSGlobalObject * global int out = 0; int r = sqlite3_db_config(self->connection(), SQLITE_DBCONFIG_DEFENSIVE, enable, &out); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } return JSValue::encode(jsUndefined()); @@ -1877,7 +1894,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSetAuthorizer, (JSGlobalObject * globalOb self->m_authorizer.set(vm, self, arg0.getObject()); int r = sqlite3_set_authorizer(self->connection(), nodeSqliteAuthorizerCallback, self); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } return JSValue::encode(jsUndefined()); @@ -2033,7 +2050,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObje // success and failure paths once FREEONCLOSE is set. The // connection itself is unchanged on failure, so existing // sessions stay valid (Node doesn't touch them here either). - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); return {}; } // The schema swap succeeded. Node leaves sessions attached here, but @@ -3196,7 +3213,7 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF int r = fn(self->session(), &nChangeset, &pChangeset); if (r != SQLITE_OK) { if (pChangeset) sqlite3_free(pChangeset); - throwSqliteError(globalObject, scope, db->connection()); + throwSqliteReturnCodeError(globalObject, scope, db->connection(), r); return {}; } auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), static_cast(nChangeset)); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index c0a48b3939fe..2165a009cc6f 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1,6 +1,6 @@ import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { existsSync, statSync } from "node:fs"; import { builtinModules, isBuiltin } from "node:module"; import path from "node:path"; @@ -601,7 +601,10 @@ describe("DatabaseSync.prototype.function()", () => { console.log(JSON.stringify({ rows: rows.length, calls: n, isOpen: db.isOpen })); `, ], - env: { ...bunEnv, Malloc: "1" }, + // Malloc=1 forces bmalloc's SystemHeap so ASAN catches a regression. + // WebKit stubs that heap out on Windows (RELEASE_BASSERT_NOT_REACHED), + // so the child would trap at JSC init before running any test code. + env: isWindows ? bunEnv : { ...bunEnv, Malloc: "1" }, stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); @@ -633,7 +636,10 @@ describe("DatabaseSync.prototype.function()", () => { console.log(JSON.stringify({ isOpen: db.isOpen, err, haveStmt: !!stmt })); `, ], - env: { ...bunEnv, Malloc: "1" }, + // Malloc=1 forces bmalloc's SystemHeap so ASAN catches a regression. + // WebKit stubs that heap out on Windows (RELEASE_BASSERT_NOT_REACHED), + // so the child would trap at JSC init before running any test code. + env: isWindows ? bunEnv : { ...bunEnv, Malloc: "1" }, stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); diff --git a/test/js/node/test/parallel/test-sqlite-serialize.js b/test/js/node/test/parallel/test-sqlite-serialize.js index 77b9d9c5f483..57b20c37fcd3 100644 --- a/test/js/node/test/parallel/test-sqlite-serialize.js +++ b/test/js/node/test/parallel/test-sqlite-serialize.js @@ -4,8 +4,23 @@ skipIfSQLiteMissing(); const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); +// BUN: on macOS bun dlopens the system libsqlite3. Some Apple builds return a +// 0-byte image from sqlite3_serialize() for a PRISTINE (never-written) +// :memory: db, where the bundled build emits the 4096-byte header page. +// Detected from the loaded library at runtime, never from the platform. +const pristineImageIsEmpty = typeof Bun !== 'undefined' && (() => { + const probe = new DatabaseSync(':memory:'); + try { + return probe.serialize().length === 0; + } catch { + return false; + } finally { + probe.close(); + } +})(); + suite('DatabaseSync.prototype.serialize()', () => { - test('returns a Uint8Array with the SQLite header', (t) => { + test('returns a Uint8Array with the SQLite header', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. const db = new DatabaseSync(':memory:'); const buf = db.serialize(); t.assert.ok(buf instanceof Uint8Array); @@ -15,7 +30,7 @@ suite('DatabaseSync.prototype.serialize()', () => { db.close(); }); - test('serializes an empty database', (t) => { + test('serializes an empty database', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. const db = new DatabaseSync(':memory:'); const buf = db.serialize(); t.assert.ok(buf instanceof Uint8Array); @@ -55,7 +70,7 @@ suite('DatabaseSync.prototype.serialize()', () => { db.close(); }); - test('accepts a schema name argument', (t) => { + test('accepts a schema name argument', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. const db = new DatabaseSync(':memory:'); const buf = db.serialize('main'); t.assert.ok(buf instanceof Uint8Array); From 90071bb5ddfcd1b20f299bf7bdcafb3027e4187d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:40:59 +0000 Subject: [PATCH 17/33] node:sqlite: refuse open() during a deferred close; read step errors from sqlite3_db_handle open() now refuses while m_deferredClose is set. m_deferredClose is a single slot, so close(); open(); close() from inside a UDF/authorizer would overwrite it and leak the first handle, and a session created on the reopened connection would sit in the vector finishDeferredClose() sweeps before any reopen check. Refusing the open makes both impossible and drops the now-unreachable reopen branch from finishDeferredClose(). Read step-path SQLite errors from sqlite3_db_handle(stmt) instead of the wrapper's m_db, which a re-entrant close() nulls (deferred close). The handle itself is still valid until the BusyScope unwinds, so the statement's back-pointer carries the real errcode/errmsg instead of sqlite3_errmsg(NULL) = "out of memory". Applied to statementStepRun/ Get/All and iterator next(); exec()/prepare() capture the connection in a local before the call for the same reason. Merge main (clean). --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 53 +++++++++++++++---------- test/js/node/sqlite/node-sqlite.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 3ae0be0d6ae8..6514698b4ad1 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -900,14 +900,14 @@ void JSDatabaseSync::closeInternal() void JSDatabaseSync::finishDeferredClose() { ASSERT(!isBusy()); + // open() refuses while m_deferredClose is set, so m_db is still null and + // m_sessions / m_registeredCallbacks belong to the deferred handle. + ASSERT(!m_db); sqlite3* handle = m_deferredClose; m_deferredClose = nullptr; if (!handle) return; deleteTrackedSessions(); sqlite3_close_v2(handle); - // If open() ran after the deferred close but before this unwind, m_db is - // the new connection — keep its registration and roots. - if (m_db) return; unregisterOpenDatabase(this); m_namedRegistrations.clear(); Locker locker { cellLock() }; @@ -989,7 +989,12 @@ void JSDatabaseSync::sweepOrphanedSessions() bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) { - if (m_db) { + // m_deferredClose: close() from inside a UDF/authorizer stashed the old + // handle and its close_v2 runs when the outermost BusyScope unwinds. + // Opening now would orphan that handle (a second close overwrites the + // single slot) and put new-connection sessions into the vector + // finishDeferredClose() is about to sweep, so refuse until it completes. + if (m_db || m_deferredClose) { throwNodeState(globalObject, scope, "database is already open"_s); return false; } @@ -1228,10 +1233,14 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncExec, (JSGlobalObject * globalObject, Cal auto sql = sqlVal.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto utf8 = sql.utf8(); - int r = sqlite3_exec(self->connection(), utf8.data(), nullptr, nullptr, nullptr); + // Capture before the call: a UDF/authorizer re-entering close() nulls + // m_db (deferred close) but the handle itself stays valid until this + // frame's BusyScope unwinds, so read the error from it. + sqlite3* conn = self->connection(); + int r = sqlite3_exec(conn, utf8.data(), nullptr, nullptr, nullptr); CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, conn); return {}; } return JSValue::encode(jsUndefined()); @@ -1251,13 +1260,15 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, auto utf8 = sql.utf8(); sqlite3_stmt* stmt = nullptr; // utf8.data() is NUL-terminated (CString); -1 lets SQLite compute the - // length and avoids narrowing a size_t into int. - int r = sqlite3_prepare_v2(self->connection(), utf8.data(), -1, &stmt, nullptr); + // length and avoids narrowing a size_t into int. Capture the connection + // before the call for the error path (see jsDatabaseSyncExec). + sqlite3* conn = self->connection(); + int r = sqlite3_prepare_v2(conn, utf8.data(), -1, &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized". CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, conn); return {}; } // sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == nullptr for empty / @@ -2699,17 +2710,19 @@ static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, Thr while (r == SQLITE_ROW) r = sqlite3_step(self->statement()); CHECK_UDF_EXCEPTION(scope); + // Don't go through self->connection(): a named-parameter getter or UDF + // callback may have called db.close() since the caller's liveness + // check, in which case the wrapper's m_db is now null (deferred close) + // and sqlite3_changes64(NULL) is a raw db->nChange deref — and on the + // error path sqlite3_errmsg(NULL) reports "out of memory" instead of + // the real message. sqlite3_db_handle reads the statement's own + // back-pointer, which survives until the BusyScope unwinds and is what + // Node's StatementSync::Run uses. + sqlite3* db = sqlite3_db_handle(self->statement()); if (r != SQLITE_DONE && r != SQLITE_OK) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, db); return {}; } - // Don't go through self->connection() here: a named-parameter getter - // or UDF callback may have called db.close() since the caller's - // liveness check, in which case the wrapper's m_db is now null and - // sqlite3_changes64(NULL) is a raw db->nChange deref. sqlite3_db_handle - // reads the statement's own back-pointer, which survives zombification - // and is what Node's StatementSync::Run uses. - sqlite3* db = sqlite3_db_handle(self->statement()); JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); RETURN_IF_EXCEPTION(scope, {}); sqlite3_int64 changes = sqlite3_changes64(db); @@ -2734,7 +2747,7 @@ static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope& CHECK_UDF_EXCEPTION(scope); if (r == SQLITE_DONE) return JSValue::encode(jsUndefined()); if (r != SQLITE_ROW) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, sqlite3_db_handle(self->statement())); return {}; } int numCols = sqlite3_column_count(self->statement()); @@ -2770,7 +2783,7 @@ static EncodedJSValue statementStepAll(JSGlobalObject* globalObject, ThrowScope& } CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_DONE) { - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, sqlite3_db_handle(self->statement())); return {}; } return JSValue::encode(rows); @@ -3043,7 +3056,7 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorNext, (JSGlobalObject * globalOb sqlite3_reset(stmt->statement()); self->setDone(); CHECK_UDF_EXCEPTION(scope); - throwSqliteError(globalObject, scope, stmt->connection()); + throwSqliteError(globalObject, scope, sqlite3_db_handle(stmt->statement())); return {}; } CHECK_UDF_EXCEPTION(scope); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 2165a009cc6f..382e113c905d 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -649,6 +649,54 @@ describe("DatabaseSync.prototype.function()", () => { exitCode: 0, }); }); + + test("open() refuses while a deferred close is pending", () => { + // m_deferredClose is a single slot; close(); open(); close() from a UDF + // would overwrite it and leak the first handle, and a session on the + // reopened connection would be swept by finishDeferredClose(). Refusing + // the open() avoids both. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (x)"); + let openErr; + db.function("g", () => { + db.close(); + try { + db.open(); + openErr = "opened"; + } catch (e: any) { + openErr = e.code; + } + return null; + }); + db.prepare("SELECT g()").run(); + expect(openErr).toBe("ERR_INVALID_STATE"); + expect(db.isOpen).toBe(false); + // Deferred close has completed on BusyScope unwind; a fresh open() works. + db.open(); + expect(db.isOpen).toBe(true); + db.close(); + }); + + test("step error after a UDF closes the database reports the real error", () => { + // The wrapper's m_db is nulled by the deferred close; reading the error + // from it would surface sqlite3_errmsg(NULL) = "out of memory". The + // statement's own back-pointer (sqlite3_db_handle) still points at the + // deferred handle until the BusyScope unwinds. + for (const fn of ["run", "get", "all"] as const) { + const db = new DatabaseSync(":memory:"); + db.function("f", () => { + db.close(); + return {}; + }); + expect(() => (db.prepare("SELECT f()")[fn] as () => void)()).toThrow( + expect.objectContaining({ + code: "ERR_SQLITE_ERROR", + errcode: 1, + message: "Returned JavaScript value cannot be converted to a SQLite value", + }), + ); + } + }); }); describe("DatabaseSync.prototype.aggregate()", () => { From f6b14a2823a462ace41b9c94690c9d12f7ae333b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:13:12 +0000 Subject: [PATCH 18/33] node:sqlite: capture the connection for serialize()/TagStore error paths too The two remaining throwSqliteError(->connection()) sites the 90071bb5dd sweep missed: jsDatabaseSyncSerialize (the authorizer fires from inside sqlite3_serialize's internal PRAGMA prepare) and JSNodeSqliteTagStore:: prepare() (a template-strings-array accessor or the authorizer can re-enter close()). Capture the connection into a local before the call, and in the TagStore path add an isOpen() re-check after reading template parts so the accessor trigger throws 'database is not open' rather than the re-read-nullptr SQLITE_MISUSE. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 6514698b4ad1..ee73fd6e6612 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -1929,7 +1929,11 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject auto dbNameUtf8 = dbName.utf8(); sqlite3_int64 size = 0; - unsigned char* data = sqlite3_serialize(self->connection(), dbNameUtf8.data(), &size, 0); + // Capture before the call: the authorizer fires from inside + // sqlite3_serialize's internal PRAGMA prepare and may re-enter close() + // (deferred, nulls m_db); read the error from the captured handle. + sqlite3* conn = self->connection(); + unsigned char* data = sqlite3_serialize(conn, dbNameUtf8.data(), &size, 0); // For non-memdb schemas (regular :memory: or file-backed) // sqlite3_serialize internally prepares `PRAGMA "".page_count`, // which fires the authorizer with SQLITE_PRAGMA. Surface a thrown @@ -1951,7 +1955,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(array); } - throwSqliteError(globalObject, scope, self->connection()); + throwSqliteError(globalObject, scope, conn); return {}; } @@ -3562,6 +3566,12 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr } if (!stmtObj) { + // A template-strings-array accessor above may have re-entered + // close(); re-check before handing SQLite the connection. + if (!db->isOpen()) { + throwNodeState(globalObject, scope, "database is not open"_s); + return nullptr; + } auto utf8 = sqlStr.utf8(); sqlite3_stmt* stmt = nullptr; // SQLITE_PREPARE_PERSISTENT: TagStore-cached statements are exactly @@ -3569,7 +3579,8 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr // the flag is documented for; it keeps them out of lookaside memory. // Intentional divergence from Node (which uses prepare_v2) — the // hint is allocator-only, not observable behavior. - int r = sqlite3_prepare_v3(db->connection(), utf8.data(), -1, SQLITE_PREPARE_PERSISTENT, &stmt, nullptr); + sqlite3* conn = db->connection(); + int r = sqlite3_prepare_v3(conn, utf8.data(), -1, SQLITE_PREPARE_PERSISTENT, &stmt, nullptr); // prepare() runs the authorizer callback (if any), which may // throw — surface that over SQLite's generic "not authorized" // so we don't overwrite the user's exception. Mirrors @@ -3580,7 +3591,7 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr } if (r != SQLITE_OK) { if (stmt) sqlite3_finalize(stmt); - throwSqliteError(globalObject, scope, db->connection()); + throwSqliteError(globalObject, scope, conn); return nullptr; } if (!stmt) { From 75505dfa6743eb86dce887ef00ad47aafcdd8e88 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 9 Jul 2026 15:39:04 -0700 Subject: [PATCH 19/33] node:sqlite: address second-pass review on #32498 - prepare(): read the options object before sqlite3_prepare_v2 so a bad option throws ERR_INVALID_ARG_TYPE (Node's precedence) and no failure path needs a compensating sqlite3_finalize; re-check isOpen after the option getters. - backup(): fire the progress callback on SQLITE_BUSY/LOCKED so a caller can throw to abort a locked destination instead of hanging the JS thread forever. - process.versions.sqlite: return the bundled amalgamation's constant on the LAZY_LOAD_SQLITE fallback (was the CI SDK's sqlite3.h); static_assert on the linked build keeps it in sync with sqlite3_local.h. - validateDatabasePath(): move the NUL-byte check inside the helper (both callers had a byte-identical copy). - Drop the raw JSDatabaseSync* backpointer from NodeSqliteSessionRecord and the m_hasOrphanedSessions dirty bit; ~JSNodeSqliteSession now only writes to the refcounted record and sweepOrphanedSessions() scans m_sessions when non-empty. - Delete the write-only db_ field from NodeSqliteUDF/NodeSqliteAggregate. - Extract Bun__sqliteCheckpointForTermination() shared by bun:sqlite's and node:sqlite's exit-close paths. - Note the ATTACH limitation on the macOS PERSIST_WAL clear; document the Buffer-path UTF-8 requirement and backup() busy hazard in the compat page; correct the sqlite build-script comment about Apple's omissions. - Tests: TagStore reentrancy from a UDF, TagStore eviction across close()/open()/deserialize(), setCustomSQLite/node:sqlite dlopen handle sharing (darwin), prepare() error precedence, backup() busy progress escape hatch, bind-param-getter reentrancy behavior. --- .claude/skills/verify/SKILL.md | 17 ++ docs/runtime/nodejs-compat.mdx | 2 +- scripts/build/deps/sqlite.ts | 7 +- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 21 ++- src/jsc/bindings/sqlite/NodeSqlite.cpp | 188 ++++++++++----------- src/jsc/bindings/sqlite/NodeSqlite.h | 15 +- test/js/node/sqlite/node-sqlite.test.ts | 181 +++++++++++++++++++- 7 files changed, 314 insertions(+), 117 deletions(-) create mode 100644 .claude/skills/verify/SKILL.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000000..50a87d1f720a --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,17 @@ +--- +description: Drive a Bun change end-to-end at its runtime surface. +--- + +Build once, then run the debug binary directly at the surface the diff touches: + +```sh +bun bd -e '' # JS-visible API changes +BUN_DEBUG_QUIET_LOGS=1 ./build/debug/bun-debug # after a build +``` + +- **Runtime API** (`Bun.*`, `node:*`, Web APIs): `bun bd -e 'require("node:sqlite")…'` and print what you observe. +- **CLI commands** (install/run/test/build): `bun bd …` in a `tempDir`. +- **Server** (`Bun.serve`): start with `port: 0`, `fetch()` it in the same script. +- **Bundler**: `bun bd build fixture.ts --outdir=…`, read the output. + +Do **not** run the test suite as verification — that is CI. Drive the changed behavior at the surface a user would, capture the output, and report it. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 67565767b9e4..d928a8db3cc0 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -173,7 +173,7 @@ This page is updated regularly and reflects the latest version of Bun's compatib ### [`node:sqlite`](https://nodejs.org/api/sqlite.html) -🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread). On macOS, Bun uses the system `libsqlite3.dylib`; `loadExtension()` (and, on older macOS releases, `createSession()`/`applyChangeset()`) require a full SQLite build — call `require("bun:sqlite").Database.setCustomSQLite(path)` before opening a database. +🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread) — throw from the `progress` callback to abort a backup that keeps hitting `SQLITE_BUSY`. A `Buffer`/`Uint8Array` database path must be valid UTF-8 (Node passes the raw bytes through; Bun rejects non-UTF-8 with `ERR_INVALID_ARG_VALUE`). On macOS, Bun uses the system `libsqlite3.dylib`; `loadExtension()` (and, on older macOS releases, `createSession()`/`applyChangeset()`) require a full SQLite build — call `require("bun:sqlite").Database.setCustomSQLite(path)` before opening a database. ### [`node:test`](https://nodejs.org/api/test.html) diff --git a/scripts/build/deps/sqlite.ts b/scripts/build/deps/sqlite.ts index 589da17bd95b..6d6eec0375d6 100644 --- a/scripts/build/deps/sqlite.ts +++ b/scripts/build/deps/sqlite.ts @@ -7,9 +7,10 @@ * Built when staticSqlite is true (the Linux/Windows default). On macOS * both bun:sqlite and node:sqlite dlopen the system libsqlite3.dylib at * runtime (LAZY_LOAD_SQLITE=1) so exactly one library is loaded per - * process — see the corruption caveat in config.ts. Apple's build lacks - * the session extension and percentile(); node:sqlite runtime-gates the - * affected APIs and points at Database.setCustomSQLite() for a full build. + * process — see the corruption caveat in config.ts. Apple's build omits + * load_extension/percentile()/geopoly/rbu (and, on older macOS releases, + * the session extension); node:sqlite runtime-gates the affected APIs and + * points at Database.setCustomSQLite() for a full build. */ import type { Dependency } from "../source.ts"; diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index af9b318e57c5..d3423490f17e 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -273,6 +273,19 @@ static VersionSqlite3* databaseForHandle(int32_t handle) return dbs[static_cast(handle)]; } +// Shared with node:sqlite's termination path (Bun__closeAllNodeSqliteDatabasesForTermination): +// with unfinalized statements close_v2 only zombifies the connection and +// defers the WAL checkpoint to a finalize that never comes, so flush the WAL +// into the main database file explicitly. Zero busy_timeout first — TRUNCATE +// waits on readers via the connection's busy-handler, so a large user-set +// timeout plus a cross-process reader would stall process.exit(); with a +// zero handler TRUNCATE degrades to a passive checkpoint immediately. +extern "C" void Bun__sqliteCheckpointForTermination(sqlite3* db) +{ + sqlite3_busy_timeout(db, 0); + sqlite3_wal_checkpoint_v2(db, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); +} + extern "C" void Bun__closeAllSQLiteDatabasesForTermination() { if (!_instance) { @@ -283,13 +296,7 @@ extern "C" void Bun__closeAllSQLiteDatabasesForTermination() for (auto& db : dbs) { if (db->db) { - // With un-finalized statements close_v2 zombifies the connection - // and defers the WAL checkpoint to a finalize that never comes. - // Checkpoint explicitly so nothing is stranded in the -wal file; - // zero busy_timeout first so a cross-process reader can't stall - // process.exit() via TRUNCATE's busy-handler wait. - sqlite3_busy_timeout(db->db, 0); - sqlite3_wal_checkpoint_v2(db->db, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); + Bun__sqliteCheckpointForTermination(db->db); // close_v2: with unfinalized statements still alive, plain // sqlite3_close() returns SQLITE_BUSY and leaves the connection // open, which would leak it once the pointer is nulled below. diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index ee73fd6e6612..612c7a40f928 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -12,6 +12,15 @@ #define LAZY_LOAD_SQLITE 0 #endif +// The bundled amalgamation's version, for process.versions.sqlite before any +// library is loaded. On the LAZY_LOAD_SQLITE (macOS) branch SQLITE_VERSION +// comes from the SDK's — the CI build machine's, not what runs +// — so use this deterministic constant instead. The static_assert on the +// !LAZY branch below fails the Linux/Windows build if it drifts from +// sqlite3_local.h. +#define BUN_SQLITE_BUNDLED_VERSION "3.53.2" +#define BUN_SQLITE_BUNDLED_VERSION_NUMBER 3053002 + #if LAZY_LOAD_SQLITE #include "lazy_sqlite3.h" #define LAZY_SQLITE_HAS_LOAD_EXTENSION() (lazy_sqlite3_load_extension != nullptr) @@ -26,6 +35,8 @@ #define SQLITE_ENABLE_COLUMN_METADATA 1 #endif #include "sqlite3_local.h" +static_assert(BUN_SQLITE_BUNDLED_VERSION_NUMBER == SQLITE_VERSION_NUMBER, + "update BUN_SQLITE_BUNDLED_VERSION to match sqlite3_local.h"); static inline int lazyLoadSQLite() { return 0; } static constexpr bool lazy_sqlite3_has_session = true; #define LAZY_SQLITE_HAS_LOAD_EXTENSION() true @@ -85,21 +96,23 @@ static constexpr bool lazy_sqlite3_has_session = true; #define SQLITE_CHANGESET_FOREIGN_KEY 5 #endif -// One-time process-global sqlite3_config() (defined in JSSQLStatement.cpp). -// Forward-declared here rather than in lazy_sqlite3.h because that header is -// only included on the dlopen path, and this must be visible on every build. +// One-time process-global sqlite3_config() and the exit-time WAL checkpoint +// helper are defined in JSSQLStatement.cpp so bun:sqlite (which does not +// depend on this file) owns them. Forward-declared here rather than in +// lazy_sqlite3.h because that header is only included on the dlopen path. extern "C" void Bun__initializeSQLite(); +extern "C" void Bun__sqliteCheckpointForTermination(sqlite3*); // process.versions.sqlite — the loaded library's version if a library has -// been loaded, else the header constant. Never triggers a dlopen: reading -// process.versions must not defeat Database.setCustomSQLite(). +// been loaded, else the bundled amalgamation's constant. Never triggers a +// dlopen: reading process.versions must not defeat Database.setCustomSQLite(). extern "C" const char* Bun__sqlite3_version() { #if LAZY_LOAD_SQLITE if (sqlite3_handle && lazy_sqlite3_libversion) return lazy_sqlite3_libversion(); #endif - return SQLITE_VERSION; + return BUN_SQLITE_BUNDLED_VERSION; } namespace Bun { @@ -405,21 +418,20 @@ static void jsValueToSqliteResult(JSGlobalObject* globalObject, sqlite3_context* // GC-traced field on the cell, see addRegisteredCallback) — NOT by a C-side // Strong<>, so a callback closure that captures the database does not pin the // cell forever; the db → closure → db cycle stays collectable, exactly like -// m_authorizer. The raw fn_/db_ pointers are safe because the context is -// only invoked while a query runs on this connection (the cell is on the -// stack). xDestroy itself MUST NOT touch db_ or fn_ — with unfinalized -// statements the connection is zombified and xDestroy may run after the -// cell has been swept (see the comment on xDestroy below); superseded roots -// are released by releaseSupersededRegistration() at the registration site. +// m_authorizer. The raw fn_ pointer is safe because the context is only +// invoked while a query runs on this connection (the cell is on the stack). +// xDestroy itself MUST NOT touch fn_ — with unfinalized statements the +// connection is zombified and xDestroy may run after the cell has been swept +// (see the comment on xDestroy below); superseded roots are released by +// releaseSupersededRegistration() at the registration site. // ───────────────────────────────────────────────────────────────────────────── struct NodeSqliteUDF { WTF_MAKE_TZONE_ALLOCATED_INLINE(NodeSqliteUDF); public: - NodeSqliteUDF(JSGlobalObject* globalObject, JSDatabaseSync* db, JSObject* fn, bool useBigIntArgs) + NodeSqliteUDF(JSGlobalObject* globalObject, JSObject* fn, bool useBigIntArgs) : globalObject_(globalObject) - , db_(db) , fn_(fn) , useBigIntArgs_(useBigIntArgs) { @@ -467,14 +479,14 @@ struct NodeSqliteUDF { // MUST stay a plain delete: with unfinalized statements the connection is // zombified and this runs from the last sqlite3_finalize() — possibly - // long after the JSDatabaseSync cell was swept — so it can't touch db_ - // or any GC state. Superseded roots are released at the registration - // site instead (releaseSupersededRegistration). + // long after the JSDatabaseSync cell was swept — so it can't touch any + // GC state. Superseded roots are released at the registration site + // instead (releaseSupersededRegistration). static void xDestroy(void* p) { delete static_cast(p); } JSGlobalObject* globalObject_; - JSDatabaseSync* db_; - // Rooted by db_->m_registeredCallbacks; see the comment above the struct. + // Rooted by the owning JSDatabaseSync's m_registeredCallbacks; see the + // comment above the struct. JSObject* fn_; bool useBigIntArgs_; }; @@ -502,10 +514,9 @@ struct NodeSqliteAggregate { bool isWindow; }; - NodeSqliteAggregate(JSGlobalObject* globalObject, JSDatabaseSync* db, + NodeSqliteAggregate(JSGlobalObject* globalObject, JSValue start, JSObject* step, JSObject* result, JSObject* inverse, bool useBigIntArgs) : globalObject_(globalObject) - , db_(db) , start_(start) , step_(step) , result_(result) @@ -652,12 +663,12 @@ struct NodeSqliteAggregate { self->valueBase(ctx, false); } // Same constraint as NodeSqliteUDF::xDestroy — may run after the cell is - // gone (zombified connection), so it must not touch db_ or GC state. + // gone (zombified connection), so it must not touch GC state. static void xDestroy(void* p) { delete static_cast(p); } JSGlobalObject* globalObject_; - JSDatabaseSync* db_; - // Rooted by db_->m_registeredCallbacks; see the comment above the struct. + // Rooted by the owning JSDatabaseSync's m_registeredCallbacks; see the + // comment above the struct. JSValue start_; JSObject* step_; JSObject* result_; @@ -852,9 +863,10 @@ JSDatabaseSync::~JSDatabaseSync() closeInternal(); return; } - // ~JSNodeSqliteSession follows record->db only while !dbGone, so it must - // be set before this cell is freelisted, and the registry must not keep a - // dangling pointer. Pure bookkeeping: neither write calls into SQLite. + // Pure bookkeeping: neither write calls into SQLite. dbGone stops + // deleteSession() double-freeing the handle sqlite3_close_v2 will free + // when the process actually exits, and the registry must not keep a + // dangling pointer. for (auto& record : m_sessions) record->dbGone = true; unregisterOpenDatabase(this); @@ -934,17 +946,8 @@ extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObje // the same use-after-free a busy close() refuses. Leave it alone. if (db->isBusy()) continue; - // With un-finalized statements close_v2 only zombifies the connection - // and defers the WAL checkpoint to a finalize that never comes, so - // flush the WAL into the main database file explicitly. Zero - // busy_timeout first: TRUNCATE waits on readers via the connection's - // busy-handler, so a large user-set {timeout: N} plus a cross-process - // reader would otherwise stall process.exit() for up to N ms; with a - // zero handler TRUNCATE degrades to a passive checkpoint immediately. - if (sqlite3* handle = db->connection()) { - sqlite3_busy_timeout(handle, 0); - sqlite3_wal_checkpoint_v2(handle, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); - } + if (sqlite3* handle = db->connection()) + Bun__sqliteCheckpointForTermination(handle); // closeInternal() re-takes openDatabasesLock to unregister, so the // snapshot lock above must already be dropped; it also nulls m_db, // making a later GC destructor a no-op rather than a double close. @@ -962,7 +965,6 @@ void JSDatabaseSync::deleteTrackedSessions() record->dbGone = true; } m_sessions.clear(); - m_hasOrphanedSessions = false; } void JSDatabaseSync::sweepOrphanedSessions() @@ -972,9 +974,8 @@ void JSDatabaseSync::sweepOrphanedSessions() // mid-sqlite3_step), so it only flags the record. Skip while busy — a // UDF callback can re-enter exec()/prepare() while the connection is // inside sqlite3_step and the preupdate hook may be iterating sessions. - if (!m_hasOrphanedSessions || m_busyDepth > 0) + if (m_sessions.isEmpty() || m_busyDepth > 0) return; - m_hasOrphanedSessions = false; m_sessions.removeAllMatching([](auto& record) { if (!record->wrapperGone) return false; @@ -1039,7 +1040,9 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) #if LAZY_LOAD_SQLITE // Apple's system libsqlite3 defaults SQLITE_FCNTL_PERSIST_WAL on; // clear it so the last close() unlinks the -wal/-shm sidecars like - // Node.js's bundled build does. + // Node.js's bundled build does. Only covers the "main" schema — a later + // ATTACH picks up Apple's default per-unixFile in unixOpen and its + // sidecars persist; addressing that needs sqlite3_db_name at close time. int off = 0; sqlite3_file_control(m_db, nullptr, SQLITE_FCNTL_PERSIST_WAL, &off); #endif @@ -1257,6 +1260,32 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, } auto sql = sqlVal.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); + + // Read options BEFORE prepare so error precedence matches Node + // (bad option → ERR_INVALID_ARG_TYPE, never a SQLite error) and no + // option-read failure needs a compensating sqlite3_finalize. + const auto& cfg = self->config(); + bool readBigInts = cfg.readBigInts; + bool returnArrays = cfg.returnArrays; + bool allowBare = cfg.allowBareNamedParameters; + bool allowUnknown = cfg.allowUnknownNamedParameters; + + JSValue optsVal = callFrame->argument(1); + if (!optsVal.isUndefined()) { + if (!optsVal.isObject()) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"options\" argument must be an object."_s); + } + JSObject* opts = optsVal.getObject(); + if (!readBoolOption(globalObject, scope, opts, "readBigInts"_s, readBigInts)) return {}; + if (!readBoolOption(globalObject, scope, opts, "returnArrays"_s, returnArrays)) return {}; + if (!readBoolOption(globalObject, scope, opts, "allowBareNamedParameters"_s, allowBare)) return {}; + if (!readBoolOption(globalObject, scope, opts, "allowUnknownNamedParameters"_s, allowUnknown)) return {}; + // An options getter above may have re-entered close(); re-check + // before handing SQLite the connection. + REQUIRE_DB_OPEN(self); + } + auto utf8 = sql.utf8(); sqlite3_stmt* stmt = nullptr; // utf8.data() is NUL-terminated (CString); -1 lets SQLite compute the @@ -1274,29 +1303,6 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncPrepare, (JSGlobalObject * globalObject, // sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == nullptr for empty / // comment-only input — Node returns a StatementSync whose accessors throw // ERR_INVALID_STATE "statement has been finalized" via REQUIRE_STMT. - // - // Inherit the database-level defaults (set via the constructor options), - // then let prepare()'s own options override per-statement. - const auto& cfg = self->config(); - bool readBigInts = cfg.readBigInts; - bool returnArrays = cfg.returnArrays; - bool allowBare = cfg.allowBareNamedParameters; - bool allowUnknown = cfg.allowUnknownNamedParameters; - - JSValue optsVal = callFrame->argument(1); - if (!optsVal.isUndefined()) { - if (!optsVal.isObject()) { - sqlite3_finalize(stmt); - return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, - "The \"options\" argument must be an object."_s); - } - JSObject* opts = optsVal.getObject(); - auto fail = [&]() { sqlite3_finalize(stmt); return EncodedJSValue {}; }; - if (!readBoolOption(globalObject, scope, opts, "readBigInts"_s, readBigInts)) return fail(); - if (!readBoolOption(globalObject, scope, opts, "returnArrays"_s, returnArrays)) return fail(); - if (!readBoolOption(globalObject, scope, opts, "allowBareNamedParameters"_s, allowBare)) return fail(); - if (!readBoolOption(globalObject, scope, opts, "allowUnknownNamedParameters"_s, allowUnknown)) return fail(); - } auto* zigGlobal = defaultGlobalObject(globalObject); auto* structure = zigGlobal->m_JSStatementSyncClassStructure.get(zigGlobal); @@ -1448,7 +1454,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncFunction, (JSGlobalObject * globalObject, // An options getter above may have re-entered close(); re-check before // handing SQLite the connection (Node segfaults here — Bun throws). REQUIRE_DB_OPEN(self); - auto* udf = new NodeSqliteUDF(globalObject, self, fn, useBigIntArgs); + auto* udf = new NodeSqliteUDF(globalObject, fn, useBigIntArgs); auto nameUtf8 = name.utf8(); int r = sqlite3_create_function_v2(self->connection(), nameUtf8.data(), argc, textRep, udf, NodeSqliteUDF::xFunc, nullptr, nullptr, NodeSqliteUDF::xDestroy); @@ -1548,7 +1554,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncAggregate, (JSGlobalObject * globalObject // An options getter above may have re-entered close(). REQUIRE_DB_OPEN(self); - auto* agg = new NodeSqliteAggregate(globalObject, self, startV, stepFn, resultFn, inverseFn, useBigIntArgs); + auto* agg = new NodeSqliteAggregate(globalObject, startV, stepFn, resultFn, inverseFn, useBigIntArgs); auto nameUtf8 = name.utf8(); auto xInverse = inverseFn ? NodeSqliteAggregate::xInverse : nullptr; auto xValue = inverseFn ? NodeSqliteAggregate::xValue : nullptr; @@ -1634,7 +1640,6 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncCreateSession, (JSGlobalObject * globalOb } auto record = adoptRef(*new NodeSqliteSessionRecord); - record->db = self; record->handle = pSession; self->trackSession(record.copyRef()); auto* zigGlobal = defaultGlobalObject(globalObject); @@ -2162,10 +2167,16 @@ void JSDatabaseSyncPrototype::finishCreation(VM& vm, JSGlobalObject* globalObjec static bool validateDatabasePath(JSGlobalObject* globalObject, ThrowScope& scope, JSValue pathVal, WTF::String& out) { auto& vm = getVM(globalObject); + auto rejectNul = [&](const WTF::String& s) -> bool { + if (s.find('\0') == WTF::notFound) return true; + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, + "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); + return false; + }; if (pathVal.isString()) { out = pathVal.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, false); - return true; + return rejectNul(out); } // Node.js only accepts Uint8Array (and Buffer, which subclasses it). // Reject other TypedArrays / DataView so the error message below is @@ -2182,7 +2193,7 @@ static bool validateDatabasePath(JSGlobalObject* globalObject, ThrowScope& scope "The \"path\" argument must be a Uint8Array containing a valid UTF-8 byte sequence."_s); return false; } - return true; + return rejectNul(out); } // URL-like object: must have href+protocol and protocol "file:" if (pathVal.isObject()) { @@ -2213,7 +2224,7 @@ static bool validateDatabasePath(JSGlobalObject* globalObject, ThrowScope& scope return false; } out = hrefStr; - return true; + return rejectNul(out); } } Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, @@ -2238,11 +2249,6 @@ JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSDatabaseSyncConstructor::construct(JSG if (!validateDatabasePath(globalObject, scope, callFrame->argument(0), location)) { return {}; } - if (location.find('\0') != WTF::notFound) { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, - "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); - return {}; - } DatabaseSyncOpenConfiguration config {}; bool openImmediately = true; @@ -3171,18 +3177,11 @@ JSNodeSqliteSession::~JSNodeSqliteSession() // GC sweep. Never call into SQLite from here — the sweep can run inside // an allocation made by a UDF callback while sqlite3_step() is executing // on this very connection — and never follow m_database, because the - // sweep order between the two cells is undefined. If the database is - // already gone it freed the handle itself (record->dbGone). Otherwise - // flag the record so the database deletes the orphaned handle on its - // next entry point; record->db is safe to touch because dbGone is set - // before ~JSDatabaseSync() finishes, so !dbGone implies the cell has not - // been swept. - if (auto record = std::exchange(m_record, nullptr)) { - if (!record->dbGone && record->handle) { - record->wrapperGone = true; - record->db->noteOrphanedSession(); - } - } + // sweep order between the two cells is undefined. Only write to the + // refcounted record; sweepOrphanedSessions() picks up the flag on the + // database's next entry point (or close()/teardown does). + if (auto record = std::exchange(m_record, nullptr)) + record->wrapperGone = true; } template @@ -3808,10 +3807,6 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal WTF::String destPath; if (!validateDatabasePath(globalObject, scope, callFrame->argument(1), destPath)) return {}; - if (destPath.find('\0') != WTF::notFound) { - return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, - "The \"path\" argument must be a string, Uint8Array, or URL without null bytes."_s); - } int rate = 100; WTF::String sourceName = "main"_s; @@ -3914,7 +3909,12 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal // Node retries SQLITE_BUSY/LOCKED indefinitely (BackupJob just calls // ScheduleWork() again with no timeout), so match that: no invented // busy budget. Back off between retries so a contended destination - // doesn't busy-spin at 100% CPU. + // doesn't busy-spin at 100% CPU. Unlike Node this loop runs on the JS + // thread, so a permanently-locked destination would be an unrecoverable + // hang; the progress callback fires on BUSY/LOCKED too so a caller can + // throw from it to abort. (Node fires progress between retries too, but + // gated on remaining_pages != 0 — Bun fires unconditionally so the + // escape hatch works even before the first successful step.) constexpr int kBusyRetrySleepMs = 25; int totalPages = 0; @@ -3923,7 +3923,7 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal totalPages = sqlite3_backup_pagecount(backup); int remaining = sqlite3_backup_remaining(backup); - if (r == SQLITE_OK && progressFn) { + if (progressFn && (r == SQLITE_OK || r == SQLITE_BUSY || r == SQLITE_LOCKED)) { JSObject* payload = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); payload->putDirect(vm, Identifier::fromString(vm, "totalPages"_s), jsNumber(totalPages), 0); payload->putDirect(vm, Identifier::fromString(vm, "remainingPages"_s), jsNumber(remaining), 0); diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index fdb4f036d45e..ffa7dd63627b 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -59,7 +59,6 @@ static constexpr size_t kNodeSqliteLimitCount = 11; // wrapperGone — the JS wrapper was swept without close(); the database // deletes the orphaned handle on its next entry point struct NodeSqliteSessionRecord : public WTF::RefCounted { - JSDatabaseSync* db { nullptr }; sqlite3_session* handle { nullptr }; bool dbGone { false }; bool wrapperGone { false }; @@ -152,11 +151,10 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { // through the shared record, never by touching this cell. void deleteTrackedSessions(); // ~JSNodeSqliteSession() cannot call into SQLite (the sweep can run - // mid-sqlite3_step) — it just flags the record and this bit. The next - // BusyScope taken on this connection (every DatabaseSync, StatementSync, - // iterator, and tag-store entry point) frees the orphaned handles; - // close() and teardown sweep unconditionally via deleteTrackedSessions(). - void noteOrphanedSession() { m_hasOrphanedSessions = true; } + // mid-sqlite3_step) — it just flags the record. The next BusyScope taken + // on this connection (every DatabaseSync, StatementSync, iterator, and + // tag-store entry point) frees any orphaned handles; close() and + // teardown sweep unconditionally via deleteTrackedSessions(). void sweepOrphanedSessions(); // setAuthorizer(cb) callback and the lazily-created limits wrapper. @@ -202,8 +200,8 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { if (db) { // Every connection entry point takes a BusyScope, so this is // where orphaned sessions get their deferred sweep — before - // the depth bump so the no-op fast path (flag check) still - // skips re-entrant calls from UDF/authorizer callbacks. + // the depth bump so re-entrant calls from UDF/authorizer + // callbacks are skipped by the m_busyDepth guard. db->sweepOrphanedSessions(); ++db->m_busyDepth; } @@ -242,7 +240,6 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { // records (not JS objects) so close() can sweep regardless of GC // ordering. WTF::Vector> m_sessions; - bool m_hasOrphanedSessions = false; // GC-traced roots for function()/aggregate() callbacks; mutated and // visited under cellLock() because visitChildren runs concurrently. WTF::Vector> m_registeredCallbacks; diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 382e113c905d..f096ce98e3df 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -277,9 +277,12 @@ describe("DatabaseSync", () => { }); test("constructor rejects non-UTF-8 Uint8Array paths instead of opening a temp db", () => { - // 0xff 0xfe is not valid UTF-8. Previously this would fall through to - // sqlite3_open_v2("") which opens an anonymous temporary database — - // silently swallowing the user's path. + // 0xff 0xfe is not valid UTF-8. Intentional divergence from Node (which + // hands the raw bytes to sqlite3_open_v2 with no UTF-8 check); Bun + // stores the path as WTF::String, so accepting arbitrary bytes would + // fall through to sqlite3_open_v2("") — an anonymous temporary + // database, silently swallowing the user's path. Documented in + // docs/runtime/nodejs-compat.mdx. expect(() => new DatabaseSync(Buffer.from([0x3a, 0xff, 0xfe]))).toThrow( expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), ); @@ -451,6 +454,21 @@ describe("DatabaseSync", () => { expect(stmt.get()).toEqual({ __proto__: null, v: 42 }); db.close(); }); + + test("prepare() reads options before compiling the SQL (Node error precedence)", () => { + // Node's DatabaseSync::Prepare validates the options object first, so a + // bad option beats a syntax error and the authorizer never fires. Also + // means no compensating sqlite3_finalize() on the option-error path. + const db = new DatabaseSync(":memory:"); + let authorized = false; + db.setAuthorizer(() => ((authorized = true), 0)); + expect(() => db.prepare("NOT SQL", { readBigInts: "x" as any })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(authorized).toBe(false); + db.setAuthorizer(null); + db.close(); + }); }); describe("DatabaseSync.prototype.function()", () => { @@ -580,6 +598,31 @@ describe("DatabaseSync.prototype.function()", () => { db.close(); }); + test("re-entering a statement from a bind-parameter getter is not guarded (Node parity)", () => { + // The isStepping() guard only covers the mid-VDBE case above. bindParams + // runs BEFORE SteppingScope, so a getter that re-enters the same + // statement passes the guard: it clears the outer's bindings, binds and + // steps its own, then the outer resumes binding. Node has no guard at + // all here; assert Bun matches Node's observable behavior (the inner + // call's bindings win for keys it binds; the outer's later keys + // overwrite). + const db = new DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT :a AS a, :b AS b"); + let inner; + const params = { + a: 1, + get b() { + inner = stmt.get({ a: 10, b: 20 }); + return 2; + }, + }; + // Inner call clears+rebinds+steps+resets while :a=1 was already bound; + // outer resumes binding only :b, so :a keeps the inner's value. + expect(stmt.get(params)).toEqual({ __proto__: null, a: 10, b: 2 }); + expect(inner).toEqual({ __proto__: null, a: 10, b: 20 }); + db.close(); + }); + test("closing the database from a UDF keeps the callbacks rooted until the scan completes", async () => { // closeInternal() clears m_registeredCallbacks; with close() no longer // refusing while busy, doing so mid-step would unroot every UDF callback @@ -1128,6 +1171,68 @@ describe("backup()", () => { ).rejects.toThrow("nope"); src.close(); }); + + test("progress fires on SQLITE_BUSY so a throw can abort a locked backup", async () => { + // Bun runs the whole backup on the JS thread, so a permanently-locked + // destination would otherwise be an unrecoverable hang. Run the whole + // scenario in a subprocess so a regression (progress never fires → + // sync loop) is a bounded timeout kill, not a wedged test process. + using dir = tempDir("node-sqlite-backup-busy", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { DatabaseSync, backup } = require("node:sqlite"); + const path = require("node:path"); + const dst = path.join(process.argv[1], "dst.db"); + // Child holds a RESERVED lock on the destination so + // sqlite3_backup_step returns SQLITE_BUSY. It self-exits so a + // regression that never fires progress still terminates. + const locker = Bun.spawn({ + cmd: [process.execPath, "-e", + "const {DatabaseSync}=require('node:sqlite');" + + "const db=new DatabaseSync(process.argv[1]);" + + "db.exec('PRAGMA locking_mode=EXCLUSIVE');" + + "db.exec('BEGIN IMMEDIATE');" + + "db.exec('CREATE TABLE lock_t(x)');" + + "console.log('locked');" + + "setTimeout(()=>{},1<<30);", + dst], + stdout: "pipe", stderr: "inherit", + }); + let ready = ""; + for await (const c of locker.stdout) { + ready += Buffer.from(c).toString(); + if (ready.includes("locked")) break; + } + if (!ready.includes("locked")) throw new Error("locker never ready"); + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t (x)"); + let calls = 0; + const p = backup(src, dst, { + progress: () => { if (++calls >= 2) throw new Error("busy-abort"); }, + }); + p.then( + () => { console.log("resolved"); process.exit(1); }, + e => { console.log("rejected:" + e.message + ":" + calls); locker.kill(); process.exit(0); }, + );`, + String(dir), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + Promise.race([proc.exited, Bun.sleep(15_000).then(() => (proc.kill(), "timeout"))]), + ]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "rejected:busy-abort:2", + stderr: expect.any(String), + exitCode: 0, + }); + }); }); describe("DatabaseSync.prototype.setAuthorizer()", () => { @@ -1273,6 +1378,47 @@ describe("createTagStore()", () => { db.setAuthorizer(null); db.close(); }); + + test("re-entering a cached tag from its own UDF throws instead of segfaulting", () => { + // TagStore::prepare's isStepping() guard — sibling of the StatementSync + // guard covered above; without it a UDF re-entering the same cached + // statement is a mid-VDBE reset segfault. + const db = new DatabaseSync(":memory:"); + const sql = db.createTagStore(); + db.function("reenterTag", () => { + try { + sql.get`SELECT reenterTag() AS r`; + return "ran"; + } catch (e: any) { + return e.code; + } + }); + expect(sql.get`SELECT reenterTag() AS r`).toEqual({ __proto__: null, r: "ERR_INVALID_STATE" }); + db.close(); + }); + + test("cached tags survive close()/open() and deserialize() via the isFinalized() eviction", () => { + // TagStore caches JSStatementSync wrappers keyed by template shape. + // close()/open() and deserialize() bump the connection's open-generation, + // which flips isFinalized() on every cached wrapper; TagStore::prepare + // must evict the stale entry and re-prepare on the new connection. + const db = new DatabaseSync(":memory:"); + const sql = db.createTagStore(); + expect(sql.get`SELECT 1 AS v`.v).toBe(1); + expect(sql.size).toBe(1); + db.close(); + db.open(); + expect(sql.get`SELECT 1 AS v`.v).toBe(1); + expect(sql.size).toBe(1); + // deserialize() bumps the generation without a close()/open() cycle. + db.exec("CREATE TABLE t (x INTEGER)"); + db.exec("INSERT INTO t VALUES (7)"); + expect(sql.get`SELECT x FROM t`.x).toBe(7); + const buf = db.serialize(); + db.deserialize(buf); + expect(sql.get`SELECT x FROM t`.x).toBe(7); + db.close(); + }); }); test.skipIf(!sqliteHasSession)("deserialize() frees open sessions instead of orphaning their preupdate hook", () => { @@ -1989,6 +2135,35 @@ describe("GC stress", () => { ); }); +// bun:sqlite's setCustomSQLite() and node:sqlite share a single process-global +// dlopen handle (lazy_sqlite3.h uses `inline` state, not `static`). Assert the +// sharing via the "already loaded" guard in reverse: opening a node:sqlite +// database populates the handle, so a subsequent setCustomSQLite() must +// refuse. If `inline` regresses to `static` the two TUs get separate handles, +// setCustomSQLite() succeeds, and this test fails. +test.skipIf(process.platform !== "darwin")("setCustomSQLite() sees a library node:sqlite already loaded", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `new (require("node:sqlite").DatabaseSync)(":memory:").close(); + let threw; + try { + require("bun:sqlite").Database.setCustomSQLite("/usr/lib/libsqlite3.dylib"); + } catch (e) { threw = e.message; } + if (!/already loaded/.test(String(threw))) throw new Error("expected already-loaded, got: " + threw); + // Reverse ordering — the doc'd remedy: setCustomSQLite BEFORE the + // first open governs node:sqlite too. + console.log("shared");`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "shared", exitCode: 0 }); + void stderr; +}); + // process.versions.sqlite must not force-dlopen the system SQLite: that // would defeat setCustomSQLite() for anyone whose imports read // process.versions before opening a database. From f2820506181c195a23d274c7cb2940b07acee3f9 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 10 Jul 2026 13:06:16 -0700 Subject: [PATCH 20/33] node:sqlite: skip sqlite3_finalize in ~JSStatementSync when a SteppingScope is live Mirrors the ~JSDatabaseSync busy-skip: process.exit() from an aggregate step under BUN_DESTRUCT_VM_ON_EXIT=1 sweeps the statement mid-step, and finalize on a running VDBE fires xFinal into JSC::call() with pointers into the swept heap. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 6 ++++++ test/js/node/sqlite/node-sqlite.test.ts | 28 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 612c7a40f928..2e68bef6981c 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -2395,6 +2395,12 @@ void JSStatementSync::finalizeStatement() JSStatementSync::~JSStatementSync() { + // A live SteppingScope here means process.exit() from inside a + // UDF/aggregate under BUN_DESTRUCT_VM_ON_EXIT=1. sqlite3_finalize on a + // running VDBE fires xFinal, which JSC::call()s raw pointers into a heap + // that lastChanceToFinalize is sweeping — same skip as ~JSDatabaseSync. + if (isStepping()) + return; // Do NOT dereference m_database here: GC may have already destroyed // the JSDatabaseSync, leaving the WriteBarrier pointing at freed // memory. sqlite3_finalize is safe even if the owning connection has diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index f096ce98e3df..b47cf99fc8c8 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1664,6 +1664,34 @@ test.skipIf(!sqliteHasSession)( }, ); +// Sibling of the above for ~JSStatementSync: process.exit() inside an +// aggregate step reaches the destructor with a SteppingScope on the stack; +// sqlite3_finalize on the running VDBE would fire xFinal into the swept heap. +test("teardown with a stepping statement and a running aggregate does not use-after-free", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(x); INSERT INTO t VALUES (1),(2)'); + db.aggregate('agg', { + start: 0, + step: (acc, x) => { if (x === 2) process.exit(0); return acc + x; }, + result: acc => acc, + }); + db.prepare('SELECT agg(x) FROM t').get();`, + ], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("heap-use-after-free"); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); +}); + // The process-exit handler must close (or at least WAL-checkpoint) unclosed // file-backed databases the way Node and bun:sqlite do; see // Bun__closeAllNodeSqliteDatabasesForTermination in NodeSqlite.cpp. From 407a22992d2b2e0fa4e08c6e503fd27831fad14f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:37:19 +0000 Subject: [PATCH 21/33] node:sqlite: fix limits HasProperty contract, subclassing, backup() terminate poll, and versions probe JSNodeSqliteLimits::getOwnPropertySlot: for HasProperty/VMInquiry, the property exists regardless of isOpen (Node's LimitsQuery never checks it), so 'sqlLength' in limits is true after close(). For Get/GetOwnProperty on a closed db, throw and return FALSE: JSC asserts !scope.exception() || !result, and returning true after throwing crashed Object.getOwnPropertyDescriptor in debug builds. JSDatabaseSyncConstructor::construct: honour callFrame->newTarget() via InternalFunction::createSubclassStructure, so a subclass instance gets the subclass prototype (instanceof, prototype chain, own methods). Matches JSX509Certificate/NodeDirent. backup(): poll vm.traps().needHandling(VMTraps::NeedTermination) each iteration so Worker.terminate() can break a BUSY spin (or a very large copy) when there is no progress callback to re-enter JS. The trap bit is written atomically by the parent's notifyNeedTermination; hasTerminationRequest() itself is only set when a safepoint handles the trap, which never happens here. On trap just clean up and return (no JS allocation on a terminating VM). Bun__sqlite3_version(): on the dlopen path with no library loaded yet, probe sqlite3_lib_path via a throwaway dlopen/dlsym/dlclose and cache the result, WITHOUT assigning the global handle. process.versions read before the first DB open now reports the version that will actually run, and Database.setCustomSQLite() is still not defeated. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 61 +++++++++++++- test/js/node/sqlite/node-sqlite.test.ts | 103 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 2e68bef6981c..66305a5833db 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -104,13 +104,36 @@ extern "C" void Bun__initializeSQLite(); extern "C" void Bun__sqliteCheckpointForTermination(sqlite3*); // process.versions.sqlite — the loaded library's version if a library has -// been loaded, else the bundled amalgamation's constant. Never triggers a -// dlopen: reading process.versions must not defeat Database.setCustomSQLite(). +// been loaded. Otherwise probe the library that WOULD be loaded via a +// throwaway dlopen and cache the result, WITHOUT assigning the global +// sqlite3_handle — so reading process.versions still doesn't defeat +// Database.setCustomSQLite(), and callers who read it before the first +// open see the version the runtime actually uses (not the bundled +// constant, which on macOS isn't linked into the binary at all). extern "C" const char* Bun__sqlite3_version() { #if LAZY_LOAD_SQLITE if (sqlite3_handle && lazy_sqlite3_libversion) return lazy_sqlite3_libversion(); +#if !OS(WINDOWS) + static const char* probed = []() -> const char* { + void* h = dlopen(sqlite3_lib_path, RTLD_LAZY | RTLD_LOCAL); + if (!h) return nullptr; + auto fn = reinterpret_cast(dlsym(h, "sqlite3_libversion")); + const char* out = nullptr; + if (fn) { + static char buf[24]; + const char* v = fn(); + size_t n = v ? strnlen(v, sizeof(buf) - 1) : 0; + memcpy(buf, v, n); + buf[n] = '\0'; + out = buf; + } + dlclose(h); + return out; + }(); + if (probed) return probed; +#endif #endif return BUN_SQLITE_BUNDLED_VERSION; } @@ -2322,7 +2345,14 @@ JSC_HOST_CALL_ATTRIBUTES EncodedJSValue JSDatabaseSyncConstructor::construct(JSG } } - auto* structure = zigGlobal->m_JSDatabaseSyncClassStructure.get(zigGlobal); + Structure* structure = zigGlobal->m_JSDatabaseSyncClassStructure.get(zigGlobal); + JSValue newTarget = callFrame->newTarget(); + if (zigGlobal->m_JSDatabaseSyncClassStructure.constructor(zigGlobal) != newTarget) [[unlikely]] { + auto* functionGlobalObject = defaultGlobalObject(getFunctionRealm(globalObject, newTarget.getObject())); + RETURN_IF_EXCEPTION(scope, {}); + structure = InternalFunction::createSubclassStructure(globalObject, newTarget.getObject(), functionGlobalObject->m_JSDatabaseSyncClassStructure.get(functionGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + } auto* db = JSDatabaseSync::create(vm, structure, std::move(location), std::move(config)); // Node attaches Symbol.for('sqlite-type') → 'node:sqlite' to every @@ -3371,12 +3401,21 @@ bool JSNodeSqliteLimits::getOwnPropertySlot(JSObject* object, JSGlobalObject* gl if (!propertyName.isSymbol()) { int id = findLimitId(propertyName.publicName()); if (id >= 0) { + // `in` / Reflect.has / VM inquiry: the property always exists; + // Node's LimitsQuery never checks IsOpen(). + if (slot.internalMethodType() == PropertySlot::InternalMethodType::HasProperty + || slot.internalMethodType() == PropertySlot::InternalMethodType::VMInquiry) { + slot.setValue(self, static_cast(PropertyAttribute::DontDelete), jsUndefined()); + return true; + } auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* db = self->database(); if (!db || !db->isOpen()) { + // JSC asserts `!scope.exception() || !result`; the exception + // propagates to the caller (Node throws from LimitsGetter). throwNodeState(globalObject, scope, "database is not open"_s); - return true; + return false; } int current = sqlite3_limit(db->connection(), id, -1); slot.setValue(self, static_cast(PropertyAttribute::DontDelete), jsNumber(current)); @@ -3945,6 +3984,20 @@ JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, Cal } if (r == SQLITE_DONE) break; + // Without a progress callback the loop never re-enters JS, so + // Worker.terminate() / the watchdog cannot land at a safepoint and + // hasTerminationRequest() is never set. Poll the trap bit directly + // (written atomically by notifyNeedTermination from the parent + // thread) so termination breaks a BUSY spin or a very large copy; + // Node's retry-forever semantics are preserved otherwise. The VM is + // being torn down, so just clean up and let the unwind happen — no + // JS allocation on a terminating VM. + if (vm.traps().needHandling(VMTraps::NeedTermination) || vm.hasPendingTerminationException()) [[unlikely]] { + sqlite3_backup_finish(backup); + sqlite3_close_v2(dest); + scope.release(); + return JSValue::encode(jsUndefined()); + } if (r == SQLITE_OK) continue; if (r == SQLITE_BUSY || r == SQLITE_LOCKED) { sqlite3_sleep(kBusyRetrySleepMs); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index b47cf99fc8c8..a2cca6009b9b 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -6,6 +6,7 @@ import { builtinModules, isBuiltin } from "node:module"; import path from "node:path"; import { DatabaseSync, Session, StatementSync, backup, constants } from "node:sqlite"; import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; // On macOS bun dlopens the system libsqlite3.dylib, which Apple builds // without SQLITE_ENABLE_SESSION. createSession()/applyChangeset() throw @@ -41,6 +42,38 @@ test("process.versions.sqlite is set", () => { expect(process.versions.sqlite).toMatch(/^3\.\d+\.\d+$/); }); +test("process.versions.sqlite read before the first open matches the library that runs", async () => { + // On the dlopen path, reading process.versions before any database is + // opened must still report the version of the library that WOULD be + // loaded (via a throwaway dlopen probe), not a bundled constant that + // isn't linked into the binary. The versions object is cached on first + // access, so this must run in a fresh process. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const v = process.versions.sqlite; + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(":memory:"); + const actual = db.prepare("SELECT sqlite_version() AS v").get().v; + db.close(); + console.log(JSON.stringify({ reported: v, actual })); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const { reported, actual } = JSON.parse(stdout.trim()); + expect({ reported, actual, stderr, exitCode }).toEqual({ + reported: actual, + actual, + stderr: expect.any(String), + exitCode: 0, + }); +}); + describe("DatabaseSync", () => { test("basic lifecycle", () => { const db = new DatabaseSync(":memory:"); @@ -189,6 +222,22 @@ describe("DatabaseSync", () => { expect(() => new StatementSync()).toThrow(/Illegal constructor/); }); + test("DatabaseSync can be subclassed", () => { + class X extends DatabaseSync { + myMethod() { + return this.isOpen; + } + } + const x = new X(":memory:"); + expect(x instanceof X).toBe(true); + expect(x instanceof DatabaseSync).toBe(true); + expect(Object.getPrototypeOf(x)).toBe(X.prototype); + expect(x.myMethod()).toBe(true); + x.exec("CREATE TABLE t (x)"); + expect(x.prepare("SELECT 1 AS v").get()).toEqual({ v: 1 }); + x.close(); + }); + test("isOpen/isTransaction/limits/sourceSQL/expandedSQL are own accessor properties", () => { // Node installs these via InstanceTemplate()->SetAccessorProperty // (DontDelete), so Object.keys() lists them and {...obj} copies them. @@ -1090,6 +1139,44 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { // Each backup_step with rate=1 fsyncs the destination once per page; keep // the page count tiny so the test stays fast on slow-fsync CI filesystems. describe("backup()", () => { + test("Worker.terminate() interrupts a backup() spinning on a locked destination", async () => { + // With no progress callback the BUSY loop never re-enters JS, so + // terminate() can only land if the loop polls vm.hasTerminationRequest(). + using dir = tempDir("node-sqlite-backup-terminate", { + "worker.mjs": ` + import { DatabaseSync, backup } from "node:sqlite"; + import { parentPort, workerData } from "node:worker_threads"; + const src = new DatabaseSync(":memory:"); + src.exec("CREATE TABLE t (x); INSERT INTO t VALUES (1)"); + parentPort.postMessage("spinning"); + // destination is held write-locked by the parent: this spins on + // SQLITE_BUSY until terminated. + await backup(src, workerData.dest); + parentPort.postMessage("done"); + `, + }); + const destPath = path.join(String(dir), "locked.db"); + const holder = new DatabaseSync(destPath); + // BEGIN IMMEDIATE takes a RESERVED lock so a writer from another + // connection sees SQLITE_BUSY. + holder.exec("BEGIN IMMEDIATE"); + + const worker = new Worker(path.join(String(dir), "worker.mjs"), { workerData: { dest: destPath } }); + const spinning = new Promise((resolve, reject) => { + worker.on("message", m => (m === "spinning" ? resolve() : reject(new Error(`unexpected: ${m}`)))); + worker.on("error", reject); + }); + await spinning; + + const exitCode = await worker.terminate(); + // The observable invariant is that terminate() returns at all — without + // the poll the worker's JS thread is stuck in sqlite3_sleep and the + // await never resolves (the test times out). + expect(typeof exitCode).toBe("number"); + holder.exec("ROLLBACK"); + holder.close(); + }); + test("re-checks the source is open after reading options", () => { // sqlite3_backup_init dereferences pSrcDb->mutex with no API-armor // guard; a hostile getter that closes the source would hand it a @@ -1297,6 +1384,22 @@ describe("db.limits", () => { expect(() => db.limits.column).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); }); + test("`in`/Reflect.has on a closed database report presence without touching sqlite3_limit", () => { + // Node's LimitsQuery never checks IsOpen — only LimitsGetter does. A + // getOwnPropertySlot that throws-and-returns-true also violates JSC's + // `!scope.exception() || !result` contract (debug ASSERT). + const db = new DatabaseSync(":memory:"); + const l = db.limits; + db.close(); + expect("sqlLength" in l).toBe(true); + expect(Reflect.has(l, "sqlLength")).toBe(true); + expect("nope" in l).toBe(false); + expect(() => Object.getOwnPropertyDescriptor(l, "sqlLength")).toThrow( + expect.objectContaining({ code: "ERR_INVALID_STATE" }), + ); + expect(() => l.sqlLength).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + }); + test("constructor {limits} option seeds sqlite3_limit on open", () => { const db = new DatabaseSync(":memory:", { limits: { variableNumber: 3 } }); expect(db.limits.variableNumber).toBe(3); From 137d34bf34932f11f801187160b24e299a5e7725 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:46:10 +0000 Subject: [PATCH 22/33] test(node:sqlite): use `using` for the backup-terminate lock holder If the test times out (the regression it guards against), tempDir's rmSync runs while the DatabaseSync still holds locked.db open and fails with EBUSY on Windows, masking the real failure. Symbol.dispose on DatabaseSync is idempotent. --- test/js/node/sqlite/node-sqlite.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index a2cca6009b9b..5f4ee158e38a 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1156,7 +1156,7 @@ describe("backup()", () => { `, }); const destPath = path.join(String(dir), "locked.db"); - const holder = new DatabaseSync(destPath); + using holder = new DatabaseSync(destPath); // BEGIN IMMEDIATE takes a RESERVED lock so a writer from another // connection sees SQLITE_BUSY. holder.exec("BEGIN IMMEDIATE"); @@ -1173,8 +1173,6 @@ describe("backup()", () => { // the poll the worker's JS thread is stuck in sqlite3_sleep and the // await never resolves (the test times out). expect(typeof exitCode).toBe("number"); - holder.exec("ROLLBACK"); - holder.close(); }); test("re-checks the source is open after reading options", () => { From 52ea3492ccc87974b7d9622e72e022e9788614cd Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 14 Jul 2026 01:45:11 +0000 Subject: [PATCH 23/33] node:sqlite: build every ERR_SQLITE_ERROR through one helper throwSqliteError() and throwSqliteMessage() each hand-rolled the same errcode/errstr property pair. Fold both onto createNodeSqliteError(), which now takes the extended result code and the message directly. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 66305a5833db..a5ceae841057 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -161,33 +161,27 @@ static ALWAYS_INLINE WTF::String sqliteText(const char* p) // Error helpers (match Node.js node_sqlite.cc shapes) // ───────────────────────────────────────────────────────────────────────────── -static JSObject* createNodeSqliteError(JSGlobalObject* globalObject, sqlite3* db) +// Every ERR_SQLITE_ERROR carries `errcode` (the extended result code) and +// `errstr` (its canonical English text), matching node_sqlite.cc. +static JSObject* createNodeSqliteError(JSGlobalObject* globalObject, int errcode, const WTF::String& message) { auto& vm = getVM(globalObject); - int errcode = sqlite3_extended_errcode(db); - const char* errstr = sqlite3_errstr(errcode); - const char* errmsg = sqlite3_errmsg(db); auto* zigGlobal = defaultGlobalObject(globalObject); - JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, sqliteText(errmsg)); + JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, message); error->putDirect(vm, Identifier::fromString(vm, "errcode"_s), jsNumber(errcode), 0); - error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(errstr)), 0); + error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(sqlite3_errstr(errcode))), 0); return error; } static void throwSqliteError(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3* db) { - scope.throwException(globalObject, createNodeSqliteError(globalObject, db)); + scope.throwException(globalObject, + createNodeSqliteError(globalObject, sqlite3_extended_errcode(db), sqliteText(sqlite3_errmsg(db)))); } static void throwSqliteMessage(JSGlobalObject* globalObject, ThrowScope& scope, int errcode, const WTF::String& message) { - auto& vm = getVM(globalObject); - auto* zigGlobal = defaultGlobalObject(globalObject); - JSObject* error = createError(zigGlobal, ErrorCode::ERR_SQLITE_ERROR, message); - const char* errstr = sqlite3_errstr(errcode); - error->putDirect(vm, Identifier::fromString(vm, "errcode"_s), jsNumber(errcode), 0); - error->putDirect(vm, Identifier::fromString(vm, "errstr"_s), jsString(vm, WTF::String::fromUTF8(errstr)), 0); - scope.throwException(globalObject, error); + scope.throwException(globalObject, createNodeSqliteError(globalObject, errcode, message)); } // The session extension, sqlite3_deserialize, sqlite3_db_config, and friends From 7c1ab08e1a06cb051e3f682216af6d4135f68bfe Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 15 Jul 2026 12:17:37 -0700 Subject: [PATCH 24/33] node:sqlite: correct the aggregate accumulator Strong<> safety comment The accumulator is whatever start()/step() returns, so it can reference the running statement; the old wording claimed it could never form a cycle. Capturing the database stays safe (the statement remains independently collectable), but capturing the statement roots it through the Strong<> and xFinal never fires. Node behaves the same way (Global in sqlite3_aggregate_context). Comment only, no behavior change. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index a5ceae841057..9a43dc18ec9a 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -514,11 +514,14 @@ struct NodeSqliteUDF { // Per-invocation accumulator state lives in sqlite3_aggregate_context — a // scratch buffer SQLite zeroes on first access and discards after xFinal. We // store a Strong<> there so the JS accumulator value survives GC between -// xStep calls (window functions step across multiple sqlite3_step()s); the -// accumulator is per-query and cannot capture the database, so it cannot -// form the cycle the registered callbacks could. The callbacks themselves -// (start/step/result/inverse) are raw pointers rooted by the cell's -// m_registeredCallbacks, same as NodeSqliteUDF above. +// xStep calls (window functions step across multiple sqlite3_step()s). +// Capturing the database in the accumulator is safe: the statement stays +// independently collectable, and finalizing it runs xFinal -> destroyState. +// Capturing the statement itself would root it through this Strong<> and +// leak, since xFinal then never fires; node has the same behavior +// (Global in sqlite3_aggregate_context, node_sqlite.cc). The +// callbacks (start/step/result/inverse) are raw pointers rooted by the +// cell's m_registeredCallbacks, same as NodeSqliteUDF above. // ───────────────────────────────────────────────────────────────────────────── struct NodeSqliteAggregate { From 522c77786397802b709116e1d53f1f097e7c4886 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:55:11 +0000 Subject: [PATCH 25/33] node:sqlite: guard session.changeset()/patchset() against re-entrant close sqlite3session_changeset/patchset run SAVEPOINT and prepared SELECTs on the connection, which fires the authorizer. sessionChangesetCommon had no BusyScope, so db.close() from the authorizer took closeInternal()'s immediate path and deleteTrackedSessions() freed the sqlite3_session* under sessionGenerateChangeset(); session.close() called sqlite3session_delete directly with no in-use check (same UAF). Take a BusyScope and set record->inUse around the call. close() now defers (finishDeferredClose runs after the changeset completes); session.close() throws ERR_INVALID_STATE while inUse; Symbol.dispose no-ops. CHECK_UDF_EXCEPTION surfaces an authorizer exception over the SQLite error. deleteTrackedSessions()/sweepOrphanedSessions()/ deserialize() all already gate on isBusy() so the BusyScope alone covers them. Spawned ASAN regression test exercises db.close(), session.close(), and Symbol.dispose from the authorizer during changeset(). --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 27 ++++++++++-- src/jsc/bindings/sqlite/NodeSqlite.h | 7 ++++ test/js/node/sqlite/node-sqlite.test.ts | 55 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 9a43dc18ec9a..9e2a73dc0651 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -3196,6 +3196,10 @@ bool JSNodeSqliteSession::isStale() const void JSNodeSqliteSession::deleteSession() { if (!m_record || m_record->handle == nullptr) return; + // A changeset()/patchset() is on the C stack for this handle; + // sqlite3session_delete would free it under sessionGenerateChangeset(). + // close() throws ERR_INVALID_STATE for this; Symbol.dispose no-ops. + if (m_record->inUse) return; if (!m_record->dbGone) { auto* db = m_database.get(); db->untrackSession(m_record.get()); @@ -3254,15 +3258,29 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF if (self->isStale()) { return throwNodeState(globalObject, scope, "database is not open"_s); } - if (self->session() == nullptr) { + auto* record = self->record(); + if (!record || record->handle == nullptr) { return throwNodeState(globalObject, scope, "session is not open"_s); } + if (record->inUse) { + return throwNodeState(globalObject, scope, "session is already generating a changeset"_s); + } + // sqlite3session_changeset/patchset internally run SAVEPOINT + prepared + // SELECTs on the connection, which fires the authorizer. A BusyScope + // defers db.close()'s sqlite3_close_v2/deleteTrackedSessions; inUse + // refuses session.close() and makes the db's session sweep skip this + // handle so it isn't freed under sessionGenerateChangeset(). + JSDatabaseSync::BusyScope busy { db }; + record->inUse = true; + sqlite3* conn = db->connection(); int nChangeset = 0; void* pChangeset = nullptr; - int r = fn(self->session(), &nChangeset, &pChangeset); + int r = fn(record->handle, &nChangeset, &pChangeset); + record->inUse = false; + CHECK_UDF_EXCEPTION(scope); if (r != SQLITE_OK) { if (pChangeset) sqlite3_free(pChangeset); - throwSqliteReturnCodeError(globalObject, scope, db->connection(), r); + throwSqliteReturnCodeError(globalObject, scope, conn, r); return {}; } auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), static_cast(nChangeset)); @@ -3294,6 +3312,9 @@ JSC_DEFINE_HOST_FUNCTION(jsSessionClose, (JSGlobalObject * globalObject, CallFra if (self->session() == nullptr) { return throwNodeState(globalObject, scope, "session is not open"_s); } + if (self->record()->inUse) { + return throwNodeState(globalObject, scope, "session is currently generating a changeset"_s); + } self->deleteSession(); return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index ffa7dd63627b..b1952a3cad45 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -58,10 +58,16 @@ static constexpr size_t kNodeSqliteLimitCount = 11; // dbGone — the database freed the handle (close()/deserialize()/teardown) // wrapperGone — the JS wrapper was swept without close(); the database // deletes the orphaned handle on its next entry point +// inUse — a sqlite3session_changeset/patchset is on the C stack for +// this handle (it re-enters the authorizer via its internal +// SAVEPOINT/SELECT); session.close() refuses and +// deleteTrackedSessions() skips so the handle isn't freed +// under sessionGenerateChangeset() struct NodeSqliteSessionRecord : public WTF::RefCounted { sqlite3_session* handle { nullptr }; bool dbGone { false }; bool wrapperGone { false }; + bool inUse { false }; }; struct DatabaseSyncOpenConfiguration { @@ -632,6 +638,7 @@ class JSNodeSqliteSession final : public JSC::JSDestructibleObject { ~JSNodeSqliteSession(); sqlite3_session* session() const { return m_record ? m_record->handle : nullptr; } + NodeSqliteSessionRecord* record() const { return m_record.get(); } JSDatabaseSync* database() const { return m_database.get(); } // True once the owning database has freed this session's handle out // from under the wrapper — close(), close()+open(), a successful diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 5f4ee158e38a..bbac7a98cece 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1095,6 +1095,61 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { dst.close(); }); + test.skipIf(!sqliteHasSession)( + "re-entering close() from the authorizer during changeset()/patchset() does not free the session mid-generate", + async () => { + // sqlite3session_changeset runs SAVEPOINT + prepared SELECTs on the + // connection, which fires the authorizer. A BusyScope defers + // db.close()'s session sweep; record->inUse refuses session.close() so + // sessionGenerateChangeset never reads a freed sqlite3_session*. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { DatabaseSync } = require("node:sqlite"); + for (const what of ["db", "sess", "dispose"]) { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, x)"); + const sess = db.createSession(); + db.prepare("INSERT INTO t VALUES (1, 'a')").run(); + let caught; + db.setAuthorizer(() => { + try { + if (what === "db") db.close(); + else if (what === "sess") sess.close(); + else sess[Symbol.dispose](); + } catch (e) { caught = e.code; } + return 0; + }); + const cs = sess.changeset(); + console.log(JSON.stringify({ what, len: cs.length, caught: caught ?? null, dbOpen: db.isOpen })); + try { db.close(); } catch {} + } + `, + ], + env: isWindows ? bunEnv : { ...bunEnv, Malloc: "1" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout.trim().split("\n").map(l => JSON.parse(l)); + // On regression ASAN aborts the process before any line is printed. + // db.close(): deferred on the first authorizer call (catches nothing), + // throws ERR_INVALID_STATE on the second (db already marked closed). + // sess.close(): record->inUse → ERR_INVALID_STATE. + // Symbol.dispose: inUse → silent no-op (tolerant). + expect({ lines, stderr, exitCode }).toEqual({ + lines: [ + { what: "db", len: 20, caught: "ERR_INVALID_STATE", dbOpen: false }, + { what: "sess", len: 20, caught: "ERR_INVALID_STATE", dbOpen: true }, + { what: "dispose", len: 20, caught: null, dbOpen: true }, + ], + stderr: expect.any(String), + exitCode: 0, + }); + }, + ); + test("default onConflict aborts and returns false", () => { const src = new DatabaseSync(":memory:"); const dst = new DatabaseSync(":memory:"); From d63b2214bf36b2c4dc1558b56011ba4e7026cc7a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:57:15 +0000 Subject: [PATCH 26/33] [autofix.ci] apply automated fixes --- test/js/node/sqlite/node-sqlite.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index bbac7a98cece..821c89cbade5 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1132,7 +1132,10 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const lines = stdout.trim().split("\n").map(l => JSON.parse(l)); + const lines = stdout + .trim() + .split("\n") + .map(l => JSON.parse(l)); // On regression ASAN aborts the process before any line is printed. // db.close(): deferred on the first authorizer call (catches nothing), // throws ERR_INVALID_STATE on the second (db already marked closed). From 39b72f95df5a2f79d8df98ceae9747729d0c05bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:31:08 +0000 Subject: [PATCH 27/33] node:sqlite: correct the inUse invariant comment and assert it; document and test the TagStore iterator-invalidation divergence The inUse header comment claimed deleteTrackedSessions() "skips" inUse records; it doesn't. The BusyScope taken before inUse is set is what guarantees deleteTrackedSessions()/sweepOrphanedSessions() are never reached while any record is inUse. Reword the header and call-site comments to say so, and ASSERT(!record->inUse) in the sweep loop so the invariant is load-bearing. Document the TagStore reset-generation bump as a deliberate divergence: Node's SQLTagStore calls raw sqlite3_reset on an LRU hit without bumping reset_generation_, so a tag.iterate iterator silently re-yields from row 1 after any other tag call on the same SQL; Bun throws ERR_INVALID_STATE instead of returning wrong rows. Add a test covering both the same-SQL (throws) and different-SQL (continues) cases. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 17 +++++++++++++---- src/jsc/bindings/sqlite/NodeSqlite.h | 8 +++++--- test/js/node/sqlite/node-sqlite.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 9e2a73dc0651..aaefb212c79a 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -978,6 +978,9 @@ extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObje void JSDatabaseSync::deleteTrackedSessions() { for (auto& record : m_sessions) { + // Every caller is gated on m_busyDepth == 0 and inUse is only set + // inside a BusyScope, so this can never see a live changeset(). + ASSERT(!record->inUse); if (record->handle) { sqlite3session_delete(record->handle); record->handle = nullptr; @@ -3266,10 +3269,10 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF return throwNodeState(globalObject, scope, "session is already generating a changeset"_s); } // sqlite3session_changeset/patchset internally run SAVEPOINT + prepared - // SELECTs on the connection, which fires the authorizer. A BusyScope - // defers db.close()'s sqlite3_close_v2/deleteTrackedSessions; inUse - // refuses session.close() and makes the db's session sweep skip this - // handle so it isn't freed under sessionGenerateChangeset(). + // SELECTs on the connection, which fires the authorizer. BusyScope + // defers db.close() (and so deleteTrackedSessions/sweepOrphanedSessions) + // until this frame unwinds; inUse refuses session.close()/Symbol.dispose + // so the handle isn't freed under sessionGenerateChangeset(). JSDatabaseSync::BusyScope busy { db }; record->inUse = true; sqlite3* conn = db->connection(); @@ -3695,6 +3698,12 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr // reason. sqlite3_stmt* stmt = stmtObj->statement(); sqlite3_reset(stmt); + // Deliberate divergence from Node v26.3.0: Node's SQLTagStore calls raw + // sqlite3_reset and never bumps the statement's reset_generation_, so an + // iterator from tag.iterate`…` silently re-yields from row 1 after any + // other tag call on the same SQL hits the LRU cache and resets it. Bump + // here so that iterator throws ERR_INVALID_STATE instead of returning + // wrong rows. stmtObj->bumpResetGeneration(); sqlite3_clear_bindings(stmt); int paramCount = sqlite3_bind_parameter_count(stmt); diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index b1952a3cad45..e4cf582f00f5 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -60,9 +60,11 @@ static constexpr size_t kNodeSqliteLimitCount = 11; // deletes the orphaned handle on its next entry point // inUse — a sqlite3session_changeset/patchset is on the C stack for // this handle (it re-enters the authorizer via its internal -// SAVEPOINT/SELECT); session.close() refuses and -// deleteTrackedSessions() skips so the handle isn't freed -// under sessionGenerateChangeset() +// SAVEPOINT/SELECT). session.close()/Symbol.dispose refuse +// while set. The BusyScope taken before inUse is set means +// deleteTrackedSessions()/sweepOrphanedSessions() are never +// reached while any record is inUse; the sweep loops ASSERT +// that, rather than branching on it. struct NodeSqliteSessionRecord : public WTF::RefCounted { sqlite3_session* handle { nullptr }; bool dbGone { false }; diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 821c89cbade5..daba6bd83cdd 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -1494,6 +1494,29 @@ describe("serialize() / deserialize()", () => { }); describe("createTagStore()", () => { + test("reusing the same SQL while a tag.iterate() iterator is live invalidates the iterator", () => { + // Deliberate divergence: Node's SQLTagStore resets the cached statement + // without bumping reset_generation_, so the iterator silently re-yields + // from row 1 (wrong data) instead of throwing. Bun throws + // ERR_INVALID_STATE. A tag call with DIFFERENT SQL is a cache miss and + // leaves the iterator alone. + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (n INTEGER); INSERT INTO t VALUES (1),(2),(3)"); + const sql = db.createTagStore(); + + const it = sql.iterate`SELECT n FROM t ORDER BY n`; + expect(it.next().value).toEqual({ n: 1 }); + expect(sql.get`SELECT n FROM t ORDER BY n`).toEqual({ n: 1 }); + expect(() => it.next()).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + + const it2 = sql.iterate`SELECT n FROM t WHERE n > 0 ORDER BY n`; + expect(it2.next().value).toEqual({ n: 1 }); + sql.get`SELECT n FROM t WHERE n > 1`; + expect(it2.next().value).toEqual({ n: 2 }); + it2.return(); + db.close(); + }); + test("caches prepared statements by template-literal shape", () => { const db = new DatabaseSync(":memory:"); db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); From 871b117aea0bb5f96e6a5a6fb88d1ed9938a5893 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:23:24 +0000 Subject: [PATCH 28/33] node:sqlite: free pChangeset when the authorizer throws on sessionGenerateChangeset's trailing RELEASE sessionGenerateChangeset transfers the buffer to *ppChangeset before running its trailing RELEASE savepoint (whose result is discarded). An authorizer that throws on that RELEASE leaves the function returning SQLITE_OK with an owned buffer and a pending exception; the bare CHECK_UDF_EXCEPTION added in 522c7778 returned early without freeing it. Use the same free-on-exception shape as jsDatabaseSyncSerialize. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index aaefb212c79a..a5b5ffbd7c34 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -3280,7 +3280,14 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF void* pChangeset = nullptr; int r = fn(record->handle, &nChangeset, &pChangeset); record->inUse = false; - CHECK_UDF_EXCEPTION(scope); + // sessionGenerateChangeset transfers the buffer to *ppChangeset before its + // trailing RELEASE (whose result is discarded) reaches the authorizer, so + // an exception there returns SQLITE_OK with an owned buffer — free it here + // (mirrors jsDatabaseSyncSerialize). + if (scope.exception()) [[unlikely]] { + if (pChangeset) sqlite3_free(pChangeset); + return {}; + } if (r != SQLITE_OK) { if (pChangeset) sqlite3_free(pChangeset); throwSqliteReturnCodeError(globalObject, scope, conn, r); From 7efc966936e43b3492b01d89ed7e0f21ac077b59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:24:33 +0000 Subject: [PATCH 29/33] node:sqlite: reword the backup() threadpool divergence note --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index a5b5ffbd7c34..81335c7869ba 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -3862,8 +3862,8 @@ void JSNodeSqliteTagStoreConstructor::finishCreation(VM& vm, JSGlobalObject*, JS // normally during the backup process". Bun runs the whole step loop // synchronously on the JS thread — the returned Promise is resolved before // this function returns and the event loop is blocked for the duration. -// TODO(node:sqlite): dispatch each step to Bun's WorkPool (webcrypto's -// PhonyWorkQueue is in-tree precedent) so this contract holds. +// Matching Node's contract would mean dispatching each step to Bun's +// WorkPool (webcrypto's PhonyWorkQueue is the in-tree precedent). // // The `progress` callback still fires between each batch of `rate` pages. JSC_DEFINE_HOST_FUNCTION(jsNodeSqliteBackup, (JSGlobalObject * globalObject, CallFrame* callFrame)) From d6cbff5bb60d01610566b320f52b74b6d0138b78 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:33:59 +0000 Subject: [PATCH 30/33] node:sqlite: correct the deleteTrackedSessions() inUse-invariant premise jsDatabaseSyncDeserialize calls deleteTrackedSessions() after taking its own BusyScope (m_busyDepth == 1), so the previous 'gated on m_busyDepth == 0' wording was literally inaccurate for that caller. The ASSERT is still valid: every caller enters with m_busyDepth == 0, and inUse is only set inside a nested BusyScope that completes synchronously. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 81335c7869ba..394f9ae0f750 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -978,8 +978,10 @@ extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObje void JSDatabaseSync::deleteTrackedSessions() { for (auto& record : m_sessions) { - // Every caller is gated on m_busyDepth == 0 and inUse is only set - // inside a BusyScope, so this can never see a live changeset(). + // Every caller enters with m_busyDepth == 0 (deserialize() checks + // isBusy() before taking its own BusyScope), and inUse is only set + // inside a nested BusyScope that completes synchronously, so this + // can never see a live changeset(). ASSERT(!record->inUse); if (record->handle) { sqlite3session_delete(record->handle); From 50dacfe0ad22d50709d9cda4104c986decf464f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:01:24 +0000 Subject: [PATCH 31/33] ci: retrigger From 0db244b12f73c951f69abc27c7be890630403de0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:44:56 +0000 Subject: [PATCH 32/33] node:sqlite: guard nodeSqliteAuthorizerCallback against re-entry with a pending exception sqlite3WalkExprNN maps WRC_Prune to continue for sibling columns inside one expression (SELECT a + b), so an authorizer that throws on column 'a' is re-invoked for 'b' with the exception still pending. Every sibling C->JS callback (xFunc, stepBase, valueBase, applyChangesetXConflict/XFilter) already checks scope.exception() before re-entering JS; add the same guard here. The post-call check still surfaces the original error either way; this is the consistency guard validateExceptionChecks targets. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 394f9ae0f750..9a5dcef359e7 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -1857,6 +1857,12 @@ static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const ch auto* globalObject = db->globalObject(); auto& vm = getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + // sqlite3WalkExprNN maps WRC_Prune to continue for sibling columns in + // one expression (SELECT a + b), so a throw on `a` re-fires the + // authorizer for `b` with the exception still pending — same guard as + // xFunc / applyChangesetXConflict. + if (scope.exception()) [[unlikely]] + return SQLITE_DENY; auto* fn = db->m_authorizer.get(); if (!fn) [[unlikely]] From b188a5e73cef4f536ad1e54ed110e6d46ffd68dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:01:09 +0000 Subject: [PATCH 33/33] node:sqlite: adopt serialize()/changeset()/patchset() buffers zero-copy Wrap the sqlite3_malloc'd output in an ArrayBuffer whose destructor runs sqlite3_free instead of createUninitialized + memcpy + free. Same technique bun:sqlite's serialize() already uses (JSSQLStatement.cpp via JSBuffer__bufferFromPointerAndLengthAndDeinit); under LAZY_LOAD_SQLITE sqlite3_free is the dlsym'd library's own free, so there is no allocator mismatch. adoptSqliteBuffer() handles the null/zero-length case so the three call sites stay one line. --- src/jsc/bindings/sqlite/NodeSqlite.cpp | 52 ++++++++++++-------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 9a5dcef359e7..3d44172d1ea3 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -157,6 +157,21 @@ static ALWAYS_INLINE WTF::String sqliteText(const char* p) return p ? sqliteText(p, strlen(p)) : WTF::String(); } +// Adopt a sqlite3_malloc'd buffer (serialize/changeset/patchset output) as a +// Uint8Array without a copy; the ArrayBuffer destructor runs sqlite3_free. +// Same technique bun:sqlite's serialize() uses (JSSQLStatement.cpp). +static JSC::JSUint8Array* adoptSqliteBuffer(JSGlobalObject* globalObject, void* data, size_t len) +{ + auto* structure = globalObject->typedArrayStructureWithTypedArrayType(); + if (!data || !len) { + if (data) sqlite3_free(data); + return JSC::JSUint8Array::create(globalObject, structure, 0); + } + auto buffer = ArrayBuffer::createFromBytes({ static_cast(data), len }, + createSharedTask([](void* p) { sqlite3_free(p); })); + return JSC::JSUint8Array::create(globalObject, structure, WTF::move(buffer), 0, len); +} + // ───────────────────────────────────────────────────────────────────────────── // Error helpers (match Node.js node_sqlite.cc shapes) // ───────────────────────────────────────────────────────────────────────────── @@ -1980,30 +1995,16 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject if (data) sqlite3_free(data); return {}; } - if (data == nullptr) { - // sqlite3_serialize returns null with size==0 for a brand-new - // empty schema whose database file hasn't been materialised yet - // (e.g. serialising an ATTACHed :memory: schema that has had no - // DDL). Node treats that as an empty Uint8Array; anything else - // is a real failure on the connection. - if (size == 0) { - auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), 0); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(array); - } + // sqlite3_serialize returns null with size==0 for a brand-new empty + // schema whose database file hasn't been materialised yet (e.g. + // serialising an ATTACHed :memory: schema that has had no DDL) — Node + // treats that as an empty Uint8Array; null with size!=0 is a real + // failure on the connection. + if (data == nullptr && size != 0) { throwSqliteError(globalObject, scope, conn); return {}; } - - size_t byteLen = static_cast(size); - auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), byteLen); - if (scope.exception()) [[unlikely]] { - sqlite3_free(data); - return {}; - } - if (byteLen > 0) memcpy(array->typedVector(), data, byteLen); - sqlite3_free(data); - return JSValue::encode(array); + RELEASE_AND_RETURN(scope, JSValue::encode(adoptSqliteBuffer(globalObject, data, static_cast(size)))); } JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -3301,14 +3302,7 @@ static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallF throwSqliteReturnCodeError(globalObject, scope, conn, r); return {}; } - auto* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), static_cast(nChangeset)); - if (scope.exception()) [[unlikely]] { - sqlite3_free(pChangeset); - return {}; - } - if (nChangeset > 0) memcpy(array->typedVector(), pChangeset, static_cast(nChangeset)); - sqlite3_free(pChangeset); - return JSValue::encode(array); + RELEASE_AND_RETURN(scope, JSValue::encode(adoptSqliteBuffer(globalObject, pChangeset, static_cast(nChangeset)))); } JSC_DEFINE_HOST_FUNCTION(jsSessionChangeset, (JSGlobalObject * globalObject, CallFrame* callFrame))