Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Publish `node:sqlite` query diagnostics with expanded SQL, database identity and duration, including safe synchronous nested queries; support statement close/disposal and the current constructor aliases.

- Copy large ArrayBuffers directly into receiver-owned contents for Worker and MessagePort messages, preserving source buffers and stored serialization bytes.

- Honor elapsed `node:vm` timeouts when native SQLite writes outlast delayed timer delivery, releasing write cursors before propagating the timeout.
Expand Down
2 changes: 2 additions & 0 deletions docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ CPU profile duration uses a monotonic clock and includes idle time. Inspector pr

### [`node:sqlite`](https://nodejs.org/api/sqlite.html)

`sqlite.db.query` diagnostics match Node 26.12.1: completed or reset queries publish synchronously with expanded SQL, the database object and duration in nanoseconds. Nested queries publish nested events; subscribers must guard their own recursion. Tracing is disabled when the channel has no subscribers. Statements support `close()` and `Symbol.dispose`, and `Database`/`Statement` alias the legacy `DatabaseSync`/`StatementSync` constructors.

🟢 Fully implemented. `backup()` runs synchronously and blocks the event loop for the duration of the copy (Node runs it on a worker thread). 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()` requires a full SQLite build, and so do `createSession()`/`applyChangeset()` on older macOS releases. To use a full SQLite build, call `require("bun:sqlite").Database.setCustomSQLite(path)` before opening a database.

### [`node:test`](https://nodejs.org/api/test.html)
Expand Down
4 changes: 4 additions & 0 deletions src/js/internal/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Shared privately with the native connection setup, including before diagnostics_channel is loaded.
const state: { publisher: ((message: unknown) => void) | undefined } = { publisher: undefined };

export default state;
12 changes: 12 additions & 0 deletions src/js/node/diagnostics_channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function markActive(channel) {
ObjectSetPrototypeOf.$call(null, channel, ActiveChannel.prototype);
channel._subscribers = [];
channel._stores = new SafeMap();
updateSqliteQueryPublisher(channel, true);
}

function maybeMarkInactive(channel) {
Expand All @@ -72,9 +73,20 @@ function maybeMarkInactive(channel) {
ObjectSetPrototypeOf.$call(null, channel, Channel.prototype);
channel._subscribers = undefined;
channel._stores = undefined;
updateSqliteQueryPublisher(channel, false);
}
}

function updateSqliteQueryPublisher(channel, active) {
if (channel.name !== "sqlite.db.query") return;
// Lazy tick-function construction cannot report an error at the recursion limit.
if (active) void process.nextTick;
const state = require("internal/sqlite");
const publish = channel.publish;
state.publisher = active ? message => publish.$call(channel, message) : undefined;
$newCppFunction("sqlite/NodeSqlite.cpp", "jsSetSqliteQueryPublisher", 1)(state.publisher);
}

function defaultTransform(data) {
return data;
}
Expand Down
144 changes: 130 additions & 14 deletions src/jsc/bindings/sqlite/NodeSqlite.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ static constexpr bool lazy_sqlite3_has_session = true;
#include "DOMIsoSubspaces.h"
#include "DOMClientIsoSubspaces.h"
#include "BunClientData.h"
#include "InternalModuleRegistry.h"

#include <JavaScriptCore/JSGlobalObject.h>
#include <JavaScriptCore/JSCast.h>
Expand Down Expand Up @@ -234,12 +235,11 @@ static ALWAYS_INLINE void putNodeInstanceGetter(VM& vm, JSObject* target, ASCIIL

// 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); \
// sqlite3_reset corrupts the running VDBE. Refuse before touching the handle.
#define REQUIRE_STMT_IDLE(self) \
do { \
if ((self)->isStepping()) [[unlikely]] \
return throwNodeState(globalObject, scope, "statement is already being executed"_s); \
} while (0)

// Pin the owning database for the duration of a StatementSync call that
Expand Down Expand Up @@ -555,6 +555,71 @@ static void unregisterOpenDatabase(JSDatabaseSync* db)
openDatabases().remove(db);
}

void JSDatabaseSync::setQueryPublisher(JSObject* publisher)
{
if (publisher)
m_queryPublisher.set(vm(), this, publisher);
else
m_queryPublisher.clear();
if (m_db)
sqlite3_trace_v2(m_db, publisher ? SQLITE_TRACE_PROFILE : 0, publisher ? queryTraceCallback : nullptr, publisher ? this : nullptr);
}

JSC_DEFINE_HOST_FUNCTION(jsSetSqliteQueryPublisher, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
auto& vm = getVM(globalObject);
auto* publisher = callFrame->argument(0).getObject();
WTF::Locker locker { openDatabasesLock };
for (auto& entry : openDatabases()) {
if (entry.value.vm == &vm)
entry.key->setQueryPublisher(publisher);
}
return JSValue::encode(jsUndefined());
}

int JSDatabaseSync::queryTraceCallback(unsigned type, void* context, void* statement, void* duration)
{
// Node's profile callback also publishes nested queries: https://github.com/nodejs/node/blob/v26.12.1/src/node_sqlite.cc#L3713
if (type != SQLITE_TRACE_PROFILE || sqliteScriptIsForbidden)
return 0;
auto* db = static_cast<JSDatabaseSync*>(context);
if (db->m_suppressQueryEvents)
return 0;
auto* publisher = db->m_queryPublisher.get();
if (!publisher)
return 0;
auto* globalObject = db->globalObject();
auto& vm = getVM(globalObject);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
if (vm.hasPendingTerminationException())
return 0;
// Profiling can run while SQLite unwinds a thrown user function.
Strong<JSC::Exception> queryException(vm, scope.exception());
if (queryException)
scope.clearException();
Strong<JSDatabaseSync> keepDatabase(vm, db);
Strong<JSObject> keepPublisher(vm, publisher);
WTF::SetForScope callbackDepth { db->m_queryCallbackDepth, db->m_queryCallbackDepth + 1 };
auto* stmt = static_cast<sqlite3_stmt*>(statement);
char* expanded = sqlite3_expanded_sql(stmt);
auto sql = sqliteText(expanded ? expanded : sqlite3_sql(stmt));
sqlite3_free(expanded);
auto* payload = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
Bun::putDirectNamed(vm, payload, "sql"_s, jsString(vm, sql));
Bun::putDirectNamed(vm, payload, "database"_s, db);
Bun::putDirectNamed(vm, payload, "duration"_s, jsNumber(static_cast<double>(*static_cast<sqlite3_int64*>(duration))));
MarkedArgumentBuffer args;
args.append(payload);
JSC::call(globalObject, publisher, JSC::getCallData(publisher), jsUndefined(), args);
if (queryException && !scope.exception()) {
auto restore = DECLARE_THROW_SCOPE(vm);
restore.throwException(globalObject, queryException.get());
restore.release();
}
(void)scope.exception();
return 0;
}

JSDatabaseSync::~JSDatabaseSync()
{
// Reachable with a BusyScope still on the stack only when process.exit()
Expand All @@ -579,6 +644,8 @@ void JSDatabaseSync::closeInternal(bool mayCallScript)
{
if (!m_db) return;
m_deferredClose = std::exchange(m_db, nullptr);
sqlite3_trace_v2(m_deferredClose, 0, nullptr, nullptr);
m_queryPublisher.clear();
if (!isBusy())
finishDeferredClose(mayCallScript);
}
Expand Down Expand Up @@ -623,6 +690,7 @@ void JSDatabaseSync::finalizeOrphanedStatements()
auto* globalObject = this->globalObject();
auto& vm = getVM(globalObject);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
WTF::SetForScope suppressTrace { m_suppressQueryEvents, true };
while (!m_orphanedStatements.isEmpty()) {
sqlite3_finalize(m_orphanedStatements.takeLast());
// An exception from a callback is unrelated to the current call, so report it instead of throwing it.
Expand Down Expand Up @@ -719,6 +787,7 @@ void JSDatabaseSync::sweepOrphanedSessions()

bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope)
{
auto& vm = getVM(globalObject);
// 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
Expand Down Expand Up @@ -813,6 +882,10 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope)
}
}

auto* global = defaultGlobalObject(globalObject);
JSValue queryState = global->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::InternalSqlite);
RETURN_IF_EXCEPTION(scope, false);
setQueryPublisher(queryState.getObject()->getDirect(vm, Identifier::fromString(vm, "publisher"_s)).getObject());
return true;
}

Expand All @@ -824,6 +897,7 @@ void JSDatabaseSync::visitChildrenImpl(JSCell* cell, Visitor& visitor)
Base::visitChildren(thisObject, visitor);
visitor.append(thisObject->m_authorizer);
visitor.append(thisObject->m_limits);
visitor.append(thisObject->m_queryPublisher);
thisObject->m_callbacks.visit(thisObject, visitor);
}
DEFINE_VISIT_CHILDREN(JSDatabaseSync);
Expand Down Expand Up @@ -874,6 +948,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncClose, (JSGlobalObject * globalObject, Ca
{
THIS_DATABASE();
REQUIRE_DB_OPEN(self);
if (self->isInQueryCallback())
return throwNodeState(globalObject, scope, "database cannot be closed while in a callback"_s);
// Node allows re-entrant close(); option-reading paths REQUIRE_DB_OPEN
// again before the sqlite call.
self->closeInternal();
Expand All @@ -887,6 +963,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDispose, (JSGlobalObject * globalObject,
auto scope = DECLARE_THROW_SCOPE(vm);
JSDatabaseSync* self = dynamicDowncast<JSDatabaseSync>(callFrame->thisValue());
if (self && self->isOpen()) {
if (self->isInQueryCallback())
return throwNodeState(globalObject, scope, "database cannot be closed while in a callback"_s);
self->closeInternal();
CHECK_UDF_EXCEPTION(scope);
}
Expand Down Expand Up @@ -1490,6 +1568,7 @@ static int nodeSqliteAuthorizerCallback(void* userData, int actionCode, const ch
if (sqliteScriptIsForbidden) [[unlikely]]
return SQLITE_DENY;
auto* db = static_cast<JSDatabaseSync*>(userData);
WTF::SetForScope authorizerDepth { db->m_authorizerCallbackDepth, db->m_authorizerCallbackDepth + 1 };
auto* globalObject = db->globalObject();
auto& vm = getVM(globalObject);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
Expand Down Expand Up @@ -1632,6 +1711,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncSerialize, (JSGlobalObject * globalObject
JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncDeserialize, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
THIS_DATABASE();
if (self->isInQueryCallback())
return throwNodeState(globalObject, scope, "database cannot be deserialized while in a callback"_s);
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
Expand Down Expand Up @@ -2049,7 +2130,12 @@ void JSStatementSync::finalizeStatement(bool mayCallScript)
remove();
m_extraMemorySize = 0;
// Cleared first: xFinal can run script, and a GC in it this cell's destructor.
sqliteFinalize(std::exchange(m_stmt, nullptr), mayCallScript);
if (mayCallScript) {
WTF::SetForScope suppressTrace { m_database->m_suppressQueryEvents, true };
sqliteFinalize(std::exchange(m_stmt, nullptr), true);
} else {
sqliteFinalize(std::exchange(m_stmt, nullptr), false);
}
}

JSStatementSync::~JSStatementSync()
Expand Down Expand Up @@ -2328,7 +2414,13 @@ JSC_DECLARE_HOST_FUNCTION(jsStatementSyncSetAllowUnknownNamedParameters);
}

struct StatementResetter {
JSStatementSync::SteppingScope stepping;
sqlite3_stmt* stmt;
explicit StatementResetter(JSStatementSync* statement)
: stepping { statement }
, stmt(statement->statement())
{
}
// A write with RETURNING that has rows left commits here, which can fail.
int resetNow() { return sqlite3_reset(std::exchange(stmt, nullptr)); }
~StatementResetter()
Expand All @@ -2344,8 +2436,7 @@ struct StatementResetter {

static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self)
{
StatementResetter resetter { self->statement() };
JSStatementSync::SteppingScope stepping { self };
StatementResetter resetter { self };
int r = sqlite3_step(self->statement());
// As Node: the rows are not read. A write has made all its changes before its
// first row, and sqlite3_changes64() has them once the statement is reset.
Expand Down Expand Up @@ -2383,8 +2474,7 @@ static EncodedJSValue statementStepRun(VM& vm, JSGlobalObject* globalObject, Thr

static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self)
{
StatementResetter resetter { self->statement() };
JSStatementSync::SteppingScope stepping { self };
StatementResetter resetter { self };
int r = sqlite3_step(self->statement());
CHECK_UDF_EXCEPTION(scope);
if (r == SQLITE_DONE) return JSValue::encode(jsUndefined());
Expand All @@ -2409,8 +2499,7 @@ static EncodedJSValue statementStepGet(JSGlobalObject* globalObject, ThrowScope&

static EncodedJSValue statementStepAll(JSGlobalObject* globalObject, ThrowScope& scope, JSStatementSync* self)
{
StatementResetter resetter { self->statement() };
JSStatementSync::SteppingScope stepping { self };
StatementResetter resetter { self };
JSArray* rows = constructEmptyArray(globalObject, nullptr, 0);
RETURN_IF_EXCEPTION(scope, {});
int r;
Expand Down Expand Up @@ -2569,7 +2658,33 @@ JSC_DEFINE_CUSTOM_GETTER(jsStatementSyncExpandedSQL, (JSGlobalObject * globalObj
return JSValue::encode(result);
}

static EncodedJSValue statementClose(JSGlobalObject* globalObject, CallFrame* callFrame, bool disposing)
{
THIS_STATEMENT();
BUSY_SCOPE_STMT(self);
if (disposing && self->isFinalized())
return JSValue::encode(jsUndefined());
REQUIRE_STMT(self);
REQUIRE_STMT_IDLE(self);
if (self->database()->m_authorizerCallbackDepth && sqlite3_stmt_busy(self->statement()))
return throwNodeState(globalObject, scope, "database cannot be accessed from an authorizer callback"_s);
self->finalizeStatement();
CHECK_UDF_EXCEPTION(scope);
return JSValue::encode(jsUndefined());
}

JSC_DEFINE_HOST_FUNCTION(jsStatementSyncClose, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return statementClose(globalObject, callFrame, false);
}

JSC_DEFINE_HOST_FUNCTION(jsStatementSyncDispose, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return statementClose(globalObject, callFrame, true);
}

static const HashTableValue JSStatementSyncPrototypeTableValues[] = {
{ "close"_s, static_cast<unsigned>(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncClose, 0 } },
{ "run"_s, static_cast<unsigned>(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncRun, 0 } },
{ "get"_s, static_cast<unsigned>(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncGet, 0 } },
{ "all"_s, static_cast<unsigned>(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncAll, 0 } },
Expand All @@ -2581,10 +2696,11 @@ static const HashTableValue JSStatementSyncPrototypeTableValues[] = {
{ "setAllowUnknownNamedParameters"_s, static_cast<unsigned>(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsStatementSyncSetAllowUnknownNamedParameters, 1 } },
};

void JSStatementSyncPrototype::finishCreation(VM& vm, JSGlobalObject*)
void JSStatementSyncPrototype::finishCreation(VM& vm, JSGlobalObject* globalObject)
{
Base::finishCreation(vm);
Bun::reifyStaticPropertyTable(vm, JSStatementSync::info(), JSStatementSyncPrototypeTableValues, *this);
putDirectNativeFunction(vm, globalObject, vm.propertyNames->disposeSymbol, 0, jsStatementSyncDispose, ImplementationVisibility::Public, NoIntrinsic, 0);
Bun::putToStringTagWithoutTransition(vm, this, info());
}

Expand Down
10 changes: 10 additions & 0 deletions src/jsc/bindings/sqlite/NodeSqlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ struct sqlite3_session;

namespace Bun {

JSC_DECLARE_HOST_FUNCTION(jsSetSqliteQueryPublisher);

class JSDatabaseSync;
class JSStatementSync;
class JSNodeSqliteSession;
Expand Down Expand Up @@ -176,9 +178,15 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject {
// wrapper object for the same reason).
JSC::WriteBarrier<JSC::JSObject> m_authorizer;
JSC::WriteBarrier<JSNodeSqliteLimits> m_limits;
unsigned m_authorizerCallbackDepth { 0 };

SqliteCallbackRoots m_callbacks;

void setQueryPublisher(JSC::JSObject*);
bool isInQueryCallback() const { return m_queryCallbackDepth > 0; }
static int queryTraceCallback(unsigned, void*, void*, void*);
bool m_suppressQueryEvents { false };

// 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 leaves the connection
Expand Down Expand Up @@ -229,6 +237,8 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject {
// The handle between closeInternal() and finishDeferredClose().
sqlite3* m_deferredClose = nullptr;
unsigned m_busyDepth = 0;
unsigned m_queryCallbackDepth = 0;
JSC::WriteBarrier<JSC::JSObject> m_queryPublisher;
// Not roots. A cell on the list has not run its destructor: a statement's
// unlinks it, and the database's unlinks whatever is left.
WTF::SentinelLinkedList<JSStatementSync, WTF::BasicRawSentinelNode<JSStatementSync>> m_statements;
Expand Down
Loading
Loading