diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index d61a20d86462..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) -πŸ”΄ 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) β€” 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/config.ts b/scripts/build/config.ts index 1569b941c307..0d7a8fd4ee4c 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -855,7 +855,11 @@ 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 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 β€” diff --git a/scripts/build/deps/sqlite.ts b/scripts/build/deps/sqlite.ts index 0f0138fa6b0c..6d6eec0375d6 100644 --- a/scripts/build/deps/sqlite.ts +++ b/scripts/build/deps/sqlite.ts @@ -1,12 +1,16 @@ /** - * 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). + * 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 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"; @@ -36,6 +40,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 f6c848be3018..0ade179e2c18 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/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2dc03fbd4246..f0782d8c2f21 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 498fdcf9278f..81f7487465e0 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -168,6 +168,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); @@ -255,6 +256,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 08b614d94c5c..9481678b0cc4 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -353,6 +353,7 @@ const errors: ErrorCodeMapping = [ ["ERR_INVALID_BUFFER_SIZE", RangeError], ["ERR_TRACE_EVENTS_CATEGORY_REQUIRED", TypeError], ["ERR_TRACE_EVENTS_UNAVAILABLE", Error], + ["ERR_SQLITE_ERROR", Error], ["ERR_CRYPTO_ARGON2_NOT_SUPPORTED", Error], // llhttp reports a missing CRLF after a chunk's data as HPE_STRICT, // distinct from a malformed chunk-size line (HPE_INVALID_CHUNK_SIZE). diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 775b6dc42627..8fd02502b5ed 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" @@ -134,6 +135,7 @@ #include "JSReactElement.h" #include "BunMarkdownMeta.h" #include "JSSQLStatement.h" +#include "sqlite/NodeSqlite.h" #include "JSStringDecoder.h" #include "JSTextEncoder.h" #include "streams/JSTextEncoderStream.h" @@ -2684,6 +2686,76 @@ 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); + 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( + [](LazyClassStructure::Initializer& init) { + // 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); + }); + + 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); + 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( [](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 3a9a2cda00aa..ca759a74da9e 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -559,6 +559,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 3e118500e5a6..aa5aec6d121b 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, [] { @@ -270,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) { @@ -279,8 +295,17 @@ extern "C" void Bun__closeAllSQLiteDatabasesForTermination() auto& dbs = _instance->databases; for (auto& db : dbs) { - if (db->db) - sqlite3_close(db->db); + if (db->db) { + 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. + 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; + } } } @@ -1194,7 +1219,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetCustomSQLite, (JSC::JSGlobalObject * l } #endif - initializeSQLite(); + Bun__initializeSQLite(); RELEASE_AND_RETURN(scope, JSValue::encode(JSC::jsBoolean(true))); } @@ -1247,7 +1272,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic return {}; } #endif - initializeSQLite(); + Bun__initializeSQLite(); size_t byteLength = array->byteLength(); void* ptr = array->vector(); @@ -1730,7 +1755,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 new file mode 100644 index 000000000000..3d44172d1ea3 --- /dev/null +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -0,0 +1,4122 @@ +// node:sqlite β€” native implementation of Node.js's `node:sqlite` module. +// See header for overview. + +// 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 + +// 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) +#else +#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" +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 +#endif + +#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 + +// 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. 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; +} + +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(); +} + +// 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) +// ───────────────────────────────────────────────────────────────────────────── + +// 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); + auto* zigGlobal = defaultGlobalObject(globalObject); + 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(sqlite3_errstr(errcode))), 0); + return error; +} + +static void throwSqliteError(JSGlobalObject* globalObject, ThrowScope& scope, sqlite3* 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) +{ + scope.throwException(globalObject, createNodeSqliteError(globalObject, errcode, message)); +} + +// 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 +// 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 {}; +} + +// 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) { \ + 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) + +// 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. +#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 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 +// 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, sqliteText(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.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); + 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(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); + 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_ 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, JSObject* fn, bool useBigIntArgs) + : globalObject_(globalObject) + , 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 = [&] { + 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 any + // GC state. Superseded roots are released at the registration site + // instead (releaseSupersededRegistration). + static void xDestroy(void* p) { delete static_cast(p); } + + JSGlobalObject* globalObject_; + // Rooted by the owning JSDatabaseSync's 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). +// 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 { + WTF_MAKE_TZONE_ALLOCATED_INLINE(NodeSqliteAggregate); + +public: + struct State { + JSC::Strong value; + bool initialized; + bool isWindow; + }; + + NodeSqliteAggregate(JSGlobalObject* globalObject, + JSValue start, JSObject* step, JSObject* result, JSObject* inverse, bool useBigIntArgs) + : globalObject_(globalObject) + , 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]] { + 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 = [&] { + 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); + + // 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]] { + sqlite3_result_error(ctx, "", 0); + if (isFinal) destroyState(ctx); + return; + } + } else { + result = state->value.get(); + } + jsValueToSqliteResult(globalObject_, ctx, result); + (void)scope.exception(); + 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 GC state. + static void xDestroy(void* p) { delete static_cast(p); } + + JSGlobalObject* globalObject_; + // Rooted by the owning JSDatabaseSync's 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, sqliteText(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, sqliteText(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); + (void)vm; + 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, {}); + } + 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; +} + +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 +// 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; + } + // 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); +} + +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(). + 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; + 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()); + // 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); + unregisterOpenDatabase(this); + 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; + 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. + db->closeInternal(); + } +} + +void JSDatabaseSync::deleteTrackedSessions() +{ + for (auto& record : m_sessions) { + // 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); + record->handle = nullptr; + } + record->dbGone = true; + } + m_sessions.clear(); +} + +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_sessions.isEmpty() || m_busyDepth > 0) + return; + 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) +{ + // 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; + } + +#if LAZY_LOAD_SQLITE + if (lazyLoadSQLite() < 0) [[unlikely]] { + scope.throwException(globalObject, createError(globalObject, WTF::String::fromUTF8(dlerror()))); + return false; + } +#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 + // 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()); + +#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. 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 + + 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 (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 (int r = sqlite3_db_config(m_db, SQLITE_DBCONFIG_DEFENSIVE, v, nullptr); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); + 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 (int r = sqlite3_busy_timeout(m_db, m_config.timeout); r != SQLITE_OK) { + throwSqliteReturnCodeError(globalObject, scope, m_db, r); + closeInternal(); + return false; + } + + 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 (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; + } + } + + 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); + +#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); + // 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()); +} + +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->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(); + // 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, conn); + 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, {}); + + // 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 + // 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, conn); + return {}; + } + // 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. + + 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, sqliteText(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, + "Cannot enable extension loading because it was disabled at database creation."_s); + } + // 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) { + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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 ? sqliteText(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; + + // 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, 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. + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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; + + // An options getter above may have re-entered close(). + REQUIRE_DB_OPEN(self); + 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; + 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. + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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()); +} + +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; + 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, {}); + } + } + + // 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); + if (r != SQLITE_OK) { + 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); + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + return {}; + } + + auto record = adoptRef(*new NodeSqliteSessionRecord); + 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]] { + 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, sqliteText(zTab))); + auto callData = JSC::getCallData(ctx->filter); + JSValue ret = JSC::call(globalObject, ctx->filter, callData, jsNull(), args); + if (scope.exception()) [[unlikely]] { + 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); + if (!lazy_sqlite3_has_session) [[unlikely]] + return throwSessionUnavailable(globalObject, scope); + 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. 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); + CHECK_UDF_EXCEPTION(scope); + 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) { + // 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)); +} + +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) { + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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; +// 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); + 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]] + return SQLITE_OK; + + auto toJS = [&](const char* s) -> JSValue { + return s ? jsString(vm, sqliteText(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]] { + 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(); + 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) { + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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; + // 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 + // JS exception over SQLite's "not authorized" β€” same as + // exec()/prepare()/deserialize()/TagStore. On this path `data` is + // already null (no cleanup needed). + if (scope.exception()) [[unlikely]] { + if (data) sqlite3_free(data); + return {}; + } + // 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 {}; + } + RELEASE_AND_RETURN(scope, JSValue::encode(adoptSqliteBuffer(globalObject, data, static_cast(size)))); +} + +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. + if (self->isBusy()) { + return throwNodeState(globalObject, scope, "cannot deserialize database while a statement is executing"_s); + } + 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(); + + // 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 + // 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); + 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). + throwSqliteReturnCodeError(globalObject, scope, self->connection(), r); + 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 } }, +}; + +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); + 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 rejectNul(out); + } + // 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 rejectNul(out); + } + // 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 rejectNul(out); + } + } + 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 {}; + } + + 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, {}); + } + } + + 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 + // 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; +} + +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); + m_extraMemorySize = stmt ? static_cast(sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_MEMUSED, 0)) : 0; + if (m_extraMemorySize) + vm.heap.reportExtraMemoryAllocated(this, m_extraMemorySize); +} + +void JSStatementSync::finalizeStatement() +{ + if (m_stmt) { + sqlite3_finalize(m_stmt); + m_stmt = nullptr; + } +} + +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 + // 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); + visitor.reportExtraMemoryVisited(thisObject->m_extraMemorySize); + 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, 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 + // 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: 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); + 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(); + // 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)); + 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: 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)) { + 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 = sqliteText(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); + // 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; + } + 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); + +#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); + } +}; + +// ─── 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) +{ + StatementResetter resetter { self->statement() }; + JSStatementSync::SteppingScope stepping { self }; + int r = sqlite3_step(self->statement()); + 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, db); + return {}; + } + 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); +} + +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()); + if (r != SQLITE_ROW) { + throwSqliteError(globalObject, scope, sqlite3_db_handle(self->statement())); + 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); +} + +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; + 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 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()) + : rowToObjectCached(globalObject, scope, self, numCols, self->useBigInts()); + RETURN_IF_EXCEPTION(scope, {}); + rows->push(globalObject, row); + RETURN_IF_EXCEPTION(scope, {}); + } + CHECK_UDF_EXCEPTION(scope); + if (r != SQLITE_DONE) { + throwSqliteError(globalObject, scope, sqlite3_db_handle(self->statement())); + return {}; + } + return JSValue::encode(rows); +} + +JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + THIS_STATEMENT(); + REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(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); + REQUIRE_STMT_IDLE(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); + REQUIRE_STMT_IDLE(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(); + REQUIRE_STMT(self); + REQUIRE_STMT_IDLE(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, sqliteText(val)) : jsNull(), 0); + }; + // 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)); + putStr("name"_s, sqlite3_column_name(self->statement(), i)); + putStr("table"_s, sqlite3_column_table_name(self->statement(), i)); + 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, sqliteText(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, sqliteText(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 } }, +}; + +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); + } + if (stmt->isStepping()) [[unlikely]] { + return throwNodeState(globalObject, scope, "statement is currently executing"_s); + } + JSDatabaseSync::BusyScope busy { stmt->database() }; + JSStatementSync::SteppingScope stepping { stmt }; + + 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); + throwSqliteError(globalObject, scope, sqlite3_db_handle(stmt->statement())); + return {}; + } + CHECK_UDF_EXCEPTION(scope); + 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. + // 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 + // 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.) + // 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()); + } + 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; + // 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()); + 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. 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 +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 {}; \ + } + +static EncodedJSValue sessionChangesetCommon(JSGlobalObject* globalObject, CallFrame* callFrame, + int (*fn)(sqlite3_session*, int*, void**)) +{ + THIS_SESSION(); + JSDatabaseSync* db = self->database(); + if (self->isStale()) { + return throwNodeState(globalObject, scope, "database is not open"_s); + } + 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. 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(); + int nChangeset = 0; + void* pChangeset = nullptr; + int r = fn(record->handle, &nChangeset, &pChangeset); + record->inUse = false; + // 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); + return {}; + } + RELEASE_AND_RETURN(scope, JSValue::encode(adoptSqliteBuffer(globalObject, pChangeset, static_cast(nChangeset)))); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionChangeset, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return sessionChangesetCommon(globalObject, callFrame, sqlite3session_changeset); +} + +JSC_DEFINE_HOST_FUNCTION(jsSessionPatchset, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return sessionChangesetCommon(globalObject, callFrame, sqlite3session_patchset); +} + +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); + } + if (self->record()->inUse) { + return throwNodeState(globalObject, scope, "session is currently generating a changeset"_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); +} + +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() +// ───────────────────────────────────────────────────────────────────────────── + +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) { + // `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 false; + } + 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; +} + +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; +} + +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) { + // 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 + // 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. + 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 + // jsDatabaseSyncPrepare's CHECK_UDF_EXCEPTION. + if (scope.exception()) [[unlikely]] { + if (stmt) sqlite3_finalize(stmt); + return nullptr; + } + if (r != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + throwSqliteError(globalObject, scope, conn); + 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)); + } + } + + // 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 + // 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); + // 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); + 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 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)) +{ + THIS_TAGSTORE(); + JSDatabaseSync::BusyScope busy { self->database() }; + JSStatementSync* stmt = self->prepare(globalObject, scope, callFrame); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, statementStepRun(vm, globalObject, scope, stmt)); +} + +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, {}); + RELEASE_AND_RETURN(scope, statementStepGet(globalObject, scope, stmt)); +} + +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, {}); + RELEASE_AND_RETURN(scope, statementStepAll(globalObject, scope, stmt)); +} + +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 } }, +}; + +void JSNodeSqliteTagStorePrototype::finishCreation(VM& vm, JSGlobalObject*) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSNodeSqliteTagStore::info(), JSNodeSqliteTagStorePrototypeTableValues, *this); + 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 +// +// 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. +// 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)) +{ + 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 {}; + + 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(); + } + } + + // 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 + // Promise before returning. + auto rejectWithPending = [&]() -> EncodedJSValue { + RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope))); + }; + + 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) { + 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(); + } + + // 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. 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; + while (true) { + r = sqlite3_backup_step(backup, rate); + totalPages = sqlite3_backup_pagecount(backup); + int remaining = sqlite3_backup_remaining(backup); + + 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); + 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; + // 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); + 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..e4cf582f00f5 --- /dev/null +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -0,0 +1,904 @@ +// node:sqlite β€” native implementation of Node.js's `node:sqlite` module. +// +// 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 + +#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 +// 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()/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 }; + bool wrapperGone { false }; + bool inUse { 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; } + + 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. 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. + // 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 may re-enter JS. + // 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; + 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 re-entrant calls from UDF/authorizer + // callbacks are skipped by the m_busyDepth guard. + db->sweepOrphanedSessions(); + ++db->m_busyDepth; + } + } + ~BusyScope() + { + 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) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm); + + 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 + // 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; + // 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; +}; + +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; } + + // 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; } + 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 + // 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; + // 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 + // 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; + unsigned m_steppingDepth = 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(). Exported so `instanceof Session` +// works, but the constructor throws ERR_ILLEGAL_CONSTRUCTOR (matches Node's +// IllegalConstructor template). +// ───────────────────────────────────────────────────────────────────────────── + +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; } + 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 + // 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*); +}; + +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 +// 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)`); 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 { +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*); +}; + +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*); + +} // namespace Bun diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index 8e9ba2bd7432..e3692eacdc8e 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,120 +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_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 @@ -158,10 +235,14 @@ 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 +#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 @@ -172,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 @@ -200,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* @@ -212,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) @@ -239,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"); @@ -247,10 +374,14 @@ 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"); + 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"); @@ -262,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"); @@ -288,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 { @@ -313,6 +486,27 @@ 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; }; + } + + // 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/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 fd53bd416930..57ffc88883ab 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 bf2a7239d15f..170e64a76bb1 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..d6083d4496ff --- /dev/null +++ b/src/jsc/modules/NodeSqliteModule.h @@ -0,0 +1,38 @@ +#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(5); + + 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, "Session"_s), + globalObject->m_JSNodeSqliteSessionClassStructure.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/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index e3592a85d14f..3cf16ede60f3 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"; @@ -2053,6 +2053,42 @@ it("keeps database handles working when many Workers open databases concurrently }); }, 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 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(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(); + expect(exitCode).toBe(0); +}); + // sqlite3_prepare_v3 treats an interior NUL byte as end-of-SQL. The exec/run // multi-statement loop used to re-prepare the same empty statement forever // once the head reached a NUL, pinning the event loop at 100% CPU. 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 36265a2fc321..6f8df13f6b3b 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -352,6 +352,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..daba6bd83cdd --- /dev/null +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -0,0 +1,2399 @@ +import { heapStats } from "bun:jsc"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { existsSync, statSync } from "node:fs"; +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 +// 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. + 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+$/); +}); + +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:"); + 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 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:"); + 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.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(); + }); + + 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("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. + 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 + // 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 */"]) { + 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(); + }); + + 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. 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" }), + ); + }); + + 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() 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; + 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" }); + }); + + 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", + { + get varargs() { + db.close(); + return true; + }, + }, + () => 0, + ), + ).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE", message: "database is not open" })); + expect(db.isOpen).toBe(false); + }); + + 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(); + src.close(); + + const db = new DatabaseSync(":memory:"); + let closeErr: unknown; + 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", () => { + // 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(); + }); + + 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()", () => { + 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(); + }); + + 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"]); + // 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(); + // 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(); + }); + + 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 + // 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 })); + `, + ], + // 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]); + // 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, + }); + }); + + 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 })); + `, + ], + // 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]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ isOpen: false, err: "ERR_INVALID_STATE", haveStmt: true }), + stderr: expect.any(String), + 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()", () => { + 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)", () => { + // 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(); + // 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 }); + }); +}); + +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)"); + const session = src.createSession(); + 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(); + 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.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:"); + 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("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"); + using 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"); + }); + + 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:"); + 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(); + }); + + 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()", () => { + 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("`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); + 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("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)"); + 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("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", () => { + // 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.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); + }, +); + +// 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. +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.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.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(); + 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(); + }); +}); + +describe("module exports", () => { + test("Session is exported and instanceof works; SQLTagStore is not exported", () => { + expect(typeof Session).toBe("function"); + expect(() => new Session()).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().constructor.name).toBe("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.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( + 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.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.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.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); + expect(() => db.loadExtension("/bun-nonexistent-extension-path")).toThrow( + expect.objectContaining({ code: "ERR_LOAD_SQLITE_EXTENSION" }), + ); + db.close(); + }); +}); + +// 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: [ + 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'); + 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. + 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("same=true\nn1=1\nb1=2\nok=ok\n"); + void stderr; + 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". +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'; + 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)))); + }); + // ~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); + 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("0\n99\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(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 100) 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(5050); + db.close(); + }); + + 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, + ); +}); + +// 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. +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); +}); diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 2e5216e97945..168be9dfb6d4 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); @@ -155,6 +157,11 @@ if (process.argv.length === 2 && installBunExposeInternalsRequireInterceptor(); 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; @@ -845,6 +852,12 @@ function skipIfWorker() { } } +function skipIfSQLiteMissing() { + if (!hasSQLite) { + skip('missing SQLite'); + } +} + function getArrayBufferViews(buf) { const { buffer, byteOffset, byteLength } = buf; @@ -1114,6 +1127,7 @@ const common = { hasCrypto, hasOpenSSL, hasQuic, + hasSQLite, hasMultiLocalhost, invalidArgTypeHelper, isAlive, @@ -1148,6 +1162,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..57b20c37fcd3 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite-serialize.js @@ -0,0 +1,320 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +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', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. + 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', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. + 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', { skip: pristineImageIsEmpty }, (t) => { // BUN: see pristineImageIsEmpty above. + 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..d1afe54512d2 --- /dev/null +++ b/test/js/node/test/parallel/test-sqlite.js @@ -0,0 +1,354 @@ +'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'); + +// 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(() => { + 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', { 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); + 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', { 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(), + { __proto__: null, rbu_enabled: 1 }, + ); + }); + + 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); + 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;