From 6fb29fa633b595bb329fd8c517a4f0ed28bf8b8d Mon Sep 17 00:00:00 2001 From: Antoine Balliet Date: Tue, 8 Sep 2026 01:58:27 +0200 Subject: [PATCH] feat: add opt-in resumable SQLite RBU support --- Package.swift | 8 ++ README.md | 1 + android/CMakeLists.txt | 1 + android/build.gradle | 13 ++ cpp/OPRBU.cpp | 170 +++++++++++++++++++++++ cpp/OPRBU.hpp | 21 +++ cpp/OPSqlite.cpp | 82 +++++++++++ cpp/OPTypes.hpp | 9 ++ cpp/OPUtils.cpp | 14 ++ docs/docs/installation.md | 2 + docs/docs/rbu.md | 109 +++++++++++++++ example/package.json | 1 + example/src/tests/index.ts | 1 + example/src/tests/rbu.ts | 245 +++++++++++++++++++++++++++++++++ example/src/tests/web.ts | 16 ++- node/jest.config.js | 1 + node/src/index.ts | 31 ++++- node/src/test.spec.ts | 18 ++- node/src/types.ts | 19 +++ node/tests/rbu-options.test.ts | 36 +++++ op-sqlite.podspec | 11 ++ scripts/turnOffEverything.js | 1 + scripts/turnOnIOSEmbedded.js | 1 + scripts/turnOnLibsql.js | 1 + scripts/turnOnSQLCipher.js | 1 + scripts/turnOnTurso.js | 3 +- src/functions.ts | 24 ++++ src/functions.web.ts | 14 ++ src/index.ts | 3 + src/index.web.ts | 3 + src/rbu.ts | 77 +++++++++++ src/types.ts | 24 ++++ 32 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 cpp/OPRBU.cpp create mode 100644 cpp/OPRBU.hpp create mode 100644 docs/docs/rbu.md create mode 100644 example/src/tests/rbu.ts create mode 100644 node/tests/rbu-options.test.ts create mode 100644 src/rbu.ts diff --git a/Package.swift b/Package.swift index f62ac90f..c443268c 100644 --- a/Package.swift +++ b/Package.swift @@ -73,6 +73,7 @@ let phoneVersion = (opsqliteConfig["iosSqlite"] as? Bool) == true let sqliteFlags = (opsqliteConfig["sqliteFlags"] as? String) ?? "" let fts5 = (opsqliteConfig["fts5"] as? Bool) == true let rtree = (opsqliteConfig["rtree"] as? Bool) == true +let rbu = (opsqliteConfig["rbu"] as? Bool) == true let useSqliteVec = (opsqliteConfig["sqliteVec"] as? Bool) == true let tokenizers = (opsqliteConfig["tokenizers"] as? [String]) ?? [] @@ -96,6 +97,9 @@ if useTurso && useSqliteVec { if useTurso && useLibsql { fatalError("[OP-SQLITE] You cannot enable both libsql and turso backend.") } +if rbu && (useSqlcipher || useLibsql || useTurso || phoneVersion) { + fatalError("[OP-SQLITE] RBU currently supports only the bundled vanilla SQLite backend.") +} if !tokenizers.isEmpty && useTurso { fatalError("[OP-SQLITE] Tokenizers are not supported with turso backend. Please disable tokenizers or do not enable turso.") } @@ -294,6 +298,10 @@ if useSqlcipher { } if fts5 { defines.append(("SQLITE_ENABLE_FTS5", "1")) } if rtree { defines.append(("SQLITE_ENABLE_RTREE", "1")) } +if rbu { + print("[OP-SQLITE] RBU enabled") + defines.append(("SQLITE_ENABLE_RBU", "1")) +} if phoneVersion { defines.append(("OP_SQLITE_USE_PHONE_VERSION", "1")) } if performanceMode { print("[OP-SQLITE] Performance mode enabled") diff --git a/README.md b/README.md index 4e7796e1..62ac2b9a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Some of the big supported features: - SQLCipher is supported as a compilation target - FTS5 plugin - Rtree plugin +- Opt-in resumable bulk updates (RBU) - sqlite-vec plugin - Reactive queries - Custom tokenizers diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index f9a76cc5..0a8a586f 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -44,6 +44,7 @@ add_library( ../cpp/OPSqlite.cpp ../cpp/OPUtils.cpp ../cpp/OPThreadPool.cpp + ../cpp/OPRBU.cpp ../cpp/OPSmartHostObject.cpp ../cpp/OPPreparedStatementHostObject.cpp ../cpp/OPDumbHostObject.cpp diff --git a/android/build.gradle b/android/build.gradle index 3049da40..59c2bf1b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -60,6 +60,7 @@ def sqliteFlags = "" def enableFTS5 = false def useSqliteVec = false def enableRtree = false +def enableRBU = false def tokenizers = [] // On the example app, the package.json is located at the root of the project @@ -100,6 +101,7 @@ if(opsqliteConfig) { useLibsql = !!opsqliteConfig["libsql"] useTurso = !!opsqliteConfig["turso"] enableRtree = !!opsqliteConfig["rtree"] + enableRBU = !!opsqliteConfig["rbu"] tokenizers = opsqliteConfig["tokenizers"] ? opsqliteConfig["tokenizers"] : [] } @@ -107,6 +109,10 @@ if(useLibsql && useTurso) { throw new GradleException("[OP-SQLITE] Error: libsql and turso backends are mutually exclusive.") } +if(enableRBU && (useSQLCipher || useLibsql || useTurso)) { + throw new GradleException("[OP-SQLITE] Error: RBU currently supports only the bundled vanilla SQLite backend.") +} + if(useSQLCipher) { println "[OP-SQLITE] using sqlcipher." } else if(useTurso) { @@ -127,6 +133,10 @@ if(enableRtree) { println "[OP-SQLITE] RTree enabled" } +if(enableRBU) { + println "[OP-SQLITE] RBU enabled" +} + if(useSqliteVec) { println "[OP-SQLITE] Sqlite-vec enabled" } @@ -158,6 +168,9 @@ if (enableFTS5) { if (enableRtree) { defaultSqliteFlags += "-DSQLITE_ENABLE_RTREE=1" } +if (enableRBU) { + defaultSqliteFlags += "-DSQLITE_ENABLE_RBU=1" +} android { namespace "com.op.sqlite" diff --git a/cpp/OPRBU.cpp b/cpp/OPRBU.cpp new file mode 100644 index 00000000..d59932ba --- /dev/null +++ b/cpp/OPRBU.cpp @@ -0,0 +1,170 @@ +#include "OPRBU.hpp" + +#include "OPTypes.hpp" +#include +#include + +#if defined(SQLITE_ENABLE_RBU) && !defined(OP_SQLITE_USE_SQLCIPHER) && \ + !defined(OP_SQLITE_USE_LIBSQL) && !defined(OP_SQLITE_USE_TURSO) && \ + !defined(OP_SQLITE_USE_PHONE_VERSION) +#define OP_SQLITE_RBU_AVAILABLE 1 + +extern "C" { +typedef struct sqlite3rbu sqlite3rbu; + +sqlite3rbu *sqlite3rbu_open(const char *target_path, const char *update_path, + const char *state_path); +int sqlite3rbu_step(sqlite3rbu *rbu); +int sqlite3rbu_close(sqlite3rbu *rbu, char **error_message); +sqlite3_int64 sqlite3rbu_progress(sqlite3rbu *rbu); +int sqlite3rbu_state(sqlite3rbu *rbu); +} +#else +#define OP_SQLITE_RBU_AVAILABLE 0 +#endif + +namespace opsqlite { + +namespace { + +[[noreturn]] void throw_rbu_error(int code, const std::string &message) { + throw OPSQLiteError(code, "[op-sqlite][RBU] " + message); +} + +#if OP_SQLITE_RBU_AVAILABLE + +constexpr int RBU_STATE_OAL = 1; +constexpr int RBU_STATE_MOVE = 2; +constexpr int RBU_STATE_CHECKPOINT = 3; +constexpr int RBU_STATE_DONE = 4; +constexpr int RBU_STATE_ERROR = 5; + +bool is_regular_file(const std::string &path) { + struct stat info {}; + return stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode); +} + +bool directory_exists(const std::string &path) { + struct stat info {}; + return stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode); +} + +std::string parent_directory(const std::string &path) { + const auto separator = path.find_last_of('/'); + if (separator == std::string::npos) { + return {}; + } + if (separator == 0) { + return "/"; + } + return path.substr(0, separator); +} + +std::string state_name(int state) { + switch (state) { + case RBU_STATE_OAL: + return "oal"; + case RBU_STATE_MOVE: + return "move"; + case RBU_STATE_CHECKPOINT: + return "checkpoint"; + case RBU_STATE_DONE: + return "done"; + case RBU_STATE_ERROR: + return "error"; + default: + return "unknown"; + } +} + +void validate_paths(const std::string &target_path, + const std::string &update_path, + const std::string &state_path) { + if (target_path.empty() || target_path.front() != '/') { + throw_rbu_error(SQLITE_MISUSE, "targetPath must be an absolute path"); + } + if (update_path.empty() || update_path.front() != '/') { + throw_rbu_error(SQLITE_MISUSE, "updatePath must be an absolute path"); + } + if (!is_regular_file(target_path)) { + throw_rbu_error(SQLITE_CANTOPEN, + "target database does not exist or is not a file: " + + target_path); + } + if (!is_regular_file(update_path)) { + throw_rbu_error(SQLITE_CANTOPEN, + "RBU update database does not exist or is not a file: " + + update_path); + } + if (target_path == update_path || + (!state_path.empty() && + (state_path == target_path || state_path == update_path))) { + throw_rbu_error(SQLITE_MISUSE, + "targetPath, updatePath, and statePath must be distinct"); + } + if (!state_path.empty()) { + if (state_path.front() != '/') { + throw_rbu_error(SQLITE_MISUSE, "statePath must be an absolute path"); + } + const auto parent = parent_directory(state_path); + if (!parent.empty() && !directory_exists(parent)) { + throw_rbu_error(SQLITE_CANTOPEN, + "statePath parent directory does not exist: " + parent); + } + } +} + +#endif + +} // namespace + +bool is_rbu_enabled() { return OP_SQLITE_RBU_AVAILABLE == 1; } + +RBUResult apply_rbu(const std::string &target_path, + const std::string &update_path, + const std::string &state_path, std::uint64_t max_steps) { +#if OP_SQLITE_RBU_AVAILABLE + validate_paths(target_path, update_path, state_path); + + sqlite3rbu *rbu = + sqlite3rbu_open(target_path.c_str(), update_path.c_str(), + state_path.empty() ? nullptr : state_path.c_str()); + if (rbu == nullptr) { + throw_rbu_error(SQLITE_NOMEM, "could not allocate an RBU handle"); + } + + int step_code = SQLITE_OK; + std::uint64_t steps = 0; + while (step_code == SQLITE_OK && steps < max_steps) { + step_code = sqlite3rbu_step(rbu); + ++steps; + } + + const auto progress = static_cast(sqlite3rbu_progress(rbu)); + const auto state = state_name(sqlite3rbu_state(rbu)); + char *error_message = nullptr; + const int close_code = sqlite3rbu_close(rbu, &error_message); + + if (close_code != SQLITE_OK && close_code != SQLITE_DONE) { + const std::string message = + error_message != nullptr ? error_message : sqlite3_errstr(close_code); + sqlite3_free(error_message); + throw_rbu_error(close_code, message); + } + + sqlite3_free(error_message); + return {close_code == SQLITE_DONE ? "complete" : "paused", steps, progress, + state}; +#else + (void)target_path; + (void)update_path; + (void)state_path; + (void)max_steps; + throw_rbu_error( + SQLITE_MISUSE, + "RBU is unavailable. Enable the bundled SQLite backend with " + "\"op-sqlite\": { \"rbu\": true } and rebuild the native app"); +#endif +} + +} // namespace opsqlite diff --git a/cpp/OPRBU.hpp b/cpp/OPRBU.hpp new file mode 100644 index 00000000..84fe76e0 --- /dev/null +++ b/cpp/OPRBU.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace opsqlite { + +struct RBUResult { + std::string status; + std::uint64_t steps; + double progress; + std::string state; +}; + +bool is_rbu_enabled(); + +RBUResult apply_rbu(const std::string &target_path, + const std::string &update_path, + const std::string &state_path, std::uint64_t max_steps); + +} // namespace opsqlite diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 7b914f71..58974274 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -9,7 +9,9 @@ #endif #include "OPLogs.h" #include "OPMacros.hpp" +#include "OPRBU.hpp" #include "OPUtils.hpp" +#include #include #include #include @@ -134,6 +136,84 @@ install(jsi::Runtime &rt, const std::shared_ptr &invoker, #endif }); + auto rbu_thread_pool = std::make_shared(); + + auto is_rbu_enabled = HFN(=) { return opsqlite::is_rbu_enabled(); }); + + auto apply_rbu = HFN(rbu_thread_pool) { + if (count != 1 || !args[0].isObject()) { + throw OPSQLiteError( + SQLITE_MISUSE, + "[op-sqlite][RBU] applyRBU expects one options object"); + } + + auto options = args[0].asObject(rt); + if (!options.hasProperty(rt, "targetPath") || + !options.getProperty(rt, "targetPath").isString()) { + throw OPSQLiteError(SQLITE_MISUSE, + "[op-sqlite][RBU] targetPath must be a string"); + } + if (!options.hasProperty(rt, "updatePath") || + !options.getProperty(rt, "updatePath").isString()) { + throw OPSQLiteError(SQLITE_MISUSE, + "[op-sqlite][RBU] updatePath must be a string"); + } + + const auto target_path = + options.getProperty(rt, "targetPath").asString(rt).utf8(rt); + const auto update_path = + options.getProperty(rt, "updatePath").asString(rt).utf8(rt); + std::string state_path; + if (options.hasProperty(rt, "statePath") && + !options.getProperty(rt, "statePath").isUndefined() && + !options.getProperty(rt, "statePath").isNull()) { + if (!options.getProperty(rt, "statePath").isString()) { + throw OPSQLiteError(SQLITE_MISUSE, + "[op-sqlite][RBU] statePath must be a string"); + } + state_path = options.getProperty(rt, "statePath").asString(rt).utf8(rt); + } + + constexpr std::uint64_t default_max_steps = 1000; + auto max_steps = default_max_steps; + if (options.hasProperty(rt, "maxSteps") && + !options.getProperty(rt, "maxSteps").isUndefined() && + !options.getProperty(rt, "maxSteps").isNull()) { + const auto value = options.getProperty(rt, "maxSteps"); + if (!value.isNumber()) { + throw OPSQLiteError( + SQLITE_MISUSE, + "[op-sqlite][RBU] maxSteps must be a positive integer"); + } + const auto number = value.asNumber(); + if (!std::isfinite(number) || number < 1 || + std::floor(number) != number || number > 9007199254740991.0) { + throw OPSQLiteError( + SQLITE_MISUSE, + "[op-sqlite][RBU] maxSteps must be a positive integer"); + } + max_steps = static_cast(number); + } + + return promisify( + rt, rbu_thread_pool, + [target_path, update_path, state_path, max_steps]() { + return std::any(opsqlite::apply_rbu(target_path, update_path, + state_path, max_steps)); + }, + [](jsi::Runtime &rt, std::any result) { + const auto rbu_result = std::any_cast(result); + jsi::Object value(rt); + value.setProperty(rt, "status", + jsi::String::createFromUtf8(rt, rbu_result.status)); + value.setProperty(rt, "steps", static_cast(rbu_result.steps)); + value.setProperty(rt, "progress", rbu_result.progress); + value.setProperty(rt, "state", + jsi::String::createFromUtf8(rt, rbu_result.state)); + return value; + }); + }); + #if defined(OP_SQLITE_USE_LIBSQL) || defined(OP_SQLITE_USE_TURSO) auto open_remote = HFN(=) { jsi::Object options = args[0].asObject(rt); @@ -226,6 +306,8 @@ install(jsi::Runtime &rt, const std::shared_ptr &invoker, module.setProperty(rt, "isLibsql", std::move(is_libsql)); module.setProperty(rt, "isTurso", std::move(is_turso)); module.setProperty(rt, "isIOSEmbedded", std::move(is_ios_embedded)); + module.setProperty(rt, "isRBUEnabled", std::move(is_rbu_enabled)); + module.setProperty(rt, "applyRBU", std::move(apply_rbu)); #if defined(OP_SQLITE_USE_LIBSQL) || defined(OP_SQLITE_USE_TURSO) module.setProperty(rt, "openRemote", std::move(open_remote)); module.setProperty(rt, "openSync", std::move(open_sync)); diff --git a/cpp/OPTypes.hpp b/cpp/OPTypes.hpp index 853483ca..01d49ce4 100644 --- a/cpp/OPTypes.hpp +++ b/cpp/OPTypes.hpp @@ -4,12 +4,21 @@ #include #include #include +#include #include #include #include namespace opsqlite { +class OPSQLiteError : public std::runtime_error { +public: + OPSQLiteError(int code, const std::string &message) + : std::runtime_error(message), code(code) {} + + int code; +}; + extern std::shared_ptr invoker; // Liveness of the current JS runtime generation. Replaced by install() and diff --git a/cpp/OPUtils.cpp b/cpp/OPUtils.cpp index f62f7e10..16c260a8 100644 --- a/cpp/OPUtils.cpp +++ b/cpp/OPUtils.cpp @@ -417,6 +417,20 @@ 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 (OPSQLiteError &e) { + auto what = std::string(e.what()); + auto code = e.code; + if (alive != nullptr && !alive->load()) { + return; + } + invoker->invokeAsync([what = std::move(what), code, resolve = resolve, + reject = reject](jsi::Runtime &rt) { + auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); + auto error = errorCtr.callAsConstructor( + rt, jsi::String::createFromUtf8(rt, what)); + error.asObject(rt).setProperty(rt, "code", code); + reject->asObject(rt).asFunction(rt).call(rt, error); + }); } catch (std::runtime_error &e) { // On Android RN is broken and does not correctly match // runtime_error to the generic exception We have to diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 3ec9cb8e..6699480f 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -65,6 +65,7 @@ SQLite is very customizable on compilation level. op-sqlite also allows you add // "sqliteFlags": "-DSQLITE_DQS=0 -DSQLITE_MY_FLAG=1", // "fts5": true, // "rtree": true, + // "rbu": true, // "libsql": true, // "turso": true, // "sqliteVec": true, @@ -82,6 +83,7 @@ All keys are optional, only turn on the features you want: - `fts5` enables the full [text search extension](https://www.sqlite.org/fts5.html). - `tokenizers` allows you to write your own C tokenizers. Read more in the corresponding section in this documentation. - `rtree` enables the [rtree extension](https://www.sqlite.org/rtree.html) +- `rbu` enables SQLite's resumable bulk update extension. See [Resumable bulk updates](./rbu.md). - `sqliteVec` enables [sqlite-vec](https://github.com/asg017/sqlite-vec), an extension for RAG embeddings - `turso` switches the backend to Turso SDK kit and enables `openRemote`, `openSync` and `sync` APIs for remote/sync workflows. diff --git a/docs/docs/rbu.md b/docs/docs/rbu.md new file mode 100644 index 00000000..8277f7c7 --- /dev/null +++ b/docs/docs/rbu.md @@ -0,0 +1,109 @@ +--- +sidebar_position: 8 +--- + +# Resumable bulk updates (RBU) + +[SQLite RBU](https://sqlite.org/rbu.html) applies a prepared bulk update incrementally. A bounded update can save its state, stop, and resume in another process while readers continue to see the original database until the update reaches the commit stage. + +OP-SQLite only applies an existing RBU database. It does not create RBU artifacts, run `sqldiff`, migrate schemas, or merge conflicting changes. The artifact must have been prepared externally for the exact base version of the target database. + +## Enable RBU + +RBU adds native code to SQLite and is disabled by default. Enable it in the application's `package.json`, then rebuild the native application (including `pod install` when using CocoaPods): + +```json +{ + "op-sqlite": { + "rbu": true + } +} +``` + +This setting adds `SQLITE_ENABLE_RBU=1` to Android CMake, CocoaPods, and SwiftPM builds. The existing low-level configuration is also supported: + +```json +{ + "op-sqlite": { + "sqliteFlags": "-DSQLITE_ENABLE_RBU=1" + } +} +``` + +The dedicated setting is recommended because it is discoverable and allows OP-SQLite to validate backend compatibility. Do not combine the two mechanisms. Like other SQLite extension flags, `SQLITE_ENABLE_RBU` is presence-based: `-DSQLITE_ENABLE_RBU=0` still compiles the extension. Omit the flag and set `rbu` to `false` to disable RBU. + +RBU currently supports only OP-SQLite's bundled vanilla SQLite on Android and Apple platforms. It is unavailable with SQLCipher, libSQL, Turso, Apple's embedded/system SQLite (`iosSqlite`), the web backend, and the Node.js test facade. `isRBUEnabled()` reports the active native build capability. Configuring `rbu: true` with an unsupported native backend fails the native build with an explanatory error. + +SQLCipher's amalgamation contains the optional RBU source, but RBU opens and inspects its internal target, update, and state connections before this API could configure encryption keys. Encrypted target and artifact behavior is therefore not proven or supported. + +## Pause and resume + +Close every ordinary OP-SQLite connection to the target before starting RBU. RBU owns its target connection for the duration of each call. + +```ts +import { applyRBU, isRBUEnabled } from "@op-engineering/op-sqlite"; + +if (!isRBUEnabled()) { + throw new Error("Rebuild OP-SQLite with RBU enabled"); +} + +const paths = { + targetPath: "/absolute/path/catalog.sqlite", + updatePath: "/absolute/path/catalog-update.sqlite", + statePath: "/absolute/path/catalog-update-state.sqlite", +}; + +let result = await applyRBU({ ...paths, maxSteps: 100 }); + +while (result.status === "paused") { + result = await applyRBU({ ...paths, maxSteps: 100 }); +} +``` + +`applyRBU()` runs on a native background thread. `maxSteps` must be a positive safe integer and bounds the number of `sqlite3rbu_step()` calls made by that invocation. It defaults to 1000 so a single call cannot monopolize native-module teardown. Keep calling `applyRBU()` with the same paths while the result is `paused`. + +The result contains: + +- `status`: `paused` or `complete`; +- `steps`: work steps performed by this invocation; +- `progress`: SQLite's cumulative `sqlite3rbu_progress()` value (work units, not a percentage); +- `state`: `oal`, `move`, `checkpoint`, `done`, `error`, or `unknown`. + +Calling `applyRBU()` again with the same completed update and preserved state returns `complete`; SQLite marks the artifact as fully applied. + +## State and recovery + +When `statePath` is provided, RBU creates a separate SQLite state database. Preserve the target database, RBU artifact, state database, and RBU-created sidecar files together until completion. Do not delete or replace the state database during an incomplete update; it is the resume cursor, and losing it can make the remaining sidecars inconsistent with a fresh application attempt. + +When `statePath` is omitted, SQLite stores tables beginning with `rbu_` inside the update database. A separate state path is usually easier to manage when the artifact itself should remain immutable. + +Closing after a bounded call persists a resumable checkpoint. Following a process termination or device restart, a new process may call `applyRBU()` with the same paths and continue from SQLite's most recently persisted state. As with SQLite RBU itself, this is crash recovery rather than distributed conflict resolution; power loss at an unlucky point can still surface a SQLite constraint or I/O error that requires replacing the artifact and its state. + +## Target restrictions + +Before applying an update: + +- close ordinary OP-SQLite connections and prevent concurrent writers; +- ensure the target is not in WAL journal mode (for example, use `PRAGMA journal_mode=DELETE` before closing it); +- use an RBU artifact prepared for the exact target database version; +- keep the files on storage that supports SQLite's required locking and atomic filesystem operations. + +RBU does not fire triggers or enforce foreign-key and `CHECK` constraints while applying changes. Review the full [SQLite RBU limitations](https://sqlite.org/rbu.html#rbu_update_limitations) when producing artifacts. + +## Errors + +Failures reject with `RBUError`. Its `code` property is the numeric SQLite result code and `message` contains SQLite's message plus OP-SQLite context. + +```ts +import { applyRBU, RBUError } from "@op-engineering/op-sqlite"; + +try { + await applyRBU(paths); +} catch (error) { + if (error instanceof RBUError) { + console.error(error.code, error.message); + } +} +``` + +Missing target/update files, malformed RBU databases, invalid options, unavailable builds, and unsupported platforms all reject instead of creating a target or silently falling back. diff --git a/example/package.json b/example/package.json index 394af3d9..87b5b15e 100644 --- a/example/package.json +++ b/example/package.json @@ -60,6 +60,7 @@ "iosSqlite": false, "fts5": true, "rtree": true, + "rbu": true, "sqliteVec": false, "performanceMode": true, "tokenizers": [ diff --git a/example/src/tests/index.ts b/example/src/tests/index.ts index 3e229734..2e83b62c 100644 --- a/example/src/tests/index.ts +++ b/example/src/tests/index.ts @@ -5,6 +5,7 @@ import "./hooks"; import "./preparedStatements"; import "./queries"; import "./reactive"; +import "./rbu"; import "./storage"; import "./tokenizer"; import "./web"; diff --git a/example/src/tests/rbu.ts b/example/src/tests/rbu.ts new file mode 100644 index 00000000..71d2dc6c --- /dev/null +++ b/example/src/tests/rbu.ts @@ -0,0 +1,245 @@ +import { + ANDROID_DATABASE_PATH, + applyRBU, + IOS_LIBRARY_PATH, + isIOSEmbedded, + isLibsql, + isRBUEnabled, + isSQLCipher, + isTurso, + open, +} from "@op-engineering/op-sqlite"; +import { describe, expect, it } from "@op-engineering/op-test"; +import { Platform } from "react-native"; + +const directory = + Platform.OS === "ios" ? IOS_LIBRARY_PATH : ANDROID_DATABASE_PATH; + +function databasePath(name: string): string { + return `${directory}/${name}`; +} + +function removeDatabase(name: string): void { + const database = open({ name, location: directory }); + database.delete(); +} + +async function captureError( + work: () => Promise, +): Promise<{ code?: number; message: string }> { + try { + await work(); + return { message: "" }; + } catch (error) { + return error as { code?: number; message: string }; + } +} + +describe("Resumable bulk updates", () => { + const unsupportedBackend = + isSQLCipher() || isLibsql() || isTurso() || isIOSEmbedded(); + + it("reports whether RBU is compiled into the active backend", () => { + if (unsupportedBackend) { + expect(isRBUEnabled()).toEqual(false); + } else { + expect(typeof isRBUEnabled()).toEqual("boolean"); + } + }); + + if (!isRBUEnabled()) { + it("rejects applyRBU when the backend does not provide RBU", async () => { + const error = await captureError(() => + applyRBU({ + targetPath: "/missing-target", + updatePath: "/missing-update", + }), + ); + + expect(error.code).toEqual(21); + expect(error.message.includes("RBU is unavailable")).toEqual(true); + }); + return; + } + + it("pauses into a separate state database, resumes, and completes atomically", async () => { + const targetName = "rbu-target.sqlite"; + const updateName = "rbu-update.sqlite"; + const stateName = "rbu-state.sqlite"; + + removeDatabase(targetName); + removeDatabase(updateName); + removeDatabase(stateName); + + const target = open({ name: targetName, location: directory }); + await target.execute( + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL)", + ); + await target.execute("INSERT INTO items (id, value) VALUES (?, ?)", [ + 1, + "before", + ]); + target.close(); + + const update = open({ name: updateName, location: directory }); + await update.execute( + "CREATE TABLE data_items (id INTEGER, value TEXT, rbu_control)", + ); + await update.execute("INSERT INTO data_items VALUES (?, ?, ?)", [ + 1, + "after", + ".x", + ]); + await update.execute(` + WITH RECURSIVE sequence(id) AS ( + VALUES(2) + UNION ALL + SELECT id + 1 FROM sequence WHERE id < 1202 + ) + INSERT INTO data_items + SELECT id, 'inserted', 0 FROM sequence + `); + update.close(); + + const options = { + targetPath: databasePath(targetName), + updatePath: databasePath(updateName), + statePath: databasePath(stateName), + }; + + const paused = await applyRBU({ ...options, maxSteps: 1 }); + expect(paused.status).toEqual("paused"); + expect(paused.steps).toEqual(1); + expect(paused.progress >= 0).toEqual(true); + + const state = open({ + name: stateName, + location: directory, + failOnCreate: true, + }); + state.close(); + + const beforeResume = open({ + name: targetName, + location: directory, + readOnly: true, + }); + const beforeRows = await beforeResume.execute( + "SELECT value FROM items WHERE id = 1", + ); + expect(beforeRows.rows[0]?.value).toEqual("before"); + beforeResume.close(); + + let complete = await applyRBU(options); + expect(complete.status).toEqual("paused"); + expect(complete.steps).toEqual(1000); + + let resumeCalls = 0; + while (complete.status === "paused" && resumeCalls < 10) { + complete = await applyRBU(options); + resumeCalls += 1; + } + expect(complete.status).toEqual("complete"); + expect(complete.state).toEqual("done"); + expect(complete.steps > 0).toEqual(true); + + const afterResume = open({ + name: targetName, + location: directory, + readOnly: true, + }); + const afterRows = await afterResume.execute( + "SELECT value FROM items WHERE id = 1", + ); + expect(afterRows.rows[0]?.value).toEqual("after"); + const compileOption = await afterResume.execute( + "SELECT sqlite_compileoption_used('ENABLE_RBU') AS enabled", + ); + expect(compileOption.rows[0]?.enabled).toEqual(1); + afterResume.close(); + + const alreadyComplete = await applyRBU(options); + expect(alreadyComplete.status).toEqual("complete"); + expect(alreadyComplete.state).toEqual("done"); + + removeDatabase(targetName); + removeDatabase(updateName); + removeDatabase(stateName); + }); + + it("rejects malformed options before entering native code", async () => { + const error = await captureError(() => + applyRBU({ targetPath: "/target", updatePath: "/update", maxSteps: 0 }), + ); + expect(error.code).toEqual(21); + expect(error.message.includes("maxSteps")).toEqual(true); + }); + + it("returns a structured error for a missing target", async () => { + const updateName = "rbu-missing-target-update.sqlite"; + removeDatabase(updateName); + const update = open({ name: updateName, location: directory }); + await update.execute( + "CREATE TABLE data_items (id INTEGER, value TEXT, rbu_control)", + ); + update.close(); + + const error = await captureError(() => + applyRBU({ + targetPath: databasePath("rbu-does-not-exist.sqlite"), + updatePath: databasePath(updateName), + }), + ); + expect(error.code).toEqual(14); + expect(error.message.includes("target database")).toEqual(true); + removeDatabase(updateName); + }); + + it("returns a structured error for a missing update", async () => { + const targetName = "rbu-missing-update-target.sqlite"; + removeDatabase(targetName); + const target = open({ name: targetName, location: directory }); + await target.execute( + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT)", + ); + target.close(); + + const error = await captureError(() => + applyRBU({ + targetPath: databasePath(targetName), + updatePath: databasePath("rbu-does-not-exist.sqlite"), + }), + ); + expect(error.code).toEqual(14); + expect(error.message.includes("RBU update database")).toEqual(true); + removeDatabase(targetName); + }); + + it("returns SQLite's error for a malformed RBU database", async () => { + const targetName = "rbu-invalid-target.sqlite"; + const updateName = "rbu-invalid-update.sqlite"; + removeDatabase(targetName); + removeDatabase(updateName); + + const target = open({ name: targetName, location: directory }); + await target.execute( + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT)", + ); + target.close(); + const update = open({ name: updateName, location: directory }); + await update.execute("CREATE TABLE data_items (id INTEGER, value TEXT)"); + update.close(); + + const error = await captureError(() => + applyRBU({ + targetPath: databasePath(targetName), + updatePath: databasePath(updateName), + }), + ); + expect(typeof error.code).toEqual("number"); + expect(error.message.length > 0).toEqual(true); + + removeDatabase(targetName); + removeDatabase(updateName); + }); +}); diff --git a/example/src/tests/web.ts b/example/src/tests/web.ts index 892271a8..0bc2c3e9 100644 --- a/example/src/tests/web.ts +++ b/example/src/tests/web.ts @@ -1,4 +1,4 @@ -import { type DB, open, openAsync } from "@op-engineering/op-sqlite"; +import { applyRBU, type DB, isRBUEnabled, open, openAsync } from "@op-engineering/op-sqlite"; import { describe, expect, it } from "@op-engineering/op-test"; import { Platform } from "react-native"; @@ -83,4 +83,18 @@ describe("Web backend", () => { expect(didThrow).toEqual(true); }); + + it("exports RBU APIs with a clear unsupported-platform error", async () => { + expect(isRBUEnabled()).toEqual(false); + + let error: { code?: number; message?: string } | undefined; + try { + await applyRBU({ targetPath: "/target.sqlite", updatePath: "/update.sqlite" }); + } catch (caught) { + error = caught as { code?: number; message?: string }; + } + + expect(error?.code).toEqual(21); + expect(error?.message?.includes("not supported on web")).toEqual(true); + }); }); diff --git a/node/jest.config.js b/node/jest.config.js index 13eec31c..e6b9fb65 100644 --- a/node/jest.config.js +++ b/node/jest.config.js @@ -17,6 +17,7 @@ export default { esModuleInterop: true, allowSyntheticDefaultImports: true, target: 'ES2020', + rootDir: '..', lib: ['ES2020'], types: ['node', 'jest'], }, diff --git a/node/src/index.ts b/node/src/index.ts index 6743eadf..be7746f3 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; import { NodeDatabase } from "./database"; -import type { DB, DBParams, OPSQLiteProxy } from "./types"; +import type { DB, DBParams, OPSQLiteProxy, RBUApplyOptions, RBUApplyResult } from "./types"; export { NodeDatabase as Database } from "./database"; export type { @@ -12,12 +12,28 @@ export type { OPSQLiteProxy, PreparedStatement, QueryResult, + RBUApplyOptions, + RBUApplyResult, + RBUState, Scalar, SQLBatchTuple, Transaction, UpdateHookOperation, } from "./types"; +export class RBUError extends Error { + code: number; + + constructor(code: number, message: string, cause?: unknown) { + super(message); + this.name = "RBUError"; + this.code = code; + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} + class OPSQLiteProxyImpl implements OPSQLiteProxy { open(options: { name: string; @@ -71,6 +87,17 @@ class OPSQLiteProxyImpl implements OPSQLiteProxy { isIOSEmbedded(): boolean { return false; } + + isRBUEnabled(): boolean { + return false; + } + + async applyRBU(_options: RBUApplyOptions): Promise { + throw new RBUError( + 21, + "[op-sqlite][RBU] applyRBU() is not supported by the Node.js test facade", + ); + } } // Create singleton instance @@ -85,6 +112,8 @@ export const isSQLCipher = proxy.isSQLCipher.bind(proxy); export const isLibsql = proxy.isLibsql.bind(proxy); export const isTurso = proxy.isTurso.bind(proxy); export const isIOSEmbedded = proxy.isIOSEmbedded.bind(proxy); +export const isRBUEnabled = proxy.isRBUEnabled.bind(proxy); +export const applyRBU = proxy.applyRBU.bind(proxy); // Default export export default proxy; diff --git a/node/src/test.spec.ts b/node/src/test.spec.ts index fa406d9a..f828c9f8 100644 --- a/node/src/test.spec.ts +++ b/node/src/test.spec.ts @@ -1,7 +1,15 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; -import { isIOSEmbedded, isLibsql, isSQLCipher, open } from "./index"; +import { + applyRBU, + isIOSEmbedded, + isLibsql, + isRBUEnabled, + isSQLCipher, + open, + RBUError, +} from "./index"; describe("op-sqlite Node.js tests", () => { let db: ReturnType; @@ -25,6 +33,14 @@ describe("op-sqlite Node.js tests", () => { expect(path).toContain("test.sqlite"); }); + test("RBU APIs report the unsupported Node.js facade", async () => { + expect(isRBUEnabled()).toBe(false); + expect(new RBUError(14, "test")).toMatchObject({ code: 14, message: "test" }); + await expect( + applyRBU({ targetPath: "target.sqlite", updatePath: "update.sqlite" }), + ).rejects.toMatchObject({ code: 21 }); + }); + test("Create table", () => { db.executeSync( "CREATE TABLE IF NOT EXISTS test_users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", diff --git a/node/src/types.ts b/node/src/types.ts index 536fea98..cdb7866e 100644 --- a/node/src/types.ts +++ b/node/src/types.ts @@ -99,6 +99,23 @@ export type DBParams = { syncInterval?: number; }; +export type RBUApplyOptions = { + targetPath: string; + updatePath: string; + statePath?: string; + /** Maximum sqlite3rbu_step() calls before persisting state. Defaults to 1000. */ + maxSteps?: number; +}; + +export type RBUState = "oal" | "move" | "checkpoint" | "done" | "error" | "unknown"; + +export type RBUApplyResult = { + status: "paused" | "complete"; + steps: number; + progress: number; + state: RBUState; +}; + export type OPSQLiteProxy = { open: (options: { name: string; location?: string; encryptionKey?: string }) => DB; openV2: (options: { path: string; encryptionKey?: string }) => DB; @@ -108,4 +125,6 @@ export type OPSQLiteProxy = { isLibsql: () => boolean; isTurso: () => boolean; isIOSEmbedded: () => boolean; + isRBUEnabled: () => boolean; + applyRBU: (options: RBUApplyOptions) => Promise; }; diff --git a/node/tests/rbu-options.test.ts b/node/tests/rbu-options.test.ts new file mode 100644 index 00000000..fdebaf57 --- /dev/null +++ b/node/tests/rbu-options.test.ts @@ -0,0 +1,36 @@ +import { RBUError, validateRBUOptions } from "../../src/rbu"; + +describe("RBU option validation", () => { + test.each([ + undefined, + {}, + { targetPath: "", updatePath: "update.sqlite" }, + { targetPath: "target.sqlite", updatePath: "" }, + { + targetPath: "/target.sqlite", + updatePath: "/update.sqlite", + statePath: "", + }, + { targetPath: "target.sqlite", updatePath: "update.sqlite", maxSteps: 0 }, + { targetPath: "target.sqlite", updatePath: "update.sqlite", maxSteps: 1.5 }, + ])("rejects malformed input %#", (options) => { + expect(() => validateRBUOptions(options as never)).toThrow(RBUError); + }); + + test("accepts bounded and unbounded valid options", () => { + expect(() => + validateRBUOptions({ + targetPath: "/target.sqlite", + updatePath: "/update.sqlite", + }), + ).not.toThrow(); + expect(() => + validateRBUOptions({ + targetPath: "/target.sqlite", + updatePath: "/update.sqlite", + statePath: "/state.sqlite", + maxSteps: 10, + }), + ).not.toThrow(); + }); +}); diff --git a/op-sqlite.podspec b/op-sqlite.podspec index 905d8e95..6279e494 100644 --- a/op-sqlite.podspec +++ b/op-sqlite.podspec @@ -47,6 +47,7 @@ phone_version = false sqlite_flags = "" fts5 = false rtree = false +rbu = false use_sqlite_vec = false tokenizers = [] @@ -59,10 +60,15 @@ if(op_sqlite_config != nil) sqlite_flags = op_sqlite_config["sqliteFlags"] || "" fts5 = op_sqlite_config["fts5"] == true rtree = op_sqlite_config["rtree"] == true + rbu = op_sqlite_config["rbu"] == true use_sqlite_vec = op_sqlite_config["sqliteVec"] == true tokenizers = op_sqlite_config["tokenizers"] || [] end +if rbu and (use_sqlcipher or use_libsql or use_turso or phone_version) then + raise "RBU currently supports only the bundled vanilla SQLite backend. Disable rbu or the alternate backend." +end + if phone_version then if use_sqlcipher then raise "SQLCipher is not supported with phone version. It cannot load extensions." @@ -175,6 +181,11 @@ Pod::Spec.new do |s| xcconfig[:GCC_PREPROCESSOR_DEFINITIONS] += " SQLITE_ENABLE_RTREE=1" end + if rbu then + log_message.call("[OP-SQLITE] RBU enabled") + xcconfig[:GCC_PREPROCESSOR_DEFINITIONS] += " SQLITE_ENABLE_RBU=1" + end + if phone_version then log_message.call("[OP-SQLITE] using iOS embedded SQLite 📱") xcconfig[:GCC_PREPROCESSOR_DEFINITIONS] += " OP_SQLITE_USE_PHONE_VERSION=1" diff --git a/scripts/turnOffEverything.js b/scripts/turnOffEverything.js index e3159700..57eafc7b 100644 --- a/scripts/turnOffEverything.js +++ b/scripts/turnOffEverything.js @@ -12,6 +12,7 @@ packageJson['op-sqlite']['sqlcipher'] = false; packageJson['op-sqlite']['iosSqlite'] = false; packageJson['op-sqlite']['fts5'] = true; packageJson['op-sqlite']['rtree'] = true; +packageJson['op-sqlite']['rbu'] = true; packageJson['op-sqlite']['sqliteVec'] = false; packageJson['op-sqlite']['tokenizers'] = ["wordtokenizer", "porter"]; diff --git a/scripts/turnOnIOSEmbedded.js b/scripts/turnOnIOSEmbedded.js index 0c6af9b8..fcc963b6 100644 --- a/scripts/turnOnIOSEmbedded.js +++ b/scripts/turnOnIOSEmbedded.js @@ -10,6 +10,7 @@ packageJson['op-sqlite']['libsql'] = false; packageJson['op-sqlite']['turso'] = false; packageJson['op-sqlite']['sqliteVec'] = false; packageJson['op-sqlite']['rtree'] = false; +packageJson['op-sqlite']['rbu'] = false; packageJson['op-sqlite']['fts5'] = true; // Save the updated package.json file diff --git a/scripts/turnOnLibsql.js b/scripts/turnOnLibsql.js index 09b0a71e..1272a477 100644 --- a/scripts/turnOnLibsql.js +++ b/scripts/turnOnLibsql.js @@ -8,6 +8,7 @@ packageJson['op-sqlite']['libsql'] = true; packageJson['op-sqlite']['turso'] = false; packageJson['op-sqlite']['sqlcipher'] = false; packageJson['op-sqlite']['iosSqlite'] = false; +packageJson['op-sqlite']['rbu'] = false; delete packageJson['op-sqlite']['tokenizers']; packageJson['op-sqlite']['sqliteVec'] = false; diff --git a/scripts/turnOnSQLCipher.js b/scripts/turnOnSQLCipher.js index fba1d3ea..45f0408a 100644 --- a/scripts/turnOnSQLCipher.js +++ b/scripts/turnOnSQLCipher.js @@ -8,6 +8,7 @@ packageJson['op-sqlite']['sqlcipher'] = true; packageJson['op-sqlite']['libsql'] = false; packageJson['op-sqlite']['turso'] = false; packageJson['op-sqlite']['iosSqlite'] = false; +packageJson['op-sqlite']['rbu'] = false; packageJson['op-sqlite']['sqliteVec'] = false; // Save the updated package.json file diff --git a/scripts/turnOnTurso.js b/scripts/turnOnTurso.js index 85890523..e63b2a78 100644 --- a/scripts/turnOnTurso.js +++ b/scripts/turnOnTurso.js @@ -9,6 +9,7 @@ packageJson['op-sqlite']['libsql'] = false; packageJson['op-sqlite']['sqlcipher'] = false; packageJson['op-sqlite']['iosSqlite'] = false; packageJson['op-sqlite']['sqliteVec'] = false; +packageJson['op-sqlite']['rbu'] = false; packageJson['op-sqlite']['tokenizers'] = []; // Save the updated package.json file @@ -17,4 +18,4 @@ fs.writeFileSync( JSON.stringify(packageJson, null, 2) ); -console.log('Turned on turso in package.json', packageJson); \ No newline at end of file +console.log('Turned on turso in package.json', packageJson); diff --git a/src/functions.ts b/src/functions.ts index 76dde5ec..61573c28 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -8,10 +8,15 @@ import type { OpenOptions, OPSQLiteProxy, QueryResult, + RBUApplyOptions, + RBUApplyResult, Scalar, SQLBatchTuple, Transaction, } from "./types"; +import { normalizeRBUError, RBUError, validateRBUOptions } from "./rbu"; + +export { RBUError }; declare global { var __OPSQLiteProxy: object | undefined; @@ -380,3 +385,22 @@ export const isIOSEmbedded = (): boolean => { return OPSQLite.isIOSEmbedded(); }; + +/** Returns whether RBU was compiled into the active native SQLite backend. */ +export const isRBUEnabled = (): boolean => { + return OPSQLite.isRBUEnabled(); +}; + +/** + * Applies a prepared RBU update using a native background thread. + * Ordinary connections to the target must be closed before calling this function. + */ +export const applyRBU = async (options: RBUApplyOptions): Promise => { + validateRBUOptions(options); + + try { + return await OPSQLite.applyRBU(options); + } catch (error) { + throw normalizeRBUError(error); + } +}; diff --git a/src/functions.web.ts b/src/functions.web.ts index f9337b54..cf02cb00 100644 --- a/src/functions.web.ts +++ b/src/functions.web.ts @@ -9,11 +9,16 @@ import type { OPSQLiteProxy, PreparedStatement, QueryResult, + RBUApplyOptions, + RBUApplyResult, RawQueryResult, Scalar, SQLBatchTuple, Transaction, } from "./types"; +import { RBUError, validateRBUOptions } from "./rbu"; + +export { RBUError }; type WorkerPromiser = (type: string, args?: Record) => Promise; @@ -512,6 +517,15 @@ export const isIOSEmbedded = (): boolean => { return false; }; +export const isRBUEnabled = (): boolean => { + return false; +}; + +export const applyRBU = async (options: RBUApplyOptions): Promise => { + validateRBUOptions(options); + throw new RBUError(21, "[op-sqlite][RBU] applyRBU() is not supported on web"); +}; + /** * @deprecated Use `isIOSEmbedded` instead. This alias will be removed in a future release. */ diff --git a/src/index.ts b/src/index.ts index eb98eba7..7ad63e13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,9 @@ export type { OPSQLiteProxy, PreparedStatement, QueryResult, + RBUApplyOptions, + RBUApplyResult, + RBUState, Scalar, SQLBatchTuple, Transaction, diff --git a/src/index.web.ts b/src/index.web.ts index 0a8f975c..e5a56f97 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -11,6 +11,9 @@ export type { OPSQLiteProxy, PreparedStatement, QueryResult, + RBUApplyOptions, + RBUApplyResult, + RBUState, Scalar, SQLBatchTuple, Transaction, diff --git a/src/rbu.ts b/src/rbu.ts new file mode 100644 index 00000000..d85af5cb --- /dev/null +++ b/src/rbu.ts @@ -0,0 +1,77 @@ +import type { RBUApplyOptions } from "./types"; + +const SQLITE_MISUSE = 21; + +export class RBUError extends Error { + code: number; + + constructor(code: number, message: string, cause?: unknown) { + super(message); + this.name = "RBUError"; + this.code = code; + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} + +export function validateRBUOptions(options: RBUApplyOptions): void { + if (options == null || typeof options !== "object") { + throw new RBUError( + SQLITE_MISUSE, + "[op-sqlite][RBU] applyRBU expects an options object", + ); + } + if ( + typeof options.targetPath !== "string" || + !options.targetPath.startsWith("/") + ) { + throw new RBUError( + SQLITE_MISUSE, + "[op-sqlite][RBU] targetPath must be an absolute path", + ); + } + if ( + typeof options.updatePath !== "string" || + !options.updatePath.startsWith("/") + ) { + throw new RBUError( + SQLITE_MISUSE, + "[op-sqlite][RBU] updatePath must be an absolute path", + ); + } + if ( + options.statePath !== undefined && + (typeof options.statePath !== "string" || + !options.statePath.startsWith("/")) + ) { + throw new RBUError( + SQLITE_MISUSE, + "[op-sqlite][RBU] statePath must be an absolute path", + ); + } + if ( + options.maxSteps !== undefined && + (!Number.isSafeInteger(options.maxSteps) || options.maxSteps < 1) + ) { + throw new RBUError( + SQLITE_MISUSE, + "[op-sqlite][RBU] maxSteps must be a positive safe integer", + ); + } +} + +export function normalizeRBUError(error: unknown): RBUError { + if (error instanceof RBUError) { + return error; + } + + const nativeError = error as { code?: unknown; message?: unknown }; + const code = + typeof nativeError?.code === "number" ? nativeError.code : SQLITE_MISUSE; + const message = + typeof nativeError?.message === "string" + ? nativeError.message + : "[op-sqlite][RBU] unknown RBU error"; + return new RBUError(code, message, error); +} diff --git a/src/types.ts b/src/types.ts index 8347c7d9..1ab7c9d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -335,6 +335,28 @@ export type DBParams = { syncInterval?: number; }; +export type RBUApplyOptions = { + /** Absolute path to the existing target SQLite database. */ + targetPath: string; + /** Absolute path to an RBU update database prepared for the exact target version. */ + updatePath: string; + /** Optional path to a separate persistent RBU state database. */ + statePath?: string; + /** Maximum sqlite3rbu_step() calls before persisting state. Defaults to 1000. */ + maxSteps?: number; +}; + +export type RBUState = "oal" | "move" | "checkpoint" | "done" | "error" | "unknown"; + +export type RBUApplyResult = { + status: "paused" | "complete"; + /** Number of RBU steps performed by this call. */ + steps: number; + /** SQLite's cumulative sqlite3rbu_progress() value. */ + progress: number; + state: RBUState; +}; + export type OPSQLiteProxy = { open: (options: { name: string; location?: string; encryptionKey?: string }) => _InternalDB; openRemote: (options: { url: string; authToken: string }) => _InternalDB; @@ -343,4 +365,6 @@ export type OPSQLiteProxy = { isLibsql: () => boolean; isTurso: () => boolean; isIOSEmbedded: () => boolean; + isRBUEnabled: () => boolean; + applyRBU: (options: RBUApplyOptions) => Promise; };