diff --git a/cpp/OPBridge.cpp b/cpp/OPBridge.cpp index 23505cd4..1b2042d6 100644 --- a/cpp/OPBridge.cpp +++ b/cpp/OPBridge.cpp @@ -10,6 +10,7 @@ #include "OPUtils.hpp" #include #include +#include #include #include #include @@ -24,6 +25,51 @@ namespace opsqlite { +/// The codes are on the error object as `code`/`extendedCode` once it reaches +/// JS, but they are also spelled out in the message so anything that only logs +/// the message still shows them. SQLite's own description stays in the middle +/// of the string, where a `toContain` style check still finds it. +static SQLiteError build_error(std::string const &context, + std::string const &message, int code, + int extended_code) { + return {"[op-sqlite] " + context + ": " + message + " (code " + + std::to_string(code) + ", extended code " + + std::to_string(extended_code) + ")", + code, extended_code}; +} + +/// Snapshots the connection's error state at the point of failure. +/// +/// The returned error is built, not thrown, on purpose: sqlite3_errmsg hands +/// back a buffer owned by the connection which any later call on it -- both +/// sqlite3_reset and sqlite3_finalize included -- is free to overwrite or +/// free, and those same calls also move the result codes. Callers capture +/// first, clean up, and only then throw. +/// +/// `status` is what the failing call returned; it is only consulted if the +/// connection somehow reports success. +/// +/// The primary code is derived from the extended one rather than read through +/// sqlite3_errcode(), which returns the extended code on connections where +/// extended result codes have been switched on. SQLite guarantees the primary +/// code lives in the low 8 bits of every extended code, so `code` stays +/// primary either way. +static SQLiteError capture_error(sqlite3 *db, std::string const &context, + int status) { + int extended_code = sqlite3_extended_errcode(db); + + if (extended_code == SQLITE_OK) { + extended_code = status; + } + + int code = extended_code & 0xff; + + const char *message = sqlite3_errmsg(db); + + return build_error(context, message != nullptr ? message : "unknown error", + code, extended_code); +} + inline void opsqlite_bind_statement(sqlite3_stmt *statement, const std::vector *values, bool should_clear_bindings) { @@ -91,7 +137,10 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, bool readOnly, bool failOnCreate) { #endif std::string final_path = opsqlite_get_db_path(name, path); - char *errMsg; + // Written to only on failure by both sqlite3_load_extension below and the + // tokenizer init calls TOKENIZER_LIST expands into, so it starts out null. + // Unused when neither of those is configured into the build. + [[maybe_unused]] char *errMsg = nullptr; sqlite3 *db; int flags = SQLITE_OPEN_FULLMUTEX; @@ -107,7 +156,9 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, int status = sqlite3_open_v2(final_path.c_str(), &db, flags, nullptr); if (status != SQLITE_OK) { - throw std::runtime_error(sqlite3_errmsg(db)); + auto error = capture_error(db, "could not open database", status); + sqlite3_close_v2(db); + throw error; } #ifdef OP_SQLITE_USE_SQLCIPHER @@ -123,10 +174,7 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, int key_status = sqlite3_key_v2(db, "main", encryption_key.data(), static_cast(encryption_key.size())); if (key_status != SQLITE_OK) { - const char *message = sqlite3_errmsg(db); - throw std::runtime_error( - "[op-sqlite] failed to set encryption key: " + - std::string(message != nullptr ? message : "unknown error")); + throw capture_error(db, "failed to set encryption key", key_status); } } #endif @@ -138,10 +186,15 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, #ifdef OP_SQLITE_USE_SQLITE_VEC const char *vec_entry_point = "sqlite3_vec_init"; - sqlite3_load_extension(db, _sqlite_vec_path.c_str(), vec_entry_point, &errMsg); + int vec_status = sqlite3_load_extension(db, _sqlite_vec_path.c_str(), + vec_entry_point, &errMsg); + + if (vec_status != SQLITE_OK) { + std::string message = errMsg != nullptr ? errMsg : "unknown error"; + sqlite3_free(errMsg); - if (errMsg != nullptr) { - throw std::runtime_error(errMsg); + throw build_error("could not load sqlite-vec", message, vec_status & 0xff, + vec_status); } #endif @@ -210,10 +263,9 @@ BridgeResult opsqlite_execute_prepared_statement( sqlite3 *db, sqlite3_stmt *statement, std::vector *results, std::shared_ptr> &metadatas) { - const char *errorMessage; + std::optional error; bool isConsuming = true; - bool isFailed = false; int result = SQLITE_OK; @@ -310,18 +362,15 @@ BridgeResult opsqlite_execute_prepared_statement( break; default: - errorMessage = sqlite3_errmsg(db); - isFailed = true; + error = capture_error(db, "statement execution error", result); isConsuming = false; } } sqlite3_reset(statement); - if (isFailed) { - throw std::runtime_error( - "[op-sqlite] SQLite code: " + std::to_string(result) + - " execution error: " + std::string(errorMessage)); + if (error.has_value()) { + throw *error; } int changedRowCount = sqlite3_changes(db); @@ -340,10 +389,12 @@ sqlite3_stmt *opsqlite_prepare_statement(sqlite3 *db, int statementStatus = sqlite3_prepare_v2(db, queryStr, -1, &statement, nullptr); - if (statementStatus == SQLITE_ERROR) { - const char *message = sqlite3_errmsg(db); - throw std::runtime_error("[op-sqlite] SQL prepare statement error: " + - std::string(message)); + // Any non-OK status means there is no statement to hand back. Only + // SQLITE_ERROR used to be caught here, so a prepare that failed with e.g. + // SQLITE_BUSY or SQLITE_NOTADB returned a null statement as if it had + // succeeded. + if (statementStatus != SQLITE_OK) { + throw capture_error(db, "SQL prepare statement error", statementStatus); } return statement; @@ -358,9 +409,8 @@ void opsqlite_finalize_statement(sqlite3_stmt *statement) { BridgeResult opsqlite_execute(sqlite3 *db, std::string const &query, const std::vector *params) { sqlite3_stmt *statement; - const char *errorMessage = nullptr; const char *remainingStatement = nullptr; - bool has_failed = false; + std::optional error; int status, current_column, column_count, column_type; std::string column_name, column_declared_type; std::vector column_names; @@ -377,9 +427,7 @@ BridgeResult opsqlite_execute(sqlite3 *db, std::string const &query, sqlite3_prepare_v2(db, query_str, -1, &statement, &remainingStatement); if (status != SQLITE_OK) { - errorMessage = sqlite3_errmsg(db); - throw std::runtime_error("[op-sqlite] sqlite query error: " + - std::string(errorMessage)); + throw capture_error(db, "sqlite query error", status); } // The statement did not fail to parse but there is nothing to do, just @@ -472,7 +520,9 @@ BridgeResult opsqlite_execute(sqlite3 *db, std::string const &query, break; default: - has_failed = true; + // Captured before the finalize below, which resets the connection's + // error state and invalidates the message buffer. + error = capture_error(db, "statement execution error", status); is_consuming_rows = false; } } @@ -480,12 +530,10 @@ BridgeResult opsqlite_execute(sqlite3 *db, std::string const &query, sqlite3_finalize(statement); } while (remainingStatement != nullptr && - strcmp(remainingStatement, "") != 0 && !has_failed); + strcmp(remainingStatement, "") != 0 && !error.has_value()); - if (has_failed) { - const char *message = sqlite3_errmsg(db); - throw std::runtime_error("[op-sqlite] statement execution error: " + - std::string(message)); + if (error.has_value()) { + throw *error; } return {.affectedRows = changedRowCount, @@ -500,11 +548,10 @@ BridgeResult opsqlite_execute_host_objects( std::shared_ptr> &metadatas) { sqlite3_stmt *statement; - const char *errorMessage; const char *remainingStatement = nullptr; + std::optional error; bool isConsuming = true; - bool isFailed = false; int result = SQLITE_OK; @@ -516,11 +563,8 @@ BridgeResult opsqlite_execute_host_objects( sqlite3_prepare_v2(db, queryStr, -1, &statement, &remainingStatement); if (statementStatus != SQLITE_OK) { - const char *message = sqlite3_errmsg(db); - throw std::runtime_error( - "[op-sqlite] SQL statement error on opsqlite_execute:\n" + - std::to_string(statementStatus) + " description:\n" + - std::string(message)); + throw capture_error(db, "SQL statement error on opsqlite_execute", + statementStatus); } // The statement did not fail to parse but there is nothing to do, just @@ -629,20 +673,19 @@ BridgeResult opsqlite_execute_host_objects( break; default: - errorMessage = sqlite3_errmsg(db); - isFailed = true; + // Captured before the finalize below, which resets the connection's + // error state and invalidates the message buffer. + error = capture_error(db, "statement execution error", result); isConsuming = false; } } sqlite3_finalize(statement); } while (remainingStatement != nullptr && - strcmp(remainingStatement, "") != 0 && !isFailed); + strcmp(remainingStatement, "") != 0 && !error.has_value()); - if (isFailed) { - throw std::runtime_error( - "[op-sqlite] SQLite error code: " + std::to_string(result) + - ", description: " + std::string(errorMessage)); + if (error.has_value()) { + throw *error; } int changedRowCount = sqlite3_changes(db); @@ -658,11 +701,10 @@ opsqlite_execute_raw(sqlite3 *db, std::string const &query, const std::vector *params, std::vector> *results) { sqlite3_stmt *statement; - const char *errorMessage; const char *remainingStatement = nullptr; + std::optional error; bool isConsuming = true; - bool isFailed = false; int step = SQLITE_OK; std::vector column_names; @@ -675,10 +717,7 @@ opsqlite_execute_raw(sqlite3 *db, std::string const &query, sqlite3_prepare_v2(db, queryStr, -1, &statement, &remainingStatement); if (statementStatus != SQLITE_OK) { - const char *message = sqlite3_errmsg(db); - throw std::runtime_error( - "[op-sqlite] SQL statement error:" + std::to_string(statementStatus) + - " description:" + std::string(message)); + throw capture_error(db, "SQL statement error", statementStatus); } // The statement did not fail to parse but there is nothing to do, just @@ -768,20 +807,19 @@ opsqlite_execute_raw(sqlite3 *db, std::string const &query, break; default: - errorMessage = sqlite3_errmsg(db); - isFailed = true; + // Captured before the finalize below, which resets the connection's + // error state and invalidates the message buffer. + error = capture_error(db, "statement execution error", step); isConsuming = false; } } sqlite3_finalize(statement); } while (remainingStatement != nullptr && - strcmp(remainingStatement, "") != 0 && !isFailed); + strcmp(remainingStatement, "") != 0 && !error.has_value()); - if (isFailed) { - throw std::runtime_error( - "[op-sqlite] SQLite error code: " + std::to_string(step) + - ", description: " + std::string(errorMessage)); + if (error.has_value()) { + throw *error; } int changedRowCount = sqlite3_changes(db); @@ -861,7 +899,7 @@ void opsqlite_load_extension(sqlite3 *db, std::string &path, status = sqlite3_enable_load_extension(db, 1); if (status != SQLITE_OK) { - throw std::runtime_error("Could not enable extension loading"); + throw capture_error(db, "could not enable extension loading", status); } const char *entry_point_cstr = nullptr; @@ -869,12 +907,19 @@ void opsqlite_load_extension(sqlite3 *db, std::string &path, entry_point_cstr = entry_point.c_str(); } - char *error_message; + char *error_message = nullptr; status = sqlite3_load_extension(db, path.c_str(), entry_point_cstr, &error_message); if (status != SQLITE_OK) { - throw std::runtime_error(error_message); + // This message is allocated by sqlite3, it does not live on the connection + // like sqlite3_errmsg's does, so it has to be copied out and freed here. + std::string message = + error_message != nullptr ? error_message : "unknown error"; + sqlite3_free(error_message); + + throw build_error("could not load extension", message, status & 0xff, + status); } #endif } diff --git a/cpp/OPDatabase.cpp b/cpp/OPDatabase.cpp index 23da58ea..94609d43 100644 --- a/cpp/OPDatabase.cpp +++ b/cpp/OPDatabase.cpp @@ -313,11 +313,15 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, "[op-sqlite] attach alias must not contain a zero byte"); } + try { #ifdef OP_SQLITE_USE_LIBSQL - opsqlite_libsql_attach(db, secondary_db_path, secondary_db_name, alias); + opsqlite_libsql_attach(db, secondary_db_path, secondary_db_name, alias); #else - opsqlite_attach(db, secondary_db_path, secondary_db_name, alias); + opsqlite_attach(db, secondary_db_path, secondary_db_name, alias); #endif + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } return {}; })); @@ -334,11 +338,15 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, throw std::runtime_error( "[op-sqlite] detach alias must not contain a zero byte"); } + try { #ifdef OP_SQLITE_USE_LIBSQL - opsqlite_libsql_detach(db, alias); + opsqlite_libsql_detach(db, alias); #else - opsqlite_detach(db, alias); + opsqlite_detach(db, alias); #endif + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } return {}; })); @@ -459,13 +467,18 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, if (count == 2 && !args[1].isNull() && !args[1].isUndefined()) { params = to_variant_vec(rt, args[1]); } + + try { #ifdef OP_SQLITE_USE_LIBSQL - auto status = opsqlite_libsql_execute(db, query, ¶ms); + auto status = opsqlite_libsql_execute(db, query, ¶ms); #else - auto status = opsqlite_execute(db, query, ¶ms); + auto status = opsqlite_execute(db, query, ¶ms); #endif - return create_js_rows(rt, status); + return create_js_rows(rt, status); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } })); js_object.setProperty(rt, "executeRawSync", HFN(this) { @@ -478,13 +491,17 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, std::vector> results; + try { #ifdef OP_SQLITE_USE_LIBSQL - auto status = opsqlite_libsql_execute_raw(db, query, ¶ms, &results); + auto status = opsqlite_libsql_execute_raw(db, query, ¶ms, &results); #else - auto status = opsqlite_execute_raw(db, query, ¶ms, &results); + auto status = opsqlite_execute_raw(db, query, ¶ms, &results); #endif - return create_raw_result(rt, status, &results); + return create_raw_result(rt, status, &results); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } })); js_object.setProperty(rt, "execute", HFN(this) { @@ -701,7 +718,12 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, entry_point = args[1].asString(rt).utf8(rt); } - opsqlite_load_extension(db, path, entry_point); + try { + opsqlite_load_extension(db, path, entry_point); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } + return {}; })); @@ -717,7 +739,12 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, query.getProperty(rt, "fireOn").asObject(rt).asArray(rt); auto variant_args = to_variant_vec(rt, js_args); - sqlite3_stmt *stmt = opsqlite_prepare_statement(db, query_str); + sqlite3_stmt *stmt = nullptr; + try { + stmt = opsqlite_prepare_statement(db, query_str); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } opsqlite_bind_statement(stmt, &variant_args, /* should_clear_bindings */ false); auto callback = @@ -777,7 +804,12 @@ void OPDatabase::create_jsi_functions(jsi::Runtime &rt, #ifdef OP_SQLITE_USE_LIBSQL libsql_stmt_t statement = opsqlite_libsql_prepare_statement(db, query); #else - sqlite3_stmt *statement = opsqlite_prepare_statement(db, query); + sqlite3_stmt *statement = nullptr; + try { + statement = opsqlite_prepare_statement(db, query); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } #endif auto preparedStatementHostObject = std::make_shared(db, statement, diff --git a/cpp/OPPreparedStatementHostObject.cpp b/cpp/OPPreparedStatementHostObject.cpp index b0170005..a9ec17b6 100644 --- a/cpp/OPPreparedStatementHostObject.cpp +++ b/cpp/OPPreparedStatementHostObject.cpp @@ -113,15 +113,20 @@ jsi::Value PreparedStatementHostObject::get(jsi::Runtime &rt, std::vector results; auto metadata = std::make_shared>(); + + try { #ifdef OP_SQLITE_USE_LIBSQL - auto status = opsqlite_libsql_execute_prepared_statement( - _db, _stmt, &results, metadata); + auto status = opsqlite_libsql_execute_prepared_statement( + _db, _stmt, &results, metadata); #else - auto status = - opsqlite_execute_prepared_statement(_db, _stmt, &results, metadata); + auto status = + opsqlite_execute_prepared_statement(_db, _stmt, &results, metadata); #endif - return create_result(rt, status, &results, metadata); + return create_result(rt, status, &results, metadata); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } }); } diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 7b914f71..e97158f9 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -96,9 +96,14 @@ install(jsi::Runtime &rt, const std::shared_ptr &invoker, } jsi::Object js_db(rt); - std::shared_ptr db = std::make_shared( - rt, js_db, path, name, path, readOnly, failOnCreate, encryption_key); - js_db.setNativeState(rt, db); + try { + std::shared_ptr db = std::make_shared( + rt, js_db, path, name, path, readOnly, failOnCreate, encryption_key); + js_db.setNativeState(rt, db); + } catch (const SQLiteError &e) { + throw_js_error(rt, e); + } + return js_db; }); diff --git a/cpp/OPTypes.hpp b/cpp/OPTypes.hpp index 853483ca..bb84204e 100644 --- a/cpp/OPTypes.hpp +++ b/cpp/OPTypes.hpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include @@ -25,6 +27,28 @@ extern std::shared_ptr invoker; // invalidate() runs. extern std::shared_ptr> generation_alive; +/// A failure reported by SQLite itself, carrying the result codes of the call +/// that failed. +/// +/// Message text cannot be used to tell failures apart: extensions substitute +/// their own strings (FTS5 reports corruption as "fts5: corruption found +/// reading blob ..."), and a primary code alone cannot distinguish +/// SQLITE_IOERR_FSYNC from SQLITE_IOERR_READ or SQLITE_CORRUPT from +/// SQLITE_CORRUPT_VTAB. The codes therefore travel with the exception, and the +/// JSI layer puts them on the JS Error as `code` / `extendedCode`. +class SQLiteError : public std::runtime_error { +public: + SQLiteError(std::string message, int code, int extended_code) + : std::runtime_error(std::move(message)), code(code), + extended_code(extended_code) {} + + /// Primary result code, e.g. SQLITE_CORRUPT (11). + int code; + /// Extended result code, e.g. SQLITE_CORRUPT_VTAB (267). Equal to `code` + /// when SQLite has no more specific code for the failure. + int extended_code; +}; + struct ArrayBuffer { std::shared_ptr data; size_t size; diff --git a/cpp/OPUtils.cpp b/cpp/OPUtils.cpp index f62f7e10..88160e88 100644 --- a/cpp/OPUtils.cpp +++ b/cpp/OPUtils.cpp @@ -337,20 +337,23 @@ BatchResult import_sql_file(sqlite3 *db, std::string path) { auto result = opsqlite_execute(db, line, nullptr); affectedRows += result.affectedRows; commands++; - } catch (std::exception &exc) { + } catch (std::exception &) { opsqlite_execute(db, "ROLLBACK", nullptr); sqFile.close(); - throw exc; + // Rethrow the original exception object: `throw exc` would copy it + // into a plain std::exception, dropping both the message and the + // SQLite result codes. + throw; } } } sqFile.close(); opsqlite_execute(db, "COMMIT", nullptr); return {"", affectedRows, commands}; - } catch (std::exception &exc) { + } catch (std::exception &) { sqFile.close(); opsqlite_execute(db, "ROLLBACK", nullptr); - throw exc; + throw; } } #endif @@ -365,12 +368,53 @@ bool file_exists(const std::string &path) { return (stat(path.c_str(), &buffer) == 0); } +jsi::Value create_js_error(jsi::Runtime &rt, const std::string &message, + int code, int extended_code) { + auto error_ctr = rt.global().getPropertyAsFunction(rt, "Error"); + auto error = + error_ctr.callAsConstructor(rt, jsi::String::createFromUtf8(rt, message)) + .asObject(rt); + + if (code >= 0) { + error.setProperty(rt, "code", jsi::Value(code)); + error.setProperty(rt, "extendedCode", jsi::Value(extended_code)); + } + + return error; +} + +void throw_js_error(jsi::Runtime &rt, const SQLiteError &error) { + throw jsi::JSError( + rt, create_js_error(rt, error.what(), error.code, error.extended_code)); +} + void log_to_console(jsi::Runtime &runtime, const std::string &message) { auto console = runtime.global().getPropertyAsObject(runtime, "console"); auto log = console.getPropertyAsFunction(runtime, "log"); log.call(runtime, jsi::String::createFromUtf8(runtime, message)); } +/// Rejects a promisified call from the thread pool. +/// +/// `resolve` is captured in the invokeAsync lambda alongside `reject` so it is +/// disposed on the JS thread. A negative `code` means the failure did not come +/// from SQLite and carries no result codes. +static void reject_with(const std::shared_ptr &invoker, + const std::shared_ptr> &alive, + const std::shared_ptr &resolve, + const std::shared_ptr &reject, + std::string message, int code, int extended_code) { + if (alive != nullptr && !alive->load()) { + return; + } + + invoker->invokeAsync([message = std::move(message), code, extended_code, + resolve, reject](jsi::Runtime &rt) { + reject->asObject(rt).asFunction(rt).call( + rt, create_js_error(rt, message, code, extended_code)); + }); +} + jsi::Value promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, std::function lambda, @@ -417,39 +461,19 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, auto jsi_result = resolve_callback(rt, std::move(result)); resolve->asObject(rt).asFunction(rt).call(rt, jsi_result); }); + } catch (SQLiteError &e) { + // Caught ahead of runtime_error, its base class, so the SQLite result + // codes make it onto the rejected Error. + reject_with(invoker, alive, resolve, reject, e.what(), e.code, + e.extended_code); } catch (std::runtime_error &e) { // On Android RN is broken and does not correctly match // runtime_error to the generic exception We have to // explicitly catch it // https://github.com/facebook/react-native/issues/48027 - // - // resolve is also captured in the invokeAsync lambda - // so it can be safely disposed on the JS thread - auto what = e.what(); - if (alive != nullptr && !alive->load()) { - return; - } - invoker->invokeAsync([what = std::string(what), resolve = resolve, - reject = reject](jsi::Runtime &rt) { - auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); - auto error = errorCtr.callAsConstructor( - rt, jsi::String::createFromAscii(rt, what)); - reject->asObject(rt).asFunction(rt).call(rt, error); - }); + reject_with(invoker, alive, resolve, reject, e.what(), -1, -1); } catch (std::exception &exc) { - auto what = exc.what(); - if (alive != nullptr && !alive->load()) { - return; - } - // resolve is also captured in the invokeAsync lambda - // so it can be safely disposed on the JS thread - invoker->invokeAsync([what = std::string(what), resolve = resolve, - reject = reject](jsi::Runtime &rt) { - auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); - auto error = errorCtr.callAsConstructor( - rt, jsi::String::createFromAscii(rt, what)); - reject->asObject(rt).asFunction(rt).call(rt, error); - }); + reject_with(invoker, alive, resolve, reject, exc.what(), -1, -1); } }; diff --git a/cpp/OPUtils.hpp b/cpp/OPUtils.hpp index 03baada4..b9827ca2 100644 --- a/cpp/OPUtils.hpp +++ b/cpp/OPUtils.hpp @@ -51,6 +51,19 @@ bool file_exists(const std::string &path); void log_to_console(jsi::Runtime &rt, const std::string &message); +/// Creates a JS `Error` with the SQLite result codes attached as `code` and +/// `extendedCode`. Pass a negative code to leave both properties out, which is +/// what non-SQLite failures (bad arguments, closed database, ...) do. +jsi::Value create_js_error(jsi::Runtime &rt, const std::string &message, + int code, int extended_code); + +/// Rethrows a SQLite failure so the result codes survive the trip into JS. +/// +/// Needed on the synchronous paths only: JSI turns a C++ exception escaping a +/// host function into a JS Error built from what() alone, dropping any +/// properties. A jsi::JSError carries our own Error object through untouched. +[[noreturn]] void throw_js_error(jsi::Runtime &rt, const SQLiteError &error); + jsi::Value promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, std::function lambda, std::function diff --git a/docs/docs/api.md b/docs/docs/api.md index 57c27ca5..f11cce16 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -251,6 +251,29 @@ let res = db.executeSync('SELECT 1'); On web, sync APIs intentionally throw. Use async methods only. +## Error codes + +When SQLite is what failed, the thrown (or rejected) `Error` carries the [result codes](https://sqlite.org/rescode.html) of the call that failed as `code` (primary, e.g. `19` for `SQLITE_CONSTRAINT`) and `extendedCode` (e.g. `1555` for `SQLITE_CONSTRAINT_PRIMARYKEY`). Both are also repeated at the end of the message. + +Branch on the codes, never on the message: extensions substitute their own text (FTS5 reports corruption as `fts5: corruption found reading blob ...`), and the primary code on its own cannot tell `SQLITE_IOERR_FSYNC` from `SQLITE_IOERR_READ`. + +```tsx +import { type SQLiteError, open } from '@op-engineering/op-sqlite'; + +const db = open({ name: 'myDb.sqlite' }); + +try { + await db.execute('INSERT INTO User (id) VALUES (?)', [1]); +} catch (e) { + const error = e as SQLiteError; + + console.log(error.code); // 19 (SQLITE_CONSTRAINT) + console.log(error.extendedCode); // 1555 (SQLITE_CONSTRAINT_PRIMARYKEY) +} +``` + +Both properties are `undefined` when the failure did not come from SQLite — a closed database, bad arguments — and on the libsql, Turso, web and node backends, whose APIs only hand back a message. + ## Transactions Wraps the code inside in a transaction. Any error thrown inside of the transaction body function will ROLLBACK the transaction. diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index eac4d9cc..c82d886e 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -4,6 +4,10 @@ sidebar_position: 11 # API Changes +## 18.1.0 + +- Errors coming from SQLite now carry their result codes: rejected/thrown `Error`s from `execute`, `executeSync`, `executeRaw`, `executeRawSync`, `executeBatch`, `prepareStatement`, `attach`, `detach`, `loadExtension` and `open` expose `code` (primary) and `extendedCode` (extended), and both are repeated in the message. Only the plain SQLite3 and SQLCipher backends report them; libsql, Turso, web and node only expose a message. See [Error codes](./api.md#error-codes). + ## 18.0.0 - **Breaking:** Removed `crsqlite` support entirely. The `crsqlite` key in the `op-sqlite` `package.json` config no longer has any effect, and the bundled `cr-sqlite` extension binaries have been removed from the package (iOS `crsqlite.xcframework`, Android `libcrsqlite` `.so`s). If you need CR-SQLite, load it yourself as a runtime extension via `loadExtension` — see [Loading Extensions](./api.md#loading-extensions). diff --git a/example/src/tests/errorCodes.ts b/example/src/tests/errorCodes.ts new file mode 100644 index 00000000..6357c0fc --- /dev/null +++ b/example/src/tests/errorCodes.ts @@ -0,0 +1,168 @@ +import { + type DB, + isLibsql, + isTurso, + open, + type SQLBatchTuple, + type SQLiteError, +} from "@op-engineering/op-sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "@op-engineering/op-test"; + +// https://sqlite.org/rescode.html +const SQLITE_ERROR = 1; +const SQLITE_CONSTRAINT = 19; +const SQLITE_CONSTRAINT_NOTNULL = 1299; +const SQLITE_CONSTRAINT_PRIMARYKEY = 1555; + +// libsql and turso only hand back a message, there are no result codes to +// report on those backends. +const backendReportsCodes = !isLibsql() && !isTurso(); + +async function captureError(fn: () => unknown): Promise { + try { + await fn(); + } catch (e) { + return e as SQLiteError; + } + + throw new Error("Expected the call to fail, it did not"); +} + +describe("Error codes", () => { + let db: DB; + + beforeEach(async () => { + db = open({ + name: "errorCodes.sqlite", + encryptionKey: "test", + }); + + await db.execute("DROP TABLE IF EXISTS User;"); + await db.execute("CREATE TABLE User (id INT PRIMARY KEY, name TEXT NOT NULL) STRICT;"); + await db.execute('INSERT INTO "User" (id, name) VALUES(?, ?)', [1, "Oscar"]); + }); + + afterEach(() => { + if (db) { + db.delete(); + // @ts-expect-error + db = null; + } + }); + + it("execute rejects with the result codes", async () => { + if (!backendReportsCodes) { + return; + } + + const error = await captureError(() => db.execute("SELECT * FROM tableThatDoesNotExist")); + + expect(error.code).toEqual(SQLITE_ERROR); + expect(error.extendedCode).toEqual(SQLITE_ERROR); + expect(error.message).toContain("no such table"); + }); + + it("execute reports the extended code of a constraint violation", async () => { + if (!backendReportsCodes) { + return; + } + + const notNull = await captureError(() => + db.execute('INSERT INTO "User" (id, name) VALUES(?, ?)', [2, null]), + ); + + expect(notNull.code).toEqual(SQLITE_CONSTRAINT); + expect(notNull.extendedCode).toEqual(SQLITE_CONSTRAINT_NOTNULL); + + // Same primary code, different extended one: this is the distinction that + // cannot be made from the message alone. + const primaryKey = await captureError(() => + db.execute('INSERT INTO "User" (id, name) VALUES(?, ?)', [1, "Oscar"]), + ); + + expect(primaryKey.code).toEqual(SQLITE_CONSTRAINT); + expect(primaryKey.extendedCode).toEqual(SQLITE_CONSTRAINT_PRIMARYKEY); + }); + + it("executeSync throws with the result codes", async () => { + if (!backendReportsCodes) { + return; + } + + const error = await captureError(() => db.executeSync("SELECT * FROM tableThatDoesNotExist")); + + expect(error.code).toEqual(SQLITE_ERROR); + expect(error.extendedCode).toEqual(SQLITE_ERROR); + }); + + it("executeRaw and executeRawSync report the result codes", async () => { + if (!backendReportsCodes) { + return; + } + + const asyncError = await captureError(() => + db.executeRaw("SELECT * FROM tableThatDoesNotExist"), + ); + expect(asyncError.code).toEqual(SQLITE_ERROR); + + const syncError = await captureError(() => + db.executeRawSync("SELECT * FROM tableThatDoesNotExist"), + ); + expect(syncError.code).toEqual(SQLITE_ERROR); + }); + + it("executeBatch rejects with the result codes", async () => { + if (!backendReportsCodes) { + return; + } + + const commands: SQLBatchTuple[] = [ + ['INSERT INTO "User" (id, name) VALUES(?, ?)', [2, "Pablo"]], + ['INSERT INTO "User" (id, name) VALUES(?, ?)', [1, "Carlos"]], + ]; + + const error = await captureError(() => db.executeBatch(commands)); + + expect(error.code).toEqual(SQLITE_CONSTRAINT); + expect(error.extendedCode).toEqual(SQLITE_CONSTRAINT_PRIMARYKEY); + + const res = await db.execute("SELECT * FROM User"); + expect(res.rows.length).toEqual(1); + }); + + it("prepared statements report the result codes", async () => { + if (!backendReportsCodes) { + return; + } + + const prepareError = await captureError(() => db.prepareStatement("NOT VALID SQL")); + expect(prepareError.code).toEqual(SQLITE_ERROR); + + const statement = db.prepareStatement('INSERT INTO "User" (id, name) VALUES(?, ?)'); + statement.bindSync([1, "Oscar"]); + + const asyncError = await captureError(() => statement.execute()); + expect(asyncError.code).toEqual(SQLITE_CONSTRAINT); + expect(asyncError.extendedCode).toEqual(SQLITE_CONSTRAINT_PRIMARYKEY); + + const syncError = await captureError(() => statement.executeSync()); + expect(syncError.code).toEqual(SQLITE_CONSTRAINT); + expect(syncError.extendedCode).toEqual(SQLITE_CONSTRAINT_PRIMARYKEY); + }); + + it("errors that do not come from sqlite carry no result codes", async () => { + const error = await captureError(() => { + db.close(); + return db.execute("SELECT 1"); + }); + + expect(error.code).toEqual(undefined); + expect(error.extendedCode).toEqual(undefined); + + // Reopen so afterEach can delete the file. + db = open({ + name: "errorCodes.sqlite", + encryptionKey: "test", + }); + }); +}); diff --git a/example/src/tests/index.ts b/example/src/tests/index.ts index 24823996..383f1e52 100644 --- a/example/src/tests/index.ts +++ b/example/src/tests/index.ts @@ -1,6 +1,7 @@ import "./blob"; import "./constants"; import "./dbsetup"; +import "./errorCodes"; import "./hooks"; import "./preparedStatements"; import "./queries"; diff --git a/src/index.ts b/src/index.ts index eb98eba7..71a06c17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export type { QueryResult, Scalar, SQLBatchTuple, + SQLiteError, Transaction, UpdateHookOperation, } from "./types"; diff --git a/src/index.web.ts b/src/index.web.ts index 0a8f975c..9855d1d6 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -13,6 +13,7 @@ export type { QueryResult, Scalar, SQLBatchTuple, + SQLiteError, Transaction, UpdateHookOperation, } from "./types"; diff --git a/src/types.ts b/src/types.ts index ea5484c9..84a50987 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,43 @@ export type Scalar = string | number | boolean | null | ArrayBuffer | ArrayBufferView; +/** + * Error thrown (or promise rejection) when SQLite itself fails. + * + * The result codes are what you should branch on, never the message: extensions + * substitute their own strings (FTS5 reports corruption as + * `fts5: corruption found reading blob ...`) and a primary code on its own + * cannot tell `SQLITE_IOERR_FSYNC` from `SQLITE_IOERR_READ`. + * + * ```ts + * try { + * await db.execute("insert into t values (?)", [1]); + * } catch (e) { + * const error = e as SQLiteError; + * if (error.code === 11) { + * // SQLITE_CORRUPT, error.extendedCode tells you which flavor + * } + * } + * ``` + * + * Both codes are only present on errors coming from SQLite on the sqlite and + * sqlcipher backends. Failures raised by op-sqlite itself (a closed database, + * bad arguments), the libsql and turso backends -- whose APIs only hand back a + * message -- and the web and node builds leave them undefined. + */ +export type SQLiteError = Error & { + /** + * Primary SQLite result code, e.g. `11` for `SQLITE_CORRUPT`. + * https://sqlite.org/rescode.html#primary_result_code_list + */ + code?: number; + /** + * Extended SQLite result code, e.g. `267` for `SQLITE_CORRUPT_VTAB`. Equal to + * `code` when SQLite has no more specific code for the failure. + * https://sqlite.org/rescode.html#extended_result_code_list + */ + extendedCode?: number; +}; + export interface OpenOptions { /** * The file name of the database to open.