diff --git a/benchmark/sqlite/sqlite-trace.js b/benchmark/sqlite/sqlite-trace.js new file mode 100644 index 00000000000000..86a0788abbe298 --- /dev/null +++ b/benchmark/sqlite/sqlite-trace.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common.js'); +const sqlite = require('node:sqlite'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +const bench = common.createBenchmark(main, { + n: [1e5], + mode: ['none', 'subscribed', 'unsubscribed'], +}); + +function main(conf) { + const { n, mode } = conf; + + const db = new sqlite.DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + const insert = db.prepare('INSERT INTO t VALUES (?)'); + + let subscriber; + if (mode === 'subscribed') { + subscriber = () => {}; + dc.subscribe('sqlite.db.query', subscriber); + } else if (mode === 'unsubscribed') { + subscriber = () => {}; + dc.subscribe('sqlite.db.query', subscriber); + dc.unsubscribe('sqlite.db.query', subscriber); + } + // mode === 'none': no subscription ever made + + let result; + bench.start(); + for (let i = 0; i < n; i++) { + result = insert.run(i); + } + bench.end(n); + + if (mode === 'subscribed') { + dc.unsubscribe('sqlite.db.query', subscriber); + } + + assert.ok(result !== undefined); +} diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index 99f7af3dd3e95b..0392446649eb28 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1911,10 +1911,75 @@ added: v16.18.0 Emitted when a new thread is created. +#### SQLite + + + +> Stability: 1 - Experimental + +##### Event: `'sqlite.db.query'` + +* `sql` {string} The expanded SQL with bound parameter values substituted. + If expansion fails, the source SQL with unsubstituted placeholders is used + instead. +* `database` {DatabaseSync} The [`DatabaseSync`][] instance that executed the + statement. +* `duration` {number} The estimated statement run time in nanoseconds. + +Emitted when a SQL statement is executed against a [`DatabaseSync`][] instance. +This allows subscribers to observe every SQL statement executed without +modifying the database code itself. Tracing is zero-cost when there are no +subscribers. + +```cjs +const dc = require('node:diagnostics_channel'); +const { DatabaseSync } = require('node:sqlite'); + +function onQuery({ sql, database, duration }) { + console.log(sql, duration); +} + +dc.subscribe('sqlite.db.query', onQuery); + +const db = new DatabaseSync(':memory:'); +db.exec('CREATE TABLE t (x INTEGER)'); +// Logs: CREATE TABLE t (x INTEGER) + +const stmt = db.prepare('INSERT INTO t VALUES (?)'); +stmt.run(42); +// Logs: INSERT INTO t VALUES (42.0) + +dc.unsubscribe('sqlite.db.query', onQuery); +``` + +```mjs +import dc from 'node:diagnostics_channel'; +import { DatabaseSync } from 'node:sqlite'; + +function onQuery({ sql, database, duration }) { + console.log(sql, duration); +} + +dc.subscribe('sqlite.db.query', onQuery); + +const db = new DatabaseSync(':memory:'); +db.exec('CREATE TABLE t (x INTEGER)'); +// Logs: CREATE TABLE t (x INTEGER) + +const stmt = db.prepare('INSERT INTO t VALUES (?)'); +stmt.run(42); +// Logs: INSERT INTO t VALUES (42.0) + +dc.unsubscribe('sqlite.db.query', onQuery); +``` + [BoundedChannel Channels]: #boundedchannel-channels [TracingChannel Channels]: #tracingchannel-channels [`'uncaughtException'`]: process.md#event-uncaughtexception [`BoundedChannel`]: #class-boundedchannel +[`DatabaseSync`]: sqlite.md#class-databasesync [`TracingChannel`]: #class-tracingchannel [`asyncEnd` event]: #asyncendevent [`asyncStart` event]: #asyncstartevent diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 35bbebea16e158..1a89cc2183a5b5 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -30,7 +30,9 @@ import sqlite from 'node:sqlite'; const sqlite = require('node:sqlite'); ``` -This module is only available under the `node:` scheme. +This module is only available under the `node:` scheme. SQL trace events can +be observed via the [`diagnostics_channel`][] module. See +[`'sqlite.db.query'`][] for details. The following example shows the basic usage of the `node:sqlite` module to open an in-memory database, write data to the database, and then read the data back. @@ -1536,6 +1538,7 @@ callback function to indicate what type of operation is being authorized. [Run-Time Limits]: https://www.sqlite.org/c3ref/limit.html [SQL injection]: https://en.wikipedia.org/wiki/SQL_injection [Type conversion between JavaScript and SQLite]: #type-conversion-between-javascript-and-sqlite +[`'sqlite.db.query'`]: diagnostics_channel.md#event-sqlitedbquery [`ATTACH DATABASE`]: https://www.sqlite.org/lang_attach.html [`PRAGMA foreign_keys`]: https://www.sqlite.org/pragma.html#pragma_foreign_keys [`SQLITE_DBCONFIG_DEFENSIVE`]: https://www.sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive @@ -1546,6 +1549,7 @@ callback function to indicate what type of operation is being authorized. [`database.applyChangeset()`]: #databaseapplychangesetchangeset-options [`database.createTagStore()`]: #databasecreatetagstoremaxsize [`database.setAuthorizer()`]: #databasesetauthorizercallback +[`diagnostics_channel`]: diagnostics_channel.md [`sqlite3_backup_finish()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupfinish [`sqlite3_backup_init()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupinit [`sqlite3_backup_step()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupstep diff --git a/lib/diagnostics_channel.js b/lib/diagnostics_channel.js index 8d2d374dc8e6ae..6b12bec54b6a42 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -72,11 +72,19 @@ function markActive(channel) { ObjectSetPrototypeOf(channel, ActiveChannel.prototype); channel._subscribers = []; channel._stores = new SafeMap(); + + // Notify native modules that this channel just got its first subscriber. + if (channel._index !== undefined) + dc_binding.notifyChannelActive(channel._index); } function maybeMarkInactive(channel) { // When there are no more active subscribers or bound, restore to fast prototype. if (!channel._subscribers.length && !channel._stores.size) { + // Notify native modules that this channel just lost its last subscriber. + if (channel._index !== undefined) + dc_binding.notifyChannelInactive(channel._index); + // eslint-disable-next-line no-use-before-define ObjectSetPrototypeOf(channel, Channel.prototype); channel._subscribers = undefined; diff --git a/src/base_object_types.h b/src/base_object_types.h index cd1a06e41a3071..1a63e1da1e8496 100644 --- a/src/base_object_types.h +++ b/src/base_object_types.h @@ -24,7 +24,8 @@ namespace node { #define UNSERIALIZABLE_BINDING_TYPES(V) \ V(http2_binding_data, http2::BindingData) \ V(http_parser_binding_data, http_parser::BindingData) \ - V(quic_binding_data, quic::BindingData) + V(quic_binding_data, quic::BindingData) \ + V(sqlite_binding_data, sqlite::BindingData) // List of (non-binding) BaseObjects that are serializable in the snapshot. // The first argument should match what the type passes to diff --git a/src/env_properties.h b/src/env_properties.h index 7f0abdac9ca452..bdb8a6c78d16cb 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -132,6 +132,7 @@ V(crypto_rsa_pss_string, "rsa-pss") \ V(cwd_string, "cwd") \ V(data_string, "data") \ + V(database_string, "database") \ V(default_is_true_string, "defaultIsTrue") \ V(defensive_string, "defensive") \ V(deserialize_info_string, "deserializeInfo") \ @@ -338,6 +339,7 @@ V(source_map_url_string, "sourceMapURL") \ V(source_url_string, "sourceURL") \ V(specifier_string, "specifier") \ + V(sql_string, "sql") \ V(stack_string, "stack") \ V(start_string, "start") \ V(state_string, "state") \ diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index 450a124c86959a..eb1a95bd414180 100644 --- a/src/node_diagnostics_channel.cc +++ b/src/node_diagnostics_channel.cc @@ -127,12 +127,40 @@ void BindingData::Deserialize(Local context, CHECK_NOT_NULL(binding); } +void BindingData::SetChannelStatusCallback(uint32_t index, + ChannelStatusCallback cb) { + channel_status_callbacks_[index] = std::move(cb); +} + +void BindingData::NotifyChannelActive(const FunctionCallbackInfo& args) { + Realm* realm = Realm::GetCurrent(args); + BindingData* binding = realm->GetBindingData(); + if (binding == nullptr) return; + CHECK(args[0]->IsUint32()); + uint32_t index = args[0].As()->Value(); + auto it = binding->channel_status_callbacks_.find(index); + if (it != binding->channel_status_callbacks_.end()) it->second(true); +} + +void BindingData::NotifyChannelInactive( + const FunctionCallbackInfo& args) { + Realm* realm = Realm::GetCurrent(args); + BindingData* binding = realm->GetBindingData(); + if (binding == nullptr) return; + CHECK(args[0]->IsUint32()); + uint32_t index = args[0].As()->Value(); + auto it = binding->channel_status_callbacks_.find(index); + if (it != binding->channel_status_callbacks_.end()) it->second(false); +} + void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, Local target) { Isolate* isolate = isolate_data->isolate(); SetMethod( isolate, target, "getOrCreateChannelIndex", GetOrCreateChannelIndex); SetMethod(isolate, target, "linkNativeChannel", LinkNativeChannel); + SetMethod(isolate, target, "notifyChannelActive", NotifyChannelActive); + SetMethod(isolate, target, "notifyChannelInactive", NotifyChannelInactive); } void BindingData::CreatePerContextProperties(Local target, @@ -148,6 +176,8 @@ void BindingData::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(GetOrCreateChannelIndex); registry->Register(LinkNativeChannel); + registry->Register(NotifyChannelActive); + registry->Register(NotifyChannelInactive); } Channel::Channel(Environment* env, diff --git a/src/node_diagnostics_channel.h b/src/node_diagnostics_channel.h index 1c1831a0f9e45f..fe0a7366e16d3b 100644 --- a/src/node_diagnostics_channel.h +++ b/src/node_diagnostics_channel.h @@ -4,6 +4,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include +#include #include #include #include @@ -53,6 +54,14 @@ class BindingData : public SnapshotableObject { static void LinkNativeChannel( const v8::FunctionCallbackInfo& args); + using ChannelStatusCallback = std::function; + void SetChannelStatusCallback(uint32_t index, ChannelStatusCallback cb); + + static void NotifyChannelActive( + const v8::FunctionCallbackInfo& args); + static void NotifyChannelInactive( + const v8::FunctionCallbackInfo& args); + static void CreatePerIsolateProperties(IsolateData* isolate_data, v8::Local target); static void CreatePerContextProperties(v8::Local target, @@ -63,6 +72,7 @@ class BindingData : public SnapshotableObject { private: InternalFieldInfo* internal_field_info_ = nullptr; + std::unordered_map channel_status_callbacks_; }; class Channel : public BaseObject { diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 91b80b4fb44c26..749b872a8f6c34 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -5,7 +5,9 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "node.h" +#include "node_diagnostics_channel.h" #include "node_errors.h" +#include "node_external_reference.h" #include "node_mem-inl.h" #include "node_url.h" #include "sqlite3.h" @@ -63,6 +65,28 @@ using v8::TryCatch; using v8::Uint8Array; using v8::Value; +BindingData::BindingData(Realm* realm, Local wrap) + : BaseObject(realm, wrap) { + MakeWeak(); +} + +void BindingData::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackFieldWithSize("open_databases", + open_databases.size() * sizeof(DatabaseSync*), + "open_databases"); +} + +void BindingData::CreatePerContextProperties(Local target, + Local unused, + Local context, + void* priv) { + Realm* realm = Realm::GetCurrent(context); + realm->AddBindingData(target); +} + +void BindingData::RegisterExternalReferences( + ExternalReferenceRegistry* registry) {} + #define CHECK_ERROR_OR_THROW(isolate, db, expr, expected, ret) \ do { \ int r_ = (expr); \ @@ -867,6 +891,9 @@ DatabaseSync::DatabaseSync(Environment* env, enable_load_extension_ = allow_load_extension; ignore_next_sqlite_error_ = false; + BindingData* binding = env->principal_realm()->GetBindingData(); + if (binding != nullptr) binding->open_databases.insert(this); + if (open) { Open(); } @@ -890,6 +917,10 @@ void DatabaseSync::DeleteSessions() { } DatabaseSync::~DatabaseSync() { + BindingData* binding = + env()->principal_realm()->GetBindingData(); + if (binding != nullptr) binding->open_databases.erase(this); + FinalizeBackups(); if (IsOpen()) { @@ -974,9 +1005,28 @@ bool DatabaseSync::Open() { env()->isolate(), this, load_extension_ret, SQLITE_OK, false); } + trace_channel_ = diagnostics_channel::Channel::Get(env(), "sqlite.db.query"); + if (trace_channel_ != nullptr && trace_channel_->HasSubscribers()) { + sqlite3_trace_v2(connection_, SQLITE_TRACE_PROFILE, TraceCallback, this); + } + return true; } +void DatabaseSync::EnableTracing() { + if (!IsOpen()) return; + if (trace_channel_ == nullptr) { + trace_channel_ = + diagnostics_channel::Channel::Get(env(), "sqlite.db.query"); + } + sqlite3_trace_v2(connection_, SQLITE_TRACE_PROFILE, TraceCallback, this); +} + +void DatabaseSync::DisableTracing() { + if (!IsOpen()) return; + sqlite3_trace_v2(connection_, 0, nullptr, nullptr); +} + void DatabaseSync::FinalizeBackups() { for (auto backup : backups_) { backup->Cleanup(); @@ -2391,6 +2441,65 @@ int DatabaseSync::AuthorizerCallback(void* user_data, return int_result; } +int DatabaseSync::TraceCallback(unsigned int type, + void* user_data, + void* p, + void* x) { + if (type != SQLITE_TRACE_PROFILE) { + return 0; + } + + DatabaseSync* db = static_cast(user_data); + Environment* env = db->env(); + + diagnostics_channel::Channel* ch = db->trace_channel_; + if (ch == nullptr || !ch->HasSubscribers()) { + return 0; + } + + Isolate* isolate = env->isolate(); + HandleScope handle_scope(isolate); + + char* expanded = sqlite3_expanded_sql(static_cast(p)); + Local sql_string; + if (expanded != nullptr) { + bool ok = String::NewFromUtf8(isolate, expanded).ToLocal(&sql_string); + sqlite3_free(expanded); + if (!ok) { + return 0; + } + } else { + // Fallback to source SQL if expanded is unavailable + const char* source = sqlite3_sql(static_cast(p)); + if (source == nullptr || + !String::NewFromUtf8(isolate, source).ToLocal(&sql_string)) { + return 0; + } + } + + // x points to the estimated statement run time in nanoseconds. A double is + // sufficient since 2^53 ns (~104 days) exceeds any realistic query duration. + sqlite3_int64 duration_ns = *static_cast(x); + + Local keys[3] = { + env->sql_string().As(), + env->database_string().As(), + env->duration_string().As(), + }; + + Local values[3] = { + sql_string, + db->object(), + Number::New(isolate, static_cast(duration_ns)), + }; + + Local payload = Object::New(isolate, Null(isolate), keys, values, 3); + + ch->Publish(env, payload); + + return 0; +} + StatementSync::StatementSync(Environment* env, Local object, BaseObjectPtr db, @@ -3733,7 +3842,31 @@ static void Initialize(Local target, Local context, void* priv) { Environment* env = Environment::GetCurrent(context); + Realm* realm = env->principal_realm(); Isolate* isolate = env->isolate(); + + // Set up the per-Environment database registry. + BindingData::CreatePerContextProperties(target, unused, context, priv); + + // Register a native callback on the sqlite.db.query diagnostic channel so + // that SQLite tracing is enabled/disabled as subscribers come and go. + auto* diag_binding = + realm->GetBindingData(); + auto* sqlite_bd = realm->GetBindingData(); + if (diag_binding != nullptr && sqlite_bd != nullptr) { + uint32_t idx = diag_binding->GetOrCreateChannelIndex("sqlite.db.query"); + BaseObjectPtr bd_ptr(sqlite_bd); + diag_binding->SetChannelStatusCallback(idx, [bd_ptr](bool is_active) { + BindingData* bd = bd_ptr.get(); + if (bd == nullptr) return; + for (DatabaseSync* db : bd->open_databases) { + if (is_active) + db->EnableTracing(); + else + db->DisableTracing(); + } + }); + } Local db_tmpl = NewFunctionTemplate(isolate, DatabaseSync::New); db_tmpl->InstanceTemplate()->SetInternalFieldCount( @@ -3812,3 +3945,5 @@ static void Initialize(Local target, } // namespace node NODE_BINDING_CONTEXT_AWARE_INTERNAL(sqlite, node::sqlite::Initialize) +NODE_BINDING_EXTERNAL_REFERENCE( + sqlite, node::sqlite::BindingData::RegisterExternalReferences) diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 3ee79cc10ec562..0d06de675c3a49 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -17,6 +17,13 @@ #include namespace node { + +namespace diagnostics_channel { +class Channel; +} // namespace diagnostics_channel + +class ExternalReferenceRegistry; + namespace sqlite { // Mapping from JavaScript property names to SQLite limit constants @@ -160,6 +167,27 @@ class StatementExecutionHelper { bool use_big_ints); }; +class DatabaseSync; + +class BindingData : public BaseObject { + public: + SET_BINDING_ID(sqlite_binding_data) + + BindingData(Realm* realm, v8::Local wrap); + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(BindingData) + SET_SELF_SIZE(BindingData) + + std::unordered_set open_databases; + + static void CreatePerContextProperties(v8::Local target, + v8::Local unused, + v8::Local context, + void* priv); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); +}; + class DatabaseSync : public BaseObject { public: enum InternalFields { @@ -202,6 +230,10 @@ class DatabaseSync : public BaseObject { const char* param2, const char* param3, const char* param4); + static int TraceCallback(unsigned int type, + void* user_data, + void* p, + void* x); void FinalizeStatements(); void RemoveBackup(BackupJob* backup); void AddBackup(BackupJob* backup); @@ -224,6 +256,8 @@ class DatabaseSync : public BaseObject { // enable that use case. void SetIgnoreNextSQLiteError(bool ignore); bool ShouldIgnoreSQLiteError(); + void EnableTracing(); + void DisableTracing(); SET_MEMORY_INFO_NAME(DatabaseSync) SET_SELF_SIZE(DatabaseSync) @@ -242,6 +276,7 @@ class DatabaseSync : public BaseObject { std::set backups_; std::set sessions_; std::unordered_set statements_; + diagnostics_channel::Channel* trace_channel_ = nullptr; friend class DatabaseSyncLimits; friend class Session; diff --git a/test/parallel/test-sqlite-trace.js b/test/parallel/test-sqlite-trace.js new file mode 100644 index 00000000000000..2e27425d452e96 --- /dev/null +++ b/test/parallel/test-sqlite-trace.js @@ -0,0 +1,194 @@ +'use strict'; + +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); + +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { DatabaseSync } = require('node:sqlite'); +const { suite, it } = require('node:test'); + +suite('sqlite.db.query diagnostics channel', () => { + it('subscriber receives SQL string for exec() statements', (t) => { + const calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + + assert.strictEqual(calls.length, 2); + assert.strictEqual(calls[0].sql, 'CREATE TABLE t (x INTEGER)'); + assert.strictEqual(calls[1].sql, 'INSERT INTO t VALUES (1)'); + }); + + it('subscriber receives SQL string for prepared INSERT statements', (t) => { + let calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + calls = []; // reset after setup + + const stmt = db.prepare('INSERT INTO t VALUES (?)'); + stmt.run(42); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].sql, 'INSERT INTO t VALUES (42.0)'); + }); + + it('subscriber receives SQL string for prepared SELECT statements', (t) => { + let calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + calls = []; // reset after setup + + const stmt = db.prepare('SELECT x FROM t WHERE x = ?'); + stmt.get(1); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].sql, 'SELECT x FROM t WHERE x = 1.0'); + }); + + it('subscriber receives SQL string for prepared UPDATE statements', (t) => { + let calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + calls = []; // reset after setup + + const stmt = db.prepare('UPDATE t SET x = ? WHERE x = ?'); + stmt.run(2, 1); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].sql, 'UPDATE t SET x = 2.0 WHERE x = 1.0'); + }); + + it('subscriber receives SQL string for prepared DELETE statements', (t) => { + let calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + calls = []; // reset after setup + + const stmt = db.prepare('DELETE FROM t WHERE x = ?'); + stmt.run(1); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].sql, 'DELETE FROM t WHERE x = 1.0'); + }); + + it('no calls received after unsubscribe', (t) => { + const calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + + db.exec('CREATE TABLE t (x INTEGER)'); + assert.strictEqual(calls.length, 1); + + dc.unsubscribe('sqlite.db.query', handler); + db.exec('INSERT INTO t VALUES (1)'); + assert.strictEqual(calls.length, 1); // No new calls after unsubscribe + }); + + it('falls back to source SQL when expansion fails', (t) => { + let calls = []; + const db = new DatabaseSync(':memory:', { limits: { length: 1000 } }); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x TEXT)'); + calls = []; // reset after setup + + const stmt = db.prepare('INSERT INTO t VALUES (?)'); + + const longValue = 'a'.repeat(977); + stmt.run(longValue); + + assert.strictEqual(calls.length, 1); + // Falls back to source SQL with unexpanded '?' placeholder + assert.strictEqual(calls[0].sql, 'INSERT INTO t VALUES (?)'); + }); + + it('database property identifies the correct database', (t) => { + const calls = []; + const db1 = new DatabaseSync(':memory:'); + const db2 = new DatabaseSync(':memory:'); + t.after(() => { db1.close(); db2.close(); }); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db1.exec('CREATE TABLE t (x INTEGER)'); + db2.exec('CREATE TABLE t (x INTEGER)'); + + assert.strictEqual(calls.length, 2); + assert.strictEqual(calls[0].database, db1); + assert.strictEqual(calls[1].database, db2); + assert.notStrictEqual(calls[0].database, calls[1].database); + }); + + it('duration is a number', (t) => { + const calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(typeof calls[0].duration, 'number'); + }); + + it('duration is non-negative', (t) => { + const calls = []; + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + db.exec('CREATE TABLE t (x INTEGER)'); + + assert.strictEqual(calls.length, 1); + assert.ok(calls[0].duration >= 0); + }); +});