From 8f3e3b4d65f767c0fbfaa30db36ef1021709e879 Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 19 Aug 2026 19:20:30 -0400 Subject: [PATCH 1/2] sqlite: add incremental blob I/O node:sqlite has a single BLOB boundary: a whole SQLite value becomes a whole Uint8Array, on read and on write. Reading one byte of a large value therefore allocates all of it, and there is no partial write path at all. Expose the SQLite incremental blob interface as database.openBlob(), returning a BlobHandle with byteLength, read(), write(), reopen(), close(), and Symbol.dispose. Keep the surface to the primitive so that streams, crypto, and compression can compose on top in userland. The public node:sqlite JavaScript API does not expose its sqlite3* handle. A custom loadable SQLite extension can use the existing connection, but absent such native code a userland implementation needs a second connection. Reading an options property can run user code. Each operation reads its options first, rechecks state, and only then captures the buffer size, buffer pointer, and blob size. This prevents accessors from invalidating state or detaching or resizing a buffer after validation. Handle operations are barred from authorizer callbacks. Identifiers containing NUL are rejected, and zero-length transfers still reach SQLite so its range, aborted-handle, and write-permission checks apply. Closing a writable handle can commit and report an error deferred from an earlier write. A close does not repeat an error already delivered, but it propagates a new commit failure after releasing the handle. BlobHandle disposal deliberately propagates close errors. DatabaseSync disposal does too because this change gives database close a new failure path through owned writable handles. Suppressing either failure would report successful disposal after SQLite rolled back the transaction. Session disposal remains unchanged because its separate error behavior is outside the scope of this change. Closed-state checks preserve idempotence. Refs: https://sqlite.org/c3ref/blob_open.html Signed-off-by: Ari Mayer --- doc/api/sqlite.md | 264 ++++++- src/env_properties.h | 7 + src/node_sqlite.cc | 561 ++++++++++++++- src/node_sqlite.h | 49 ++ test/parallel/test-sqlite-blob.js | 1079 +++++++++++++++++++++++++++++ 5 files changed, 1954 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-sqlite-blob.js diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index ab09d3109648..dd8acbaa7a1e 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -314,7 +314,10 @@ Closes the database connection. An exception is thrown if the database is not open. An [`ERR_INVALID_STATE`][] error is thrown if the method is called while a statement is executing, such as inside a user-defined function, an aggregate function, an authorizer callback, or a [`'sqlite.db.query'`][] subscriber. This -method is a wrapper around [`sqlite3_close_v2()`][]. +method is a wrapper around [`sqlite3_close_v2()`][]. Outstanding +[`BlobHandle`][]s are closed before the connection. If closing one reports a +deferred commit error, every handle and the connection are still closed, and +the exception is propagated to the caller. ### `database.loadExtension(path[, entryPoint])` @@ -877,6 +880,98 @@ added: Creates and attaches a session to the database. This method is a wrapper around [`sqlite3session_create()`][] and [`sqlite3session_attach()`][]. +### `database.openBlob(options)` + + + +* `options` {Object} The configuration options for the blob handle. + * `table` {string} The name of the table containing the value. + * `column` {string} The name of the column containing the value. + * `row` {number|bigint} The `ROWID` of the row containing the value. + * `readOnly` {boolean} If `true`, the handle is opened for reading only. + **Default:** `false`. + * `dbName` {string} Name of the database containing the table. This is + useful when multiple databases have been added using + [`ATTACH DATABASE`][]. **Default:** `'main'`. +* Returns: {BlobHandle} A handle to the value. + +Opens a handle for incremental reading and writing of a single BLOB or TEXT +value, without materializing the whole value in memory. This method is a +wrapper around [`sqlite3_blob_open()`][]. + +```mjs +import { Buffer } from 'node:buffer'; +import { DatabaseSync } from 'node:sqlite'; + +const database = new DatabaseSync(':memory:'); +database.exec('CREATE TABLE files (name TEXT, data BLOB)'); + +// The value must already be the right size. zeroblob(N) reserves N bytes. +const { lastInsertRowid } = database + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(?))') + .run('greeting.txt', 11); + +using blob = database.openBlob({ + table: 'files', + column: 'data', + row: lastInsertRowid, +}); + +blob.write(Buffer.from('hello'), { position: 0 }); +blob.write(Buffer.from(' world'), { position: 5 }); +``` + +```cjs +const { Buffer } = require('node:buffer'); +const { DatabaseSync } = require('node:sqlite'); + +const database = new DatabaseSync(':memory:'); +database.exec('CREATE TABLE files (name TEXT, data BLOB)'); + +const { lastInsertRowid } = database + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(?))') + .run('greeting.txt', 11); + +const blob = database.openBlob({ + table: 'files', + column: 'data', + row: lastInsertRowid, +}); + +try { + blob.write(Buffer.from('hello'), { position: 0 }); + blob.write(Buffer.from(' world'), { position: 5 }); +} finally { + blob.close(); +} +``` + +SQLite places several restrictions on the values a handle can be opened on. +An exception is thrown if any of them is not met: + +* The table must have a `ROWID`. Views and `WITHOUT ROWID` tables cannot be + used. +* Virtual tables cannot be used. +* A table containing any generated columns cannot be used, even when the + handle is opened on a different, non-generated column. +* The row must exist and the column must hold a BLOB or TEXT value. +* A column that is part of an index, `PRIMARY KEY`, or `UNIQUE` constraint can + only be opened with `readOnly` set to `true`. +* When foreign key constraints are enabled, a column that is part of a child + key definition can also only be opened with `readOnly` set to `true`. + +While a handle opened for writing is open, SQLite keeps the current +transaction open. In autocommit mode the transaction is committed when the +last such handle is closed, which is why [`blobHandle.close()`][] can report an +error that an earlier write did not. + +Writes through a handle change raw bytes directly; they are not SQL `UPDATE` +statements. They do not run triggers or enforce column constraints such as +`CHECK`, and they can leave a TEXT value containing invalid UTF-8. Applications +are responsible for preserving those invariants. + ### `database.applyChangeset(changeset[, options])` Closes the database connection. If the database connection is already closed -then this is a no-op. +then this is a no-op. Otherwise, errors are reported under the same conditions +as [`database.close()`][]. + +## Class: `BlobHandle` + + + +A handle to a single BLOB or TEXT value, returned by +[`database.openBlob()`][]. It reads and writes ranges of bytes in place, +allowing a value larger than the memory available to the process to be +processed a chunk at a time. + +A handle cannot change the size of the value it points at. The row must +already hold a value of the intended size, typically created with SQLite's +`zeroblob(N)` function or by an `UPDATE`. + +A handle expires if any column of the row it points at is modified. Until a +read or write detects the expiration, `blobHandle.byteLength` continues to +report the size of the original value. An otherwise-valid read or write then +throws an error whose `errcode` is `SQLITE_ABORT` and aborts the handle. Its +`byteLength` then reports `0`, and subsequent reads, writes, and attempts to +[`blobHandle.reopen()`][] throw `SQLITE_ABORT`. Closing it still succeeds. A +successful `reopen()` before the failed read or write can make the handle +usable again. + +```mjs +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; + +const database = new DatabaseSync('files.db'); +using blob = database.openBlob({ + table: 'files', + column: 'data', + row: 1, + readOnly: true, +}); + +// Hash the value 64 KiB at a time, never holding more than one chunk. +const hash = createHash('sha256'); +const chunk = Buffer.alloc(65536); + +for (let position = 0; position < blob.byteLength; position += chunk.length) { + const bytesRead = blob.read(chunk, { + position, + length: Math.min(chunk.length, blob.byteLength - position), + }); + hash.update(chunk.subarray(0, bytesRead)); +} + +console.log(hash.digest('hex')); +``` + +### `blobHandle.byteLength` + + + +* {number} The size of the value in bytes. + +The reported size is updated after [`blobHandle.reopen()`][] succeeds. It +reports `0` after a failed read or write detects that the row has changed, or +after SQLite fails to reopen the handle on another row. This property is a +wrapper around [`sqlite3_blob_bytes()`][]. + +### `blobHandle.read(buffer[, options])` + + + +* `buffer` {Buffer|TypedArray|DataView} The buffer to read into. +* `options` {Object} + * `offset` {number|bigint} The location in `buffer` to start writing at. + **Default:** `0`. + * `length` {number|bigint} The number of bytes to read. **Default:** + `buffer.byteLength - offset`. + * `position` {number|bigint} The location in the value to start reading from. + **Default:** `0`. +* Returns: {number} The number of bytes read. + +Reads a range of bytes from the value into `buffer`. This method is a wrapper +around [`sqlite3_blob_read()`][]. + +SQLite's incremental reads are all-or-nothing: unlike +[`filehandle.read()`][], there is no short read at the end of the value. A +request for a range that extends past `blobHandle.byteLength` throws +`ERR_OUT_OF_RANGE` and reads nothing, so the return value is always equal to +the number of bytes requested. It is returned for symmetry with the other read +APIs. + +### `blobHandle.write(buffer[, options])` + + + +* `buffer` {Buffer|TypedArray|DataView} The buffer to write from. +* `options` {Object} + * `offset` {number|bigint} The location in `buffer` to start reading from. + **Default:** `0`. + * `length` {number|bigint} The number of bytes to write. **Default:** + `buffer.byteLength - offset`. + * `position` {number|bigint} The location in the value to start writing at. + **Default:** `0`. +* Returns: {number} The number of bytes written. + +Writes a range of bytes from `buffer` into the value. This method is a wrapper +around [`sqlite3_blob_write()`][]. + +A write cannot grow or shrink the value, and cannot extend past +`blobHandle.byteLength`; such a request throws `ERR_OUT_OF_RANGE`. An exception +is also thrown if the handle was opened with `readOnly` set to `true`. + +### `blobHandle.reopen(row)` + + + +* `row` {number|bigint} The `ROWID` of the row to move to. + +Moves the handle to the same column of a different row of the same table. +This is faster than closing the handle and opening a new one. This method is a +wrapper around [`sqlite3_blob_reopen()`][]. + +`blobHandle.byteLength` reflects the new row once the move succeeds. If SQLite +cannot reopen the handle on the requested row after the argument is validated, +SQLite aborts the handle and `blobHandle.byteLength` reports `0`. Reads, writes, +and further attempts to reopen it throw `SQLITE_ABORT`, while closing it still +succeeds. + +### `blobHandle.close()` + + + +Closes the handle. An exception is thrown if the database or the handle is not +open, or if SQLite reports an error while flushing a previous write. The handle +is released in either case. This method is a wrapper around +[`sqlite3_blob_close()`][]. + +### `blobHandle[Symbol.dispose]()` + + + +Closes the handle. If the handle is already closed, does nothing. Otherwise, +errors are reported under the same conditions as [`blobHandle.close()`][]. ## Class: `Session` @@ -1902,6 +2150,7 @@ callback function to indicate what type of operation is being authorized. [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 +[`BlobHandle`]: #class-blobhandle [`ERR_INVALID_STATE`]: errors.md#err_invalid_state [`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 @@ -1910,14 +2159,25 @@ callback function to indicate what type of operation is being authorized. [`SQLITE_MAX_FUNCTION_ARG`]: https://www.sqlite.org/limits.html#max_function_arg [`SQLITE_PREPARE_PERSISTENT`]: https://sqlite.org/c3ref/c_prepare_dont_log.html#sqlitepreparepersistent [`SQLTagStore`]: #class-sqltagstore +[`blobHandle.close()`]: #blobhandleclose +[`blobHandle.reopen()`]: #blobhandlereopenrow [`database.applyChangeset()`]: #databaseapplychangesetchangeset-options +[`database.close()`]: #databaseclose [`database.createTagStore()`]: #databasecreatetagstoremaxsize +[`database.openBlob()`]: #databaseopenbloboptions [`database.serialize()`]: #databaseserializedbname [`database.setAuthorizer()`]: #databasesetauthorizercallback [`diagnostics_channel`]: diagnostics_channel.md +[`filehandle.read()`]: fs.md#filehandlereadbuffer-offset-length-position [`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 +[`sqlite3_blob_bytes()`]: https://www.sqlite.org/c3ref/blob_bytes.html +[`sqlite3_blob_close()`]: https://www.sqlite.org/c3ref/blob_close.html +[`sqlite3_blob_open()`]: https://www.sqlite.org/c3ref/blob_open.html +[`sqlite3_blob_read()`]: https://www.sqlite.org/c3ref/blob_read.html +[`sqlite3_blob_reopen()`]: https://www.sqlite.org/c3ref/blob_reopen.html +[`sqlite3_blob_write()`]: https://www.sqlite.org/c3ref/blob_write.html [`sqlite3_changes64()`]: https://www.sqlite.org/c3ref/changes.html [`sqlite3_close_v2()`]: https://www.sqlite.org/c3ref/close.html [`sqlite3_column_database_name()`]: https://www.sqlite.org/c3ref/column_database_name.html diff --git a/src/env_properties.h b/src/env_properties.h index eb26d3b6cf05..7522926d3049 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -112,6 +112,7 @@ "transferList") \ V(clone_untransferable_str, "Found invalid value in transferList.") \ V(code_string, "code") \ + V(column_string, "column") \ V(config_string, "config") \ V(constants_string, "constants") \ V(crypto_dh_string, "dh") \ @@ -144,6 +145,7 @@ V(cwd_string, "cwd") \ V(data_string, "data") \ V(database_string, "database") \ + V(db_name_string, "dbName") \ V(default_is_true_string, "defaultIsTrue") \ V(defensive_string, "defensive") \ V(deserialize_info_string, "deserializeInfo") \ @@ -270,6 +272,7 @@ V(node_string, "node") \ V(object_string, "Object") \ V(ocsp_request_string, "OCSPRequest") \ + V(offset_string, "offset") \ V(ok_string, "ok") \ V(oncertcb_string, "oncertcb") \ V(onchange_string, "onchange") \ @@ -318,6 +321,7 @@ V(port1_string, "port1") \ V(port2_string, "port2") \ V(port_string, "port") \ + V(position_string, "position") \ V(primordials_string, "primordials") \ V(process_string, "process") \ V(progress_string, "progress") \ @@ -330,6 +334,7 @@ V(read_host_object_string, "_readHostObject") \ V(readable_string, "readable") \ V(read_bigints_string, "readBigInts") \ + V(read_only_string, "readOnly") \ V(reason_string, "reason") \ V(remaining_pages_string, "remainingPages") \ V(rename_string, "rename") \ @@ -343,6 +348,7 @@ V(result_string, "result") \ V(return_arrays_string, "returnArrays") \ V(return_string, "return") \ + V(row_string, "row") \ V(salt_length_string, "saltLength") \ V(secp256k1_string, "secp256k1") \ V(search_string, "search") \ @@ -474,6 +480,7 @@ V(socketaddress_constructor_template, v8::FunctionTemplate) \ V(space_stats_template, v8::DictionaryTemplate) \ V(sqlite_column_template, v8::DictionaryTemplate) \ + V(sqlite_blob_handle_constructor_template, v8::FunctionTemplate) \ V(sqlite_limits_template, v8::ObjectTemplate) \ V(sqlite_run_result_template, v8::DictionaryTemplate) \ V(sqlite_statement_sync_constructor_template, v8::FunctionTemplate) \ diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 04a6b2fda95b..00e13858b078 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -5,6 +5,7 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "node.h" +#include "node_buffer.h" #include "node_diagnostics_channel.h" #include "node_errors.h" #include "node_external_reference.h" @@ -1032,6 +1033,23 @@ void DatabaseSync::DeleteSessions() { } } +int DatabaseSync::CloseBlobs() { + // Open blob handles hold a cursor into the database and must be closed + // before the connection is. https://www.sqlite.org/c3ref/blob_close.html + // + // Closing a read-write handle can commit the open transaction, so it can + // fail. Every handle is released regardless; the first error is returned so + // that an explicit close() can report a write that did not make it to disk. + int first_error = SQLITE_OK; + while (!blobs_.empty()) { + int r = (*blobs_.begin())->Delete(); + if (r != SQLITE_OK && first_error == SQLITE_OK) { + first_error = r; + } + } + return first_error; +} + DatabaseSync::~DatabaseSync() { BindingData* binding = env()->principal_realm()->GetBindingData(); @@ -1042,6 +1060,7 @@ DatabaseSync::~DatabaseSync() { if (IsOpen()) { FinalizeStatements(); DeleteSessions(); + CloseBlobs(); connection_.reset(); } } @@ -1588,17 +1607,24 @@ void DatabaseSync::Close(const FunctionCallbackInfo& args) { env, db->IsInCallback(), "database cannot be closed while in a callback"); db->FinalizeStatements(); db->DeleteSessions(); + int blob_error = db->CloseBlobs(); int r = sqlite3_close_v2(db->connection_.get()); + if (r == SQLITE_OK && blob_error != SQLITE_OK) { + // Report the lost write rather than a success that dropped it. The + // connection is gone, so the error comes from the code alone. + db->connection_.release(); + THROW_ERR_SQLITE_ERROR(env->isolate(), blob_error); + return; + } CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); db->connection_.release(); } void DatabaseSync::Dispose(const v8::FunctionCallbackInfo& args) { - v8::TryCatch try_catch(args.GetIsolate()); + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + if (!db->IsOpen()) return; Close(args); - if (try_catch.HasCaught()) { - CHECK(try_catch.CanContinue()); - } } void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { @@ -2339,6 +2365,334 @@ void DatabaseSync::CreateSession(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(session->object()); } +namespace { + +// Converts a JavaScript number or BigInt to a 64-bit integer, throwing if the +// value is not an integer that can be represented exactly. +bool ToInt64(Environment* env, + Local value, + const char* name, + int64_t* result) { + if (value->IsNumber()) { + if (!IsSafeJsInt(value)) { + THROW_ERR_OUT_OF_RANGE( + env->isolate(), "The \"%s\" argument must be a safe integer.", name); + return false; + } + *result = static_cast(value.As()->Value()); + return true; + } + + if (value->IsBigInt()) { + bool lossless; + *result = value.As()->Int64Value(&lossless); + if (!lossless) { + THROW_ERR_OUT_OF_RANGE(env->isolate(), + "The \"%s\" argument must be within the range of " + "a signed 64-bit integer.", + name); + return false; + } + return true; + } + + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"%s\" argument must be a number or a BigInt.", + name); + return false; +} + +bool GetInt64Option(Environment* env, + Local options, + Local key, + const char* name, + int64_t default_value, + int64_t* result) { + Local value; + if (!options->Get(env->context(), key).ToLocal(&value)) { + return false; + } + + if (value->IsUndefined()) { + *result = default_value; + return true; + } + + return ToInt64(env, value, name, result); +} + +bool GetStringOption(Environment* env, + Local options, + Local key, + const char* name, + bool required, + std::string* result) { + Local value; + if (!options->Get(env->context(), key).ToLocal(&value)) { + return false; + } + + if (value->IsUndefined() && !required) { + return true; + } + + if (!value->IsString()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"%s\" argument must be a string.", name); + return false; + } + + *result = Utf8Value(env->isolate(), value).ToString(); + + // SQLite takes these as NUL-terminated C strings, so an embedded NUL would + // silently select a different identifier than the one that was passed. + if (result->find('\0') != std::string::npos) { + THROW_ERR_INVALID_ARG_VALUE( + env->isolate(), + "The \"%s\" argument must not contain null bytes.", + name); + return false; + } + + return true; +} + +bool GetBoolOption(Environment* env, + Local options, + Local key, + const char* name, + bool* result) { + Local value; + if (!options->Get(env->context(), key).ToLocal(&value)) { + return false; + } + + if (value->IsUndefined()) { + return true; + } + + if (!value->IsBoolean()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"%s\" argument must be a boolean.", name); + return false; + } + + *result = value.As()->Value(); + return true; +} + +// Reads the { offset, length, position } options shared by BlobHandle's read() +// and write(). +// +// This is the only point at which a blob operation can re-enter JavaScript: +// the options object may expose accessors, or be a Proxy, so reading a +// property runs arbitrary user code. That code can detach or resize the +// buffer, close the handle, or close the database. Nothing derived from the +// buffer, the handle, or the connection may therefore be captured before this +// returns -- see ValidateBlobRange(). +// +// |length| is left empty when the caller did not specify one; it defaults to +// the remainder of the buffer, which is not known yet. +bool ReadBlobRangeOptions(Environment* env, + const FunctionCallbackInfo& args, + int64_t* offset, + std::optional* length, + int64_t* position) { + *offset = 0; + *length = std::nullopt; + *position = 0; + + if (args.Length() <= 1 || args[1]->IsUndefined()) { + return true; + } + + if (!args[1]->IsObject()) { + THROW_ERR_INVALID_ARG_TYPE(env->isolate(), + "The \"options\" argument must be an object."); + return false; + } + + Local options = args[1].As(); + if (!GetInt64Option( + env, options, env->offset_string(), "options.offset", 0, offset)) { + return false; + } + + Local length_value; + if (!options->Get(env->context(), env->length_string()) + .ToLocal(&length_value)) { + return false; + } + if (!length_value->IsUndefined()) { + int64_t value; + if (!ToInt64(env, length_value, "options.length", &value)) { + return false; + } + *length = value; + } + + return GetInt64Option( + env, options, env->position_string(), "options.position", 0, position); +} + +// Validates a range read by ReadBlobRangeOptions() against the buffer and the +// blob. Both sizes must be read *after* that call, never before. +bool ValidateBlobRange(Environment* env, + int64_t buffer_length, + int64_t blob_length, + int64_t offset, + std::optional requested_length, + int64_t position, + int64_t* length) { + if (offset < 0 || offset > buffer_length) { + THROW_ERR_OUT_OF_RANGE( + env->isolate(), + "The \"options.offset\" argument must be >= 0 and <= %" PRId64 ".", + buffer_length); + return false; + } + + const int64_t available = buffer_length - offset; + const int64_t len = requested_length.value_or(available); + if (len < 0 || len > available) { + THROW_ERR_OUT_OF_RANGE( + env->isolate(), + "The \"options.length\" argument must be >= 0 and <= %" PRId64 ".", + available); + return false; + } + + if (position < 0) { + THROW_ERR_OUT_OF_RANGE(env->isolate(), + "The \"options.position\" argument must be >= 0."); + return false; + } + + // sqlite3_blob_read() and sqlite3_blob_write() take their length and offset + // as int. Every value SQLite can store fits, but a buffer need not, and the + // blob-relative checks below are skipped for an aborted handle. + constexpr int64_t kMaxRange = std::numeric_limits::max(); + if (len > kMaxRange || position > kMaxRange) { + THROW_ERR_OUT_OF_RANGE(env->isolate(), + "The \"options.length\" and \"options.position\" " + "arguments must be <= %" PRId64 ".", + kMaxRange); + return false; + } + + // sqlite3_blob_bytes() reports zero both for an empty value and for a handle + // that SQLite has aborted -- because the row was modified while the handle + // was open, or because reopen() failed. The two are indistinguishable here, + // so the remaining checks are skipped and SQLite is left to report + // SQLITE_ABORT rather than a range error that would name the wrong cause. + if (blob_length > 0) { + if (position > blob_length) { + THROW_ERR_OUT_OF_RANGE( + env->isolate(), + "The \"options.position\" argument must be >= 0 and <= %" PRId64 ".", + blob_length); + return false; + } + + const int64_t remaining = blob_length - position; + if (len > remaining) { + THROW_ERR_OUT_OF_RANGE(env->isolate(), + "The requested range extends past the end of the " + "blob. The blob is %" PRId64 " bytes and %" PRId64 + " bytes were requested at position %" PRId64 ".", + blob_length, + len, + position); + return false; + } + } + + *length = len; + return true; +} + +} // namespace + +void DatabaseSync::OpenBlob(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + // sqlite3_blob_open() prepares and steps a statement internally, which an + // authorizer callback is not allowed to do on its own connection. + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); + + if (!args[0]->IsObject()) { + THROW_ERR_INVALID_ARG_TYPE(isolate, + "The \"options\" argument must be an object."); + return; + } + + Local options = args[0].As(); + std::string table; + std::string column; + std::string db_name = "main"; + bool read_only = false; + int64_t row; + + if (!GetStringOption( + env, options, env->table_string(), "options.table", true, &table) || + !GetStringOption(env, + options, + env->column_string(), + "options.column", + true, + &column) || + !GetStringOption(env, + options, + env->db_name_string(), + "options.dbName", + false, + &db_name) || + !GetBoolOption(env, + options, + env->read_only_string(), + "options.readOnly", + &read_only)) { + return; + } + + Local row_value; + if (!options->Get(env->context(), env->row_string()).ToLocal(&row_value)) { + return; + } + if (!ToInt64(env, row_value, "options.row", &row)) { + return; + } + + // Reading the options above can run user code -- the object may expose + // accessors, or be a Proxy -- and that code can close the database. The + // entry guards have to be repeated before the connection is used. + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); + + sqlite3_blob* blob; + int r = sqlite3_blob_open(db->connection_.get(), + db_name.c_str(), + table.c_str(), + column.c_str(), + row, + read_only ? 0 : 1, + &blob); + CHECK_ERROR_OR_THROW(isolate, db, r, SQLITE_OK, void()); + + BaseObjectPtr handle = + BlobHandle::Create(env, BaseObjectPtr(db), blob); + if (!handle) { + sqlite3_blob_close(blob); + return; + } + + args.GetReturnValue().Set(handle->object()); +} + void Backup(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); if (args.Length() < 1 || !args[0]->IsObject()) { @@ -4362,6 +4716,202 @@ void Session::Delete() { } } +BlobHandle::BlobHandle(Environment* env, + Local object, + BaseObjectPtr database, + sqlite3_blob* blob) + : BaseObject(env, object), blob_(blob), database_(std::move(database)) { + database_->blobs_.insert(this); + MakeWeak(); +} + +BlobHandle::~BlobHandle() { + Delete(); +} + +BaseObjectPtr BlobHandle::Create( + Environment* env, + BaseObjectPtr database, + sqlite3_blob* blob) { + Local obj; + if (!GetConstructorTemplate(env) + ->InstanceTemplate() + ->NewInstance(env->context()) + .ToLocal(&obj)) { + return nullptr; + } + + return MakeBaseObject(env, obj, std::move(database), blob); +} + +Local BlobHandle::GetConstructorTemplate(Environment* env) { + Local tmpl = env->sqlite_blob_handle_constructor_template(); + if (tmpl.IsEmpty()) { + Isolate* isolate = env->isolate(); + tmpl = NewFunctionTemplate(isolate, IllegalConstructor); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "BlobHandle")); + tmpl->InstanceTemplate()->SetInternalFieldCount( + BlobHandle::kInternalFieldCount); + SetSideEffectFreeGetter(isolate, + tmpl, + FIXED_ONE_BYTE_STRING(isolate, "byteLength"), + BlobHandle::ByteLengthGetter); + SetProtoMethod(isolate, tmpl, "read", BlobHandle::Read); + SetProtoMethod(isolate, tmpl, "write", BlobHandle::Write); + SetProtoMethod(isolate, tmpl, "reopen", BlobHandle::Reopen); + SetProtoMethod(isolate, tmpl, "close", BlobHandle::Close); + SetProtoDispose(isolate, tmpl, BlobHandle::Dispose); + env->set_sqlite_blob_handle_constructor_template(tmpl); + } + return tmpl; +} + +void BlobHandle::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("database", database_); +} + +#define THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob) \ + do { \ + THROW_AND_RETURN_ON_BAD_STATE( \ + (env), !(blob)->database_->IsOpen(), "database is not open"); \ + THROW_AND_RETURN_ON_BAD_STATE( \ + (env), (blob)->blob_ == nullptr, "blob handle is closed"); \ + } while (0) + +void BlobHandle::ByteLengthGetter(const FunctionCallbackInfo& args) { + BlobHandle* blob; + ASSIGN_OR_RETURN_UNWRAP(&blob, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob); + // Not cached: reopen() can point the handle at a differently sized value. + args.GetReturnValue().Set(sqlite3_blob_bytes(blob->blob_)); +} + +void BlobHandle::Transfer(const FunctionCallbackInfo& args, + bool is_write) { + BlobHandle* blob; + ASSIGN_OR_RETURN_UNWRAP(&blob, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob); + + if (!args[0]->IsArrayBufferView()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"buffer\" argument must be a TypedArray or a DataView."); + return; + } + + // Reading the options can run user code, so it happens before anything that + // that code could invalidate is captured. + int64_t offset; + std::optional requested_length; + int64_t position; + if (!ReadBlobRangeOptions(env, args, &offset, &requested_length, &position)) { + return; + } + + // The handle and the database may have been closed while the options were + // being read, so the entry guards have to be repeated rather than trusted. + THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob); + // A write modifies the connection, and even a read moves its cursor and + // error state, which SQLite forbids from inside an authorizer callback. + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, blob->database_.get()); + + // Likewise the buffer may have been detached or resized, so its size and its + // data pointer are read now and validated together. + int64_t length; + if (!ValidateBlobRange(env, + static_cast(Buffer::Length(args[0])), + sqlite3_blob_bytes(blob->blob_), + offset, + requested_length, + position, + &length)) { + return; + } + + // SQLite validates the range, the aborted state and write permission even + // for a zero-length transfer, so the call is always made. A detached buffer + // has no data pointer, but it also has no bytes, so a dummy address is + // enough to keep those checks without dereferencing null. + char dummy; + char* data = length == 0 ? &dummy : Buffer::Data(args[0]) + offset; + int r = is_write ? sqlite3_blob_write(blob->blob_, + data, + static_cast(length), + static_cast(position)) + : sqlite3_blob_read(blob->blob_, + data, + static_cast(length), + static_cast(position)); + blob->last_result_ = r; + CHECK_ERROR_OR_THROW( + env->isolate(), blob->database_.get(), r, SQLITE_OK, void()); + args.GetReturnValue().Set(static_cast(length)); +} + +void BlobHandle::Read(const FunctionCallbackInfo& args) { + Transfer(args, false); +} + +void BlobHandle::Write(const FunctionCallbackInfo& args) { + Transfer(args, true); +} + +void BlobHandle::Reopen(const FunctionCallbackInfo& args) { + BlobHandle* blob; + ASSIGN_OR_RETURN_UNWRAP(&blob, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, blob->database_.get()); + + int64_t row; + if (!ToInt64(env, args[0], "row", &row)) { + return; + } + + int r = sqlite3_blob_reopen(blob->blob_, row); + blob->last_result_ = r; + CHECK_ERROR_OR_THROW( + env->isolate(), blob->database_.get(), r, SQLITE_OK, void()); +} + +void BlobHandle::Close(const FunctionCallbackInfo& args) { + BlobHandle* blob; + ASSIGN_OR_RETURN_UNWRAP(&blob, args.This()); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_IF_BLOB_UNUSABLE(env, blob); + // Closing a read-write handle can commit the open transaction. + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, blob->database_.get()); + + // sqlite3_blob_close() can report an error deferred from an earlier write. + // The handle is released either way, so this is the only path that surfaces + // it; the implicit paths below cannot throw. + int r = blob->Delete(); + CHECK_ERROR_OR_THROW( + env->isolate(), blob->database_.get(), r, SQLITE_OK, void()); +} + +void BlobHandle::Dispose(const FunctionCallbackInfo& args) { + BlobHandle* blob; + ASSIGN_OR_RETURN_UNWRAP(&blob, args.This()); + if (blob->blob_ == nullptr) return; + Close(args); +} + +int BlobHandle::Delete() { + if (blob_ == nullptr) return SQLITE_OK; + sqlite3_blob* blob = blob_; + blob_ = nullptr; + if (database_) { + database_->blobs_.erase(this); + } + + int r = sqlite3_blob_close(blob); + // Only a code the caller has not already been told about is worth raising. + return r == last_result_ ? SQLITE_OK : r; +} + void DefineConstants(Local target) { NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_OMIT); NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_REPLACE); @@ -4467,6 +5017,7 @@ static void Initialize(Local target, isolate, db_tmpl, "aggregate", DatabaseSync::AggregateFunction); SetProtoMethod( isolate, db_tmpl, "createSession", DatabaseSync::CreateSession); + SetProtoMethod(isolate, db_tmpl, "openBlob", DatabaseSync::OpenBlob); SetProtoMethod( isolate, db_tmpl, "applyChangeset", DatabaseSync::ApplyChangeset); SetProtoMethod(isolate, @@ -4507,6 +5058,8 @@ static void Initialize(Local target, StatementSync::GetConstructorTemplate(env)); SetConstructorFunction( context, target, "Session", Session::GetConstructorTemplate(env)); + SetConstructorFunction( + context, target, "BlobHandle", BlobHandle::GetConstructorTemplate(env)); target->Set(context, env->constants_string(), constants).Check(); diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 8f6801401e55..408f7a39cfb7 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -168,6 +168,7 @@ class StatementSyncIterator; class StatementSync; class BackupJob; class Session; +class BlobHandle; inline void FinalizeStatement(sqlite3_stmt* stmt) { sqlite3_finalize(stmt); @@ -241,6 +242,7 @@ class DatabaseSync : public BaseObject { static void AggregateFunction( const v8::FunctionCallbackInfo& args); static void CreateSession(const v8::FunctionCallbackInfo& args); + static void OpenBlob(const v8::FunctionCallbackInfo& args); static void ApplyChangeset(const v8::FunctionCallbackInfo& args); static void EnableLoadExtension( const v8::FunctionCallbackInfo& args); @@ -264,6 +266,7 @@ class DatabaseSync : public BaseObject { void RemoveBackup(BackupJob* backup); void AddBackup(BackupJob* backup); void FinalizeBackups(); + int CloseBlobs(); void UntrackStatement(StatementSync* statement); bool IsOpen(); bool use_big_ints() const { return open_config_.get_use_big_ints(); } @@ -335,9 +338,11 @@ class DatabaseSync : public BaseObject { std::set backups_; std::unordered_set sessions_; + std::unordered_set blobs_; std::unordered_set statements_; BaseObjectPtr trace_channel_; + friend class BlobHandle; friend class DatabaseSyncLimits; friend class Session; friend class SQLTagStore; @@ -465,6 +470,50 @@ class Session : public BaseObject { friend class DatabaseSync; }; +class BlobHandle : public BaseObject { + public: + BlobHandle(Environment* env, + v8::Local object, + BaseObjectPtr database, + sqlite3_blob* blob); + ~BlobHandle() override; + static v8::Local GetConstructorTemplate( + Environment* env); + static BaseObjectPtr Create(Environment* env, + BaseObjectPtr database, + sqlite3_blob* blob); + static void ByteLengthGetter(const v8::FunctionCallbackInfo& args); + static void Read(const v8::FunctionCallbackInfo& args); + static void Write(const v8::FunctionCallbackInfo& args); + static void Transfer(const v8::FunctionCallbackInfo& args, + bool is_write); + static void Reopen(const v8::FunctionCallbackInfo& args); + static void Close(const v8::FunctionCallbackInfo& args); + static void Dispose(const v8::FunctionCallbackInfo& args); + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(BlobHandle) + SET_SELF_SIZE(BlobHandle) + + private: + // Releases the underlying handle and stops tracking it on the database. + // sqlite3_blob_close() can report an error deferred from an earlier write, + // which is returned here so that the explicit close() path can throw while + // the implicit paths (garbage collection, database close) ignore it. The + // handle is released either way. + int Delete(); + sqlite3_blob* blob_; + // sqlite3_blob_close() reports the underlying statement's last result code, + // which is not necessarily new: an operation that already threw leaves its + // error behind for the close to repeat. The last code seen is kept so that + // only a genuinely new failure -- a commit that did not go through -- is + // reported a second time. + int last_result_ = SQLITE_OK; + BaseObjectPtr database_; // The parent database. + + friend class DatabaseSync; +}; + class SQLTagStore : public BaseObject { public: enum InternalFields { diff --git a/test/parallel/test-sqlite-blob.js b/test/parallel/test-sqlite-blob.js new file mode 100644 index 000000000000..0de26f1bd54a --- /dev/null +++ b/test/parallel/test-sqlite-blob.js @@ -0,0 +1,1079 @@ +'use strict'; +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); + +// This test exercises database.openBlob() and the BlobHandle it returns: the +// options it accepts, the ranges it allows, what happens when the value or the +// connection goes away underneath a handle, and what an option accessor that +// runs user code can and cannot do to an in-flight transfer. + +const tmpdir = require('../common/tmpdir'); +const { join } = require('node:path'); +const { BlobHandle, DatabaseSync } = require('node:sqlite'); +const { suite, test } = require('node:test'); + +tmpdir.refresh(); + +let dbCount = 0; + +function nextDbPath() { + return join(tmpdir.path, `blob-${dbCount++}.db`); +} + +function makeDb(size = 16) { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE files (name TEXT, data BLOB)'); + const { lastInsertRowid } = db + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(?))') + .run('a.bin', size); + return { db, row: lastInsertRowid }; +} + +suite('DatabaseSync.prototype.openBlob()', () => { + test('returns a BlobHandle for an existing value', (t) => { + const { db, row } = makeDb(32); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + t.assert.ok(blob instanceof BlobHandle); + t.assert.strictEqual(blob.byteLength, 32); + blob.close(); + db.close(); + }); + + test('accepts a bigint rowid', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ + table: 'files', + column: 'data', + row: BigInt(row), + }); + t.assert.strictEqual(blob.byteLength, 16); + blob.close(); + db.close(); + }); + + test('opens a value in an attached database', (t) => { + const { db, row } = makeDb(); + db.exec('ATTACH DATABASE \':memory:\' AS other'); + db.exec('CREATE TABLE other.files (data BLOB)'); + db.prepare('INSERT INTO other.files VALUES (zeroblob(8))').run(); + const blob = db.openBlob({ + dbName: 'other', + table: 'files', + column: 'data', + row, + }); + t.assert.strictEqual(blob.byteLength, 8); + blob.close(); + db.close(); + }); + + test('throws if the database is not open', (t) => { + const { db, row } = makeDb(); + db.close(); + t.assert.throws(() => { + db.openBlob({ table: 'files', column: 'data', row }); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('throws if options is not an object', (t) => { + const { db } = makeDb(); + for (const options of [undefined, null, 'files', 5]) { + t.assert.throws(() => { + db.openBlob(options); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be an object/, + }); + } + db.close(); + }); + + test('validates individual options', (t) => { + const { db, row } = makeDb(); + const base = { table: 'files', column: 'data', row }; + + t.assert.throws(() => { + db.openBlob({ ...base, table: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.table" argument must be a string/, + }); + t.assert.throws(() => { + db.openBlob({ ...base, column: undefined }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.column" argument must be a string/, + }); + t.assert.throws(() => { + db.openBlob({ ...base, row: '1' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.row" argument must be a number or a BigInt/, + }); + t.assert.throws(() => { + db.openBlob({ ...base, row: 1.5 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.row" argument must be a safe integer/, + }); + t.assert.throws(() => { + db.openBlob({ ...base, readOnly: 'yes' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.readOnly" argument must be a boolean/, + }); + t.assert.throws(() => { + db.openBlob({ ...base, dbName: 5 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.dbName" argument must be a string/, + }); + db.close(); + }); + + test('surfaces SQLite errors for unusable rows and columns', (t) => { + const { db, row } = makeDb(); + t.assert.throws(() => { + db.openBlob({ table: 'files', column: 'data', row: 9999 }); + }, { code: 'ERR_SQLITE_ERROR', message: /no such rowid/ }); + t.assert.throws(() => { + db.openBlob({ table: 'files', column: 'nope', row }); + }, { code: 'ERR_SQLITE_ERROR', message: /no such column/ }); + t.assert.throws(() => { + db.openBlob({ table: 'nope', column: 'data', row }); + }, { code: 'ERR_SQLITE_ERROR' }); + + db.exec('CREATE TABLE wr (k TEXT PRIMARY KEY, d BLOB) WITHOUT ROWID'); + db.prepare('INSERT INTO wr VALUES (?, zeroblob(8))').run('x'); + t.assert.throws(() => { + db.openBlob({ table: 'wr', column: 'd', row: 1 }); + }, { code: 'ERR_SQLITE_ERROR', message: /without rowid/ }); + + db.exec('CREATE TABLE ix (d BLOB UNIQUE)'); + db.prepare('INSERT INTO ix VALUES (zeroblob(8))').run(); + t.assert.throws(() => { + db.openBlob({ table: 'ix', column: 'd', row: 1 }); + }, { code: 'ERR_SQLITE_ERROR', message: /indexed column/ }); + const blob = db.openBlob({ + table: 'ix', + column: 'd', + row: 1, + readOnly: true, + }); + t.assert.strictEqual(blob.byteLength, 8); + blob.close(); + + db.exec('CREATE VIRTUAL TABLE vt USING fts5(d)'); + db.exec("INSERT INTO vt VALUES ('value')"); + t.assert.throws(() => { + db.openBlob({ table: 'vt', column: 'd', row: 1, readOnly: true }); + }, { code: 'ERR_SQLITE_ERROR', message: /cannot open virtual table/ }); + + db.exec(` + CREATE TABLE generated ( + d BLOB, + size INTEGER GENERATED ALWAYS AS (length(d)) + ) + `); + db.exec('INSERT INTO generated (d) VALUES (zeroblob(8))'); + t.assert.throws(() => { + db.openBlob({ + table: 'generated', + column: 'd', + row: 1, + readOnly: true, + }); + }, { code: 'ERR_SQLITE_ERROR', message: /generated columns/ }); + db.close(); + }); + + test('cannot be called from an authorizer callback', (t) => { + const { db, row } = makeDb(); + let error; + db.setAuthorizer(() => { + try { + db.openBlob({ table: 'files', column: 'data', row }); + } catch (err) { + error = err; + } + return 0; + }); + db.prepare('SELECT 1').get(); + t.assert.strictEqual(error.code, 'ERR_INVALID_STATE'); + t.assert.match(error.message, /authorizer callback/); + db.close(); + }); + + test('refuses a writable handle on a foreign key child column', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE parents (k BLOB PRIMARY KEY)'); + db.exec('CREATE TABLE children (fk BLOB REFERENCES parents(k))'); + db.prepare('INSERT INTO parents VALUES (zeroblob(8))').run(); + db.prepare('INSERT INTO children VALUES (zeroblob(8))').run(); + // The column carries no index of its own, so the refusal below can only + // come from the foreign key rule. + t.assert.deepStrictEqual( + db.prepare('PRAGMA index_list(children)').all(), + [], + ); + + t.assert.throws(() => { + db.openBlob({ table: 'children', column: 'fk', row: 1 }); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 1, + message: /foreign key/, + }); + + const readOnly = db.openBlob({ + table: 'children', + column: 'fk', + row: 1, + readOnly: true, + }); + t.assert.strictEqual(readOnly.byteLength, 8); + readOnly.close(); + db.close(); + }); + + test('opens a foreign key child column without enforcement', (t) => { + // The same schema and the same rows as above: only the pragma moves. + const db = new DatabaseSync(':memory:', { + enableForeignKeyConstraints: false, + }); + db.exec('CREATE TABLE parents (k BLOB PRIMARY KEY)'); + db.exec('CREATE TABLE children (fk BLOB REFERENCES parents(k))'); + db.prepare('INSERT INTO parents VALUES (zeroblob(8))').run(); + db.prepare('INSERT INTO children VALUES (zeroblob(8))').run(); + const blob = db.openBlob({ table: 'children', column: 'fk', row: 1 }); + t.assert.strictEqual(blob.write(Buffer.from([1, 2, 3, 4])), 4); + blob.close(); + db.close(); + }); +}); + +suite('BlobHandle', () => { + test('cannot be constructed directly', (t) => { + t.assert.throws(() => { + new BlobHandle(); + }, { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); + }); + + test('round-trips a value written in chunks', (t) => { + const { db, row } = makeDb(32); + { + using blob = db.openBlob({ table: 'files', column: 'data', row }); + t.assert.strictEqual(blob.write(Buffer.alloc(16, 0xaa)), 16); + t.assert.strictEqual( + blob.write(Buffer.alloc(16, 0xbb), { position: 16 }), + 16, + ); + } + + const expected = Buffer.concat([ + Buffer.alloc(16, 0xaa), + Buffer.alloc(16, 0xbb), + ]); + const { data } = db + .prepare('SELECT data FROM files WHERE rowid = ?') + .get(row); + t.assert.deepStrictEqual(Buffer.from(data), expected); + + using blob = db.openBlob({ + table: 'files', + column: 'data', + row, + readOnly: true, + }); + const actual = Buffer.alloc(32); + for (let position = 0; position < blob.byteLength; position += 8) { + t.assert.strictEqual( + blob.read(actual, { offset: position, length: 8, position }), + 8, + ); + } + t.assert.deepStrictEqual(actual, expected); + db.close(); + }); + + test('reads and writes TypedArrays and DataViews', (t) => { + const { db, row } = makeDb(8); + using blob = db.openBlob({ table: 'files', column: 'data', row }); + blob.write(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + + const u8 = new Uint8Array(8); + t.assert.strictEqual(blob.read(u8), 8); + t.assert.deepStrictEqual(u8, new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + + const view = new DataView(new ArrayBuffer(8)); + t.assert.strictEqual(blob.read(view), 8); + t.assert.strictEqual(view.getUint8(0), 1); + + // A view onto part of a buffer only sees its own range. + const backing = new Uint8Array(16).fill(0xff); + t.assert.strictEqual(blob.read(backing.subarray(8)), 8); + t.assert.deepStrictEqual(backing.subarray(0, 8), new Uint8Array(8).fill(0xff)); + t.assert.deepStrictEqual( + backing.subarray(8), + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + ); + db.close(); + }); + + test('accepts bigint range options', (t) => { + const { db, row } = makeDb(8); + using blob = db.openBlob({ table: 'files', column: 'data', row }); + blob.write(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])); + const buf = Buffer.alloc(4); + t.assert.strictEqual(blob.read(buf, { position: 4n, length: 4n }), 4); + t.assert.deepStrictEqual(buf, Buffer.from([5, 6, 7, 8])); + db.close(); + }); + + test('rejects a buffer that is not a view', (t) => { + const { db, row } = makeDb(); + using blob = db.openBlob({ table: 'files', column: 'data', row }); + for (const buffer of [undefined, null, 'abc', [1, 2, 3], + new ArrayBuffer(8)]) { + for (const method of ['read', 'write']) { + t.assert.throws(() => { + blob[method](buffer); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "buffer" argument must be a TypedArray or a DataView/, + }); + } + } + db.close(); + }); + + test('validates the range options', (t) => { + const { db, row } = makeDb(16); + using blob = db.openBlob({ table: 'files', column: 'data', row }); + const buf = Buffer.alloc(8); + + t.assert.throws(() => { + blob.read(buf, null); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be an object/, + }); + t.assert.throws(() => { + blob.read(buf, { offset: -1 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.offset" argument must be >= 0 and <= 8/, + }); + t.assert.throws(() => { + blob.read(buf, { offset: 9 }); + }, { code: 'ERR_OUT_OF_RANGE' }); + t.assert.throws(() => { + blob.read(buf, { length: 9 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.length" argument must be >= 0 and <= 8/, + }); + t.assert.throws(() => { + blob.read(buf, { position: -1 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.position" argument must be >= 0/, + }); + t.assert.throws(() => { + blob.read(buf, { position: 17 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.position" argument must be >= 0 and <= 16/, + }); + t.assert.throws(() => { + blob.read(buf, { position: '0' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.position" argument must be a number or a BigInt/, + }); + t.assert.throws(() => { + blob.read(buf, { position: 1.5 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /The "options\.position" argument must be a safe integer/, + }); + db.close(); + }); + + test('reads and writes are all-or-nothing', (t) => { + const { db, row } = makeDb(16); + using blob = db.openBlob({ table: 'files', column: 'data', row }); + // Unlike filehandle.read(), there is no short read at the end. + for (const method of ['read', 'write']) { + t.assert.throws(() => { + blob[method](Buffer.alloc(8), { position: 12 }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /extends past the end of the blob/, + }); + } + // A request that ends exactly at the end of the value is fine. + t.assert.strictEqual(blob.read(Buffer.alloc(8), { position: 8 }), 8); + db.close(); + }); + + test('cannot write through a read-only handle', (t) => { + const { db, row } = makeDb(); + using blob = db.openBlob({ + table: 'files', + column: 'data', + row, + readOnly: true, + }); + t.assert.throws(() => { + blob.write(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', message: /readonly/ }); + db.close(); + }); + + test('reopen() moves the handle to another row', (t) => { + const { db, row } = makeDb(16); + const { lastInsertRowid: other } = db + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(?))') + .run('b.bin', 64); + + using blob = db.openBlob({ table: 'files', column: 'data', row }); + t.assert.strictEqual(blob.byteLength, 16); + blob.write(Buffer.alloc(16, 0x01)); + blob.reopen(other); + t.assert.strictEqual(blob.byteLength, 64); + blob.write(Buffer.alloc(64, 0x02)); + + const rows = db.prepare('SELECT data FROM files ORDER BY rowid').all(); + t.assert.deepStrictEqual(Buffer.from(rows[0].data), Buffer.alloc(16, 0x01)); + t.assert.deepStrictEqual(Buffer.from(rows[1].data), Buffer.alloc(64, 0x02)); + db.close(); + }); + + test('reopen() validates its argument', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + t.assert.throws(() => { + blob.reopen('1'); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "row" argument must be a number or a BigInt/, + }); + // Argument validation does not call sqlite3_blob_reopen() and therefore + // does not abort the handle. + t.assert.strictEqual(blob.byteLength, 16); + t.assert.strictEqual(blob.read(Buffer.alloc(4)), 4); + blob.close(); + db.close(); + }); + + test('a handle is aborted when its row is modified', (t) => { + const { db, row } = makeDb(16); + const blob = db.openBlob({ + table: 'files', + column: 'data', + row, + readOnly: true, + }); + db.prepare('UPDATE files SET name = ? WHERE rowid = ?').run('b.bin', row); + + // Modifying the row expires the handle without changing the byte count + // cached by SQLite for the value that was originally opened. + t.assert.strictEqual(blob.byteLength, 16); + + // The first I/O detects the expiration and aborts the handle. + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4, message: /aborted/ }); + t.assert.strictEqual(blob.byteLength, 0); + + // Once aborted, every data operation keeps reporting SQLITE_ABORT. + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4, message: /aborted/ }); + t.assert.throws(() => { + blob.write(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4 }); + t.assert.throws(() => { + blob.reopen(row); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4 }); + blob.close(); + db.close(); + }); + + test('reopen() can recover an expired handle before I/O', (t) => { + const { db, row } = makeDb(16); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + db.prepare('UPDATE files SET data = zeroblob(8) WHERE rowid = ?').run(row); + + // The expired handle still has its old cached size until I/O detects the + // expiration, so reopening it first can retarget it to the replacement. + t.assert.strictEqual(blob.byteLength, 16); + blob.reopen(row); + t.assert.strictEqual(blob.byteLength, 8); + t.assert.strictEqual(blob.read(Buffer.alloc(8)), 8); + blob.close(); + db.close(); + }); + + test('a failed reopen() aborts the handle', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + t.assert.throws(() => { + blob.reopen(9999); + }, { code: 'ERR_SQLITE_ERROR', message: /no such rowid/ }); + // A failed sqlite3_blob_reopen(), rather than row invalidation by itself, + // is what changes sqlite3_blob_bytes() to zero. + t.assert.strictEqual(blob.byteLength, 0); + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4, message: /aborted/ }); + t.assert.throws(() => { + blob.write(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4 }); + t.assert.throws(() => { + blob.reopen(row); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4 }); + blob.close(); + db.close(); + }); + + test('close() makes the handle unusable', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + blob.close(); + for (const fn of [ + () => blob.byteLength, + () => blob.read(Buffer.alloc(4)), + () => blob.write(Buffer.alloc(4)), + () => blob.reopen(row), + () => blob.close(), + ]) { + t.assert.throws(fn, { + code: 'ERR_INVALID_STATE', + message: /blob handle is closed/, + }); + } + db.close(); + }); + + test('Symbol.dispose is idempotent', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + blob[Symbol.dispose](); + blob[Symbol.dispose](); + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_INVALID_STATE' }); + db.close(); + }); + + test('closing the database invalidates open handles', (t) => { + const { db, row } = makeDb(); + const blob = db.openBlob({ table: 'files', column: 'data', row }); + db.close(); + for (const fn of [ + () => blob.byteLength, + () => blob.read(Buffer.alloc(4)), + () => blob.close(), + ]) { + t.assert.throws(fn, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + } + }); + + test('an empty value can be opened but not read past', (t) => { + const { db } = makeDb(); + const { lastInsertRowid } = db + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(0))') + .run('empty.bin'); + using blob = db.openBlob({ + table: 'files', + column: 'data', + row: lastInsertRowid, + }); + t.assert.strictEqual(blob.byteLength, 0); + t.assert.strictEqual(blob.read(Buffer.alloc(0)), 0); + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR' }); + db.close(); + }); + + test('opens TEXT values as well as BLOB values', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE notes (body TEXT)'); + const { lastInsertRowid } = db + .prepare('INSERT INTO notes VALUES (?)') + .run('hello world'); + using blob = db.openBlob({ + table: 'notes', + column: 'body', + row: lastInsertRowid, + readOnly: true, + }); + t.assert.strictEqual(blob.byteLength, 11); + const buf = Buffer.alloc(5); + blob.read(buf, { position: 6 }); + t.assert.strictEqual(buf.toString(), 'world'); + db.close(); + }); + + test('writes bypass SQL update semantics', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE files ( + data BLOB CHECK (hex(substr(data, 1, 1)) = '00') + ); + CREATE TABLE audit (event TEXT); + CREATE TRIGGER files_updated AFTER UPDATE ON files BEGIN + INSERT INTO audit VALUES ('updated'); + END; + INSERT INTO files VALUES (zeroblob(4)); + `); + const blob = db.openBlob({ table: 'files', column: 'data', row: 1 }); + blob.write(Buffer.from([0xff])); + blob.close(); + + t.assert.strictEqual( + db.prepare('SELECT hex(data) AS data FROM files').get().data, + 'FF000000', + ); + t.assert.strictEqual( + db.prepare('SELECT count(*) AS count FROM audit').get().count, + 0, + ); + t.assert.strictEqual( + db.prepare('PRAGMA integrity_check').get().integrity_check, + 'CHECK constraint failed in files', + ); + db.close(); + }); + + test('writes TEXT values as raw bytes', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec("CREATE TABLE notes (body TEXT); INSERT INTO notes VALUES ('text')"); + const blob = db.openBlob({ table: 'notes', column: 'body', row: 1 }); + blob.write(Buffer.from([0xff])); + blob.close(); + + const result = db + .prepare('SELECT typeof(body) AS type, hex(body) AS body FROM notes') + .get(); + t.assert.strictEqual(result.type, 'text'); + t.assert.strictEqual(result.body, 'FF657874'); + db.close(); + }); + + test('streams a value larger than the buffers used to move it', (t) => { + const size = 4 * 1024 * 1024; + const { db } = makeDb(); + const { lastInsertRowid } = db + .prepare('INSERT INTO files (name, data) VALUES (?, zeroblob(?))') + .run('big.bin', size); + const options = { table: 'files', column: 'data', row: lastInsertRowid }; + const chunk = Buffer.alloc(64 * 1024); + + { + using blob = db.openBlob(options); + for (let position = 0; position < size; position += chunk.length) { + chunk.fill((position / chunk.length) & 0xff); + blob.write(chunk, { position }); + } + } + + using blob = db.openBlob({ ...options, readOnly: true }); + t.assert.strictEqual(blob.byteLength, size); + for (let position = 0; position < size; position += chunk.length) { + blob.read(chunk, { position }); + t.assert.deepStrictEqual( + chunk, + Buffer.alloc(chunk.length, (position / chunk.length) & 0xff), + ); + } + db.close(); + }); +}); + +suite('BlobHandle range limits', () => { + test('rejects a range larger than SQLite can address', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE files (data BLOB)'); + db.prepare('INSERT INTO files VALUES (zeroblob(0))').run(); + using blob = db.openBlob({ table: 'files', column: 'data', row: 1 }); + // byteLength is 0, so the blob-relative checks are skipped; the int range + // of sqlite3_blob_read() still has to be enforced. Only position is + // reachable here -- length is already bounded by the size of the buffer. + t.assert.throws(() => { + blob.read(Buffer.alloc(8), { position: 2n ** 31n }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /must be <= 2147483647/, + }); + db.close(); + }); +}); + +suite('BlobHandle reentrancy', () => { + // Reading the options object can run user code: it may expose accessors or + // be a Proxy. That code must not be able to invalidate state the operation + // has already validated. + function setup(size = 4096) { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (d BLOB)'); + db.prepare('INSERT INTO t VALUES (zeroblob(?))').run(size); + return { db, blob: db.openBlob({ table: 't', column: 'd', row: 1 }) }; + } + + function optionsThatRun(fn, length) { + return { + offset: 0, + length, + get position() { fn(); return 0; }, + }; + } + + test('a getter that detaches the buffer cannot cause a bad write', (t) => { + for (const method of ['read', 'write']) { + const { db, blob } = setup(); + const buffer = new Uint8Array(4096); + const options = optionsThatRun(() => { + structuredClone(buffer.buffer, { transfer: [buffer.buffer] }); + }, 4096); + t.assert.throws(() => { + blob[method](buffer, options); + }, { code: 'ERR_OUT_OF_RANGE' }); + db.close(); + } + }); + + test('a getter that shrinks a resizable buffer cannot overrun it', (t) => { + const { db, blob } = setup(); + const ab = new ArrayBuffer(4096, { maxByteLength: 4096 }); + const buffer = new Uint8Array(ab); + t.assert.throws(() => { + blob.read(buffer, optionsThatRun(() => ab.resize(8), 4096)); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /"options\.length" argument must be >= 0 and <= 8/, + }); + db.close(); + }); + + test('a getter that closes the handle is reported as a closed handle', (t) => { + const { db, blob } = setup(); + t.assert.throws(() => { + blob.read(new Uint8Array(16), optionsThatRun(() => blob.close(), 16)); + }, { + code: 'ERR_INVALID_STATE', + message: /blob handle is closed/, + }); + db.close(); + }); + + test('a getter that closes the database is reported as a closed db', (t) => { + const { db, blob } = setup(); + t.assert.throws(() => { + blob.read(new Uint8Array(16), optionsThatRun(() => db.close(), 16)); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('openBlob() rechecks the database after reading its options', (t) => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (d BLOB)'); + db.prepare('INSERT INTO t VALUES (zeroblob(64))').run(); + t.assert.throws(() => { + db.openBlob({ + table: 't', + column: 'd', + get row() { db.close(); return 1; }, + }); + }, { + code: 'ERR_INVALID_STATE', + message: /database is not open/, + }); + }); + + test('a Proxy options object is handled the same way', (t) => { + const { db, blob } = setup(); + const buffer = new Uint8Array(4096); + const options = new Proxy({}, { + get(target, prop) { + if (prop === 'position') { + structuredClone(buffer.buffer, { transfer: [buffer.buffer] }); + return 0; + } + return prop === 'length' ? 4096 : 0; + }, + }); + t.assert.throws(() => { + blob.read(buffer, options); + }, { code: 'ERR_OUT_OF_RANGE' }); + db.close(); + }); +}); + +suite('BlobHandle guards found in review', () => { + const NUL = String.fromCharCode(0); + + function setup(size = 8) { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (d BLOB)'); + db.prepare('INSERT INTO t VALUES (zeroblob(?))').run(size); + return db; + } + + test('identifier options reject embedded null bytes', (t) => { + const db = setup(); + // SQLite takes these as NUL-terminated C strings, so truncation would + // silently open a different table, column or database. + for (const [key, value] of [ + ['table', `t${NUL}nope`], + ['column', `d${NUL}nope`], + ['dbName', `main${NUL}nope`], + ]) { + t.assert.throws(() => { + db.openBlob({ table: 't', column: 'd', row: 1, [key]: value }); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: new RegExp(`"options\\.${key}" argument must not contain`), + }); + } + db.close(); + }); + + test('operations are barred from an authorizer callback', (t) => { + const db = setup(); + const blob = db.openBlob({ table: 't', column: 'd', row: 1 }); + const results = {}; + db.setAuthorizer(() => { + for (const [name, fn] of [ + ['read', () => blob.read(Buffer.alloc(4))], + ['write', () => blob.write(Buffer.alloc(4))], + ['reopen', () => blob.reopen(1)], + ['close', () => blob.close()], + ['dispose', () => blob[Symbol.dispose]()], + ]) { + try { + fn(); + results[name] = 'no throw'; + } catch (err) { + results[name] = err.code; + } + } + return 0; + }); + db.prepare('SELECT 1').get(); + t.assert.deepStrictEqual(results, { + read: 'ERR_INVALID_STATE', + write: 'ERR_INVALID_STATE', + reopen: 'ERR_INVALID_STATE', + close: 'ERR_INVALID_STATE', + dispose: 'ERR_INVALID_STATE', + }); + db.close(); + }); + + test('a zero-length transfer is still checked by SQLite', (t) => { + const db = setup(); + // A zero-length range transfers nothing, but read-only enforcement and + // aborted-handle detection still have to apply. + const readOnly = db.openBlob({ + table: 't', + column: 'd', + row: 1, + readOnly: true, + }); + t.assert.throws(() => { + readOnly.write(Buffer.alloc(0)); + }, { code: 'ERR_SQLITE_ERROR', message: /readonly/ }); + readOnly.close(); + + const aborted = db.openBlob({ table: 't', column: 'd', row: 1 }); + db.prepare('UPDATE t SET d = zeroblob(8) WHERE rowid = 1').run(); + t.assert.throws(() => { + aborted.read(Buffer.alloc(0)); + }, { code: 'ERR_SQLITE_ERROR', errcode: 4 }); + aborted.close(); + db.close(); + }); + + test('closing does not repeat an error that already threw', (t) => { + const db = setup(); + const blob = db.openBlob({ + table: 't', + column: 'd', + row: 1, + readOnly: true, + }); + t.assert.throws(() => { + blob.write(Buffer.alloc(4)); + }, { code: 'ERR_SQLITE_ERROR' }); + // sqlite3_blob_close() reports the statement's last result code, which is + // the failure above. It has already been delivered once. + blob.close(); + db.close(); + }); + + test('a BigInt outside the 64-bit range is a range error', (t) => { + const db = setup(); + for (const row of [2n ** 64n, -(2n ** 64n)]) { + t.assert.throws(() => { + db.openBlob({ table: 't', column: 'd', row }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: /within the range of a signed 64-bit integer/, + }); + } + db.close(); + }); + + test('a transfer at a nonzero buffer offset lands at that offset', (t) => { + const db = setup(); + const blob = db.openBlob({ table: 't', column: 'd', row: 1 }); + blob.write(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])); + const buffer = Buffer.alloc(16, 0xff); + t.assert.strictEqual(blob.read(buffer, { offset: 4, length: 4 }), 4); + t.assert.deepStrictEqual(buffer.subarray(0, 4), Buffer.alloc(4, 0xff)); + t.assert.deepStrictEqual(buffer.subarray(4, 8), Buffer.from([1, 2, 3, 4])); + t.assert.deepStrictEqual(buffer.subarray(8), Buffer.alloc(8, 0xff)); + blob.close(); + db.close(); + }); +}); + +suite('BlobHandle deferred commit', () => { + // A write through a blob handle is committed when the handle is closed, so a + // failure there belongs to the close rather than to the write that has + // already returned. A reader on a second connection holds the shared lock + // that the commit needs, which makes such a failure reachable without an + // error injecting VFS. The result code is SQLITE_BUSY instead of an I/O + // error, but it travels the same path. + function lockedForCommit(t) { + const path = nextDbPath(); + const writer = new DatabaseSync(path, { timeout: 0 }); + const reader = new DatabaseSync(path, { timeout: 0 }); + t.after(() => { + for (const db of [reader, writer]) { + try { + db.close(); + } catch { + // Already closed, or closing reported the blocked commit. + } + } + }); + writer.exec('CREATE TABLE files (data BLOB)'); + writer.prepare('INSERT INTO files VALUES (zeroblob(16))').run(); + // A deferred transaction takes no lock until it reads. + reader.exec('BEGIN'); + reader.prepare('SELECT count(*) FROM files').get(); + return { path, writer, reader }; + } + + test('close reports a commit that could not be taken', (t) => { + const { writer, reader } = lockedForCommit(t); + const blob = writer.openBlob({ table: 'files', column: 'data', row: 1 }); + t.assert.strictEqual(blob.write(Buffer.from([1, 2, 3, 4])), 4); + t.assert.throws(() => { + blob.close(); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 5, + message: /database is locked/, + }); + // The handle is released even though the close reported an error. + t.assert.throws(() => { + blob.close(); + }, { code: 'ERR_INVALID_STATE' }); + + reader.exec('COMMIT'); + t.assert.strictEqual( + writer.prepare('SELECT hex(data) AS h FROM files').get().h, + '0'.repeat(32), + ); + }); + + test('database.close() reports a commit that could not be taken', (t) => { + const { path, writer, reader } = lockedForCommit(t); + const blob = writer.openBlob({ table: 'files', column: 'data', row: 1 }); + const otherBlob = writer.openBlob({ + table: 'files', + column: 'data', + row: 1, + readOnly: true, + }); + blob.write(Buffer.from([1, 2, 3, 4])); + t.assert.throws(() => { + writer.close(); + }, { code: 'ERR_SQLITE_ERROR', errcode: 5 }); + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_INVALID_STATE' }); + t.assert.throws(() => { + otherBlob.read(Buffer.alloc(4)); + }, { code: 'ERR_INVALID_STATE' }); + t.assert.throws(() => { + writer.prepare('SELECT 1'); + }, { code: 'ERR_INVALID_STATE', message: /database is not open/ }); + + reader.exec('COMMIT'); + const observer = new DatabaseSync(path); + t.assert.strictEqual( + observer.prepare('SELECT hex(data) AS h FROM files').get().h, + '0'.repeat(32), + ); + observer.close(); + }); + + test('Symbol.dispose reports a blocked commit', (t) => { + const { writer, reader } = lockedForCommit(t); + const blob = writer.openBlob({ table: 'files', column: 'data', row: 1 }); + blob.write(Buffer.from([1, 2, 3, 4])); + t.assert.throws(() => { + blob[Symbol.dispose](); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 5, + errstr: 'database is locked', + }); + blob[Symbol.dispose](); + + reader.exec('COMMIT'); + t.assert.strictEqual( + writer.prepare('SELECT hex(data) AS h FROM files').get().h, + '0'.repeat(32), + ); + }); + + test('database Symbol.dispose reports a blocked commit', (t) => { + const { path, writer, reader } = lockedForCommit(t); + const blob = writer.openBlob({ table: 'files', column: 'data', row: 1 }); + blob.write(Buffer.from([1, 2, 3, 4])); + t.assert.throws(() => { + writer[Symbol.dispose](); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 5, + }); + writer[Symbol.dispose](); + t.assert.throws(() => { + blob.read(Buffer.alloc(4)); + }, { code: 'ERR_INVALID_STATE' }); + + reader.exec('COMMIT'); + const observer = new DatabaseSync(path); + t.assert.strictEqual( + observer.prepare('SELECT hex(data) AS h FROM files').get().h, + '0'.repeat(32), + ); + observer.close(); + }); +}); From 706eb9d77b23f365971f38f1c52a13b20e5049be Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 19 Aug 2026 22:55:06 -0400 Subject: [PATCH 2/2] sqlite: set errstr on errcode-only errors The int overload of THROW_ERR_SQLITE_ERROR() sets errcode on the error it throws but not errstr, because it passes the string it just looked up as the message and then calls the const char* overload of CreateSQLiteError(), which takes that as a plain message and leaves the errstr slot empty. The int overload of CreateSQLiteError() already sets both, so call that instead. Every other error raised by node:sqlite carries errstr, and the tests for StatementSync already assert it, so the two paths reaching this helper -- applyChangeset() and a close() that reports a blob commit -- were the only ones where it was missing. Signed-off-by: Ari Mayer --- src/node_sqlite.cc | 10 +--------- test/parallel/test-sqlite-blob.js | 7 ++++++- test/parallel/test-sqlite-session.js | 13 +++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 00e13858b078..aaa151df6ce4 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -355,16 +355,8 @@ inline void THROW_ERR_SQLITE_ERROR(Isolate* isolate, const char* message) { } inline void THROW_ERR_SQLITE_ERROR(Isolate* isolate, int errcode) { - const char* errstr = sqlite3_errstr(errcode); - - Environment* env = Environment::GetCurrent(isolate); Local error; - if (CreateSQLiteError(isolate, errstr).ToLocal(&error) && - error - ->Set(isolate->GetCurrentContext(), - env->errcode_string(), - Integer::New(isolate, errcode)) - .IsJust()) { + if (CreateSQLiteError(isolate, errcode).ToLocal(&error)) { isolate->ThrowException(error); } } diff --git a/test/parallel/test-sqlite-blob.js b/test/parallel/test-sqlite-blob.js index 0de26f1bd54a..1048dfa2398d 100644 --- a/test/parallel/test-sqlite-blob.js +++ b/test/parallel/test-sqlite-blob.js @@ -987,6 +987,7 @@ suite('BlobHandle deferred commit', () => { }, { code: 'ERR_SQLITE_ERROR', errcode: 5, + errstr: 'database is locked', message: /database is locked/, }); // The handle is released even though the close reported an error. @@ -1013,7 +1014,11 @@ suite('BlobHandle deferred commit', () => { blob.write(Buffer.from([1, 2, 3, 4])); t.assert.throws(() => { writer.close(); - }, { code: 'ERR_SQLITE_ERROR', errcode: 5 }); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 5, + errstr: 'database is locked', + }); t.assert.throws(() => { blob.read(Buffer.alloc(4)); }, { code: 'ERR_INVALID_STATE' }); diff --git a/test/parallel/test-sqlite-session.js b/test/parallel/test-sqlite-session.js index a8bbaa77d064..137b800eb6ca 100644 --- a/test/parallel/test-sqlite-session.js +++ b/test/parallel/test-sqlite-session.js @@ -110,6 +110,19 @@ test('database.applyChangeset() - closed database results in exception', (t) => }); }); +test('database.applyChangeset() - error carries errcode and errstr', (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE data (key INTEGER PRIMARY KEY)'); + t.assert.throws(() => { + database.applyChangeset(Buffer.from([0xff, 0xff, 0xff])); + }, { + code: 'ERR_SQLITE_ERROR', + errcode: 11, + errstr: 'database disk image is malformed', + }); + database.close(); +}); + test('database.createSession() - use table option to track specific table', (t) => { const database1 = new DatabaseSync(':memory:'); const database2 = new DatabaseSync(':memory:');