diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index ab09d310964..dd8acbaa7a1 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 eb26d3b6cf0..7522926d304 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 04a6b2fda95..aaa151df6ce 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" @@ -354,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); } } @@ -1032,6 +1025,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 +1052,7 @@ DatabaseSync::~DatabaseSync() { if (IsOpen()) { FinalizeStatements(); DeleteSessions(); + CloseBlobs(); connection_.reset(); } } @@ -1588,17 +1599,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 +2357,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 +4708,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 +5009,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 +5050,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 8f6801401e5..408f7a39cfb 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 00000000000..1048dfa2398 --- /dev/null +++ b/test/parallel/test-sqlite-blob.js @@ -0,0 +1,1084 @@ +'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, + errstr: 'database is locked', + 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, + errstr: 'database is locked', + }); + 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(); + }); +}); diff --git a/test/parallel/test-sqlite-session.js b/test/parallel/test-sqlite-session.js index a8bbaa77d06..137b800eb6c 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:');