[](https://www.npmjs.com/package/@npiesco/absurder-sql)
[](https://www.npmjs.com/package/@npiesco/absurder-sql)
@@ -14,6 +14,7 @@
[](https://webassembly.org/)
[](https://www.sqlite.org/)
[](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
+[](docs/HYBRID_OPFS_PLAN.md)
**Capabilities:**
[](docs/DUAL_MODE.md)
@@ -24,15 +25,15 @@
[](monitoring/grafana/)
[](browser-extension/)
-> *SQLite + IndexedDB + Custom VFS that's absurdly absurder than absurd-sql*
+> *SQLite + IndexedDB/OPFS + Custom VFS that's absurdly absurder than absurd-sql*
## This is an absurd*er* project.
-It implements a custom SQLite Virtual File System (VFS) backend that treats **IndexedDB like a disk** and stores data in blocks there. Your database lives permanently in browser storage with **intelligent block-level I/O**—reading and writing 4KB chunks with LRU caching—avoiding the performance nightmare of serializing entire database files on every operation.
+It implements a custom SQLite Virtual File System (VFS) backend that treats **browser storage like a disk**. In browsers, AbsurderSQL can use **IndexedDB directly** or a **Hybrid OPFS + IndexedDB backend** that keeps blocks in OPFS and metadata in IndexedDB. Your database lives permanently in browser storage with **intelligent block-level I/O**—reading and writing 4KB chunks with LRU caching—avoiding the performance nightmare of serializing entire database files on every operation.
**It basically stores a whole database into another database using a custom VFS. Which is absurd*er*.**
-But AbsurderSQL takes it further: it's **absurdly better**. Unlike absurd-sql, your data isn't locked in IndexedDB forever—you can **[export and import](docs/EXPORT_IMPORT.md)** standard SQLite files. Need to query from both browser and CLI? Use **[dual-mode persistence](docs/DUAL_MODE.md)**—same database structure, IndexedDB in the browser and real files on the server. Multiple tabs? **[Multi-tab coordination](docs/MULTI_TAB_GUIDE.md)** with automatic leader election prevents conflicts. Want production observability? Optional **[Prometheus + Grafana monitoring](monitoring/)** with **[DevTools extension](browser-extension/)** for debugging WASM telemetry.
+But AbsurderSQL takes it further: it's **absurdly better**. Unlike absurd-sql, your data isn't locked in browser storage forever—you can **[export and import](docs/EXPORT_IMPORT.md)** standard SQLite files. Need to query from both browser and CLI? Use **[dual-mode persistence](docs/DUAL_MODE.md)**—same database structure, browser persistence on IndexedDB or Hybrid OPFS+IndexedDB, and real files on the server. Multiple tabs? **[Multi-tab coordination](docs/MULTI_TAB_GUIDE.md)** with automatic leader election prevents conflicts. Need faster worker-backed browser persistence? Use the Hybrid backend with OPFS block I/O and IndexedDB metadata fallback. Want production observability? Optional **[Prometheus + Grafana monitoring](monitoring/)** with **[DevTools extension](browser-extension/)** for debugging WASM telemetry.
**Read the [blog post](https://iscopesolutions.net/) that explains the absurdity in detail.**
@@ -42,18 +43,41 @@ But AbsurderSQL takes it further: it's **absurdly better**. Unlike absurd-sql, y
A high-performance **tri-mode** Rust library that brings full SQLite functionality to **browsers, native applications, and mobile devices**:
-- **Browser (WASM)**: SQLite → IndexedDB with multi-tab coordination, Web Worker support, and full export/import
+- **Browser (WASM)**: SQLite → IndexedDB or Hybrid OPFS+IndexedDB, with multi-tab coordination, Web Worker support, and full export/import
- **Native/CLI**: SQLite → Real filesystem with traditional `.db` files
- **Mobile (React Native)**: SQLite → Device filesystem via UniFFI with SQLCipher encryption for iOS and Android
**Unique Advantages:**
-Export/import databases as standard SQLite files (absurd-sql has no export/import—data is permanently locked in IndexedDB). Build web apps that store data in IndexedDB, then query the same database structure from CLI/server using standard SQLite tools. Multi-tab coordination with automatic leader election prevents conflicts. Perfect for offline-first applications with backup/restore, data migration, and optional server synchronization.
+Export/import databases as standard SQLite files (absurd-sql has no export/import—data is permanently locked in browser storage). Build web apps that store data with IndexedDB by default or Hybrid OPFS+IndexedDB in worker contexts, then query the same database structure from CLI/server using standard SQLite tools. Multi-tab coordination with automatic leader election prevents conflicts. Perfect for offline-first applications with backup/restore, data migration, and optional server synchronization.
**Production Observability (Optional):** When enabled with `--features telemetry`, includes complete monitoring stack: Prometheus metrics, OpenTelemetry tracing, pre-built Grafana dashboards, production-ready alert rules with runbooks, and a Chrome/Firefox DevTools extension for debugging WASM telemetry. All telemetry features are opt-in—default builds include zero monitoring overhead.
Enabling production-ready SQL operations with crash consistency, multi-tab coordination, complete data portability, optional observability, and the flexibility to run anywhere from web apps to server applications.
+## Browser Storage Backends
+
+AbsurderSQL now exposes three browser constructors so you can choose persistence behavior explicitly:
+
+- `Database.newDatabase(name)` uses the default IndexedDB-backed browser path and works on the main thread.
+- `Database.newDatabaseAuto(name)` probes for OPFS `SyncAccessHandle` support and selects `Hybrid` in supported worker contexts, otherwise falls back to IndexedDB.
+- `Database.newDatabaseWithBackend(name, 'IndexedDB' | 'OPFS' | 'Hybrid')` forces a specific backend.
+- `db.getStorageBackend()` returns the backend actually in use.
+
+`Hybrid` is the recommended worker backend: blocks live in OPFS for fast block I/O, while IndexedDB stores metadata and provides fallback durability. Main-thread browser code should continue using IndexedDB or rely on `newDatabaseAuto()` to fall back automatically.
+
+```javascript
+import init, { Database } from '@npiesco/absurder-sql';
+
+await init();
+
+const mainThreadDb = await Database.newDatabase('app-main');
+const autoDb = await Database.newDatabaseAuto('app-auto');
+const workerHybridDb = await Database.newDatabaseWithBackend('app-worker', 'Hybrid');
+
+console.log(await autoDb.getStorageBackend());
+```
+
## Tri-Mode Architecture
AbsurderSQL runs in **three modes** - Browser (WASM), Native (Rust CLI/Server), and Mobile (React Native):
@@ -102,8 +126,9 @@ graph TB
end
subgraph "Browser Persistence"
- INDEXEDDB["IndexedDB (Browser Storage)"]
- LOCALSTORAGE["localStorage (Coordination)"]
+ INDEXEDDB["IndexedDB (Browser Storage)"]
+ OPFS["OPFS (Worker SyncAccessHandle)"]
+ LOCALSTORAGE["localStorage (Coordination)"]
end
subgraph "Native Persistence"
@@ -141,7 +166,8 @@ graph TB
SYNC -->|metadata| META
EXPORT -->|read blocks| BS
IMPORT -->|write blocks| BS
- BS -->|"WASM mode"| INDEXEDDB
+ BS -->|"WASM IndexedDB"| INDEXEDDB
+ BS -->|"WASM Hybrid/OPFS"| OPFS
BS -->|"Native mode"| FILESYSTEM
NATIVE_DB -->|"fs_persist"| BLOCKS
UNIFFI -->|"Mobile mode"| DEVICE_FS
@@ -162,6 +188,7 @@ graph TB
style EXPORT fill:#ec4899,stroke:#333,color:#fff
style IMPORT fill:#8b5cf6,stroke:#333,color:#fff
style INDEXEDDB fill:#22c55e,stroke:#333,color:#000
+ style OPFS fill:#14b8a6,stroke:#333,color:#fff
style QUEUE fill:#ef4444,stroke:#333,color:#fff
style OBS fill:#92400e,stroke:#333,color:#fff
style PROM fill:#1c1c1c,stroke:#333,color:#fff
@@ -220,7 +247,10 @@ absurder-sql/
│ │ ├── import.rs # Database import from SQLite files
│ │ ├── retry_logic.rs # Retry logic for transient failures
│ │ ├── fs_persist.rs # Native filesystem persistence
+│ │ ├── backend_detect.rs # Browser backend auto-detection
│ │ ├── wasm_indexeddb.rs # WASM IndexedDB integration
+│ │ ├── wasm_opfs.rs # WASM OPFS block storage bridge
+│ │ ├── hybrid_store.rs # Hybrid OPFS + IndexedDB orchestration
│ │ ├── wasm_vfs_sync.rs # WASM VFS sync coordination
│ │ ├── recovery.rs # Crash recovery logic
│ │ ├── auto_sync.rs # Native auto-sync
@@ -247,10 +277,19 @@ absurder-sql/
│ ├── lru_cache_tests.rs # Cache tests
│ ├── e2e/ # Playwright E2E tests
│ │ ├── dual_mode_persistence.spec.js # Browser + CLI validation
+│ │ ├── worker-hybrid-opfs.spec.js # Worker Hybrid/OPFS durability coverage
+│ │ ├── benchmark-page.spec.js # Benchmark page smoke coverage
│ │ ├── advanced-features.spec.js
│ │ └── multi-tab-vite.spec.js
│ └── ... # 65+ test files total
│
+├── pwa/ # Production Next.js PWA
+│ ├── app/ # Application routes, including the database UI
+│ ├── lib/db/ # SharedWorker coordinator, runtime worker, typed proxy
+│ ├── public/sw.js # Offline service worker and production asset cache
+│ ├── e2e/ # Production PWA Playwright coverage
+│ └── playwright.pwa.config.ts # Serial next build/start test configuration
+│
├── examples/ # Browser demos and documentation
│ ├── vite-app/ # Production Vite application
│ ├── export_import_demo.html # Export/import 4-step wizard demo
@@ -259,6 +298,7 @@ absurder-sql/
│ ├── sql_demo.html # Interactive SQL demo page
│ ├── web_demo.html # Full-featured web interface
│ ├── benchmark.html # Performance comparison tool
+│ ├── absurder-benchmark-worker.js # Worker benchmark runner for Hybrid backend
│ ├── multi-tab-demo.html # Multi-tab coordination demo
│ ├── worker-example.html # Web Worker demo
│ ├── worker-db.js # Web Worker implementation
@@ -269,6 +309,7 @@ absurder-sql/
│ ├── EXPORT_IMPORT.md # Export/import guide (DATABASE PORTABILITY)
│ ├── DUAL_MODE.md # Tri-mode persistence guide
│ ├── MULTI_TAB_GUIDE.md # Multi-tab coordination
+│ ├── HYBRID_OPFS_PLAN.md # Hybrid OPFS implementation status and validation
│ ├── TRANSACTION_SUPPORT.md # Transaction handling
│ ├── BENCHMARK.md # Performance benchmarks
│ ├── ENCRYPTION.md # SQLCipher encryption guide
@@ -769,6 +810,38 @@ worker.postMessage({ type: 'processData' });
**Example:** See `examples/worker-example.html` for a complete working demo
+### Production PWA (Hybrid OPFS + IndexedDB)
+
+The checked-in [`pwa/`](pwa/) is a production Next.js PWA backed by AbsurderSQL's local WASM build. A `SharedWorker` coordinates tabs while one elected dedicated Worker owns and serializes the physical database runtime. The Hybrid backend stores blocks in OPFS, keeps coordination metadata and fallback data in IndexedDB, and reopens logical database handles after runtime handoff. The service worker precaches the emitted application, worker, WASM, JavaScript, and CSS assets for offline reload.
+
+Build the local WASM package first, then start the PWA:
+
+```bash
+# Repository root: build WASM and sync it into pwa/lib
+npm install
+npm run build
+
+cd pwa
+npm install
+npm run dev
+```
+
+These commands use your configured npm registry; do not add a direct public-registry override.
+
+Run the production PWA storage suite:
+
+```bash
+cd pwa
+npx playwright install chromium # First run only
+npm run test:pwa
+```
+
+`test:pwa` runs serially against `next build` and `next start`. It verifies Hybrid selection and reopen, two-tab shared ownership with continued writes after one tab closes, and offline reload from persisted OPFS data.
+
+**Verified 2026-07-26:** all 3 production PWA Playwright scenarios passed in Chrome. Installation through the configured Microsoft CFS feed completed, `npm audit --json` reported 0 vulnerabilities, dependency resolution passed, and the production build completed.
+
+**Implementation provenance:** the PWA is an AbsurderSQL implementation by `npiesco`; it does not import Onyx source or packages. Onyx commits `d039d03`, `a4ecdea`, `417e23d`, `8a18a7c`, and `1d90c3c`, also by `npiesco`, supplied behavioral and production-test references. AbsurderSQL's Hybrid recovery, multi-tab coordination, and benchmark commits remain the implementation authority. See [**docs/HYBRID_OPFS_PLAN.md**](docs/HYBRID_OPFS_PLAN.md#pwa-integration-update-2026-07-25) for the per-section code and author references.
+
### Native/CLI Usage (Filesystem)
```bash
@@ -848,6 +921,8 @@ await db.close();
- Full export/import for backup/restore
- Type-safe from Rust to TypeScript via UniFFI
+**Verified 2026-07-26:** the locked `uniffi-bindings` host matrix passes all 67 Rust integration tests. That includes all 9 real export/import cases: new-database restore, same-vault restore, encrypted round-trip, BLOB preservation, invalid handles, and missing files. The same matrix passes `cargo fmt` and `cargo clippy --all-targets -- -D warnings`.
+
**Setup:** See [**absurder-sql-mobile/README.md**](absurder-sql-mobile/README.md) for build instructions.
#### Mobile Development Environment Setup
@@ -1405,4 +1480,4 @@ This is a strong copyleft license that requires:
See [LICENSE.md](LICENSE.md) for the full license text.
-**Why AGPL-3.0?** This license ensures that improvements to AbsurderSQL remain open source and benefit the entire community, even when used in cloud/SaaS environments.
\ No newline at end of file
+**Why AGPL-3.0?** This license ensures that improvements to AbsurderSQL remain open source and benefit the entire community, even when used in cloud/SaaS environments.
diff --git a/absurder-sql-mobile/Cargo.lock b/absurder-sql-mobile/Cargo.lock
index b8bd96b0..1b4e6c1b 100644
--- a/absurder-sql-mobile/Cargo.lock
+++ b/absurder-sql-mobile/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "absurder-sql"
-version = "0.1.25"
+version = "0.1.26"
dependencies = [
"crc32fast",
"futures",
@@ -696,11 +696,12 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
-version = "0.3.81"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
- "once_cell",
+ "cfg-if",
+ "futures-util",
"wasm-bindgen",
]
@@ -1610,9 +1611,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -1621,38 +1622,21 @@ dependencies = [
"wasm-bindgen-shared",
]
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.104"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.54"
+version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1660,31 +1644,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
"syn",
- "wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
-version = "0.3.81"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1692,9 +1676,9 @@ dependencies = [
[[package]]
name = "weblocks"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e770224d4d61ccfd8ecc036d8f109e30d48827aafa9b2f192ebe4958391487f"
+checksum = "5d835efa9b443e2edc6ecaf1a1b7773e4d775216215bcc88e687c18282c43ec6"
dependencies = [
"futures-channel",
"js-sys",
diff --git a/absurder-sql-mobile/README.md b/absurder-sql-mobile/README.md
index 30d19f7d..76abcd0c 100644
--- a/absurder-sql-mobile/README.md
+++ b/absurder-sql-mobile/README.md
@@ -578,8 +578,14 @@ All functions are exported with `#[uniffi::export]`:
## Testing
```bash
-# Rust tests
-cargo test
+# Complete UniFFI host integration suite
+cargo test --locked --features uniffi-bindings
+
+# Formatting and warning-free lint gate
+cargo fmt -- --check
+cargo clippy --locked --features uniffi-bindings --all-targets -- -D warnings
+
+# Platform encryption matrices
cargo test --features encryption
cargo test --features encryption-ios
@@ -589,6 +595,8 @@ npx react-native run-ios --simulator="iPhone 16"
npx react-native run-android
```
+**Latest locked host validation (2026-07-26):** 67/67 UniFFI tests passed. The export/import module passed 9/9 real database cases, including encrypted round-trip, same-vault restore, restore into a new database, and BLOB preservation.
+
---
## License
diff --git a/absurder-sql-mobile/build.rs b/absurder-sql-mobile/build.rs
index 4e5cca82..06cbab6a 100644
--- a/absurder-sql-mobile/build.rs
+++ b/absurder-sql-mobile/build.rs
@@ -1,13 +1,12 @@
/// Build script for AbsurderSQL Mobile
/// UniFFI 0.29+ uses proc-macros, so no UDL scaffolding generation needed
/// The uniffi::export macro generates everything at compile time
-
use std::env;
use std::path::PathBuf;
fn main() {
let target = env::var("TARGET").unwrap();
-
+
// For Android: Use pre-built SQLCipher and OpenSSL static libraries
if target.contains("android") {
let abi = if target.contains("aarch64") {
@@ -21,18 +20,21 @@ fn main() {
} else {
"arm64-v8a" // default
};
-
+
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let lib_dir = manifest_dir.join(format!("android/src/main/jni/sqlcipher-libs/{}", abi));
let include_dir = manifest_dir.join("android/src/main/jni/sqlcipher-libs/include");
-
- println!("cargo:warning=Using pre-built SQLCipher libraries from: {}", lib_dir.display());
-
+
+ println!(
+ "cargo:warning=Using pre-built SQLCipher libraries from: {}",
+ lib_dir.display()
+ );
+
// Tell cargo where to find the libraries
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=sqlcipher");
println!("cargo:rustc-link-lib=static=crypto");
-
+
// Tell libsqlite3-sys to use our prebuilt SQLCipher
unsafe {
env::set_var("SQLCIPHER_LIB_DIR", lib_dir.to_str().unwrap());
@@ -40,11 +42,11 @@ fn main() {
env::set_var("LIBSQLITE3_SYS_USE_PKG_CONFIG", "0");
}
}
-
+
// UniFFI 0.29+ uses proc-macros exclusively
// Bindings are generated via #[uniffi::export] annotations
// No build script scaffolding needed
-
+
#[cfg(feature = "uniffi-bindings")]
{
println!("cargo:warning=UniFFI bindings feature enabled - using proc-macro approach");
diff --git a/absurder-sql-mobile/src/__tests__/android_path_resolution_bug_test.rs b/absurder-sql-mobile/src/__tests__/android_path_resolution_bug_test.rs
index 74c1a400..90e9cc60 100644
--- a/absurder-sql-mobile/src/__tests__/android_path_resolution_bug_test.rs
+++ b/absurder-sql-mobile/src/__tests__/android_path_resolution_bug_test.rs
@@ -1,5 +1,5 @@
//! Test demonstrating Android path resolution bug
-//!
+//!
//! This test FAILS on current code, proving the bug exists.
//! After the fix is implemented, this test will PASS.
@@ -14,10 +14,10 @@ mod android_path_resolution_bug_tests {
fn test_android_relative_path_must_become_absolute() {
// THE BUG: On Android, relative paths stay relative
// They should be resolved to absolute paths in app data directory
-
+
let relative_path = "test.db";
let resolved = resolve_db_path(relative_path);
-
+
// THIS TEST WILL FAIL ON CURRENT CODE
// Current bug: resolved = "test.db" (relative)
// After fix: resolved = "/data/data/com.absurdersqltestapp/files/databases/test.db" (absolute)
@@ -27,7 +27,7 @@ mod android_path_resolution_bug_tests {
relative_path,
resolved
);
-
+
// Additionally verify it's in a writable location
assert!(
resolved.contains("/files/") || resolved.contains("/data/"),
@@ -35,7 +35,7 @@ mod android_path_resolution_bug_tests {
resolved
);
}
-
+
#[test]
#[serial]
#[cfg(not(target_os = "android"))]
@@ -43,14 +43,17 @@ mod android_path_resolution_bug_tests {
// Reference test showing expected behavior on other platforms
let relative_path = "test.db";
let resolved = resolve_db_path(relative_path);
-
+
#[cfg(target_os = "ios")]
{
// iOS should resolve to Documents directory
- assert!(resolved.contains("Documents"), "iOS should use Documents directory");
+ assert!(
+ resolved.contains("Documents"),
+ "iOS should use Documents directory"
+ );
assert!(resolved.starts_with('/'), "iOS should return absolute path");
}
-
+
#[cfg(not(target_os = "ios"))]
{
// Desktop/other platforms may keep relative paths
diff --git a/absurder-sql-mobile/src/__tests__/cursor_rowid_zero_test.rs b/absurder-sql-mobile/src/__tests__/cursor_rowid_zero_test.rs
index 20375220..3160d0f9 100644
--- a/absurder-sql-mobile/src/__tests__/cursor_rowid_zero_test.rs
+++ b/absurder-sql-mobile/src/__tests__/cursor_rowid_zero_test.rs
@@ -1,6 +1,6 @@
+use crate::*;
use serial_test::serial;
use std::ffi::{CStr, CString};
-use crate::*;
/// Test that cursor pagination handles rowid=0 correctly
/// This MUST NOT skip rowid=0
@@ -10,48 +10,50 @@ fn test_cursor_handles_rowid_zero() {
unsafe {
let name = CString::new("test_rowid_zero.db").unwrap();
let handle = absurder_db_new(name.as_ptr());
-
+
let drop_sql = CString::new("DROP TABLE IF EXISTS test").unwrap();
let result = absurder_db_execute(handle, drop_sql.as_ptr());
absurder_free_string(result);
-
- let create_sql = CString::new("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
+
+ let create_sql =
+ CString::new("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
let result = absurder_db_execute(handle, create_sql.as_ptr());
absurder_free_string(result);
-
+
// Insert rows starting from ID 0 (rowids will be 0-999)
for i in 0..1000 {
- let insert_sql = CString::new(format!("INSERT INTO test VALUES ({}, 'value_{}')", i, i)).unwrap();
+ let insert_sql =
+ CString::new(format!("INSERT INTO test VALUES ({}, 'value_{}')", i, i)).unwrap();
let result = absurder_db_execute(handle, insert_sql.as_ptr());
absurder_free_string(result);
}
-
+
// Verify all rows are there
let count_sql = CString::new("SELECT COUNT(*) as count FROM test").unwrap();
let count_result = absurder_db_execute(handle, count_sql.as_ptr());
let count_str = CStr::from_ptr(count_result).to_str().unwrap();
println!("Count query result: {}", count_str);
absurder_free_string(count_result);
-
+
// Stream in batches of 100
let select_sql = CString::new("SELECT * FROM test ORDER BY id").unwrap();
let stream_handle = absurder_stmt_prepare_stream(handle, select_sql.as_ptr());
-
+
let mut total_rows = 0;
let mut first_id = None;
let mut last_id = None;
-
+
loop {
let batch_json = absurder_stmt_fetch_next(stream_handle, 100);
let batch_str = CStr::from_ptr(batch_json).to_str().unwrap();
-
+
// Parse as Row array: [{values: [{type, value}, ...]}, ...]
let batch: Vec = serde_json::from_str(batch_str).unwrap();
-
+
if batch.is_empty() {
break;
}
-
+
// Track first and last IDs from values array (id is first column, index 0)
if first_id.is_none() && !batch.is_empty() {
if let Some(values) = batch[0].get("values").and_then(|v| v.as_array()) {
@@ -71,20 +73,34 @@ fn test_cursor_handles_rowid_zero() {
}
}
}
-
+
absurder_free_string(batch_json);
total_rows += batch.len();
}
-
+
println!("Total rows fetched: {}", total_rows);
println!("First ID: {:?}", first_id);
println!("Last ID: {:?}", last_id);
-
+
// MUST fetch all 1000 rows, starting from ID 0
- assert_eq!(first_id, Some(0), "First row must have ID 0, not {:?}", first_id);
- assert_eq!(last_id, Some(999), "Last row must have ID 999, not {:?}", last_id);
- assert_eq!(total_rows, 1000, "Expected 1000 rows, got {} - rowid 0 was skipped!", total_rows);
-
+ assert_eq!(
+ first_id,
+ Some(0),
+ "First row must have ID 0, not {:?}",
+ first_id
+ );
+ assert_eq!(
+ last_id,
+ Some(999),
+ "Last row must have ID 999, not {:?}",
+ last_id
+ );
+ assert_eq!(
+ total_rows, 1000,
+ "Expected 1000 rows, got {} - rowid 0 was skipped!",
+ total_rows
+ );
+
absurder_stmt_stream_close(stream_handle);
absurder_db_close(handle);
}
diff --git a/absurder-sql-mobile/src/__tests__/index_helpers_test.rs b/absurder-sql-mobile/src/__tests__/index_helpers_test.rs
index 6f6fa6d9..9d595c5a 100644
--- a/absurder-sql-mobile/src/__tests__/index_helpers_test.rs
+++ b/absurder-sql-mobile/src/__tests__/index_helpers_test.rs
@@ -1,7 +1,7 @@
+use crate::*;
use serial_test::serial;
use std::ffi::{CStr, CString};
use std::thread;
-use crate::*;
#[test]
#[serial]
@@ -12,33 +12,38 @@ fn test_create_single_column_index() {
let name = CString::new(db_name).unwrap();
let handle = absurder_db_new(name.as_ptr());
assert_ne!(handle, 0);
-
+
// Drop table if exists for clean test state
let drop_sql = CString::new("DROP TABLE IF EXISTS users").unwrap();
let result = absurder_db_execute(handle, drop_sql.as_ptr());
if !result.is_null() {
absurder_free_string(result);
}
-
+
// Create table
- let create_sql = CString::new("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)").unwrap();
+ let create_sql =
+ CString::new("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)")
+ .unwrap();
let result = absurder_db_execute(handle, create_sql.as_ptr());
assert!(!result.is_null());
absurder_free_string(result);
-
+
// Create index on email column
let table = CString::new("users").unwrap();
let column = CString::new("email").unwrap();
let result = absurder_create_index(handle, table.as_ptr(), column.as_ptr());
assert_eq!(result, 0, "Index creation should succeed");
-
+
// Verify index exists by checking sqlite_master
- let check_sql = CString::new("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_email'").unwrap();
+ let check_sql = CString::new(
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_email'",
+ )
+ .unwrap();
let result = absurder_db_execute(handle, check_sql.as_ptr());
let result_str = CStr::from_ptr(result).to_str().unwrap();
assert!(result_str.contains("idx_users_email"), "Index should exist");
absurder_free_string(result);
-
+
absurder_db_close(handle);
}
}
@@ -52,33 +57,36 @@ fn test_create_multi_column_index() {
let name = CString::new(db_name).unwrap();
let handle = absurder_db_new(name.as_ptr());
assert_ne!(handle, 0);
-
+
// Drop table if exists for clean test state
let drop_sql = CString::new("DROP TABLE IF EXISTS orders").unwrap();
let result = absurder_db_execute(handle, drop_sql.as_ptr());
if !result.is_null() {
absurder_free_string(result);
}
-
+
// Create table
let create_sql = CString::new("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, product_id INTEGER, created_at TEXT)").unwrap();
let result = absurder_db_execute(handle, create_sql.as_ptr());
assert!(!result.is_null());
absurder_free_string(result);
-
+
// Create composite index on user_id and product_id
let table = CString::new("orders").unwrap();
let columns = CString::new("user_id,product_id").unwrap();
let result = absurder_create_index(handle, table.as_ptr(), columns.as_ptr());
assert_eq!(result, 0, "Composite index creation should succeed");
-
+
// Verify index exists
let check_sql = CString::new("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_orders_user_id_product_id'").unwrap();
let result = absurder_db_execute(handle, check_sql.as_ptr());
let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert!(result_str.contains("idx_orders_user_id_product_id"), "Composite index should exist");
+ assert!(
+ result_str.contains("idx_orders_user_id_product_id"),
+ "Composite index should exist"
+ );
absurder_free_string(result);
-
+
absurder_db_close(handle);
}
}
@@ -103,13 +111,13 @@ fn test_create_index_invalid_table() {
let name = CString::new(db_name).unwrap();
let handle = absurder_db_new(name.as_ptr());
assert_ne!(handle, 0);
-
+
// Try to create index on non-existent table
let table = CString::new("nonexistent_table").unwrap();
let column = CString::new("email").unwrap();
let result = absurder_create_index(handle, table.as_ptr(), column.as_ptr());
assert_eq!(result, -1, "Should return error for invalid table");
-
+
absurder_db_close(handle);
}
}
@@ -123,18 +131,18 @@ fn test_create_index_null_inputs() {
let name = CString::new(db_name).unwrap();
let handle = absurder_db_new(name.as_ptr());
assert_ne!(handle, 0);
-
+
let table = CString::new("users").unwrap();
let column = CString::new("email").unwrap();
-
+
// Null table name
let result = absurder_create_index(handle, std::ptr::null(), column.as_ptr());
assert_eq!(result, -1, "Should return error for null table");
-
+
// Null column name
let result = absurder_create_index(handle, table.as_ptr(), std::ptr::null());
assert_eq!(result, -1, "Should return error for null columns");
-
+
absurder_db_close(handle);
}
}
diff --git a/absurder-sql-mobile/src/__tests__/registry_test.rs b/absurder-sql-mobile/src/__tests__/registry_test.rs
index 78897633..13dac3dc 100644
--- a/absurder-sql-mobile/src/__tests__/registry_test.rs
+++ b/absurder-sql-mobile/src/__tests__/registry_test.rs
@@ -26,14 +26,14 @@ fn test_handle_counter_accessible() {
let counter = crate::registry::HANDLE_COUNTER.lock();
*counter
};
-
+
let next = {
let mut counter = crate::registry::HANDLE_COUNTER.lock();
let val = *counter;
*counter += 1;
val
};
-
+
assert!(next >= initial, "Counter should increment");
}
@@ -42,7 +42,7 @@ fn test_error_handling_accessible() {
// Verify error handling functions work
crate::registry::clear_last_error();
crate::registry::set_last_error("test error".to_string());
-
+
// Error should be set (we can't easily test thread-local retrieval here)
assert!(true, "Error handling functions should be accessible");
}
@@ -50,9 +50,7 @@ fn test_error_handling_accessible() {
#[test]
fn test_runtime_accessible() {
// Verify RUNTIME is accessible
- let result = crate::registry::RUNTIME.block_on(async {
- 42
- });
-
+ let result = crate::registry::RUNTIME.block_on(async { 42 });
+
assert_eq!(result, 42, "Runtime should execute async code");
}
diff --git a/absurder-sql-mobile/src/__tests__/streaming_api_test.rs b/absurder-sql-mobile/src/__tests__/streaming_api_test.rs
index a0c41bec..932dafb5 100644
--- a/absurder-sql-mobile/src/__tests__/streaming_api_test.rs
+++ b/absurder-sql-mobile/src/__tests__/streaming_api_test.rs
@@ -1,5 +1,5 @@
//! Tests for Streaming Results API
-//!
+//!
//! Tests cursor-based pagination for large result sets
use crate::*;
@@ -20,14 +20,19 @@ fn test_streaming_statement_basic() {
}
// Create table and insert test data
- let create_sql = CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
+ let create_sql =
+ CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
let create_result = unsafe { absurder_db_execute(handle, create_sql.as_ptr()) };
assert!(!create_result.is_null(), "Failed to create table");
unsafe { absurder_free_string(create_result) };
// Insert 1000 rows (starting from 1 to match SQLite default rowid behavior)
for i in 1..=1000 {
- let insert_sql = CString::new(format!("INSERT INTO test_stream VALUES ({}, 'value{}')", i, i)).unwrap();
+ let insert_sql = CString::new(format!(
+ "INSERT INTO test_stream VALUES ({}, 'value{}')",
+ i, i
+ ))
+ .unwrap();
let result = unsafe { absurder_db_execute(handle, insert_sql.as_ptr()) };
assert!(!result.is_null(), "Failed to insert row {}", i);
unsafe { absurder_free_string(result) };
@@ -46,7 +51,7 @@ fn test_streaming_statement_basic() {
let batch_str = unsafe { CStr::from_ptr(batch_json) }.to_str().unwrap();
let batch: Vec = serde_json::from_str(batch_str).unwrap();
-
+
unsafe { absurder_free_string(batch_json) };
if batch.is_empty() {
@@ -81,14 +86,19 @@ fn test_streaming_statement_early_break() {
}
// Create table and insert test data
- let create_sql = CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
+ let create_sql =
+ CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
let create_result = unsafe { absurder_db_execute(handle, create_sql.as_ptr()) };
assert!(!create_result.is_null());
unsafe { absurder_free_string(create_result) };
// Insert 1000 rows (starting from 1 to match SQLite default rowid behavior)
for i in 1..=1000 {
- let insert_sql = CString::new(format!("INSERT INTO test_stream VALUES ({}, 'value{}')", i, i)).unwrap();
+ let insert_sql = CString::new(format!(
+ "INSERT INTO test_stream VALUES ({}, 'value{}')",
+ i, i
+ ))
+ .unwrap();
let result = unsafe { absurder_db_execute(handle, insert_sql.as_ptr()) };
assert!(!result.is_null());
unsafe { absurder_free_string(result) };
@@ -107,7 +117,7 @@ fn test_streaming_statement_early_break() {
let batch_str = unsafe { CStr::from_ptr(batch_json) }.to_str().unwrap();
let batch: Vec = serde_json::from_str(batch_str).unwrap();
-
+
unsafe { absurder_free_string(batch_json) };
total_rows += batch.len();
}
@@ -137,7 +147,8 @@ fn test_streaming_statement_empty_result() {
}
// Create table (no data)
- let create_sql = CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
+ let create_sql =
+ CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
let create_result = unsafe { absurder_db_execute(handle, create_sql.as_ptr()) };
assert!(!create_result.is_null());
unsafe { absurder_free_string(create_result) };
@@ -153,7 +164,7 @@ fn test_streaming_statement_empty_result() {
let batch_str = unsafe { CStr::from_ptr(batch_json) }.to_str().unwrap();
let batch: Vec = serde_json::from_str(batch_str).unwrap();
-
+
unsafe { absurder_free_string(batch_json) };
assert_eq!(batch.len(), 0, "Expected empty result");
@@ -191,14 +202,19 @@ fn test_streaming_statement_configurable_batch_size() {
}
// Create table and insert test data
- let create_sql = CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
+ let create_sql =
+ CString::new("CREATE TABLE test_stream (id INTEGER PRIMARY KEY, value TEXT)").unwrap();
let create_result = unsafe { absurder_db_execute(handle, create_sql.as_ptr()) };
assert!(!create_result.is_null());
unsafe { absurder_free_string(create_result) };
// Insert 500 rows (starting from 1 to match SQLite default rowid behavior)
for i in 1..=500 {
- let insert_sql = CString::new(format!("INSERT INTO test_stream VALUES ({}, 'value{}')", i, i)).unwrap();
+ let insert_sql = CString::new(format!(
+ "INSERT INTO test_stream VALUES ({}, 'value{}')",
+ i, i
+ ))
+ .unwrap();
let result = unsafe { absurder_db_execute(handle, insert_sql.as_ptr()) };
assert!(!result.is_null());
unsafe { absurder_free_string(result) };
@@ -215,7 +231,7 @@ fn test_streaming_statement_configurable_batch_size() {
let batch_str = unsafe { CStr::from_ptr(batch_json) }.to_str().unwrap();
let batch: Vec = serde_json::from_str(batch_str).unwrap();
-
+
unsafe { absurder_free_string(batch_json) };
assert_eq!(batch.len(), 50, "Expected batch size of 50");
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_batch_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_batch_test.rs
index e2b5fad0..098196b0 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_batch_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_batch_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI execute_batch function
-///
-/// Tests batch SQL execution for improved performance
-
+//! Tests for UniFFI execute_batch function
+//!
+//! Tests batch SQL execution for improved performance
#[cfg(test)]
mod uniffi_batch_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_execute_batch_multiple_inserts() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_batch_insert_{:?}.db", thread_id),
@@ -22,14 +21,19 @@ mod uniffi_batch_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(handle, "DROP TABLE IF EXISTS items".to_string()).ok();
- execute(handle, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ handle,
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Execute batch of inserts
let statements = vec![
"INSERT INTO items (name) VALUES ('item1')".to_string(),
@@ -38,15 +42,19 @@ mod uniffi_batch_tests {
"INSERT INTO items (name) VALUES ('item4')".to_string(),
"INSERT INTO items (name) VALUES ('item5')".to_string(),
];
-
+
let result = execute_batch(handle, statements);
- assert!(result.is_ok(), "Batch insert should succeed: {:?}", result.err());
-
+ assert!(
+ result.is_ok(),
+ "Batch insert should succeed: {:?}",
+ result.err()
+ );
+
// Verify all rows were inserted
- let select_result = execute(handle, "SELECT COUNT(*) FROM items".to_string())
- .expect("Failed to query");
+ let select_result =
+ execute(handle, "SELECT COUNT(*) FROM items".to_string()).expect("Failed to query");
assert_eq!(select_result.rows.len(), 1, "Should have count result");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -62,9 +70,11 @@ mod uniffi_batch_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Execute batch with mixed operations
let statements = vec![
"DROP TABLE IF EXISTS users".to_string(),
@@ -73,15 +83,22 @@ mod uniffi_batch_tests {
"INSERT INTO users (name, age) VALUES ('Bob', 25)".to_string(),
"UPDATE users SET age = 31 WHERE name = 'Alice'".to_string(),
];
-
+
let result = execute_batch(handle, statements);
- assert!(result.is_ok(), "Mixed batch should succeed: {:?}", result.err());
-
+ assert!(
+ result.is_ok(),
+ "Mixed batch should succeed: {:?}",
+ result.err()
+ );
+
// Verify data
- let select_result = execute(handle, "SELECT name, age FROM users WHERE name = 'Alice'".to_string())
- .expect("Failed to query");
+ let select_result = execute(
+ handle,
+ "SELECT name, age FROM users WHERE name = 'Alice'".to_string(),
+ )
+ .expect("Failed to query");
assert_eq!(select_result.rows.len(), 1, "Should find Alice");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -97,37 +114,42 @@ mod uniffi_batch_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT UNIQUE)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT UNIQUE)".to_string(),
+ )
+ .expect("Failed to create table");
+
// First batch succeeds
let statements1 = vec![
"INSERT INTO products (name) VALUES ('widget')".to_string(),
"INSERT INTO products (name) VALUES ('gadget')".to_string(),
];
execute_batch(handle, statements1).expect("First batch should succeed");
-
+
// Second batch should fail due to UNIQUE constraint and rollback all changes
let statements2 = vec![
"INSERT INTO products (name) VALUES ('tool')".to_string(),
"INSERT INTO products (name) VALUES ('widget')".to_string(), // Duplicate - should fail
"INSERT INTO products (name) VALUES ('device')".to_string(),
];
-
+
let result = execute_batch(handle, statements2);
assert!(result.is_err(), "Batch with duplicate should fail");
-
+
// Verify only first batch succeeded (2 rows)
- let count_result = execute(handle, "SELECT COUNT(*) FROM products".to_string())
- .expect("Failed to query");
+ let count_result =
+ execute(handle, "SELECT COUNT(*) FROM products".to_string()).expect("Failed to query");
assert_eq!(count_result.rows.len(), 1, "Should have count");
// Should still have exactly 2 rows (widget, gadget) - no partial insert from failed batch
-
+
close_database(handle).expect("Failed to close database");
}
@@ -143,13 +165,15 @@ mod uniffi_batch_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
let statements: Vec = vec![];
let result = execute_batch(handle, statements);
assert!(result.is_ok(), "Empty batch should succeed");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -173,18 +197,20 @@ mod uniffi_batch_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
let statements = vec![
"DROP TABLE IF EXISTS test".to_string(),
"CREATE TABLE test (id INTEGER)".to_string(),
"INVALID SQL STATEMENT".to_string(),
];
-
+
let result = execute_batch(handle, statements);
assert!(result.is_err(), "Invalid SQL in batch should fail");
-
+
close_database(handle).expect("Failed to close database");
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_create_async_proof.rs b/absurder-sql-mobile/src/__tests__/uniffi_create_async_proof.rs
index 05714397..e75568d4 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_create_async_proof.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_create_async_proof.rs
@@ -1,11 +1,11 @@
#[cfg(test)]
-mod uniffi_create_async_proof {
+mod tests {
+ use crate::registry::{DB_REGISTRY, HANDLE_COUNTER, RUNTIME};
use crate::uniffi_api::types::*;
- use crate::registry::{RUNTIME, DB_REGISTRY, HANDLE_COUNTER};
- use absurder_sql::{SqliteIndexedDB, DatabaseConfig as CoreDatabaseConfig};
+ use absurder_sql::{DatabaseConfig as CoreDatabaseConfig, SqliteIndexedDB};
use serial_test::serial;
use std::sync::Arc;
- use parking_lot::Mutex;
+ use tokio::sync::Mutex;
/// Proof: async version of create_database that doesn't block
pub async fn create_database_async_proof(config: DatabaseConfig) -> Result {
@@ -20,29 +20,30 @@ mod uniffi_create_async_proof {
let docs = PathBuf::from(home).join("Documents").join(&config.name);
docs.to_string_lossy().to_string()
}
-
+
#[cfg(not(target_os = "ios"))]
{
config.name.clone()
}
};
-
+
let core_config = CoreDatabaseConfig {
name: resolved_path,
..Default::default()
};
-
+
// This is async - no blocking!
- let db = SqliteIndexedDB::new(core_config).await
+ let db = SqliteIndexedDB::new(core_config)
+ .await
.map_err(|e| e.to_string())?;
-
+
let mut counter = HANDLE_COUNTER.lock();
*counter += 1;
let handle = *counter;
drop(counter);
-
+
DB_REGISTRY.lock().insert(handle, Arc::new(Mutex::new(db)));
-
+
Ok(handle)
}
@@ -50,7 +51,7 @@ mod uniffi_create_async_proof {
#[serial]
fn test_async_version_works() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("async_proof_{:?}.db", thread_id),
@@ -60,19 +61,17 @@ mod uniffi_create_async_proof {
journal_mode: None,
auto_vacuum: None,
};
-
+
// This would be called from UniFFI async runtime
- let result = RUNTIME.block_on(async {
- create_database_async_proof(config).await
- });
-
+ let result = RUNTIME.block_on(async { create_database_async_proof(config).await });
+
assert!(result.is_ok(), "Async version should work");
-
+
if let Ok(handle) = result {
use crate::uniffi_api::core::close_database;
close_database(handle).ok();
}
-
+
println!("✅ Async version works - this is what we need for create_database!");
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_create_async_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_create_async_test.rs
index 82522337..d0d4af13 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_create_async_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_create_async_test.rs
@@ -1,8 +1,8 @@
#[cfg(test)]
mod uniffi_create_async_tests {
+ use crate::registry::RUNTIME;
use crate::uniffi_api::core::*;
use crate::uniffi_api::types::*;
- use crate::registry::RUNTIME;
use serial_test::serial;
use std::time::Instant;
@@ -12,7 +12,7 @@ mod uniffi_create_async_tests {
#[serial]
fn test_create_database_timing() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("timing_test_{:?}.db", thread_id),
@@ -22,24 +22,27 @@ mod uniffi_create_async_tests {
journal_mode: None,
auto_vacuum: None,
};
-
+
let start = Instant::now();
let result = RUNTIME.block_on(async { create_database(config.clone()).await });
let duration = start.elapsed();
-
+
println!("create_database took {:?}", duration);
-
+
// Should complete relatively quickly even though it's async
assert!(result.is_ok(), "Database creation should succeed");
-
+
// Clean up
if let Ok(handle) = result {
close_database(handle).ok();
}
-
+
// Async version might take a bit longer than pure sync but should still be reasonable
if duration.as_millis() > 1000 {
- println!("WARNING: create_database is taking too long! Duration: {:?}", duration);
+ println!(
+ "WARNING: create_database is taking too long! Duration: {:?}",
+ duration
+ );
}
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_databaseconfig_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_databaseconfig_test.rs
index 0fc61229..8a65d4b2 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_databaseconfig_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_databaseconfig_test.rs
@@ -59,8 +59,7 @@ mod uniffi_databaseconfig_tests {
.expect("Failed to create database with page_size");
// Verify database works and page_size is applied
- let result = execute(handle, "PRAGMA page_size".to_string())
- .expect("PRAGMA should work");
+ let result = execute(handle, "PRAGMA page_size".to_string()).expect("PRAGMA should work");
assert!(!result.rows.is_empty(), "Should have page_size result");
@@ -131,10 +130,10 @@ mod uniffi_databaseconfig_tests {
let config = DatabaseConfig {
name: format!("uniffi_config_mobile_opt_{:?}.db", thread_id),
encryption_key: None,
- cache_size: Some(2000_i64), // Good for mobile
- page_size: Some(4096_i64), // 4KB typical for mobile
+ cache_size: Some(2000_i64), // Good for mobile
+ page_size: Some(4096_i64), // 4KB typical for mobile
journal_mode: Some("MEMORY".to_string()), // Fast for mobile
- auto_vacuum: Some(true), // Keep db compact
+ auto_vacuum: Some(true), // Keep db compact
};
let handle = RUNTIME
@@ -147,11 +146,9 @@ mod uniffi_databaseconfig_tests {
.expect("Table creation should work");
// Insert and query to verify full functionality
- execute(handle, "INSERT INTO test VALUES (1)".to_string())
- .expect("Insert should work");
+ execute(handle, "INSERT INTO test VALUES (1)".to_string()).expect("Insert should work");
- let result = execute(handle, "SELECT * FROM test".to_string())
- .expect("Select should work");
+ let result = execute(handle, "SELECT * FROM test".to_string()).expect("Select should work");
assert_eq!(result.rows.len(), 1);
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_encryption_blocking_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_encryption_blocking_test.rs
index 3e4255de..55487df1 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_encryption_blocking_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_encryption_blocking_test.rs
@@ -10,9 +10,9 @@ mod uniffi_encryption_blocking_tests {
#[serial]
fn test_create_encrypted_database_async_no_block() {
use crate::registry::RUNTIME;
-
+
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("encrypted_async_test_{:?}.db", thread_id),
@@ -22,27 +22,30 @@ mod uniffi_encryption_blocking_tests {
journal_mode: None,
auto_vacuum: None,
};
-
+
let start = Instant::now();
let result = RUNTIME.block_on(async { create_encrypted_database(config.clone()).await });
let duration = start.elapsed();
-
+
println!("create_encrypted_database (async) took {:?}", duration);
-
+
// Should complete
assert!(result.is_ok(), "Encrypted database creation should succeed");
-
+
// Clean up
if let Ok(handle) = result {
close_database(handle).ok();
}
-
+
// Cleanup: delete test database file
let db_path = format!("encrypted_async_test_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
-
+
// Async version should complete reasonably quickly (not blocking JS thread)
- println!("✅ Async create_encrypted_database completed in {:?}", duration);
+ println!(
+ "✅ Async create_encrypted_database completed in {:?}",
+ duration
+ );
}
/// Proof that async version would not block
@@ -50,9 +53,9 @@ mod uniffi_encryption_blocking_tests {
#[serial]
fn test_async_encrypted_would_not_block() {
use crate::registry::RUNTIME;
-
+
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("encrypted_async_proof_{:?}.db", thread_id),
@@ -62,38 +65,41 @@ mod uniffi_encryption_blocking_tests {
journal_mode: None,
auto_vacuum: None,
};
-
+
// If create_encrypted_database was async, this is how it would work
let start = Instant::now();
let result = RUNTIME.block_on(async {
// This simulates what async version would do
- use absurder_sql::{SqliteIndexedDB, DatabaseConfig as CoreDatabaseConfig};
-
+ use absurder_sql::{DatabaseConfig as CoreDatabaseConfig, SqliteIndexedDB};
+
let key = config.encryption_key.as_ref().unwrap();
let resolved_path = config.name.clone();
-
+
let core_config = CoreDatabaseConfig {
name: resolved_path,
..Default::default()
};
-
+
SqliteIndexedDB::new_encrypted(core_config, key).await
});
let duration = start.elapsed();
-
+
println!("Async encrypted database creation took {:?}", duration);
-
- assert!(result.is_ok(), "Async encrypted database creation should succeed");
-
+
+ assert!(
+ result.is_ok(),
+ "Async encrypted database creation should succeed"
+ );
+
// Clean up
if let Ok(db) = result {
drop(db);
}
-
+
// Cleanup: delete test database file
let db_path = format!("encrypted_async_proof_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
-
+
println!("✅ Async version works and doesn't block");
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_encryption_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_encryption_test.rs
index bd4e572b..70b03418 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_encryption_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_encryption_test.rs
@@ -1,18 +1,18 @@
/// Tests for UniFFI encryption functions
-///
+///
/// Tests database encryption with SQLCipher
#[cfg(test)]
mod uniffi_encryption_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_create_encrypted_database() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_encrypted_{:?}.db", thread_id),
@@ -22,24 +22,31 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_encrypted_database(config).await }).expect("Failed to create encrypted database");
+
+ let handle = RUNTIME
+ .block_on(async { create_encrypted_database(config).await })
+ .expect("Failed to create encrypted database");
assert!(handle > 0, "Handle should be valid");
-
+
// Verify we can execute queries
execute(handle, "DROP TABLE IF EXISTS test".to_string()).ok();
- execute(handle, "CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)".to_string())
- .expect("Failed to create table");
-
- execute(handle, "INSERT INTO test (data) VALUES ('encrypted_data')".to_string())
- .expect("Failed to insert");
-
- let result = execute(handle, "SELECT * FROM test".to_string())
- .expect("Failed to query");
+ execute(
+ handle,
+ "CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
+ execute(
+ handle,
+ "INSERT INTO test (data) VALUES ('encrypted_data')".to_string(),
+ )
+ .expect("Failed to insert");
+
+ let result = execute(handle, "SELECT * FROM test".to_string()).expect("Failed to query");
assert_eq!(result.rows.len(), 1, "Should have 1 row");
-
+
close_database(handle).expect("Failed to close database");
-
+
// Cleanup: delete test database file
let db_path = format!("uniffi_encrypted_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
@@ -57,7 +64,7 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
+
let result = RUNTIME.block_on(async { create_encrypted_database(config).await });
assert!(result.is_err(), "Should fail without encryption key");
}
@@ -74,7 +81,7 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
+
let result = RUNTIME.block_on(async { create_encrypted_database(config).await });
assert!(result.is_err(), "Should fail with short key");
}
@@ -83,7 +90,7 @@ mod uniffi_encryption_tests {
#[serial]
fn test_rekey_database() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_rekey_{:?}.db", thread_id),
@@ -93,27 +100,34 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_encrypted_database(config).await }).expect("Failed to create encrypted database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_encrypted_database(config).await })
+ .expect("Failed to create encrypted database");
+
// Create table and insert data
execute(handle, "DROP TABLE IF EXISTS users".to_string()).ok();
- execute(handle, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO users (name) VALUES ('Alice')".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO users (name) VALUES ('Alice')".to_string(),
+ )
+ .expect("Failed to insert");
+
// Rekey the database
- rekey_database(handle, "new_password_456".to_string())
- .expect("Failed to rekey database");
-
+ rekey_database(handle, "new_password_456".to_string()).expect("Failed to rekey database");
+
// Verify data is still accessible
let result = execute(handle, "SELECT * FROM users".to_string())
.expect("Failed to query after rekey");
assert_eq!(result.rows.len(), 1, "Should still have 1 row after rekey");
-
+
close_database(handle).expect("Failed to close database");
-
+
// Cleanup: delete test database file
let db_path = format!("uniffi_rekey_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
@@ -130,7 +144,7 @@ mod uniffi_encryption_tests {
#[serial]
fn test_rekey_short_key() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_rekey_short_{:?}.db", thread_id),
@@ -140,14 +154,16 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_encrypted_database(config).await }).expect("Failed to create encrypted database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_encrypted_database(config).await })
+ .expect("Failed to create encrypted database");
+
let result = rekey_database(handle, "short".to_string());
assert!(result.is_err(), "Should fail with short key");
-
+
close_database(handle).expect("Failed to close database");
-
+
// Cleanup: delete test database file
let db_path = format!("uniffi_rekey_short_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
@@ -157,9 +173,9 @@ mod uniffi_encryption_tests {
#[serial]
fn test_encrypted_database_isolation() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
-
+
// Create first encrypted database
let config1 = DatabaseConfig {
name: format!("uniffi_enc1_{:?}.db", thread_id),
@@ -169,12 +185,16 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
- let handle1 = RUNTIME.block_on(async { create_encrypted_database(config1).await }).expect("Failed to create db1");
-
+ let handle1 = RUNTIME
+ .block_on(async { create_encrypted_database(config1).await })
+ .expect("Failed to create db1");
+
execute(handle1, "DROP TABLE IF EXISTS data1".to_string()).ok();
- execute(handle1, "CREATE TABLE data1 (value TEXT)".to_string()).expect("Failed to create table");
- execute(handle1, "INSERT INTO data1 VALUES ('secret1')".to_string()).expect("Failed to insert");
-
+ execute(handle1, "CREATE TABLE data1 (value TEXT)".to_string())
+ .expect("Failed to create table");
+ execute(handle1, "INSERT INTO data1 VALUES ('secret1')".to_string())
+ .expect("Failed to insert");
+
// Create second encrypted database with different key
let config2 = DatabaseConfig {
name: format!("uniffi_enc2_{:?}.db", thread_id),
@@ -184,22 +204,28 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
- let handle2 = RUNTIME.block_on(async { create_encrypted_database(config2).await }).expect("Failed to create db2");
-
+ let handle2 = RUNTIME
+ .block_on(async { create_encrypted_database(config2).await })
+ .expect("Failed to create db2");
+
execute(handle2, "DROP TABLE IF EXISTS data2".to_string()).ok();
- execute(handle2, "CREATE TABLE data2 (value TEXT)".to_string()).expect("Failed to create table");
- execute(handle2, "INSERT INTO data2 VALUES ('secret2')".to_string()).expect("Failed to insert");
-
+ execute(handle2, "CREATE TABLE data2 (value TEXT)".to_string())
+ .expect("Failed to create table");
+ execute(handle2, "INSERT INTO data2 VALUES ('secret2')".to_string())
+ .expect("Failed to insert");
+
// Verify each database has its own data
- let result1 = execute(handle1, "SELECT * FROM data1".to_string()).expect("Failed to query db1");
+ let result1 =
+ execute(handle1, "SELECT * FROM data1".to_string()).expect("Failed to query db1");
assert_eq!(result1.rows.len(), 1, "DB1 should have 1 row");
-
- let result2 = execute(handle2, "SELECT * FROM data2".to_string()).expect("Failed to query db2");
+
+ let result2 =
+ execute(handle2, "SELECT * FROM data2".to_string()).expect("Failed to query db2");
assert_eq!(result2.rows.len(), 1, "DB2 should have 1 row");
-
+
close_database(handle1).expect("Failed to close db1");
close_database(handle2).expect("Failed to close db2");
-
+
// Cleanup: delete test database files
let db_path1 = format!("uniffi_enc1_{:?}.db", thread_id);
let db_path2 = format!("uniffi_enc2_{:?}.db", thread_id);
@@ -211,7 +237,7 @@ mod uniffi_encryption_tests {
#[serial]
fn test_encrypted_with_transactions() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_enc_txn_{:?}.db", thread_id),
@@ -221,25 +247,32 @@ mod uniffi_encryption_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_encrypted_database(config).await }).expect("Failed to create encrypted database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_encrypted_database(config).await })
+ .expect("Failed to create encrypted database");
+
execute(handle, "DROP TABLE IF EXISTS items".to_string()).ok();
- execute(handle, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ handle,
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Test transaction
begin_transaction(handle).expect("Failed to begin transaction");
- execute(handle, "INSERT INTO items (name) VALUES ('item1')".to_string())
- .expect("Failed to insert");
+ execute(
+ handle,
+ "INSERT INTO items (name) VALUES ('item1')".to_string(),
+ )
+ .expect("Failed to insert");
commit(handle).expect("Failed to commit");
-
- let result = execute(handle, "SELECT * FROM items".to_string())
- .expect("Failed to query");
+
+ let result = execute(handle, "SELECT * FROM items".to_string()).expect("Failed to query");
assert_eq!(result.rows.len(), 1, "Should have 1 row after commit");
-
+
close_database(handle).expect("Failed to close database");
-
+
// Cleanup: delete test database file
let db_path = format!("uniffi_enc_txn_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path);
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_execute_params_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_execute_params_test.rs
index 4ab888fe..0c1f2d87 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_execute_params_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_execute_params_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI execute_with_params() function
-///
-/// Tests parameterized query execution with proper SQL injection prevention
-
+//! Tests for UniFFI execute_with_params() function
+//!
+//! Tests parameterized query execution with proper SQL injection prevention
#[cfg(test)]
mod uniffi_execute_params_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_execute_with_params_insert() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_params_insert_{:?}.db", thread_id),
@@ -22,30 +21,42 @@ mod uniffi_execute_params_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(handle, "DROP TABLE IF EXISTS users".to_string()).ok();
- execute(handle, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ handle,
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Insert with parameters
let params = vec!["Alice".to_string(), "30".to_string()];
let result = execute_with_params(
handle,
"INSERT INTO users (name, age) VALUES (?, ?)".to_string(),
- params
+ params,
+ );
+ assert!(
+ result.is_ok(),
+ "INSERT with params should succeed: {:?}",
+ result.err()
);
- assert!(result.is_ok(), "INSERT with params should succeed: {:?}", result.err());
let query_result = result.unwrap();
assert_eq!(query_result.rows_affected, 1, "Should affect 1 row");
-
+
// Verify data was inserted
- let select_result = execute(handle, "SELECT * FROM users WHERE name = 'Alice'".to_string())
- .expect("SELECT should succeed");
+ let select_result = execute(
+ handle,
+ "SELECT * FROM users WHERE name = 'Alice'".to_string(),
+ )
+ .expect("SELECT should succeed");
assert_eq!(select_result.rows.len(), 1, "Should find 1 row");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -61,29 +72,40 @@ mod uniffi_execute_params_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Setup
execute(handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO products (name, price) VALUES ('Widget', 9.99)".to_string())
- .expect("Failed to insert");
- execute(handle, "INSERT INTO products (name, price) VALUES ('Gadget', 19.99)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO products (name, price) VALUES ('Widget', 9.99)".to_string(),
+ )
+ .expect("Failed to insert");
+ execute(
+ handle,
+ "INSERT INTO products (name, price) VALUES ('Gadget', 19.99)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Query with parameter
let params = vec!["Widget".to_string()];
let result = execute_with_params(
handle,
"SELECT * FROM products WHERE name = ?".to_string(),
- params
+ params,
);
assert!(result.is_ok(), "SELECT with params should succeed");
let query_result = result.unwrap();
assert_eq!(query_result.rows.len(), 1, "Should return 1 row");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -99,31 +121,48 @@ mod uniffi_execute_params_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Setup
execute(handle, "DROP TABLE IF EXISTS accounts".to_string()).ok();
- execute(handle, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, balance REAL)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO accounts (username, balance) VALUES ('alice', 1000.0)".to_string())
- .expect("Failed to insert");
- execute(handle, "INSERT INTO accounts (username, balance) VALUES ('bob', 500.0)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, balance REAL)"
+ .to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO accounts (username, balance) VALUES ('alice', 1000.0)".to_string(),
+ )
+ .expect("Failed to insert");
+ execute(
+ handle,
+ "INSERT INTO accounts (username, balance) VALUES ('bob', 500.0)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Try SQL injection (should be safely escaped)
let malicious_input = "alice' OR '1'='1".to_string();
let params = vec![malicious_input];
let result = execute_with_params(
handle,
"SELECT * FROM accounts WHERE username = ?".to_string(),
- params
+ params,
);
assert!(result.is_ok(), "Query should succeed");
let query_result = result.unwrap();
// Should return 0 rows because the literal string doesn't match
- assert_eq!(query_result.rows.len(), 0, "SQL injection should be prevented, found {} rows", query_result.rows.len());
-
+ assert_eq!(
+ query_result.rows.len(),
+ 0,
+ "SQL injection should be prevented, found {} rows",
+ query_result.rows.len()
+ );
+
close_database(handle).expect("Failed to close database");
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_execute_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_execute_test.rs
index 8ed09be1..d73fb4b0 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_execute_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_execute_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI execute() function
-///
-/// Tests that the execute function works correctly with UniFFI exports
-
+//! Tests for UniFFI execute() function
+//!
+//! Tests that the execute function works correctly with UniFFI exports
#[cfg(test)]
mod uniffi_execute_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_execute_simple_query() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_exec_simple_{:?}.db", thread_id),
@@ -22,30 +21,48 @@ mod uniffi_execute_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.unwrap_or_else(|e| panic!("Failed to create database {}: {:?}", config.name, e));
assert!(handle > 0, "Database handle should be non-zero");
-
+
// Drop and recreate table for clean test state
- let drop_result = execute(handle, "DROP TABLE IF EXISTS uniffi_execute_simple".to_string());
- assert!(drop_result.is_ok(), "DROP TABLE IF EXISTS failed: {:?}", drop_result.err());
-
- let create_result = execute(handle, "CREATE TABLE uniffi_execute_simple (id INTEGER PRIMARY KEY, value TEXT)".to_string());
- assert!(create_result.is_ok(), "CREATE TABLE failed: {:?}", create_result.err());
-
+ let drop_result = execute(
+ handle,
+ "DROP TABLE IF EXISTS uniffi_execute_simple".to_string(),
+ );
+ assert!(
+ drop_result.is_ok(),
+ "DROP TABLE IF EXISTS failed: {:?}",
+ drop_result.err()
+ );
+
+ let create_result = execute(
+ handle,
+ "CREATE TABLE uniffi_execute_simple (id INTEGER PRIMARY KEY, value TEXT)".to_string(),
+ );
+ assert!(
+ create_result.is_ok(),
+ "CREATE TABLE failed: {:?}",
+ create_result.err()
+ );
+
// Insert data
- let insert_result = execute(handle, "INSERT INTO uniffi_execute_simple (value) VALUES ('hello')".to_string());
+ let insert_result = execute(
+ handle,
+ "INSERT INTO uniffi_execute_simple (value) VALUES ('hello')".to_string(),
+ );
assert!(insert_result.is_ok(), "INSERT should succeed");
let insert_query = insert_result.unwrap();
assert_eq!(insert_query.rows_affected, 1, "Should affect 1 row");
-
+
// Query data
let select_result = execute(handle, "SELECT * FROM uniffi_execute_simple".to_string());
assert!(select_result.is_ok(), "SELECT should succeed");
let select_query = select_result.unwrap();
assert_eq!(select_query.rows.len(), 1, "Should return 1 row");
-
+
// Clean up
close_database(handle).expect("Failed to close database");
}
@@ -62,13 +79,15 @@ mod uniffi_execute_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Try invalid SQL
let result = execute(handle, "INVALID SQL STATEMENT".to_string());
assert!(result.is_err(), "Invalid SQL should fail");
-
+
// Clean up
close_database(handle).expect("Failed to close database");
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_export_import_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_export_import_test.rs
index 8e241f16..921c3646 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_export_import_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_export_import_test.rs
@@ -1,11 +1,10 @@
-/// Tests for UniFFI export and import functions
-///
-/// Tests database backup (export) and restore (import) operations
-
+//! Tests for UniFFI export and import functions
+//!
+//! Tests database backup (export) and restore (import) operations
#[cfg(test)]
mod uniffi_export_import_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
use std::path::PathBuf;
@@ -13,7 +12,7 @@ mod uniffi_export_import_tests {
#[serial]
fn test_export_database() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_export_{:?}.db", thread_id),
@@ -23,27 +22,42 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table and insert data
execute(handle, "DROP TABLE IF EXISTS users".to_string()).ok();
- execute(handle, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO users (name) VALUES ('Alice')".to_string())
- .expect("Failed to insert");
- execute(handle, "INSERT INTO users (name) VALUES ('Bob')".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO users (name) VALUES ('Alice')".to_string(),
+ )
+ .expect("Failed to insert");
+ execute(
+ handle,
+ "INSERT INTO users (name) VALUES ('Bob')".to_string(),
+ )
+ .expect("Failed to insert");
+
// Export database
let export_path = format!("/tmp/uniffi_export_{:?}.db", thread_id);
let export_result = export_database(handle, export_path.clone());
- assert!(export_result.is_ok(), "Export should succeed: {:?}", export_result.err());
-
+ assert!(
+ export_result.is_ok(),
+ "Export should succeed: {:?}",
+ export_result.err()
+ );
+
// Verify export file exists
let path = PathBuf::from(&export_path);
assert!(path.exists(), "Export file should exist");
-
+
// Clean up
close_database(handle).expect("Failed to close database");
std::fs::remove_file(export_path).ok();
@@ -53,7 +67,7 @@ mod uniffi_export_import_tests {
#[serial]
fn test_import_database() {
let thread_id = std::thread::current().id();
-
+
// First, create and export a database
let source_config = DatabaseConfig {
name: format!("uniffi_import_source_{:?}.db", thread_id),
@@ -63,24 +77,34 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let source_handle = RUNTIME.block_on(async { create_database(source_config).await }).expect("Failed to create source database");
-
+
+ let source_handle = RUNTIME
+ .block_on(async { create_database(source_config).await })
+ .expect("Failed to create source database");
+
// Create table and insert data
execute(source_handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(source_handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string())
- .expect("Failed to create table");
- execute(source_handle, "INSERT INTO products (name, price) VALUES ('Widget', 9.99)".to_string())
- .expect("Failed to insert");
- execute(source_handle, "INSERT INTO products (name, price) VALUES ('Gadget', 19.99)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ source_handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ source_handle,
+ "INSERT INTO products (name, price) VALUES ('Widget', 9.99)".to_string(),
+ )
+ .expect("Failed to insert");
+ execute(
+ source_handle,
+ "INSERT INTO products (name, price) VALUES ('Gadget', 19.99)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Export to file
let backup_path = format!("/tmp/uniffi_import_{:?}.db", thread_id);
- export_database(source_handle, backup_path.clone())
- .expect("Failed to export");
+ export_database(source_handle, backup_path.clone()).expect("Failed to export");
close_database(source_handle).expect("Failed to close source");
-
+
// Now create a new database and import
let target_config = DatabaseConfig {
name: format!("uniffi_import_target_{:?}.db", thread_id),
@@ -90,18 +114,24 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let target_handle = RUNTIME.block_on(async { create_database(target_config).await }).expect("Failed to create target database");
-
+
+ let target_handle = RUNTIME
+ .block_on(async { create_database(target_config).await })
+ .expect("Failed to create target database");
+
// Import from backup
let import_result = import_database(target_handle, backup_path.clone());
- assert!(import_result.is_ok(), "Import should succeed: {:?}", import_result.err());
-
+ assert!(
+ import_result.is_ok(),
+ "Import should succeed: {:?}",
+ import_result.err()
+ );
+
// Verify data was imported
let result = execute(target_handle, "SELECT COUNT(*) FROM products".to_string())
.expect("Failed to query");
assert_eq!(result.rows.len(), 1, "Should have 1 row");
-
+
// Clean up
close_database(target_handle).expect("Failed to close target");
std::fs::remove_file(backup_path).ok();
@@ -111,7 +141,7 @@ mod uniffi_export_import_tests {
#[serial]
fn test_export_import_round_trip() {
let thread_id = std::thread::current().id();
-
+
// Create original database
let original_config = DatabaseConfig {
name: format!("uniffi_roundtrip_orig_{:?}.db", thread_id),
@@ -121,29 +151,36 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let original_handle = RUNTIME.block_on(async { create_database(original_config).await }).expect("Failed to create database");
-
+
+ let original_handle = RUNTIME
+ .block_on(async { create_database(original_config).await })
+ .expect("Failed to create database");
+
// Create schema and data
execute(original_handle, "DROP TABLE IF EXISTS items".to_string()).ok();
- execute(original_handle, "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT)".to_string())
- .expect("Failed to create table");
+ execute(
+ original_handle,
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
for i in 0..100 {
- execute(original_handle, format!("INSERT INTO items (value) VALUES ('item_{}')", i))
- .expect("Failed to insert");
+ execute(
+ original_handle,
+ format!("INSERT INTO items (value) VALUES ('item_{}')", i),
+ )
+ .expect("Failed to insert");
}
-
+
// Export
let backup_path = format!("/tmp/uniffi_roundtrip_{:?}.db", thread_id);
- export_database(original_handle, backup_path.clone())
- .expect("Failed to export");
-
+ export_database(original_handle, backup_path.clone()).expect("Failed to export");
+
// Get count from original
let original_count = execute(original_handle, "SELECT COUNT(*) FROM items".to_string())
.expect("Failed to query original");
-
+
close_database(original_handle).expect("Failed to close original");
-
+
// Create new database and import
let restored_config = DatabaseConfig {
name: format!("uniffi_roundtrip_restored_{:?}.db", thread_id),
@@ -153,17 +190,22 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let restored_handle = RUNTIME.block_on(async { create_database(restored_config).await }).expect("Failed to create restored database");
- import_database(restored_handle, backup_path.clone())
- .expect("Failed to import");
-
+
+ let restored_handle = RUNTIME
+ .block_on(async { create_database(restored_config).await })
+ .expect("Failed to create restored database");
+ import_database(restored_handle, backup_path.clone()).expect("Failed to import");
+
// Verify data matches
let restored_count = execute(restored_handle, "SELECT COUNT(*) FROM items".to_string())
.expect("Failed to query restored");
-
- assert_eq!(original_count.rows.len(), restored_count.rows.len(), "Row counts should match");
-
+
+ assert_eq!(
+ original_count.rows.len(),
+ restored_count.rows.len(),
+ "Row counts should match"
+ );
+
// Clean up
close_database(restored_handle).expect("Failed to close restored");
std::fs::remove_file(backup_path).ok();
@@ -195,12 +237,14 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
let result = import_database(handle, "/tmp/nonexistent_file_12345.db".to_string());
assert!(result.is_err(), "Import of nonexistent file should fail");
-
+
close_database(handle).expect("Failed to close database");
}
@@ -208,7 +252,7 @@ mod uniffi_export_import_tests {
#[serial]
fn test_export_import_with_blobs() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_blobs_{:?}.db", thread_id),
@@ -218,29 +262,42 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table with BLOB column
execute(handle, "DROP TABLE IF EXISTS blob_test".to_string()).ok();
- execute(handle, "CREATE TABLE blob_test (id INTEGER PRIMARY KEY, data BLOB)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ handle,
+ "CREATE TABLE blob_test (id INTEGER PRIMARY KEY, data BLOB)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Insert blob data using SQLite's hex literal format
- execute(handle, "INSERT INTO blob_test (data) VALUES (X'48656C6C6F')".to_string())
- .expect("Failed to insert blob 1"); // "Hello" in hex
- execute(handle, "INSERT INTO blob_test (data) VALUES (X'576F726C64')".to_string())
- .expect("Failed to insert blob 2"); // "World" in hex
- execute(handle, "INSERT INTO blob_test (data) VALUES (X'DEADBEEF')".to_string())
- .expect("Failed to insert blob 3"); // Random bytes
-
+ execute(
+ handle,
+ "INSERT INTO blob_test (data) VALUES (X'48656C6C6F')".to_string(),
+ )
+ .expect("Failed to insert blob 1"); // "Hello" in hex
+ execute(
+ handle,
+ "INSERT INTO blob_test (data) VALUES (X'576F726C64')".to_string(),
+ )
+ .expect("Failed to insert blob 2"); // "World" in hex
+ execute(
+ handle,
+ "INSERT INTO blob_test (data) VALUES (X'DEADBEEF')".to_string(),
+ )
+ .expect("Failed to insert blob 3"); // Random bytes
+
// Export database
let backup_path = format!("/tmp/uniffi_blob_backup_{:?}.db", thread_id);
- export_database(handle, backup_path.clone())
- .expect("Failed to export database with blobs");
-
+ export_database(handle, backup_path.clone()).expect("Failed to export database with blobs");
+
close_database(handle).expect("Failed to close original database");
-
+
// Import to new database
let restored_config = DatabaseConfig {
name: format!("uniffi_blobs_restored_{:?}.db", thread_id),
@@ -250,17 +307,22 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let restored_handle = RUNTIME.block_on(async { create_database(restored_config).await }).expect("Failed to create restored database");
+
+ let restored_handle = RUNTIME
+ .block_on(async { create_database(restored_config).await })
+ .expect("Failed to create restored database");
import_database(restored_handle, backup_path.clone())
.expect("Failed to import database with blobs");
-
+
// Verify blob data is preserved
- let result = execute(restored_handle, "SELECT hex(data) as hex_data FROM blob_test ORDER BY id".to_string())
- .expect("Failed to query restored blobs");
-
+ let result = execute(
+ restored_handle,
+ "SELECT hex(data) as hex_data FROM blob_test ORDER BY id".to_string(),
+ )
+ .expect("Failed to query restored blobs");
+
assert_eq!(result.rows.len(), 3, "Should have 3 blob rows");
-
+
// Verify the hex values match what we inserted (typed rows)
use crate::uniffi_api::ColumnValue;
fn get_text_value(row: &crate::uniffi_api::Row, idx: usize) -> String {
@@ -269,14 +331,23 @@ mod uniffi_export_import_tests {
_ => String::new(),
}
}
- assert!(get_text_value(&result.rows[0], 0).contains("48656C6C6F"), "First blob should be 'Hello' in hex");
- assert!(get_text_value(&result.rows[1], 0).contains("576F726C64"), "Second blob should be 'World' in hex");
- assert!(get_text_value(&result.rows[2], 0).contains("DEADBEEF"), "Third blob should be DEADBEEF");
-
+ assert!(
+ get_text_value(&result.rows[0], 0).contains("48656C6C6F"),
+ "First blob should be 'Hello' in hex"
+ );
+ assert!(
+ get_text_value(&result.rows[1], 0).contains("576F726C64"),
+ "Second blob should be 'World' in hex"
+ );
+ assert!(
+ get_text_value(&result.rows[2], 0).contains("DEADBEEF"),
+ "Third blob should be DEADBEEF"
+ );
+
// Clean up
close_database(restored_handle).expect("Failed to close restored database");
std::fs::remove_file(backup_path).ok();
-
+
let db_path1 = format!("uniffi_blobs_{:?}.db", thread_id);
let db_path2 = format!("uniffi_blobs_restored_{:?}.db", thread_id);
let _ = std::fs::remove_file(&db_path1);
@@ -284,7 +355,7 @@ mod uniffi_export_import_tests {
}
/// Test export/import round-trip with ENCRYPTED database
- ///
+ ///
/// This is the critical test for vault backup/restore functionality.
/// When a database is encrypted with SQLCipher, the exported file (via VACUUM INTO)
/// is also encrypted with the same key. The import function must be able to
@@ -293,10 +364,10 @@ mod uniffi_export_import_tests {
#[serial]
fn test_encrypted_export_import_round_trip() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let encryption_key = "test-vault-password-123!";
-
+
// Create encrypted database (simulating a vault)
let original_config = DatabaseConfig {
name: format!("uniffi_encrypted_roundtrip_orig_{:?}.db", thread_id),
@@ -306,12 +377,17 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let original_handle = RUNTIME.block_on(async { create_database(original_config).await })
+
+ let original_handle = RUNTIME
+ .block_on(async { create_database(original_config).await })
.expect("Failed to create encrypted database");
-
+
// Create schema and data (simulating vault credentials)
- execute(original_handle, "DROP TABLE IF EXISTS credentials".to_string()).ok();
+ execute(
+ original_handle,
+ "DROP TABLE IF EXISTS credentials".to_string(),
+ )
+ .ok();
execute(original_handle, "CREATE TABLE credentials (id INTEGER PRIMARY KEY, name TEXT, username TEXT, password TEXT)".to_string())
.expect("Failed to create credentials table");
execute(original_handle, "INSERT INTO credentials (name, username, password) VALUES ('GitHub', 'user1', 'secret123')".to_string())
@@ -320,23 +396,26 @@ mod uniffi_export_import_tests {
.expect("Failed to insert credential 2");
execute(original_handle, "INSERT INTO credentials (name, username, password) VALUES ('AWS', 'admin', 'aws-key-789')".to_string())
.expect("Failed to insert credential 3");
-
+
// Export encrypted database
let backup_path = format!("/tmp/uniffi_encrypted_roundtrip_{:?}.db", thread_id);
export_database(original_handle, backup_path.clone())
.expect("Failed to export encrypted database");
-
+
// Verify export file exists
let path = PathBuf::from(&backup_path);
assert!(path.exists(), "Encrypted export file should exist");
-
+
// Get count from original before closing
- let original_result = execute(original_handle, "SELECT COUNT(*) as cnt FROM credentials".to_string())
- .expect("Failed to query original");
+ let original_result = execute(
+ original_handle,
+ "SELECT COUNT(*) as cnt FROM credentials".to_string(),
+ )
+ .expect("Failed to query original");
assert_eq!(original_result.rows.len(), 1, "Should have count row");
-
+
close_database(original_handle).expect("Failed to close original");
-
+
// Create NEW encrypted database with SAME key and import
// This simulates restoring a vault backup
let restored_config = DatabaseConfig {
@@ -347,49 +426,60 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let restored_handle = RUNTIME.block_on(async { create_database(restored_config).await })
+
+ let restored_handle = RUNTIME
+ .block_on(async { create_database(restored_config).await })
.expect("Failed to create restored encrypted database");
-
+
// Import from encrypted backup - THIS IS THE KEY TEST
// The backup file is encrypted, so import_database must handle this
let import_result = import_database(restored_handle, backup_path.clone());
- assert!(import_result.is_ok(), "Import of encrypted backup should succeed: {:?}", import_result.err());
-
+ assert!(
+ import_result.is_ok(),
+ "Import of encrypted backup should succeed: {:?}",
+ import_result.err()
+ );
+
// Verify data was imported correctly
- let restored_result = execute(restored_handle, "SELECT COUNT(*) as cnt FROM credentials".to_string())
- .expect("Failed to query restored");
+ let restored_result = execute(
+ restored_handle,
+ "SELECT COUNT(*) as cnt FROM credentials".to_string(),
+ )
+ .expect("Failed to query restored");
assert_eq!(restored_result.rows.len(), 1, "Should have count row");
-
+
// Verify actual credential data
- let credentials = execute(restored_handle, "SELECT name, username, password FROM credentials ORDER BY id".to_string())
- .expect("Failed to query credentials");
+ let credentials = execute(
+ restored_handle,
+ "SELECT name, username, password FROM credentials ORDER BY id".to_string(),
+ )
+ .expect("Failed to query credentials");
assert_eq!(credentials.rows.len(), 3, "Should have 3 credentials");
-
+
// Clean up
close_database(restored_handle).expect("Failed to close restored");
std::fs::remove_file(&backup_path).ok();
}
/// Test import into SAME encrypted database (vault restore scenario)
- ///
+ ///
/// This reproduces the exact vault use case:
/// 1. Create encrypted vault with credentials
/// 2. Export vault to backup file
/// 3. Delete credentials from vault (simulate data loss)
/// 4. Import backup into SAME vault (not a new database)
/// 5. Verify credentials are restored
- ///
+ ///
/// The key difference from test_encrypted_export_import_round_trip is that
/// we import into the SAME database handle, not a new one.
#[test]
#[serial]
fn test_encrypted_import_into_same_vault() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let encryption_key = "vault-master-password-123!";
-
+
// Create encrypted vault
let vault_config = DatabaseConfig {
name: format!("uniffi_same_vault_import_{:?}.db", thread_id),
@@ -399,10 +489,11 @@ mod uniffi_export_import_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let vault_handle = RUNTIME.block_on(async { create_database(vault_config).await })
+
+ let vault_handle = RUNTIME
+ .block_on(async { create_database(vault_config).await })
.expect("Failed to create encrypted vault");
-
+
// Create credentials table and add data
execute(vault_handle, "DROP TABLE IF EXISTS credentials".to_string()).ok();
execute(vault_handle, "CREATE TABLE credentials (id INTEGER PRIMARY KEY, name TEXT, username TEXT, password TEXT)".to_string())
@@ -411,55 +502,75 @@ mod uniffi_export_import_tests {
.expect("Failed to insert credential 1");
execute(vault_handle, "INSERT INTO credentials (name, username, password) VALUES ('Account2', 'user2@test.com', 'pass2')".to_string())
.expect("Failed to insert credential 2");
-
+
// Verify initial data
- let initial_count = execute(vault_handle, "SELECT COUNT(*) as cnt FROM credentials".to_string())
- .expect("Failed to count initial");
+ let initial_count = execute(
+ vault_handle,
+ "SELECT COUNT(*) as cnt FROM credentials".to_string(),
+ )
+ .expect("Failed to count initial");
assert_eq!(initial_count.rows.len(), 1, "Should have count row");
-
+
// Export vault to backup
let backup_path = format!("/tmp/uniffi_same_vault_backup_{:?}.db", thread_id);
- export_database(vault_handle, backup_path.clone())
- .expect("Failed to export vault");
-
+ export_database(vault_handle, backup_path.clone()).expect("Failed to export vault");
+
// Verify backup file exists
- assert!(PathBuf::from(&backup_path).exists(), "Backup file should exist");
-
+ assert!(
+ PathBuf::from(&backup_path).exists(),
+ "Backup file should exist"
+ );
+
// Delete all credentials (simulate data loss)
execute(vault_handle, "DELETE FROM credentials".to_string())
.expect("Failed to delete credentials");
-
+
// Verify credentials are gone
- let after_delete = execute(vault_handle, "SELECT COUNT(*) as cnt FROM credentials".to_string())
- .expect("Failed to count after delete");
+ let after_delete = execute(
+ vault_handle,
+ "SELECT COUNT(*) as cnt FROM credentials".to_string(),
+ )
+ .expect("Failed to count after delete");
// Extract count from first row
let count_after_delete = match &after_delete.rows[0].values[0] {
crate::uniffi_api::types::ColumnValue::Integer { value } => *value,
_ => panic!("Expected integer count"),
};
- assert_eq!(count_after_delete, 0, "Should have 0 credentials after delete");
-
+ assert_eq!(
+ count_after_delete, 0,
+ "Should have 0 credentials after delete"
+ );
+
// Import backup into SAME vault - THIS IS THE KEY TEST
let import_result = import_database(vault_handle, backup_path.clone());
- assert!(import_result.is_ok(), "Import into same vault should succeed: {:?}", import_result.err());
-
+ assert!(
+ import_result.is_ok(),
+ "Import into same vault should succeed: {:?}",
+ import_result.err()
+ );
+
// Verify credentials were restored
- let restored_count = execute(vault_handle, "SELECT COUNT(*) as cnt FROM credentials".to_string())
- .expect("Failed to count restored");
+ let restored_count = execute(
+ vault_handle,
+ "SELECT COUNT(*) as cnt FROM credentials".to_string(),
+ )
+ .expect("Failed to count restored");
let count_restored = match &restored_count.rows[0].values[0] {
crate::uniffi_api::types::ColumnValue::Integer { value } => *value,
_ => panic!("Expected integer count"),
};
assert_eq!(count_restored, 2, "Should have 2 credentials after import");
-
+
// Verify actual data
- let credentials = execute(vault_handle, "SELECT name, username FROM credentials ORDER BY id".to_string())
- .expect("Failed to query credentials");
+ let credentials = execute(
+ vault_handle,
+ "SELECT name, username FROM credentials ORDER BY id".to_string(),
+ )
+ .expect("Failed to query credentials");
assert_eq!(credentials.rows.len(), 2, "Should have 2 credential rows");
-
+
// Clean up
close_database(vault_handle).expect("Failed to close vault");
std::fs::remove_file(&backup_path).ok();
}
-
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_index_helpers_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_index_helpers_test.rs
index c91343f1..307049ce 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_index_helpers_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_index_helpers_test.rs
@@ -1,11 +1,11 @@
#[cfg(feature = "uniffi-bindings")]
-use serial_test::serial;
+use crate::registry::RUNTIME;
#[cfg(feature = "uniffi-bindings")]
use crate::uniffi_api::core::*;
#[cfg(feature = "uniffi-bindings")]
use crate::uniffi_api::types::*;
#[cfg(feature = "uniffi-bindings")]
-use crate::registry::RUNTIME;
+use serial_test::serial;
#[cfg(all(test, feature = "uniffi-bindings"))]
mod uniffi_index_helpers_tests {
@@ -24,21 +24,32 @@ mod uniffi_index_helpers_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).unwrap();
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .unwrap();
+
// Drop and create table
let _ = execute(handle, "DROP TABLE IF EXISTS users".to_string());
- execute(handle, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)".to_string()).unwrap();
-
+ execute(
+ handle,
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)".to_string(),
+ )
+ .unwrap();
+
// Create index on email column
let result = create_index(handle, "users".to_string(), "email".to_string());
assert!(result.is_ok(), "Index creation should succeed");
-
+
// Verify index exists
- let query_result = execute(handle, "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_email'".to_string()).unwrap();
+ let query_result = execute(
+ handle,
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_email'"
+ .to_string(),
+ )
+ .unwrap();
assert_eq!(query_result.rows.len(), 1, "Index should exist");
-
+
close_database(handle).unwrap();
}
@@ -54,21 +65,27 @@ mod uniffi_index_helpers_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).unwrap();
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .unwrap();
+
// Drop and create table
let _ = execute(handle, "DROP TABLE IF EXISTS orders".to_string());
execute(handle, "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, product_id INTEGER, created_at TEXT)".to_string()).unwrap();
-
+
// Create composite index
- let result = create_index(handle, "orders".to_string(), "user_id,product_id".to_string());
+ let result = create_index(
+ handle,
+ "orders".to_string(),
+ "user_id,product_id".to_string(),
+ );
assert!(result.is_ok(), "Composite index creation should succeed");
-
+
// Verify index exists
let query_result = execute(handle, "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_orders_user_id_product_id'".to_string()).unwrap();
assert_eq!(query_result.rows.len(), 1, "Composite index should exist");
-
+
close_database(handle).unwrap();
}
@@ -79,8 +96,11 @@ mod uniffi_index_helpers_tests {
assert!(result.is_err(), "Should return error for invalid handle");
match result.unwrap_err() {
DatabaseError::NotFound { message } => {
- assert!(message.contains("not found"), "Error message should mention 'not found'");
- },
+ assert!(
+ message.contains("not found"),
+ "Error message should mention 'not found'"
+ );
+ }
_ => panic!("Expected NotFound error"),
}
}
@@ -97,13 +117,15 @@ mod uniffi_index_helpers_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).unwrap();
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .unwrap();
+
// Try to create index on non-existent table
let result = create_index(handle, "nonexistent_table".to_string(), "email".to_string());
assert!(result.is_err(), "Should return error for invalid table");
-
+
close_database(handle).unwrap();
}
@@ -119,17 +141,19 @@ mod uniffi_index_helpers_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).unwrap();
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .unwrap();
+
// Empty table name
let result = create_index(handle, "".to_string(), "email".to_string());
assert!(result.is_err(), "Should return error for empty table");
-
+
// Empty column name
let result = create_index(handle, "users".to_string(), "".to_string());
assert!(result.is_err(), "Should return error for empty columns");
-
+
close_database(handle).unwrap();
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_integration_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_integration_test.rs
index d83e0fc1..db4be17d 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_integration_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_integration_test.rs
@@ -1,5 +1,5 @@
/// Tests for UniFFI integration
-///
+///
/// These tests verify that UniFFI annotations work correctly
/// and that the generated bindings are type-safe.
@@ -18,7 +18,7 @@ mod uniffi_integration_tests {
let _ = "uniffi feature enabled";
assert!(true, "UniFFI feature is available");
}
-
+
#[cfg(not(feature = "uniffi"))]
{
panic!("UniFFI feature is not enabled. Add uniffi dependency and feature flag.");
@@ -36,7 +36,7 @@ mod uniffi_integration_tests {
// they should be accessible through the generated scaffolding
assert!(true, "UniFFI annotations are present");
}
-
+
#[cfg(not(feature = "uniffi"))]
{
panic!("UniFFI feature not enabled for annotations test");
@@ -48,15 +48,15 @@ mod uniffi_integration_tests {
fn test_existing_ffi_still_works() {
// Validate that existing FFI functions still compile
// This ensures we maintain backward compatibility during migration
-
+
// The existing FFI should always be available
use std::ffi::CString;
-
+
// Test that we can still create database handles
// (This validates the old FFI path still works)
let test_name = CString::new("test.db").unwrap();
let handle = unsafe { crate::ffi::core::absurder_db_new(test_name.as_ptr()) };
-
+
if handle != 0 {
// Clean up
unsafe { crate::ffi::core::absurder_db_close(handle) };
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_prepared_statement_result_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_prepared_statement_result_test.rs
index 08c87894..121ed5e6 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_prepared_statement_result_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_prepared_statement_result_test.rs
@@ -32,11 +32,7 @@ mod uniffi_prepared_statement_result_tests {
.block_on(async { create_database(config).await })
.expect("Failed to create database");
- execute(
- handle,
- "DROP TABLE IF EXISTS products".to_string(),
- )
- .ok();
+ execute(handle, "DROP TABLE IF EXISTS products".to_string()).ok();
execute(
handle,
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string(),
@@ -190,8 +186,7 @@ mod uniffi_prepared_statement_result_tests {
let stmt_handle = prepare_statement(handle, "SELECT * FROM timing".to_string())
.expect("Failed to prepare statement");
- let result =
- execute_statement(stmt_handle, vec![]).expect("Failed to execute statement");
+ let result = execute_statement(stmt_handle, vec![]).expect("Failed to execute statement");
// execution_time_ms should be populated (>= 0)
assert!(
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_prepared_statements_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_prepared_statements_test.rs
index 4e19f5ec..e7e7aa9c 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_prepared_statements_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_prepared_statements_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI prepared statement functions
-///
-/// Tests prepared statement creation, execution, and cleanup
-
+//! Tests for UniFFI prepared statement functions
+//!
+//! Tests prepared statement creation, execution, and cleanup
#[cfg(test)]
mod uniffi_prepared_statements_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_prepare_statement_simple() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_prepare_simple_{:?}.db", thread_id),
@@ -22,20 +21,26 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(db_handle, "DROP TABLE IF EXISTS test".to_string()).ok();
- execute(db_handle, "CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Prepare statement
- let stmt_handle = prepare_statement(db_handle, "INSERT INTO test (value) VALUES (?)".to_string())
- .expect("Failed to prepare statement");
-
+ let stmt_handle =
+ prepare_statement(db_handle, "INSERT INTO test (value) VALUES (?)".to_string())
+ .expect("Failed to prepare statement");
+
assert!(stmt_handle > 0, "Statement handle should be valid");
-
+
// Finalize statement
finalize_statement(stmt_handle).expect("Failed to finalize statement");
close_database(db_handle).expect("Failed to close database");
@@ -53,27 +58,35 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(db_handle, "DROP TABLE IF EXISTS users".to_string()).ok();
- execute(db_handle, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Prepare statement
- let stmt_handle = prepare_statement(db_handle, "INSERT INTO users (name, age) VALUES (?, ?)".to_string())
- .expect("Failed to prepare statement");
-
+ let stmt_handle = prepare_statement(
+ db_handle,
+ "INSERT INTO users (name, age) VALUES (?, ?)".to_string(),
+ )
+ .expect("Failed to prepare statement");
+
// Execute with params
let params = vec!["Alice".to_string(), "30".to_string()];
execute_statement(stmt_handle, params).expect("Failed to execute statement");
-
+
// Verify insertion
- let result = execute(db_handle, "SELECT name, age FROM users".to_string())
- .expect("Failed to query");
+ let result =
+ execute(db_handle, "SELECT name, age FROM users".to_string()).expect("Failed to query");
assert_eq!(result.rows.len(), 1, "Should have 1 row");
-
+
finalize_statement(stmt_handle).expect("Failed to finalize statement");
close_database(db_handle).expect("Failed to close database");
}
@@ -90,26 +103,40 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create and populate table
execute(db_handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(db_handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string())
- .expect("Failed to create table");
- execute(db_handle, "INSERT INTO products (name, price) VALUES ('Widget', 10.50)".to_string())
- .expect("Failed to insert");
- execute(db_handle, "INSERT INTO products (name, price) VALUES ('Gadget', 20.99)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ db_handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ db_handle,
+ "INSERT INTO products (name, price) VALUES ('Widget', 10.50)".to_string(),
+ )
+ .expect("Failed to insert");
+ execute(
+ db_handle,
+ "INSERT INTO products (name, price) VALUES ('Gadget', 20.99)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Prepare select statement
- let stmt_handle = prepare_statement(db_handle, "SELECT name, price FROM products WHERE price > ?".to_string())
- .expect("Failed to prepare statement");
-
+ let stmt_handle = prepare_statement(
+ db_handle,
+ "SELECT name, price FROM products WHERE price > ?".to_string(),
+ )
+ .expect("Failed to prepare statement");
+
// Execute with param
let params = vec!["15.00".to_string()];
execute_statement(stmt_handle, params).expect("Failed to execute statement");
-
+
finalize_statement(stmt_handle).expect("Failed to finalize statement");
close_database(db_handle).expect("Failed to close database");
}
@@ -126,30 +153,36 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table
execute(db_handle, "DROP TABLE IF EXISTS items".to_string()).ok();
- execute(db_handle, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Prepare statement once
- let stmt_handle = prepare_statement(db_handle, "INSERT INTO items (name) VALUES (?)".to_string())
- .expect("Failed to prepare statement");
-
+ let stmt_handle =
+ prepare_statement(db_handle, "INSERT INTO items (name) VALUES (?)".to_string())
+ .expect("Failed to prepare statement");
+
// Execute multiple times with different params
for i in 1..=5 {
let params = vec![format!("item_{}", i)];
execute_statement(stmt_handle, params)
- .expect(&format!("Failed to execute statement {}", i));
+ .unwrap_or_else(|error| panic!("Failed to execute statement {}: {}", i, error));
}
-
+
// Verify all insertions
- let result = execute(db_handle, "SELECT COUNT(*) FROM items".to_string())
- .expect("Failed to query");
+ let result =
+ execute(db_handle, "SELECT COUNT(*) FROM items".to_string()).expect("Failed to query");
assert_eq!(result.rows.len(), 1, "Should have count result");
-
+
finalize_statement(stmt_handle).expect("Failed to finalize statement");
close_database(db_handle).expect("Failed to close database");
}
@@ -166,12 +199,14 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
let result = prepare_statement(db_handle, "INVALID SQL STATEMENT".to_string());
assert!(result.is_err(), "Invalid SQL should fail to prepare");
-
+
close_database(db_handle).expect("Failed to close database");
}
@@ -209,21 +244,23 @@ mod uniffi_prepared_statements_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
execute(db_handle, "DROP TABLE IF EXISTS test".to_string()).ok();
execute(db_handle, "CREATE TABLE test (id INTEGER)".to_string())
.expect("Failed to create table");
-
+
let stmt_handle = prepare_statement(db_handle, "SELECT * FROM test".to_string())
.expect("Failed to prepare statement");
-
+
finalize_statement(stmt_handle).expect("First finalize should succeed");
-
+
let result = finalize_statement(stmt_handle);
assert!(result.is_err(), "Second finalize should fail");
-
+
close_database(db_handle).expect("Failed to close database");
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_queryresult_fields_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_queryresult_fields_test.rs
index 2c2644fe..f2606bab 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_queryresult_fields_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_queryresult_fields_test.rs
@@ -1,12 +1,11 @@
-/// Tests for QueryResult fields: last_insert_id and execution_time_ms
-///
-/// TDD Phase 2: Verify QueryResult includes timing and row ID information
-/// to match core library behavior.
-
+//! Tests for QueryResult fields: last_insert_id and execution_time_ms
+//!
+//! TDD Phase 2: Verify QueryResult includes timing and row ID information
+//! to match core library behavior.
#[cfg(test)]
mod uniffi_queryresult_fields_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
/// Test that last_insert_id is populated after INSERT
@@ -25,33 +24,61 @@ mod uniffi_queryresult_fields_tests {
auto_vacuum: None,
};
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.expect("Failed to create database");
// Create table with AUTOINCREMENT
execute(handle, "DROP TABLE IF EXISTS insert_test".to_string()).ok();
- execute(handle, "CREATE TABLE insert_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)".to_string())
- .expect("CREATE TABLE failed");
+ execute(
+ handle,
+ "CREATE TABLE insert_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"
+ .to_string(),
+ )
+ .expect("CREATE TABLE failed");
// Insert first row
- let result1 = execute(handle, "INSERT INTO insert_test (name) VALUES ('Alice')".to_string())
- .expect("INSERT 1 failed");
-
- assert!(result1.last_insert_id.is_some(), "last_insert_id should be populated after INSERT");
- assert_eq!(result1.last_insert_id.unwrap(), 1, "First insert should have rowid 1");
+ let result1 = execute(
+ handle,
+ "INSERT INTO insert_test (name) VALUES ('Alice')".to_string(),
+ )
+ .expect("INSERT 1 failed");
+
+ assert!(
+ result1.last_insert_id.is_some(),
+ "last_insert_id should be populated after INSERT"
+ );
+ assert_eq!(
+ result1.last_insert_id.unwrap(),
+ 1,
+ "First insert should have rowid 1"
+ );
// Insert second row
- let result2 = execute(handle, "INSERT INTO insert_test (name) VALUES ('Bob')".to_string())
- .expect("INSERT 2 failed");
-
- assert!(result2.last_insert_id.is_some(), "last_insert_id should be populated after second INSERT");
- assert_eq!(result2.last_insert_id.unwrap(), 2, "Second insert should have rowid 2");
+ let result2 = execute(
+ handle,
+ "INSERT INTO insert_test (name) VALUES ('Bob')".to_string(),
+ )
+ .expect("INSERT 2 failed");
+
+ assert!(
+ result2.last_insert_id.is_some(),
+ "last_insert_id should be populated after second INSERT"
+ );
+ assert_eq!(
+ result2.last_insert_id.unwrap(),
+ 2,
+ "Second insert should have rowid 2"
+ );
// SELECT should NOT have last_insert_id
- let result3 = execute(handle, "SELECT * FROM insert_test".to_string())
- .expect("SELECT failed");
+ let result3 =
+ execute(handle, "SELECT * FROM insert_test".to_string()).expect("SELECT failed");
- assert!(result3.last_insert_id.is_none(), "SELECT should not have last_insert_id");
+ assert!(
+ result3.last_insert_id.is_none(),
+ "SELECT should not have last_insert_id"
+ );
close_database(handle).expect("Failed to close database");
}
@@ -72,24 +99,34 @@ mod uniffi_queryresult_fields_tests {
auto_vacuum: None,
};
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.expect("Failed to create database");
execute(handle, "DROP TABLE IF EXISTS timing_test".to_string()).ok();
- execute(handle, "CREATE TABLE timing_test (id INTEGER, value TEXT)".to_string())
- .expect("CREATE TABLE failed");
+ execute(
+ handle,
+ "CREATE TABLE timing_test (id INTEGER, value TEXT)".to_string(),
+ )
+ .expect("CREATE TABLE failed");
// Execute a query and check timing
- let result = execute(handle, "SELECT * FROM timing_test".to_string())
- .expect("SELECT failed");
+ let result =
+ execute(handle, "SELECT * FROM timing_test".to_string()).expect("SELECT failed");
// execution_time_ms should be >= 0 (it's a valid time measurement)
- assert!(result.execution_time_ms >= 0.0,
- "execution_time_ms should be non-negative, got {}", result.execution_time_ms);
+ assert!(
+ result.execution_time_ms >= 0.0,
+ "execution_time_ms should be non-negative, got {}",
+ result.execution_time_ms
+ );
// It should be a reasonable value (less than 10 seconds for a simple query)
- assert!(result.execution_time_ms < 10000.0,
- "execution_time_ms should be reasonable, got {} ms", result.execution_time_ms);
+ assert!(
+ result.execution_time_ms < 10000.0,
+ "execution_time_ms should be reasonable, got {} ms",
+ result.execution_time_ms
+ );
close_database(handle).expect("Failed to close database");
}
@@ -110,31 +147,40 @@ mod uniffi_queryresult_fields_tests {
auto_vacuum: None,
};
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.expect("Failed to create database");
execute(handle, "DROP TABLE IF EXISTS workload_test".to_string()).ok();
- execute(handle, "CREATE TABLE workload_test (id INTEGER, data TEXT)".to_string())
- .expect("CREATE TABLE failed");
+ execute(
+ handle,
+ "CREATE TABLE workload_test (id INTEGER, data TEXT)".to_string(),
+ )
+ .expect("CREATE TABLE failed");
// Insert many rows - this should take measurable time
execute(handle, "BEGIN TRANSACTION".to_string()).expect("BEGIN failed");
for i in 0..100 {
- execute(handle, format!("INSERT INTO workload_test VALUES ({}, 'data_{}')", i, i))
- .expect("INSERT failed");
+ execute(
+ handle,
+ format!("INSERT INTO workload_test VALUES ({}, 'data_{}')", i, i),
+ )
+ .expect("INSERT failed");
}
execute(handle, "COMMIT".to_string()).expect("COMMIT failed");
// Query all rows
- let result = execute(handle, "SELECT * FROM workload_test".to_string())
- .expect("SELECT failed");
+ let result =
+ execute(handle, "SELECT * FROM workload_test".to_string()).expect("SELECT failed");
// Should have 100 rows
assert_eq!(result.rows.len(), 100, "Should have 100 rows");
// execution_time_ms should be present
- assert!(result.execution_time_ms >= 0.0,
- "execution_time_ms should be present");
+ assert!(
+ result.execution_time_ms >= 0.0,
+ "execution_time_ms should be present"
+ );
log::info!("Query of 100 rows took {} ms", result.execution_time_ms);
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_row_columnvalue_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_row_columnvalue_test.rs
index a18527f6..0bec08b6 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_row_columnvalue_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_row_columnvalue_test.rs
@@ -1,12 +1,11 @@
-/// Tests for UniFFI Row and ColumnValue type exports
-///
-/// TDD Phase 1: Verify Row and ColumnValue types are properly exported via UniFFI
-/// and that execute() returns typed rows instead of JSON strings.
-
+//! Tests for UniFFI Row and ColumnValue type exports
+//!
+//! TDD Phase 1: Verify Row and ColumnValue types are properly exported via UniFFI
+//! and that execute() returns typed rows instead of JSON strings.
#[cfg(test)]
mod uniffi_row_columnvalue_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
/// Test that Row type exists and can be used
@@ -18,7 +17,9 @@ mod uniffi_row_columnvalue_tests {
let row = Row {
values: vec![
ColumnValue::Integer { value: 42 },
- ColumnValue::Text { value: "hello".to_string() },
+ ColumnValue::Text {
+ value: "hello".to_string(),
+ },
],
};
@@ -32,15 +33,16 @@ mod uniffi_row_columnvalue_tests {
// Test all variants exist and can be created (UniFFI uses struct variants)
let null_val = ColumnValue::Null;
let int_val = ColumnValue::Integer { value: 42 };
- let real_val = ColumnValue::Real { value: 3.14 };
- let text_val = ColumnValue::Text { value: "hello".to_string() };
- let blob_val = ColumnValue::Blob { value: vec![0x01, 0x02, 0x03] };
+ let real_val = ColumnValue::Real { value: 3.5 };
+ let text_val = ColumnValue::Text {
+ value: "hello".to_string(),
+ };
+ let blob_val = ColumnValue::Blob {
+ value: vec![0x01, 0x02, 0x03],
+ };
// Verify we can match on variants
- match null_val {
- ColumnValue::Null => assert!(true),
- _ => panic!("Expected Null variant"),
- }
+ assert!(matches!(null_val, ColumnValue::Null));
match int_val {
ColumnValue::Integer { value } => assert_eq!(value, 42),
@@ -48,7 +50,7 @@ mod uniffi_row_columnvalue_tests {
}
match real_val {
- ColumnValue::Real { value } => assert!((value - 3.14).abs() < 0.001),
+ ColumnValue::Real { value } => assert!((value - 3.5).abs() < 0.001),
_ => panic!("Expected Real variant"),
}
@@ -79,7 +81,8 @@ mod uniffi_row_columnvalue_tests {
auto_vacuum: None,
};
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.unwrap_or_else(|e| panic!("Failed to create database {}: {:?}", config.name, e));
// Create table and insert data
@@ -91,8 +94,8 @@ mod uniffi_row_columnvalue_tests {
.expect("INSERT failed");
// Query and verify typed rows
- let result = execute(handle, "SELECT * FROM typed_rows_test".to_string())
- .expect("SELECT failed");
+ let result =
+ execute(handle, "SELECT * FROM typed_rows_test".to_string()).expect("SELECT failed");
assert_eq!(result.columns.len(), 4, "Should have 4 columns");
assert_eq!(result.rows.len(), 1, "Should have 1 row");
@@ -142,26 +145,33 @@ mod uniffi_row_columnvalue_tests {
auto_vacuum: None,
};
- let handle = RUNTIME.block_on(async { create_database(config.clone()).await })
+ let handle = RUNTIME
+ .block_on(async { create_database(config.clone()).await })
.expect("Failed to create database");
execute(handle, "DROP TABLE IF EXISTS null_test".to_string()).ok();
- execute(handle, "CREATE TABLE null_test (id INTEGER, value TEXT)".to_string())
- .expect("CREATE TABLE failed");
+ execute(
+ handle,
+ "CREATE TABLE null_test (id INTEGER, value TEXT)".to_string(),
+ )
+ .expect("CREATE TABLE failed");
// Insert NULL value
- execute(handle, "INSERT INTO null_test (id, value) VALUES (1, NULL)".to_string())
- .expect("INSERT failed");
+ execute(
+ handle,
+ "INSERT INTO null_test (id, value) VALUES (1, NULL)".to_string(),
+ )
+ .expect("INSERT failed");
- let result = execute(handle, "SELECT * FROM null_test".to_string())
- .expect("SELECT failed");
+ let result = execute(handle, "SELECT * FROM null_test".to_string()).expect("SELECT failed");
let row = &result.rows[0];
- match &row.values[1] {
- ColumnValue::Null => assert!(true, "NULL correctly typed"),
- other => panic!("Expected Null variant, got {:?}", other),
- }
+ assert!(
+ matches!(&row.values[1], ColumnValue::Null),
+ "Expected Null variant, got {:?}",
+ row.values[1]
+ );
close_database(handle).expect("Failed to close database");
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_streaming_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_streaming_test.rs
index e1651b15..27aa5a63 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_streaming_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_streaming_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI streaming statement functions
-///
-/// Tests cursor-based streaming for memory-efficient large result sets
-
+//! Tests for UniFFI streaming statement functions
+//!
+//! Tests cursor-based streaming for memory-efficient large result sets
#[cfg(test)]
mod uniffi_streaming_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_stream_basic() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_stream_basic_{:?}.db", thread_id),
@@ -22,37 +21,46 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table and insert data
execute(db_handle, "DROP TABLE IF EXISTS items".to_string()).ok();
- execute(db_handle, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
for i in 1..=10 {
- execute(db_handle, format!("INSERT INTO items (name) VALUES ('item_{}')", i))
- .expect("Failed to insert");
+ execute(
+ db_handle,
+ format!("INSERT INTO items (name) VALUES ('item_{}')", i),
+ )
+ .expect("Failed to insert");
}
-
+
// Prepare streaming statement
- let stream_handle = prepare_stream(db_handle, "SELECT * FROM items ORDER BY id".to_string())
- .expect("Failed to prepare stream");
-
+ let stream_handle =
+ prepare_stream(db_handle, "SELECT * FROM items ORDER BY id".to_string())
+ .expect("Failed to prepare stream");
+
assert!(stream_handle > 0, "Stream handle should be valid");
-
+
// Fetch first batch
let batch1 = fetch_next(stream_handle, 5).expect("Failed to fetch batch");
assert_eq!(batch1.rows.len(), 5, "First batch should have 5 rows");
-
+
// Fetch second batch
let batch2 = fetch_next(stream_handle, 5).expect("Failed to fetch batch");
assert_eq!(batch2.rows.len(), 5, "Second batch should have 5 rows");
-
+
// Fetch third batch (should be empty)
let batch3 = fetch_next(stream_handle, 5).expect("Failed to fetch batch");
assert_eq!(batch3.rows.len(), 0, "Third batch should be empty");
-
+
close_stream(stream_handle).expect("Failed to close stream");
close_database(db_handle).expect("Failed to close database");
}
@@ -69,39 +77,47 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create table and insert large dataset
execute(db_handle, "DROP TABLE IF EXISTS data".to_string()).ok();
- execute(db_handle, "CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT)".to_string(),
+ )
+ .expect("Failed to create table");
+
// Insert 100 rows
for i in 1..=100 {
- execute(db_handle, format!("INSERT INTO data (value) VALUES ('value_{}')", i))
- .expect("Failed to insert");
+ execute(
+ db_handle,
+ format!("INSERT INTO data (value) VALUES ('value_{}')", i),
+ )
+ .expect("Failed to insert");
}
-
+
// Stream with batch size of 10
let stream_handle = prepare_stream(db_handle, "SELECT * FROM data".to_string())
.expect("Failed to prepare stream");
-
+
let mut total_rows = 0;
let mut batch_count = 0;
-
+
loop {
let batch = fetch_next(stream_handle, 10).expect("Failed to fetch batch");
- if batch.rows.len() == 0 {
+ if batch.rows.is_empty() {
break;
}
total_rows += batch.rows.len();
batch_count += 1;
}
-
+
assert_eq!(total_rows, 100, "Should fetch all 100 rows");
assert_eq!(batch_count, 10, "Should have 10 batches");
-
+
close_stream(stream_handle).expect("Failed to close stream");
close_database(db_handle).expect("Failed to close database");
}
@@ -118,26 +134,37 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create and populate table
execute(db_handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(db_handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, price REAL)".to_string())
- .expect("Failed to create table");
-
+ execute(
+ db_handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, price REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+
for i in 1..=50 {
- execute(db_handle, format!("INSERT INTO products (price) VALUES ({})", i * 10))
- .expect("Failed to insert");
+ execute(
+ db_handle,
+ format!("INSERT INTO products (price) VALUES ({})", i * 10),
+ )
+ .expect("Failed to insert");
}
-
+
// Stream with WHERE clause
- let stream_handle = prepare_stream(db_handle, "SELECT * FROM products WHERE price > 250".to_string())
- .expect("Failed to prepare stream");
-
+ let stream_handle = prepare_stream(
+ db_handle,
+ "SELECT * FROM products WHERE price > 250".to_string(),
+ )
+ .expect("Failed to prepare stream");
+
let batch = fetch_next(stream_handle, 100).expect("Failed to fetch batch");
assert_eq!(batch.rows.len(), 25, "Should fetch 25 rows (price 260-500)");
-
+
close_stream(stream_handle).expect("Failed to close stream");
close_database(db_handle).expect("Failed to close database");
}
@@ -154,20 +181,22 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Create empty table
execute(db_handle, "DROP TABLE IF EXISTS empty".to_string()).ok();
execute(db_handle, "CREATE TABLE empty (id INTEGER)".to_string())
.expect("Failed to create table");
-
+
let stream_handle = prepare_stream(db_handle, "SELECT * FROM empty".to_string())
.expect("Failed to prepare stream");
-
+
let batch = fetch_next(stream_handle, 10).expect("Failed to fetch batch");
assert_eq!(batch.rows.len(), 0, "Batch should be empty");
-
+
close_stream(stream_handle).expect("Failed to close stream");
close_database(db_handle).expect("Failed to close database");
}
@@ -191,12 +220,14 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
let result = prepare_stream(db_handle, "INVALID SQL".to_string());
assert!(result.is_err(), "Invalid SQL should fail");
-
+
close_database(db_handle).expect("Failed to close database");
}
@@ -219,22 +250,24 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
execute(db_handle, "DROP TABLE IF EXISTS test".to_string()).ok();
execute(db_handle, "CREATE TABLE test (id INTEGER)".to_string())
.expect("Failed to create table");
-
+
let stream_handle = prepare_stream(db_handle, "SELECT * FROM test".to_string())
.expect("Failed to prepare stream");
-
+
let result = fetch_next(stream_handle, 0);
assert!(result.is_err(), "Batch size 0 should fail");
-
+
let result = fetch_next(stream_handle, -1);
assert!(result.is_err(), "Negative batch size should fail");
-
+
close_stream(stream_handle).expect("Failed to close stream");
close_database(db_handle).expect("Failed to close database");
}
@@ -258,21 +291,23 @@ mod uniffi_streaming_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let db_handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let db_handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
execute(db_handle, "DROP TABLE IF EXISTS test".to_string()).ok();
execute(db_handle, "CREATE TABLE test (id INTEGER)".to_string())
.expect("Failed to create table");
-
+
let stream_handle = prepare_stream(db_handle, "SELECT * FROM test".to_string())
.expect("Failed to prepare stream");
-
+
close_stream(stream_handle).expect("First close should succeed");
-
+
let result = close_stream(stream_handle);
assert!(result.is_err(), "Second close should fail");
-
+
close_database(db_handle).expect("Failed to close database");
}
}
diff --git a/absurder-sql-mobile/src/__tests__/uniffi_transactions_test.rs b/absurder-sql-mobile/src/__tests__/uniffi_transactions_test.rs
index 30bb37fe..fbdeaa87 100644
--- a/absurder-sql-mobile/src/__tests__/uniffi_transactions_test.rs
+++ b/absurder-sql-mobile/src/__tests__/uniffi_transactions_test.rs
@@ -1,18 +1,17 @@
-/// Tests for UniFFI transaction functions
-///
-/// Tests begin_transaction, commit, and rollback operations
-
+//! Tests for UniFFI transaction functions
+//!
+//! Tests begin_transaction, commit, and rollback operations
#[cfg(test)]
mod uniffi_transactions_tests {
- use crate::uniffi_api::*;
use crate::registry::RUNTIME;
+ use crate::uniffi_api::*;
use serial_test::serial;
#[test]
#[serial]
fn test_transaction_begin_commit() {
let _ = env_logger::builder().is_test(true).try_init();
-
+
let thread_id = std::thread::current().id();
let config = DatabaseConfig {
name: format!("uniffi_tx_commit_{:?}.db", thread_id),
@@ -22,36 +21,58 @@ mod uniffi_transactions_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Setup table
execute(handle, "DROP TABLE IF EXISTS accounts".to_string()).ok();
- execute(handle, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO accounts (balance) VALUES (100.0)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO accounts (balance) VALUES (100.0)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Commit the implicit transaction from INSERT before starting explicit transaction
commit(handle).ok();
-
+
// Begin transaction
let begin_result = begin_transaction(handle);
- assert!(begin_result.is_ok(), "Begin transaction should succeed: {:?}", begin_result.err());
-
+ assert!(
+ begin_result.is_ok(),
+ "Begin transaction should succeed: {:?}",
+ begin_result.err()
+ );
+
// Update balance in transaction
- execute(handle, "UPDATE accounts SET balance = 200.0 WHERE id = 1".to_string())
- .expect("Failed to update");
-
+ execute(
+ handle,
+ "UPDATE accounts SET balance = 200.0 WHERE id = 1".to_string(),
+ )
+ .expect("Failed to update");
+
// Commit transaction
let commit_result = commit(handle);
- assert!(commit_result.is_ok(), "Commit should succeed: {:?}", commit_result.err());
-
+ assert!(
+ commit_result.is_ok(),
+ "Commit should succeed: {:?}",
+ commit_result.err()
+ );
+
// Verify changes persisted
- let result = execute(handle, "SELECT balance FROM accounts WHERE id = 1".to_string())
- .expect("Failed to query");
+ let result = execute(
+ handle,
+ "SELECT balance FROM accounts WHERE id = 1".to_string(),
+ )
+ .expect("Failed to query");
assert_eq!(result.rows.len(), 1);
-
+
close_database(handle).expect("Failed to close database");
}
@@ -67,36 +88,54 @@ mod uniffi_transactions_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Setup table
execute(handle, "DROP TABLE IF EXISTS accounts".to_string()).ok();
- execute(handle, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO accounts (balance) VALUES (100.0)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO accounts (balance) VALUES (100.0)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Commit the implicit transaction from INSERT before starting explicit transaction
commit(handle).ok();
-
+
// Begin transaction
begin_transaction(handle).expect("Failed to begin transaction");
-
+
// Update balance in transaction
- execute(handle, "UPDATE accounts SET balance = 999.0 WHERE id = 1".to_string())
- .expect("Failed to update");
-
+ execute(
+ handle,
+ "UPDATE accounts SET balance = 999.0 WHERE id = 1".to_string(),
+ )
+ .expect("Failed to update");
+
// Rollback transaction
let rollback_result = rollback(handle);
- assert!(rollback_result.is_ok(), "Rollback should succeed: {:?}", rollback_result.err());
-
+ assert!(
+ rollback_result.is_ok(),
+ "Rollback should succeed: {:?}",
+ rollback_result.err()
+ );
+
// Verify changes were rolled back
- let result = execute(handle, "SELECT balance FROM accounts WHERE id = 1".to_string())
- .expect("Failed to query");
+ let result = execute(
+ handle,
+ "SELECT balance FROM accounts WHERE id = 1".to_string(),
+ )
+ .expect("Failed to query");
assert_eq!(result.rows.len(), 1);
// Balance should still be 100.0, not 999.0
-
+
close_database(handle).expect("Failed to close database");
}
@@ -112,37 +151,54 @@ mod uniffi_transactions_tests {
journal_mode: None,
auto_vacuum: None,
};
-
- let handle = RUNTIME.block_on(async { create_database(config).await }).expect("Failed to create database");
-
+
+ let handle = RUNTIME
+ .block_on(async { create_database(config).await })
+ .expect("Failed to create database");
+
// Setup
execute(handle, "DROP TABLE IF EXISTS products".to_string()).ok();
- execute(handle, "CREATE TABLE products (id INTEGER PRIMARY KEY, stock INTEGER)".to_string())
- .expect("Failed to create table");
- execute(handle, "INSERT INTO products (stock) VALUES (50)".to_string())
- .expect("Failed to insert");
-
+ execute(
+ handle,
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, stock INTEGER)".to_string(),
+ )
+ .expect("Failed to create table");
+ execute(
+ handle,
+ "INSERT INTO products (stock) VALUES (50)".to_string(),
+ )
+ .expect("Failed to insert");
+
// Commit the implicit transaction from INSERT before starting explicit transaction
commit(handle).ok();
-
+
// Begin transaction
begin_transaction(handle).expect("Failed to begin transaction");
-
+
// Multiple operations in transaction
- execute(handle, "UPDATE products SET stock = stock - 10 WHERE id = 1".to_string())
- .expect("Failed to update");
- execute(handle, "UPDATE products SET stock = stock - 5 WHERE id = 1".to_string())
- .expect("Failed to update");
-
+ execute(
+ handle,
+ "UPDATE products SET stock = stock - 10 WHERE id = 1".to_string(),
+ )
+ .expect("Failed to update");
+ execute(
+ handle,
+ "UPDATE products SET stock = stock - 5 WHERE id = 1".to_string(),
+ )
+ .expect("Failed to update");
+
// Commit
commit(handle).expect("Failed to commit");
-
+
// Verify cumulative changes
- let result = execute(handle, "SELECT stock FROM products WHERE id = 1".to_string())
- .expect("Failed to query");
+ let result = execute(
+ handle,
+ "SELECT stock FROM products WHERE id = 1".to_string(),
+ )
+ .expect("Failed to query");
assert_eq!(result.rows.len(), 1);
// Stock should be 35 (50 - 10 - 5)
-
+
close_database(handle).expect("Failed to close database");
}
@@ -150,11 +206,14 @@ mod uniffi_transactions_tests {
#[serial]
fn test_transaction_invalid_handle() {
let result = begin_transaction(999999);
- assert!(result.is_err(), "Begin transaction with invalid handle should fail");
-
+ assert!(
+ result.is_err(),
+ "Begin transaction with invalid handle should fail"
+ );
+
let result = commit(999999);
assert!(result.is_err(), "Commit with invalid handle should fail");
-
+
let result = rollback(999999);
assert!(result.is_err(), "Rollback with invalid handle should fail");
}
diff --git a/absurder-sql-mobile/src/lib.rs b/absurder-sql-mobile/src/lib.rs
index 1d6ef9df..c629d2e6 100644
--- a/absurder-sql-mobile/src/lib.rs
+++ b/absurder-sql-mobile/src/lib.rs
@@ -86,11 +86,19 @@ mod uniffi_prepared_statements_test;
#[path = "__tests__/uniffi_streaming_test.rs"]
mod uniffi_streaming_test;
-#[cfg(all(test, feature = "uniffi-bindings", any(feature = "encryption", feature = "encryption-ios")))]
+#[cfg(all(
+ test,
+ feature = "uniffi-bindings",
+ any(feature = "encryption", feature = "encryption-ios")
+))]
#[path = "__tests__/uniffi_encryption_test.rs"]
mod uniffi_encryption_test;
-#[cfg(all(test, feature = "uniffi-bindings", any(feature = "encryption", feature = "encryption-ios")))]
+#[cfg(all(
+ test,
+ feature = "uniffi-bindings",
+ any(feature = "encryption", feature = "encryption-ios")
+))]
#[path = "__tests__/uniffi_encryption_blocking_test.rs"]
mod uniffi_encryption_blocking_test;
@@ -108,4 +116,4 @@ mod uniffi_prepared_statement_result_test;
#[cfg(all(test, feature = "uniffi-bindings"))]
#[path = "__tests__/uniffi_databaseconfig_test.rs"]
-mod uniffi_databaseconfig_test;
\ No newline at end of file
+mod uniffi_databaseconfig_test;
diff --git a/absurder-sql-mobile/src/registry.rs b/absurder-sql-mobile/src/registry.rs
index 712c90da..3479ec0d 100644
--- a/absurder-sql-mobile/src/registry.rs
+++ b/absurder-sql-mobile/src/registry.rs
@@ -8,12 +8,13 @@
//! - Tokio runtime for async operations
//! - Thread-local error handling
+use absurder_sql::SqliteIndexedDB;
+use once_cell::sync::Lazy;
+use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
-use parking_lot::Mutex;
-use once_cell::sync::Lazy;
-use absurder_sql::SqliteIndexedDB;
use tokio::runtime::Runtime;
+use tokio::sync::Mutex as AsyncMutex;
/// Wrapper for PreparedStatement that stores SQL for on-demand preparation
/// We store the SQL and database handle, then prepare fresh on each execute
@@ -35,58 +36,51 @@ pub struct StreamingStatement {
/// Global database registry
/// Maps handles (u64) to Arc> instances
-/// We need Mutex because SqliteIndexedDB::execute() requires &mut self
-pub static DB_REGISTRY: Lazy>>>>> = Lazy::new(|| {
- Arc::new(Mutex::new(HashMap::new()))
-});
+/// We need an async mutex because database operations await while holding exclusive access.
+pub type DatabaseHandle = Arc>;
+pub type DatabaseRegistry = HashMap;
+
+pub static DB_REGISTRY: Lazy>> =
+ Lazy::new(|| Arc::new(Mutex::new(HashMap::new())));
/// Global prepared statement registry
/// Maps statement handles (u64) to PreparedStatementWrapper instances
-pub static STMT_REGISTRY: Lazy>>> = Lazy::new(|| {
- Arc::new(Mutex::new(HashMap::new()))
-});
+pub static STMT_REGISTRY: Lazy>>> =
+ Lazy::new(|| Arc::new(Mutex::new(HashMap::new())));
/// Global streaming statement registry
/// Maps stream handles (u64) to StreamingStatement instances
-pub static STREAM_REGISTRY: Lazy>>> = Lazy::new(|| {
- Arc::new(Mutex::new(HashMap::new()))
-});
+pub static STREAM_REGISTRY: Lazy>>> =
+ Lazy::new(|| Arc::new(Mutex::new(HashMap::new())));
/// Counter for generating unique stream handles
-pub static STREAM_HANDLE_COUNTER: Lazy>> = Lazy::new(|| {
- Arc::new(Mutex::new(1))
-});
+pub static STREAM_HANDLE_COUNTER: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(1)));
/// Counter for generating unique database handles
-pub static HANDLE_COUNTER: Lazy>> = Lazy::new(|| {
- Arc::new(Mutex::new(1))
-});
+pub static HANDLE_COUNTER: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(1)));
/// Counter for generating unique statement handles
-pub static STMT_HANDLE_COUNTER: Lazy>> = Lazy::new(|| {
- Arc::new(Mutex::new(1))
-});
+pub static STMT_HANDLE_COUNTER: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(1)));
/// Global Tokio runtime for executing async database operations
-pub static RUNTIME: Lazy = Lazy::new(|| {
- Runtime::new().expect("Failed to create Tokio runtime")
-});
+pub static RUNTIME: Lazy =
+ Lazy::new(|| Runtime::new().expect("Failed to create Tokio runtime"));
/// Android data directory path (set during initialization)
/// On Android, this is typically "/data/data/{package}/files"
-pub static ANDROID_DATA_DIR: Lazy>> = Lazy::new(|| {
- Mutex::new(None)
-});
+pub static ANDROID_DATA_DIR: Lazy>> = Lazy::new(|| Mutex::new(None));
/// Set the Android data directory path
/// This function is called from JNI during app initialization
#[unsafe(no_mangle)]
-pub extern "C" fn absurdersql_set_android_data_directory(path: *const std::os::raw::c_char) -> bool {
+pub extern "C" fn absurdersql_set_android_data_directory(
+ path: *const std::os::raw::c_char,
+) -> bool {
if path.is_null() {
log::error!("absurdersql_set_android_data_directory: received null path");
return false;
}
-
+
let c_str = unsafe { std::ffi::CStr::from_ptr(path) };
match c_str.to_str() {
Ok(path_str) => {
@@ -96,9 +90,11 @@ pub extern "C" fn absurdersql_set_android_data_directory(path: *const std::os::r
true
}
Err(e) => {
- log::error!("Failed to convert Android data directory path to string: {}", e);
+ log::error!(
+ "Failed to convert Android data directory path to string: {}",
+ e
+ );
false
}
}
}
-
diff --git a/absurder-sql-mobile/src/uniffi_api/core.rs b/absurder-sql-mobile/src/uniffi_api/core.rs
index 4377a931..ebcf6f7a 100644
--- a/absurder-sql-mobile/src/uniffi_api/core.rs
+++ b/absurder-sql-mobile/src/uniffi_api/core.rs
@@ -1,18 +1,19 @@
/// UniFFI core database operations
-///
+///
/// These functions are automatically exported to TypeScript, Swift, and Kotlin
/// using the #[uniffi::export] macro.
-
-use super::types::{DatabaseConfig, DatabaseError, QueryResult, Row, ColumnValue};
-use crate::registry::{DB_REGISTRY, HANDLE_COUNTER, RUNTIME};
+use super::types::{ColumnValue, DatabaseConfig, DatabaseError, QueryResult, Row};
#[cfg(target_os = "android")]
use crate::registry::ANDROID_DATA_DIR;
-use absurder_sql::{SqliteIndexedDB, DatabaseConfig as CoreDatabaseConfig, ColumnValue as CoreColumnValue};
-use std::sync::Arc;
+use crate::registry::{DB_REGISTRY, HANDLE_COUNTER, RUNTIME};
+use absurder_sql::{
+ ColumnValue as CoreColumnValue, DatabaseConfig as CoreDatabaseConfig, SqliteIndexedDB,
+};
use std::path::Path;
#[cfg(any(target_os = "android", target_os = "ios"))]
use std::path::PathBuf;
-use parking_lot::Mutex;
+use std::sync::Arc;
+use tokio::sync::Mutex as AsyncMutex;
/// Convert a core ColumnValue to UniFFI ColumnValue
fn convert_column_value(cv: &CoreColumnValue) -> ColumnValue {
@@ -34,8 +35,12 @@ fn convert_row(core_row: &absurder_sql::Row) -> Row {
}
}
+fn quote_sqlite_identifier(identifier: &str) -> String {
+ format!("\"{}\"", identifier.replace('"', "\"\""))
+}
+
/// Resolve database path to an absolute path appropriate for the platform
-///
+///
/// - Android: Resolves relative paths to /data/data/{package}/files/databases/
/// - iOS: Resolves relative paths to ~/Documents/
/// - Other: Returns path as-is (may be relative)
@@ -44,7 +49,7 @@ pub fn resolve_db_path(path: &str) -> String {
if path.starts_with('/') {
return path.to_string();
}
-
+
// Platform-specific resolution for relative paths
#[cfg(target_os = "android")]
{
@@ -54,19 +59,22 @@ pub fn resolve_db_path(path: &str) -> String {
let full_path = databases_dir.join(path);
return full_path.to_string_lossy().to_string();
} else {
- log::warn!("Android data directory not set! Relative path will not be resolved: {}", path);
+ log::warn!(
+ "Android data directory not set! Relative path will not be resolved: {}",
+ path
+ );
log::warn!("Call AbsurderSqlInitializer.initialize() before creating databases");
return path.to_string();
}
}
-
+
#[cfg(target_os = "ios")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let docs = PathBuf::from(home).join("Documents").join(path);
return docs.to_string_lossy().to_string();
}
-
+
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
// Desktop platforms: keep relative paths as-is
@@ -75,24 +83,24 @@ pub fn resolve_db_path(path: &str) -> String {
}
/// Create a new database and return a handle
-///
+///
/// This function creates a database with the given configuration.
/// Returns a unique handle that can be used for subsequent operations.
-///
+///
/// # Arguments
/// * `config` - Database configuration including name and optional encryption
-///
+///
/// # Returns
/// * `u64` - Database handle (0 indicates error)
#[uniffi::export(async_runtime = "tokio")]
pub async fn create_database(config: DatabaseConfig) -> Result {
log::info!("UniFFI: Creating database: {}", config.name);
-
+
// Resolve path using platform-specific logic
let resolved_path = resolve_db_path(&config.name);
-
+
log::info!("UniFFI: Resolved database path: {}", resolved_path);
-
+
// Ensure parent directory exists (especially for Android databases directory)
if let Some(parent) = Path::new(&resolved_path).parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
@@ -102,7 +110,7 @@ pub async fn create_database(config: DatabaseConfig) -> Result Result {
// Generate handle - HANDLE_COUNTER is now Arc>
@@ -123,10 +131,12 @@ pub async fn create_database(config: DatabaseConfig) -> Result Result Result {
log::info!("UniFFI: Executing SQL on handle {}: {}", handle, sql);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Execute query using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&sql).await
});
-
+
match result {
Ok(query_result) => {
// Convert rows to typed Row structs
- let rows: Vec = query_result.rows.iter()
- .map(convert_row)
- .collect();
+ let rows: Vec = query_result.rows.iter().map(convert_row).collect();
Ok(QueryResult {
columns: query_result.columns,
@@ -188,13 +197,13 @@ pub fn execute(handle: u64, sql: String) -> Result {
}
/// Close a database
-///
+///
/// # Arguments
/// * `handle` - Database handle to close
#[uniffi::export]
pub fn close_database(handle: u64) -> Result<(), DatabaseError> {
log::info!("UniFFI: Closing database handle: {}", handle);
-
+
let mut registry = DB_REGISTRY.lock();
if registry.remove(&handle).is_some() {
Ok(())
@@ -204,46 +213,53 @@ pub fn close_database(handle: u64) -> Result<(), DatabaseError> {
}
/// Execute SQL query with parameters on a database
-///
+///
/// This function provides parameterized query execution to prevent SQL injection.
/// Parameters are passed as a vector of strings and bound to ? placeholders in the SQL.
-///
+///
/// # Arguments
/// * `handle` - Database handle
/// * `sql` - SQL query with ? placeholders for parameters
/// * `params` - Vector of parameter values as strings
-///
+///
/// # Returns
/// * `QueryResult` - Query results with columns and rows
#[uniffi::export]
-pub fn execute_with_params(handle: u64, sql: String, params: Vec) -> Result {
- log::info!("UniFFI: Executing SQL with {} params on handle {}: {}", params.len(), handle, sql);
-
+pub fn execute_with_params(
+ handle: u64,
+ sql: String,
+ params: Vec,
+) -> Result {
+ log::info!(
+ "UniFFI: Executing SQL with {} params on handle {}: {}",
+ params.len(),
+ handle,
+ sql
+ );
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Convert string params to core ColumnValue
- let column_params: Vec = params.into_iter()
- .map(|s| CoreColumnValue::Text(s))
- .collect();
-
+ let column_params: Vec =
+ params.into_iter().map(CoreColumnValue::Text).collect();
+
// Execute parameterized query using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute_with_params(&sql, &column_params).await
});
-
+
match result {
Ok(query_result) => {
// Convert rows to typed Row structs
- let rows: Vec = query_result.rows.iter()
- .map(convert_row)
- .collect();
+ let rows: Vec = query_result.rows.iter().map(convert_row).collect();
Ok(QueryResult {
columns: query_result.columns,
@@ -263,33 +279,34 @@ pub fn execute_with_params(handle: u64, sql: String, params: Vec) -> Res
}
/// Begin a database transaction
-///
+///
/// Starts a new transaction. All subsequent operations will be part of this transaction
/// until commit() or rollback() is called.
-///
+///
/// # Arguments
/// * `handle` - Database handle
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if transaction started successfully
#[uniffi::export]
pub fn begin_transaction(handle: u64) -> Result<(), DatabaseError> {
log::info!("UniFFI: Beginning transaction on handle {}", handle);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Begin transaction using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute("BEGIN TRANSACTION").await
});
-
+
match result {
Ok(_) => {
log::info!("UniFFI: Transaction begun on handle {}", handle);
@@ -305,32 +322,33 @@ pub fn begin_transaction(handle: u64) -> Result<(), DatabaseError> {
}
/// Commit the current database transaction
-///
+///
/// Commits all changes made within the current transaction to the database.
-///
+///
/// # Arguments
/// * `handle` - Database handle
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if transaction committed successfully
#[uniffi::export]
pub fn commit(handle: u64) -> Result<(), DatabaseError> {
log::info!("UniFFI: Committing transaction on handle {}", handle);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Commit transaction using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute("COMMIT").await
});
-
+
match result {
Ok(_) => {
log::info!("UniFFI: Transaction committed on handle {}", handle);
@@ -346,32 +364,33 @@ pub fn commit(handle: u64) -> Result<(), DatabaseError> {
}
/// Rollback the current database transaction
-///
+///
/// Discards all changes made within the current transaction.
-///
+///
/// # Arguments
/// * `handle` - Database handle
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if transaction rolled back successfully
#[uniffi::export]
pub fn rollback(handle: u64) -> Result<(), DatabaseError> {
log::info!("UniFFI: Rolling back transaction on handle {}", handle);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Rollback transaction using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute("ROLLBACK").await
});
-
+
match result {
Ok(_) => {
log::info!("UniFFI: Transaction rolled back on handle {}", handle);
@@ -387,33 +406,38 @@ pub fn rollback(handle: u64) -> Result<(), DatabaseError> {
}
/// Export database to file using VACUUM INTO (async, non-blocking)
-///
+///
/// Creates a backup of the database at the specified path.
/// This is an async function that won't block the calling thread.
-///
+///
/// # Arguments
/// * `handle` - Database handle
/// * `path` - File path where the backup will be created
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if export succeeded
#[uniffi::export(async_runtime = "tokio")]
pub async fn export_database_async(handle: u64, path: String) -> Result<(), DatabaseError> {
- log::info!("UniFFI: Async exporting database handle {} to {}", handle, path);
-
+ log::info!(
+ "UniFFI: Async exporting database handle {} to {}",
+ handle,
+ path
+ );
+
// Resolve path using platform-specific logic
let resolved_path = resolve_db_path(&path);
-
+
log::info!("UniFFI: Resolved export path to: {}", resolved_path);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Delete export file if it exists (VACUUM INTO fails if file exists)
if let Ok(canonical_path) = std::path::Path::new(&resolved_path).canonicalize() {
let _ = std::fs::remove_file(canonical_path);
@@ -421,52 +445,56 @@ pub async fn export_database_async(handle: u64, path: String) -> Result<(), Data
// If canonicalize fails, try to delete anyway
let _ = std::fs::remove_file(&resolved_path);
}
-
+
// Escape single quotes in path for SQL
let escaped_path = resolved_path.replace("'", "''");
let export_sql = format!("VACUUM INTO '{}'", escaped_path);
-
+
// Execute export asynchronously
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&export_sql).await.map_err(|e| {
log::error!("UniFFI: Failed to export database: {}", e);
DatabaseError::SqlError {
message: e.to_string(),
}
})?;
-
- log::info!("UniFFI: Database exported successfully to {}", resolved_path);
+
+ log::info!(
+ "UniFFI: Database exported successfully to {}",
+ resolved_path
+ );
Ok(())
}
/// Export database to file using VACUUM INTO (sync, blocking)
-///
+///
/// Creates a backup of the database at the specified path.
/// This is a synchronous function - use export_database_async for non-blocking operation.
-///
+///
/// # Arguments
/// * `handle` - Database handle
/// * `path` - File path where the backup will be created
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if export succeeded
#[uniffi::export]
pub fn export_database(handle: u64, path: String) -> Result<(), DatabaseError> {
log::info!("UniFFI: Exporting database handle {} to {}", handle, path);
-
+
// Resolve path using platform-specific logic
let resolved_path = resolve_db_path(&path);
-
+
log::info!("UniFFI: Resolved export path to: {}", resolved_path);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Delete export file if it exists (VACUUM INTO fails if file exists)
if let Ok(canonical_path) = std::path::Path::new(&resolved_path).canonicalize() {
let _ = std::fs::remove_file(canonical_path);
@@ -474,20 +502,23 @@ pub fn export_database(handle: u64, path: String) -> Result<(), DatabaseError> {
// If canonicalize fails, try to delete anyway
let _ = std::fs::remove_file(&resolved_path);
}
-
+
// Escape single quotes in path for SQL
let escaped_path = resolved_path.replace("'", "''");
let export_sql = format!("VACUUM INTO '{}'", escaped_path);
-
+
// Execute export using async runtime
let result = RUNTIME.block_on(async move {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&export_sql).await
});
-
+
match result {
Ok(_) => {
- log::info!("UniFFI: Database exported successfully to {}", resolved_path);
+ log::info!(
+ "UniFFI: Database exported successfully to {}",
+ resolved_path
+ );
Ok(())
}
Err(e) => {
@@ -500,68 +531,79 @@ pub fn export_database(handle: u64, path: String) -> Result<(), DatabaseError> {
}
/// Import database from file
-///
+///
/// Restores a database from a backup file created by export_database.
/// This will copy all tables and data from the backup into the current database.
-///
+///
/// For encrypted databases, uses ATTACH DATABASE which inherits the encryption key
/// from the main connection, allowing import of encrypted backup files.
-///
+///
/// # Arguments
/// * `handle` - Database handle
/// * `path` - File path of the backup to import
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if import succeeded
#[uniffi::export]
pub fn import_database(handle: u64, path: String) -> Result<(), DatabaseError> {
- log::info!("UniFFI: Importing database from {} to handle {}", path, handle);
-
+ log::info!(
+ "UniFFI: Importing database from {} to handle {}",
+ path,
+ handle
+ );
+
// Resolve path using platform-specific logic
let resolved_path = resolve_db_path(&path);
log::info!("UniFFI: Resolved import path to: {}", resolved_path);
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Verify the import file exists
if !std::path::Path::new(&resolved_path).exists() {
let error_msg = format!("Import file does not exist: {}", resolved_path);
log::error!("UniFFI: {}", error_msg);
- return Err(DatabaseError::SqlError {
- message: error_msg,
- });
+ return Err(DatabaseError::SqlError { message: error_msg });
}
-
+
// Execute import using async runtime
// Use ATTACH DATABASE to open the backup file with the same encryption key
let result = RUNTIME.block_on(async {
- let mut dest_guard = db_arc.lock();
-
+ let mut dest_guard = db_arc.lock().await;
+
// Escape path for SQL
let escaped_path = resolved_path.replace('\'', "''");
-
+
// Attach the backup database - this uses the same encryption key as main db
let attach_sql = format!("ATTACH DATABASE '{}' AS import_db", escaped_path);
dest_guard.execute(&attach_sql).await?;
log::info!("UniFFI: Attached import database");
-
+
// Get list of tables from the attached database
- let tables_result = dest_guard.execute(
- "SELECT name FROM import_db.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
- ).await?;
-
- log::info!("UniFFI: Tables query returned {} rows, columns: {:?}", tables_result.rows.len(), tables_result.columns);
+ let tables_result = dest_guard
+ .execute(
+ "SELECT name FROM import_db.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
+ )
+ .await?;
+
+ log::info!(
+ "UniFFI: Tables query returned {} rows, columns: {:?}",
+ tables_result.rows.len(),
+ tables_result.columns
+ );
for (i, row) in tables_result.rows.iter().enumerate() {
log::info!("UniFFI: Row {}: {:?}", i, row.values);
}
-
- let table_names: Vec = tables_result.rows.iter()
+
+ let table_names: Vec = tables_result
+ .rows
+ .iter()
.filter_map(|row| {
if let Some(absurder_sql::ColumnValue::Text(value)) = row.values.first() {
Some(value.clone())
@@ -570,17 +612,26 @@ pub fn import_database(handle: u64, path: String) -> Result<(), DatabaseError> {
}
})
.collect();
-
- log::info!("UniFFI: Found {} tables to import: {:?}", table_names.len(), table_names);
-
+
+ log::info!(
+ "UniFFI: Found {} tables to import: {:?}",
+ table_names.len(),
+ table_names
+ );
+
for table_name in &table_names {
log::info!("UniFFI: Importing table: {}", table_name);
-
+ let table_name_literal = table_name.replace('\'', "''");
+ let quoted_table_name = quote_sqlite_identifier(table_name);
+
// Get CREATE TABLE statement from import_db
- let schema_result = dest_guard.execute(
- &format!("SELECT sql FROM import_db.sqlite_master WHERE type='table' AND name='{}'", table_name)
- ).await?;
-
+ let schema_result = dest_guard
+ .execute(&format!(
+ "SELECT sql FROM import_db.sqlite_master WHERE type='table' AND name='{}'",
+ table_name_literal
+ ))
+ .await?;
+
let create_sql = if let Some(row) = schema_result.rows.first() {
if let Some(absurder_sql::ColumnValue::Text(value)) = row.values.first() {
value.clone()
@@ -590,30 +641,39 @@ pub fn import_database(handle: u64, path: String) -> Result<(), DatabaseError> {
} else {
continue;
};
-
+
// Drop existing table and recreate
- let _ = dest_guard.execute(&format!("DROP TABLE IF EXISTS {}", table_name)).await;
+ dest_guard
+ .execute(&format!("DROP TABLE IF EXISTS main.{}", quoted_table_name))
+ .await?;
dest_guard.execute(&create_sql).await?;
-
+
// Copy data from import_db to main db
let insert_sql = format!(
"INSERT INTO main.{} SELECT * FROM import_db.{}",
- table_name, table_name
+ quoted_table_name, quoted_table_name
);
let insert_result = dest_guard.execute(&insert_sql).await?;
- log::info!("UniFFI: Imported {} rows into table {}", insert_result.affected_rows, table_name);
+ log::info!(
+ "UniFFI: Imported {} rows into table {}",
+ insert_result.affected_rows,
+ table_name
+ );
}
-
+
// Detach the import database
dest_guard.execute("DETACH DATABASE import_db").await?;
log::info!("UniFFI: Detached import database");
-
+
Ok::<(), absurder_sql::DatabaseError>(())
});
-
+
match result {
Ok(_) => {
- log::info!("UniFFI: Database imported successfully from {}", resolved_path);
+ log::info!(
+ "UniFFI: Database imported successfully from {}",
+ resolved_path
+ );
Ok(())
}
Err(e) => {
@@ -626,34 +686,39 @@ pub fn import_database(handle: u64, path: String) -> Result<(), DatabaseError> {
}
/// Execute a batch of SQL statements in a transaction
-///
+///
/// Executes multiple SQL statements atomically. If any statement fails,
/// the entire batch is rolled back.
-///
+///
/// # Arguments
/// * `handle` - Database handle
/// * `statements` - Vector of SQL statements to execute
-///
+///
/// # Returns
/// * `Result<(), DatabaseError>` - Ok if all statements executed successfully
#[uniffi::export]
pub fn execute_batch(handle: u64, statements: Vec) -> Result<(), DatabaseError> {
- log::info!("UniFFI: Executing batch of {} statements on handle {}", statements.len(), handle);
-
+ log::info!(
+ "UniFFI: Executing batch of {} statements on handle {}",
+ statements.len(),
+ handle
+ );
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Execute batch using async runtime
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute_batch(&statements).await
});
-
+
match result {
Ok(_) => {
log::info!("UniFFI: Batch executed successfully on handle {}", handle);
@@ -669,33 +734,38 @@ pub fn execute_batch(handle: u64, statements: Vec) -> Result<(), Databas
}
/// Prepare a SQL statement for repeated execution
-///
+///
/// Creates a prepared statement that can be executed multiple times with different parameters.
/// This is more efficient than calling execute_with_params() repeatedly for the same SQL.
-///
+///
/// # Arguments
/// * `db_handle` - Database handle
/// * `sql` - SQL statement with ? placeholders for parameters
-///
+///
/// # Returns
/// * `Result` - Statement handle on success
#[uniffi::export]
pub fn prepare_statement(db_handle: u64, sql: String) -> Result {
- use crate::registry::{STMT_REGISTRY, STMT_HANDLE_COUNTER, PreparedStatementWrapper};
-
- log::info!("UniFFI: Preparing statement for db handle {}: {}", db_handle, sql);
-
+ use crate::registry::{PreparedStatementWrapper, STMT_HANDLE_COUNTER, STMT_REGISTRY};
+
+ log::info!(
+ "UniFFI: Preparing statement for db handle {}: {}",
+ db_handle,
+ sql
+ );
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&db_handle)
+ registry
+ .get(&db_handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Validate SQL by attempting to prepare it
{
- let mut db = db_arc.lock();
+ let mut db = RUNTIME.block_on(db_arc.lock());
match db.prepare(&sql) {
Ok(stmt) => {
// SQL is valid, finalize the test statement
@@ -709,7 +779,7 @@ pub fn prepare_statement(db_handle: u64, sql: String) -> Result Result Result` - Query results including columns, rows,
/// rows_affected, last_insert_id, and execution_time_ms
#[uniffi::export]
-pub fn execute_statement(stmt_handle: u64, params: Vec) -> Result {
+pub fn execute_statement(
+ stmt_handle: u64,
+ params: Vec,
+) -> Result {
use crate::registry::STMT_REGISTRY;
-
- log::info!("UniFFI: Executing statement {} with {} params", stmt_handle, params.len());
-
+
+ log::info!(
+ "UniFFI: Executing statement {} with {} params",
+ stmt_handle,
+ params.len()
+ );
+
// Get statement info from registry
let (db_handle, sql) = {
let stmt_registry = STMT_REGISTRY.lock();
@@ -762,34 +843,34 @@ pub fn execute_statement(stmt_handle: u64, params: Vec) -> Result = params.into_iter()
- .map(|s| absurder_sql::ColumnValue::Text(s))
+ let column_params: Vec = params
+ .into_iter()
+ .map(absurder_sql::ColumnValue::Text)
.collect();
-
+
// Execute with params
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute_with_params(&sql, &column_params).await
});
-
+
match result {
Ok(query_result) => {
log::info!("UniFFI: Statement {} executed successfully", stmt_handle);
// Convert rows to typed Row structs
- let rows: Vec = query_result.rows.iter()
- .map(convert_row)
- .collect();
+ let rows: Vec = query_result.rows.iter().map(convert_row).collect();
Ok(QueryResult {
columns: query_result.columns,
@@ -809,21 +890,21 @@ pub fn execute_statement(stmt_handle: u64, params: Vec) -> Result` - Ok if finalization succeeded
#[uniffi::export]
pub fn finalize_statement(stmt_handle: u64) -> Result<(), DatabaseError> {
use crate::registry::STMT_REGISTRY;
-
+
log::info!("UniFFI: Finalizing statement {}", stmt_handle);
-
+
let mut stmt_registry = STMT_REGISTRY.lock();
match stmt_registry.remove(&stmt_handle) {
Some(_) => {
@@ -840,39 +921,44 @@ pub fn finalize_statement(stmt_handle: u64) -> Result<(), DatabaseError> {
}
/// Prepare a streaming statement for cursor-based iteration
-///
+///
/// Creates a streaming statement that can fetch results in batches,
/// avoiding memory issues with large result sets.
-///
+///
/// # Arguments
/// * `db_handle` - Database handle
/// * `sql` - SQL SELECT statement
-///
+///
/// # Returns
/// * `Result` - Stream handle on success
#[uniffi::export]
pub fn prepare_stream(db_handle: u64, sql: String) -> Result {
- use crate::registry::{STREAM_REGISTRY, STREAM_HANDLE_COUNTER, StreamingStatement};
-
- log::info!("UniFFI: Preparing stream for db handle {}: {}", db_handle, sql);
-
+ use crate::registry::{STREAM_HANDLE_COUNTER, STREAM_REGISTRY, StreamingStatement};
+
+ log::info!(
+ "UniFFI: Preparing stream for db handle {}: {}",
+ db_handle,
+ sql
+ );
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&db_handle)
+ registry
+ .get(&db_handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Validate SQL by attempting to execute with LIMIT 0
// This ensures the SQL is valid before we store it
{
let validation_sql = format!("{} LIMIT 0", sql);
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&validation_sql).await
});
-
+
if let Err(e) = result {
log::error!("UniFFI: Invalid SQL for stream: {}", e);
return Err(DatabaseError::SqlError {
@@ -880,7 +966,7 @@ pub fn prepare_stream(db_handle: u64, sql: String) -> Result
});
}
}
-
+
// Generate unique stream handle
let stream_handle = {
let mut counter = STREAM_HANDLE_COUNTER.lock();
@@ -888,47 +974,55 @@ pub fn prepare_stream(db_handle: u64, sql: String) -> Result
*counter += 1;
handle
};
-
+
// Create streaming statement
let stream = StreamingStatement {
db_handle,
sql: sql.clone(),
last_rowid: 0, // Assumes rowids start >= 1 (SQLite default)
};
-
+
// Register stream
let mut stream_registry = STREAM_REGISTRY.lock();
stream_registry.insert(stream_handle, stream);
-
- log::info!("UniFFI: Created stream handle {} for SQL: {}", stream_handle, sql);
+
+ log::info!(
+ "UniFFI: Created stream handle {} for SQL: {}",
+ stream_handle,
+ sql
+ );
Ok(stream_handle)
}
/// Fetch next batch of rows from streaming statement
-///
+///
/// Fetches the next batch of rows from the stream using cursor-based pagination (WHERE rowid > last_rowid).
/// This provides O(n) complexity instead of O(n²) OFFSET pagination.
/// Returns an empty result when no more rows are available.
-///
+///
/// # Arguments
/// * `stream_handle` - Stream handle from prepare_stream()
/// * `batch_size` - Number of rows to fetch (must be > 0)
-///
+///
/// # Returns
/// * `Result` - Batch of rows
#[uniffi::export]
pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result {
use crate::registry::STREAM_REGISTRY;
-
- log::info!("UniFFI: Fetching next {} rows from stream {}", batch_size, stream_handle);
-
+
+ log::info!(
+ "UniFFI: Fetching next {} rows from stream {}",
+ batch_size,
+ stream_handle
+ );
+
if batch_size <= 0 {
log::error!("UniFFI: Invalid batch size: {}", batch_size);
return Err(DatabaseError::SqlError {
message: format!("Batch size must be > 0, got {}", batch_size),
});
}
-
+
// Get stream from registry
let (db_handle, sql, last_rowid) = {
let stream_registry = STREAM_REGISTRY.lock();
@@ -937,7 +1031,7 @@ pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result {
@@ -948,15 +1042,16 @@ pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result Result {} LIMIT {}", with_rowid, last_rowid, batch_size)
+ format!(
+ "{} AND rowid > {} LIMIT {}",
+ with_rowid, last_rowid, batch_size
+ )
} else if with_rowid.to_uppercase().contains(" ORDER BY") {
let parts: Vec<&str> = with_rowid.splitn(2, " ORDER BY ").collect();
- format!("{} WHERE rowid > {} ORDER BY {} LIMIT {}", parts[0], last_rowid, parts[1], batch_size)
+ format!(
+ "{} WHERE rowid > {} ORDER BY {} LIMIT {}",
+ parts[0], last_rowid, parts[1], batch_size
+ )
} else {
- format!("{} WHERE rowid > {} LIMIT {}", with_rowid, last_rowid, batch_size)
+ format!(
+ "{} WHERE rowid > {} LIMIT {}",
+ with_rowid, last_rowid, batch_size
+ )
}
};
-
+
// Execute query
let result = RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&paginated_sql).await
});
-
+
match result {
Ok(query_result) => {
- log::info!("UniFFI: Fetched {} rows from stream {}", query_result.rows.len(), stream_handle);
-
+ log::info!(
+ "UniFFI: Fetched {} rows from stream {}",
+ query_result.rows.len(),
+ stream_handle
+ );
+
// Update last_rowid by extracting max _rowid from results
if !query_result.rows.is_empty() {
// Find _rowid column index
@@ -1026,11 +1134,9 @@ pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result = query_result.rows.iter()
- .map(convert_row)
- .collect();
+ let rows: Vec = query_result.rows.iter().map(convert_row).collect();
// Convert to UniFFI QueryResult
let uniffi_result = QueryResult {
@@ -1044,7 +1150,11 @@ pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result {
- log::error!("UniFFI: Failed to fetch from stream {}: {}", stream_handle, e);
+ log::error!(
+ "UniFFI: Failed to fetch from stream {}: {}",
+ stream_handle,
+ e
+ );
Err(DatabaseError::SqlError {
message: e.to_string(),
})
@@ -1053,21 +1163,21 @@ pub fn fetch_next(stream_handle: u64, batch_size: i32) -> Result` - Ok if close succeeded
#[uniffi::export]
pub fn close_stream(stream_handle: u64) -> Result<(), DatabaseError> {
use crate::registry::STREAM_REGISTRY;
-
+
log::info!("UniFFI: Closing stream {}", stream_handle);
-
+
let mut stream_registry = STREAM_REGISTRY.lock();
match stream_registry.remove(&stream_handle) {
Some(_) => {
@@ -1084,26 +1194,28 @@ pub fn close_stream(stream_handle: u64) -> Result<(), DatabaseError> {
}
/// Create an encrypted database with SQLCipher
-///
+///
/// Creates a new encrypted database using AES-256 encryption.
/// The encryption key must be at least 8 characters long.
-///
+///
/// # Arguments
/// * `config` - Database configuration with name and encryption_key
-///
+///
/// # Returns
/// * `Result` - Database handle on success
#[cfg(any(feature = "encryption", feature = "encryption-ios"))]
#[uniffi::export(async_runtime = "tokio")]
pub async fn create_encrypted_database(config: DatabaseConfig) -> Result {
log::info!("UniFFI: Creating encrypted database: {}", config.name);
-
+
// Validate encryption key is provided
- let key = config.encryption_key.as_ref()
+ let key = config
+ .encryption_key
+ .as_ref()
.ok_or_else(|| DatabaseError::InvalidParameter {
message: "Encryption key is required for encrypted database".to_string(),
})?;
-
+
// Validate key length (minimum 8 characters)
if key.len() < 8 {
log::error!("UniFFI: Encryption key too short: {} characters", key.len());
@@ -1111,12 +1223,15 @@ pub async fn create_encrypted_database(config: DatabaseConfig) -> Result Result {
// Generate handle
@@ -1143,10 +1258,12 @@ pub async fn create_encrypted_database(config: DatabaseConfig) -> Result Result` - Ok if rekey succeeded
#[cfg(any(feature = "encryption", feature = "encryption-ios"))]
#[uniffi::export]
pub fn rekey_database(handle: u64, new_key: String) -> Result<(), DatabaseError> {
log::info!("UniFFI: Rekeying database handle {}", handle);
-
+
// Validate key length (minimum 8 characters)
if new_key.len() < 8 {
- log::error!("UniFFI: New encryption key too short: {} characters", new_key.len());
+ log::error!(
+ "UniFFI: New encryption key too short: {} characters",
+ new_key.len()
+ );
return Err(DatabaseError::InvalidParameter {
message: "New encryption key must be at least 8 characters long".to_string(),
});
}
-
+
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::DatabaseClosed)?
.clone()
};
-
+
// Rekey the database
let result = RUNTIME.block_on(async {
- let db = db_arc.lock();
+ let db = db_arc.lock().await;
db.rekey(&new_key).await
});
-
+
match result {
Ok(()) => {
log::info!("UniFFI: Successfully rekeyed database handle {}", handle);
@@ -1208,7 +1329,7 @@ pub fn rekey_database(handle: u64, new_key: String) -> Result<(), DatabaseError>
}
/// Get the UniFFI version being used
-///
+///
/// This is a simple test function to verify UniFFI is working
#[uniffi::export]
pub fn get_uniffi_version() -> String {
@@ -1216,28 +1337,24 @@ pub fn get_uniffi_version() -> String {
}
/// Create an index on a table for improved query performance
-///
+///
/// # Arguments
/// * `handle` - Database handle from create_database()
/// * `table` - Table name
/// * `columns` - Comma-separated column names (e.g., "email" or "user_id,product_id")
-///
+///
/// # Returns
/// * Ok(()) on success
/// * Err(DatabaseError) on failure
-///
+///
/// # Index Naming
/// Automatically generates index name as `idx_{table}_{columns}` where columns are joined with underscores
-///
+///
/// # Examples
/// - Single column: `create_index(handle, "users", "email")` creates `idx_users_email`
/// - Multiple columns: `create_index(handle, "orders", "user_id,product_id")` creates `idx_orders_user_id_product_id`
#[uniffi::export]
-pub fn create_index(
- handle: u64,
- table: String,
- columns: String,
-) -> Result<(), DatabaseError> {
+pub fn create_index(handle: u64, table: String, columns: String) -> Result<(), DatabaseError> {
// Validate inputs
if table.is_empty() {
return Err(DatabaseError::InvalidParameter {
@@ -1254,7 +1371,8 @@ pub fn create_index(
// Get database from registry
let db_arc = {
let registry = DB_REGISTRY.lock();
- registry.get(&handle)
+ registry
+ .get(&handle)
.ok_or(DatabaseError::NotFound {
message: format!("Database handle {} not found", handle),
})?
@@ -1275,7 +1393,7 @@ pub fn create_index(
// Execute CREATE INDEX
RUNTIME.block_on(async {
- let mut db = db_arc.lock();
+ let mut db = db_arc.lock().await;
db.execute(&sql).await
})?;
diff --git a/absurder-sql-mobile/src/uniffi_api/mod.rs b/absurder-sql-mobile/src/uniffi_api/mod.rs
index b2ee3eb0..b2ac0f52 100644
--- a/absurder-sql-mobile/src/uniffi_api/mod.rs
+++ b/absurder-sql-mobile/src/uniffi_api/mod.rs
@@ -1,11 +1,10 @@
-/// UniFFI API module
-///
-/// This module uses UniFFI 0.29+ proc-macros to auto-generate bindings
-/// for React Native (iOS/Android) and WASM.
-///
-/// This will coexist with the legacy FFI during migration, controlled
-/// by the "uniffi-bindings" feature flag.
-
+//! UniFFI API module
+//!
+//! This module uses UniFFI 0.29+ proc-macros to auto-generate bindings
+//! for React Native (iOS/Android) and WASM.
+//!
+//! This will coexist with the legacy FFI during migration, controlled
+//! by the "uniffi-bindings" feature flag.
#[cfg(feature = "uniffi-bindings")]
pub mod core;
diff --git a/absurder-sql-mobile/src/uniffi_api/types.rs b/absurder-sql-mobile/src/uniffi_api/types.rs
index b21bd621..a627c1fb 100644
--- a/absurder-sql-mobile/src/uniffi_api/types.rs
+++ b/absurder-sql-mobile/src/uniffi_api/types.rs
@@ -2,7 +2,6 @@
///
/// These types are automatically bridged to TypeScript, Swift, and Kotlin
/// by UniFFI's code generation.
-
use serde::{Deserialize, Serialize};
/// Column value types matching SQLite's type system
@@ -58,16 +57,16 @@ pub struct DatabaseConfig {
pub enum DatabaseError {
#[error("Database not found: {message}")]
NotFound { message: String },
-
+
#[error("SQL error: {message}")]
SqlError { message: String },
-
+
#[error("IO error: {message}")]
IoError { message: String },
-
+
#[error("Invalid parameter: {message}")]
InvalidParameter { message: String },
-
+
#[error("Database is closed")]
DatabaseClosed,
}
diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md
index e439c357..9e7b12ef 100644
--- a/docs/BENCHMARK.md
+++ b/docs/BENCHMARK.md
@@ -1,18 +1,60 @@
-# SQLite IndexedDB Performance Benchmark
+# SQLite Storage Backend Benchmark
Compare the performance of different SQLite-in-browser implementations.
## Implementations Compared
-1. **AbsurderSQL** (This library) - Rust/WASM SQLite with custom IndexedDB VFS backend
-2. **absurd-sql** - James Long's JavaScript SQLite implementation
-3. **Raw IndexedDB** - Direct IndexedDB API usage (baseline)
+1. **AbsurderSQL IndexedDB** - Rust/WASM SQLite with an explicit IndexedDB backend on the main thread
+2. **AbsurderSQL Hybrid** - Rust/WASM SQLite with an explicit Hybrid backend in a Worker, using OPFS for blocks and IndexedDB for metadata
+3. **absurd-sql** - James Long's JavaScript SQLite implementation
+4. **Raw IndexedDB** - Direct IndexedDB API usage (baseline)
-## Latest Results
+## Latest Local Run
+
+### Durable Backend Sweep (2026-07-25)
+
+Captured with system Chrome `150.0.7871.184` on Windows ARM64 using `BENCHMARK_SWEEP=1`, a 100-byte row payload, batches of up to 100 rows, and explicit `sync()` after each write phase:
+
+| Rows | Backend | Insert | Read | Update | Delete |
+|------|---------|--------|------|--------|--------|
+| 100 | IndexedDB | 30.5ms | 4.3ms | 12.2ms | 12.6ms |
+| 100 | Hybrid | 37.1ms | 3.4ms | 32.7ms | 23.8ms |
+| 1,000 | IndexedDB | 89.6ms | 12.2ms | 30.6ms | 21.9ms |
+| 1,000 | Hybrid | 152.7ms | 8.3ms | 67.2ms | 55.9ms |
+| 10,000 | IndexedDB | 711.1ms | 47.0ms | 225.9ms | 122.3ms |
+| 10,000 | Hybrid | 1.07s | 47.9ms | 432.4ms | 378.7ms |
+
+This run validates both durable paths at the planned workload sizes; it is not a universal performance claim. Hybrid used OPFS for blocks and IndexedDB for metadata in a dedicated worker.
+
+### Four-Way Comparison (2026-04-07)
+
+Captured on 2026-04-07 from the benchmark page using Playwright on this machine with:
+- `1000` rows
+- `100` batch size
+- `100` byte row payload
+- `PRAGMA journal_mode=MEMORY`
+- explicit `sync()` included in the AbsurderSQL IndexedDB and Hybrid write timings
| Implementation | Insert | Read | Update | Delete |
|---------------|--------|------|--------|--------|
-| **AbsurderSQL** 🏆 | **3.2ms** | **1.2ms** | **400μs** | **400μs** |
+| AbsurderSQL IndexedDB | 160.0ms | 39.4ms | 68.4ms | 48.1ms |
+| AbsurderSQL Hybrid | 178.7ms | 26.0ms | 100.2ms | 54.5ms |
+| absurd-sql | 70.4ms | 23.3ms | 19.8ms | 9.0ms |
+| Raw IndexedDB | 42.8ms | 7.1ms | 28.8ms | 22.5ms |
+
+### Notes
+
+- This run is machine- and browser-specific; treat it as a fresh local reference point, not a universal claim.
+- Hybrid improved AbsurderSQL read latency versus explicit IndexedDB in this run, but it did not win the write-heavy operations.
+- Because AbsurderSQL write timings now include `sync()`, these numbers reflect durable backend persistence rather than only SQLite's in-memory execution time.
+
+## Legacy Results
+
+The table below reflects the earlier IndexedDB-only benchmark run before explicit IndexedDB and Hybrid variants were added to the page.
+
+| Implementation | Insert | Read | Update | Delete |
+|---------------|--------|------|--------|--------|
+| **AbsurderSQL IndexedDB** 🏆 | **3.2ms** | **1.2ms** | **400μs** | **400μs** |
| absurd-sql | 3.8ms | 2.1ms | 800μs | 700μs |
| Raw IndexedDB | 24.1ms | 1.4ms | 14.1ms | 6.3ms |
@@ -43,6 +85,24 @@ python3 -m http.server 8080
http://localhost:8080/examples/benchmark.html
```
+The page now compares:
+- AbsurderSQL IndexedDB via `Database.newDatabaseWithBackend(..., 'IndexedDB')`
+- AbsurderSQL Hybrid via a dedicated Worker using `Database.newDatabaseWithBackend(..., 'Hybrid')`
+- absurd-sql in its existing Worker harness
+- Raw IndexedDB
+
+AbsurderSQL write timings now include an explicit `sync()` so the benchmark captures durable backend persistence rather than only SQLite's in-memory work.
+
+There is also a browser smoke test for the page in `tests/e2e/benchmark-page.spec.js` which runs the real benchmark page with smaller inputs and verifies that the explicit IndexedDB and Hybrid variants both complete.
+
+The same spec contains the formal durable workload sweep at 100, 1,000, and 10,000 rows. It is opt-in locally because the 10,000-row case is intentionally slower:
+
+```bash
+BENCHMARK_SWEEP=1 npx playwright test tests/e2e/benchmark-page.spec.js --reporter=line
+```
+
+The benchmark honors the configured batch size for both AbsurderSQL backends and calls `sync()` after each write phase. CI enables the sweep and prints a `BENCHMARK_SWEEP_RESULTS` JSON record; do not treat those machine-specific timings as a universal performance claim.
+
## Benchmark Tests
### 1. INSERT Performance
diff --git a/docs/HYBRID_OPFS_PLAN.md b/docs/HYBRID_OPFS_PLAN.md
new file mode 100644
index 00000000..7232743b
--- /dev/null
+++ b/docs/HYBRID_OPFS_PLAN.md
@@ -0,0 +1,441 @@
+# AbsurderSQL Hybrid OPFS+IDB Storage Backend — Implementation Plan
+
+## Overview
+
+Add an **OPFS (Origin Private File System)** storage backend to AbsurderSQL, alongside the existing IndexedDB backend. The preferred mode is **Hybrid**: OPFS for fast block I/O, IndexedDB for metadata persistence and cross-browser fallback.
+
+This follows the same proven pattern as fewfs's `HybridBlockStore`.
+
+## Progress Update (2026-04-07)
+
+- Completed the first backend-selection foundation slice.
+- Added `StorageBackend` state to `BlockStorage`.
+- Added `opfs` and `hybrid` feature flags to `Cargo.toml`.
+- Added `backend_detect.rs` and real main-thread fallback detection to IndexedDB, with worker-side `SyncAccessHandle` probing.
+- Added `new_wasm_with_backend()` / `new_wasm_auto()` in `constructors.rs`.
+- Exposed `Database.newDatabaseAuto()` and `db.getStorageBackend()` in the WASM API.
+- Added integration test `tests/e2e/backend-auto-fallback.spec.js` validating main-thread auto backend selection and reopen persistence.
+- Subsequent branch work also stabilized the broader browser harness and supporting demos; those fixes are already committed on `ft/hybrid-obfs`.
+- Added `wasm_opfs.rs` with a single-file OPFS bridge and initial block write/read/delete helpers.
+- Wired backend-aware persistence into the current WASM sync paths so worker `Hybrid` / `OPFS` sync mirrors blocks into OPFS while continuing to mirror into IndexedDB.
+- Implemented OPFS-first restore in `constructors.rs` for `Hybrid` / `OPFS` backends, with fallback to IndexedDB when no OPFS data exists.
+- Added `hybrid_store.rs` so `Hybrid` reopen now restores OPFS blocks, loads IndexedDB metadata, cross-validates checksums, and falls back to IndexedDB when OPFS data is corrupted.
+- Added `hybrid_persist()` in `hybrid_store.rs` and routed the existing WASM sync paths through it instead of keeping OPFS+IDB mirroring duplicated inline.
+- Upgraded the IndexedDB metadata mirror to persist real block metadata instead of version-only placeholders, with checksum values encoded in a JS-safe format.
+- Added integration test `tests/e2e/worker-hybrid-opfs.spec.js` validating worker auto backend selection, real OPFS file creation on sync, OPFS-only reopen when the IndexedDB mirror is deleted, and Hybrid fallback when OPFS bytes are corrupted.
+- Made `exportToFile()` / `importFromFile()` worker-safe by routing them through `export_import_lock.rs`, which now falls back to a local async lock when `Window` is unavailable.
+- Updated `import.rs` so browser imports clear stale OPFS mirrors and persist imported blocks through `hybrid_persist()` for `Hybrid` / `OPFS` backends instead of treating IndexedDB as the only durable target.
+- Extended `tests/e2e/worker-hybrid-opfs.spec.js` with a worker import regression proving imported data survives after deleting the IndexedDB mirror.
+- Made worker cleanup tolerant of missing `Window` / `localStorage` so `Database.deleteDatabase()` no longer fails after successful worker-side OPFS cleanup.
+- Extended `tests/e2e/worker-hybrid-opfs.spec.js` with a worker delete regression proving `deleteDatabase()` succeeds in workers and removes the OPFS file.
+- Made `sync_operations.rs` backend-aware so lower-level `BlockStorage::sync()` now persists through `hybrid_persist()` for `Hybrid` / `OPFS` instead of leaving `exportToFile()`'s internal flush on an IndexedDB-only path.
+- Serialized per-database OPFS helper access so sync, close, export, and cleanup no longer race on overlapping `createSyncAccessHandle()` calls.
+- Fixed the awaited WASM sync path to persist the dirty block set instead of treating `GLOBAL_STORAGE` equality as durable persistence, which restores the worker `exportToFile()` flush guarantee for `Hybrid` / `OPFS`.
+- Extended `tests/e2e/worker-hybrid-opfs.spec.js` with a worker export-flush regression proving `exportToFile()` creates the OPFS mirror strongly enough to survive IndexedDB mirror deletion.
+- Reconciled Hybrid OPFS restore against IndexedDB block metadata so orphan OPFS tail blocks are pruned from both in-memory state and the persisted OPFS file on reopen.
+- Extended `tests/e2e/worker-hybrid-opfs.spec.js` with a worker orphan-tail regression proving reopen trims stale OPFS bytes back to the metadata-backed size.
+- Exposed `Database.newDatabaseWithBackend()` in the WASM API and added a worker regression proving explicit `Hybrid` selection works end-to-end.
+- Extended `tests/e2e/worker-hybrid-opfs.spec.js` with worker crash-recovery regressions covering both rollback-heal and finalize-style reopen flows by simulating torn Hybrid persistence in IndexedDB while preserving the OPFS mirror.
+- Wired `examples/benchmark.html` to compare explicit AbsurderSQL IndexedDB and explicit worker Hybrid backends side-by-side, with write timings now including `sync()` so the benchmark path exercises durable persistence.
+- Added `tests/e2e/benchmark-page.spec.js` so the benchmark page now has browser smoke coverage proving the explicit IndexedDB and Hybrid variants both complete successfully.
+- Refreshed `docs/BENCHMARK.md` with a fresh 2026-04-07 local result set covering explicit AbsurderSQL IndexedDB, explicit AbsurderSQL Hybrid, absurd-sql, and raw IndexedDB.
+- Refreshed `README.md` and `pkg/README.md` with browser backend selection guidance, explicit Hybrid/OPFS API coverage, and worker fallback behavior.
+- The formal 100/1000/10000 durable IndexedDB-vs-Hybrid workload sweep is now implemented behind `BENCHMARK_SWEEP=1`; measured results remain machine/browser-specific and should be recorded only after an actual run.
+
+Validation completed for this slice:
+
+- `wasm-pack build --dev --target web --out-dir pkg` passed.
+- `npm exec -- playwright test tests/e2e/backend-auto-fallback.spec.js --reporter=line` passed.
+- `npm exec -- playwright test tests/e2e/backend-auto-fallback.spec.js tests/e2e/worker-hybrid-opfs.spec.js --project=chromium --reporter=line` passed.
+- `npm exec -- playwright test tests/e2e/worker-hybrid-opfs.spec.js --project=chromium --reporter=line --grep "restores from OPFS after IndexedDB mirror deletion"` passed.
+- `npm exec -- playwright test tests/e2e/worker-hybrid-opfs.spec.js --project=chromium --reporter=line --grep "falls back to IndexedDB when OPFS data is corrupted"` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --reporter=line` passed.
+- `npx playwright test tests/e2e/import-export.spec.js --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker deleteDatabase succeeds without Window" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker exportToFile flush persists Hybrid data" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker reopen reconciles orphan OPFS tail" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker explicit backend constructor can request Hybrid" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker crash recovery heals torn Hybrid OPFS state after IndexedDB fallback" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js --grep "worker crash recovery finalizes Hybrid data when commit marker lags persisted blocks" --reporter=line` passed.
+- `npx playwright test tests/e2e/worker-hybrid-opfs.spec.js tests/e2e/benchmark-page.spec.js --reporter=line` passed.
+- `npx playwright test tests/e2e/benchmark-page.spec.js --reporter=line` passed.
+- Full root Playwright validation for the branch was later brought green during the follow-on harness repair work.
+- `cargo test` passed.
+- `cargo test -q` passed.
+- `cargo clippy --all-targets --features telemetry,fs_persist -- -D warnings` passed.
+- `cargo fmt --all --check` passed.
+
+## PWA Integration Update (2026-07-25)
+
+The PWA integration uses Onyx as a behavioral and test-design reference only. It imports no Onyx source or package at runtime; AbsurderSQL's checked-in WASM package, storage APIs, and multi-tab behavior remain the implementation.
+
+### 1. Integrate the Hybrid backend without replacing AbsurderSQL coordination
+
+- The Hybrid backend and recovery work came from AbsurderSQL commit `0c4b3aa` (`npiesco`, “Finish Hybrid OPFS recovery and benchmark coverage”) and its preceding `4b12d91`–`b57f772` series. The integrated code lives in `src/storage/wasm_opfs.rs`, `src/storage/hybrid_store.rs`, and `tests/e2e/worker-hybrid-opfs.spec.js`.
+- AbsurderSQL's own multi-tab implementation remains the authority: `src/storage/leader_election.rs`, `examples/multi-tab-wrapper.js`, and commits `5c40914` / `2d0e991` by Nicholas Piesco. The PWA adds one coordinated physical database/OPFS owner; it does not import or replace those library modules.
+
+### 2. Give the PWA one worker-owned database runtime
+
+- Onyx reference: commit `d039d03` by `npiesco` (“Share one browser vault across tabs and restarts”), especially `src/lib/backend/index.ts`, `src/lib/backend/sharedWorker.ts`, and `src/lib/backend/worker.ts` on `npiesco-selected-directory-persistence`. Its SharedWorker coordinates tabs while an elected dedicated Worker owns APIs unavailable in SharedWorker contexts.
+- AbsurderSQL implementation: `pwa/lib/db/database.shared-worker.ts` coordinates clients and runtime election, `pwa/lib/db/database.worker.ts` owns and serializes the real WASM `Database` instances in an OPFS-capable dedicated Worker, and `pwa/lib/db/database-proxy.ts` provides the typed main-thread facade. Logical handles reopen from persisted storage after runtime handoff.
+
+### 3. Make Hybrid/OPFS storage authoritative
+
+- Onyx reference: commits `a4ecdea` and `417e23d` by `npiesco`, covering durable browser persistence and hardened restart behavior.
+- AbsurderSQL reference: commits `671343d` and `8030549` by `npiesco`, covering OPFS-first restore and checksum-validated IndexedDB fallback.
+- PWA implementation: `pwa/app/db/page.tsx` no longer restores a side backup before opening the real VFS. Writes persist through the elected dedicated Worker's Hybrid backend; visibility changes only issue a best-effort `sync()`.
+
+### 4. Build locally and cache the complete production runtime
+
+- Onyx reference: commits `8a18a7c` and `1d90c3c` by `npiesco`, covering production startup artifacts and precaching the emitted PWA runtime.
+- AbsurderSQL implementation: `scripts/sync_pwa_package.js` copies the locally generated glue, WASM, declarations, and hashed wasm-bindgen snippets into `pwa/lib`. `pwa/scripts/generate-precache.mjs` records emitted Next assets, while `pwa/public/sw.js` caches navigation, coordinator/runtime workers, WASM, JS, and CSS assets for offline reuse.
+
+### 5. Validate persistence with production Playwright tests
+
+- Onyx test-method reference: `pw.pwa.config.ts`, `tests/pwa-browser.spec.ts`, `tests/pwa-journal-recovery.spec.ts`, and `tests/pwa-first-install-offline.spec.ts` on the persistence branch; the relevant commits are `a4ecdea`, `417e23d`, and `1d90c3c`, all by `npiesco`.
+- AbsurderSQL implementation: `pwa/playwright.pwa.config.ts` runs serially against `next build`/`next start`; `pwa/e2e/pwa-hybrid-storage.spec.ts` verifies Hybrid selection and reopen, two-tab shared ownership with continued writes after one tab closes, and offline reload from persisted OPFS data.
+- Local production validation on 2026-07-26: all three PWA tests passed in Chrome after verifying dedicated-worker Hybrid selection, cross-tab runtime handoff, and offline reload.
+- Root browser coverage remains in AbsurderSQL's `tests/e2e/worker-hybrid-opfs.spec.js` and now includes the formal durable IndexedDB/Hybrid sweep in `tests/e2e/benchmark-page.spec.js`.
+
+### 6. CI and benchmark gates
+
+- AbsurderSQL reference: commit `0c4b3aa` by `npiesco` introduced the explicit backend benchmark harness.
+- The benchmark now honors its batch-size control, and the opt-in `BENCHMARK_SWEEP=1` Playwright case exercises 100, 1,000, and 10,000-row block-producing workloads for explicit IndexedDB and Hybrid backends.
+- `.github/workflows/ci.yml` builds the WASM package, runs the Hybrid recovery plus benchmark suite in Chromium, and runs the production PWA storage suite separately.
+- Local validation on 2026-07-25: the focused static suite passed 13 tests with one opt-in sweep skipped; the explicit sweep then passed separately and produced the measured table in `docs/BENCHMARK.md`.
+
+### 7. Keep the production PWA dependency graph audit-clean
+
+- This is an AbsurderSQL-specific remediation by `npiesco`; Onyx supplied no runtime dependency and is not relevant to this dependency graph.
+- The configured Microsoft CFS feed exposes Next.js 16.3.0-preview.6 as the available direct fix beyond affected 16.2.10, so `next` and `eslint-config-next` are pinned together at that exact version.
+- Scoped overrides select PostCSS 8.5.20 and Sharp 0.35.3 under Next. The local `pwa/vendor/brace-expansion-compat` adapter retains the callable API required by ESLint's older minimatch consumers while delegating expansion to `@isaacs/brace-expansion` 5.0.1.
+- Local validation on 2026-07-26 through the configured CFS registry: a full install completed, `npm audit --json` reported 0 total vulnerabilities, `npm ls --depth=0` exited successfully, the production build completed, and all three PWA Playwright scenarios passed in Chrome.
+- The repository-wide PWA lint command now starts successfully with the remediated graph, but remains non-green on the existing application lint backlog; dependency compatibility is therefore validated by resolution, audit, production build, and Playwright rather than represented as a clean lint baseline.
+
+## Why
+
+| Metric | IndexedDB (current) | OPFS SyncAccessHandle |
+|--------|---------------------|-----------------------|
+| Read 1000 blocks | ~500-2000ms (async, tx overhead) | ~5-50ms (sync, sequential) |
+| Write 100 dirty blocks | ~100-500ms (async, tx overhead) | ~1-10ms (sync, sequential) |
+| Restore on reload | Slow (deserialize from IDB) | Fast (direct byte reads) |
+| API style | Async-only (Promises) | **Synchronous** from Worker context |
+
+OPFS `SyncAccessHandle` is synchronous — a perfect match for SQLite's synchronous VFS callbacks. Currently the VFS writes synchronously to `GLOBAL_STORAGE` (in-memory) and IndexedDB persistence is deferred async. With OPFS, we can optionally persist synchronously in the VFS hot path too.
+
+## Architecture
+
+### Current Flow (IDB-only)
+```
+VFS x_write → GLOBAL_STORAGE (sync, in-memory) → IndexedDB (async, deferred)
+VFS x_read → GLOBAL_STORAGE (sync, in-memory) ← IndexedDB (async, restore only)
+```
+
+### New Flow (Hybrid)
+```
+VFS x_write → GLOBAL_STORAGE (sync) → OPFS SyncAccessHandle (sync, from Worker)
+ → IDB metadata (async, deferred)
+
+VFS x_read → GLOBAL_STORAGE cache → OPFS SyncAccessHandle (sync, cache miss)
+ ← OPFS restore (on reload)
+ ← IDB metadata restore (on reload)
+```
+
+### Fallback Flow (IDB-only, no OPFS available)
+```
+(unchanged — identical to current behavior)
+```
+
+---
+
+## Scope: What Changes, What Doesn't
+
+### Unchanged
+- `vfs_sync.rs` — `GLOBAL_STORAGE`, `GLOBAL_METADATA`, `GLOBAL_COMMIT_MARKER` thread-locals
+- `indexeddb_vfs.rs` — VFS registration, `x_read`/`x_write`/`x_sync` callbacks
+- `io_operations.rs` — `read_block_sync`/`write_block_sync` (these write to GLOBAL_STORAGE)
+- `metadata.rs` — `ChecksumManager`, `BlockMetadataPersist`
+- `leader_election.rs` — localStorage + BroadcastChannel (unchanged)
+- All native/mobile code — gated behind `#[cfg(not(target_arch = "wasm32"))]`
+
+### New Files
+| File | Purpose | ~LOC |
+|------|---------|------|
+| `src/storage/wasm_opfs.rs` | OPFS block read/write/delete via `FileSystemSyncAccessHandle` | ~600-800 |
+| `src/storage/hybrid_store.rs` | Hybrid orchestrator: OPFS blocks + IDB metadata | ~300-400 |
+| `src/storage/backend_detect.rs` | Runtime feature detection (OPFS available?) | ~50-80 |
+
+### Modified Files
+| File | Change |
+|------|--------|
+| `src/storage/mod.rs` | Add `pub mod wasm_opfs;`, `pub mod hybrid_store;`, `pub mod backend_detect;` |
+| `src/storage/block_storage.rs` | Add `StorageBackend` enum field, expose backend choice |
+| `src/storage/constructors.rs` | New `new_wasm_hybrid()` / `new_wasm_with_backend()` constructors |
+| `src/storage/wasm_vfs_sync.rs` | Dispatch sync to OPFS or IDB based on backend |
+| `src/storage/sync_operations.rs` | Backend-aware sync dispatch |
+| `src/storage/recovery.rs` | OPFS recovery path (directory scan for orphan blocks) |
+| `src/storage/export.rs` / `import.rs` | Read blocks from OPFS when exporting |
+| `Cargo.toml` | New feature flag `opfs`, `hybrid` |
+
+---
+
+## Detailed Design
+
+### 1. Feature Flags
+
+```toml
+# Cargo.toml
+[features]
+opfs = [] # OPFS-only backend (Worker required)
+hybrid = ["opfs"] # OPFS blocks + IDB metadata (recommended)
+```
+
+Both are `#[cfg(target_arch = "wasm32")]` only — mobile/native compilation ignores them entirely.
+
+### 2. `StorageBackend` Enum
+
+```rust
+// block_storage.rs
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum StorageBackend {
+ IndexedDB, // Current behavior (default)
+ Opfs, // OPFS-only (requires Worker with SyncAccessHandle)
+ Hybrid, // OPFS for blocks, IDB for metadata (recommended)
+}
+
+impl Default for StorageBackend {
+ fn default() -> Self {
+ StorageBackend::IndexedDB
+ }
+}
+```
+
+Add to `BlockStorage`:
+```rust
+pub struct BlockStorage {
+ // ... existing fields ...
+ backend: StorageBackend,
+}
+```
+
+### 3. `wasm_opfs.rs` — OPFS Block Operations
+
+Mirror the `wasm_indexeddb.rs` API surface:
+
+```rust
+/// Persist blocks to OPFS
+/// Directory layout: /{db_name}/block_{id}.bin (one file per block)
+pub async fn persist_to_opfs(
+ db_name: &str,
+ blocks: Vec<(u64, Vec)>,
+) -> Result<(), DatabaseError>
+
+/// Restore all blocks from OPFS into GLOBAL_STORAGE
+pub async fn restore_from_opfs(db_name: &str) -> Result<(), DatabaseError>
+
+/// Delete specific blocks from OPFS
+pub async fn delete_blocks_from_opfs(
+ db_name: &str,
+ block_ids: &[u64],
+) -> Result<(), DatabaseError>
+
+/// Delete entire database directory from OPFS
+pub async fn delete_all_from_opfs(db_name: &str) -> Result<(), DatabaseError>
+
+/// Check if OPFS is available (SyncAccessHandle support)
+pub fn is_opfs_available() -> bool
+```
+
+**OPFS directory structure:**
+```
+/{db_name}/
+ block_0000000000.bin (4096 bytes each)
+ block_0000000001.bin
+ ...
+ block_NNNNNNNNNN.bin
+```
+
+**Alternative (single-file, fewfs-style):**
+```
+/{db_name}/
+ blocks.dat (contiguous block file, offset = block_id * 4096)
+ manifest.json (block allocation map, commit marker)
+```
+
+The single-file approach is faster (one `SyncAccessHandle`, seek to offset) but requires a manifest. The per-file approach is simpler and survives partial writes better. **Recommend: single-file** for performance, since AbsurderSQL already has checksumming for integrity.
+
+### 4. `hybrid_store.rs` — Orchestrator
+
+```rust
+/// Persist using hybrid strategy:
+/// - Blocks → OPFS (fast, synchronous from Worker)
+/// - Metadata (checksums, commit marker, allocation map) → IndexedDB (async)
+pub async fn hybrid_persist(
+ db_name: &str,
+ blocks: Vec<(u64, Vec)>,
+ metadata: Vec<(u64, u64)>, // (block_id, checksum)
+ commit_marker: u64,
+) -> Result<(), DatabaseError>
+
+/// Restore using hybrid strategy:
+/// - Blocks ← OPFS
+/// - Metadata ← IndexedDB
+/// - Cross-validate checksums
+pub async fn hybrid_restore(db_name: &str) -> Result<(), DatabaseError>
+```
+
+### 5. `backend_detect.rs` — Runtime Detection
+
+```rust
+/// Probe for OPFS SyncAccessHandle support
+/// Returns StorageBackend::Hybrid if available, else StorageBackend::IndexedDB
+pub async fn detect_best_backend() -> StorageBackend
+```
+
+Implementation: attempt `navigator.storage.getDirectory()`, create a temp file, try `createSyncAccessHandle()`. Clean up and return result. This is the same pattern as fewfs's `shim.js` but in Rust via `web-sys`.
+
+### 6. Constructor Changes
+
+```rust
+// constructors.rs
+#[cfg(target_arch = "wasm32")]
+pub async fn new_wasm_with_backend(
+ db_name: &str,
+ backend: StorageBackend,
+) -> Result {
+ match backend {
+ StorageBackend::IndexedDB => new_wasm(db_name).await,
+ StorageBackend::Opfs => new_wasm_opfs(db_name).await,
+ StorageBackend::Hybrid => new_wasm_hybrid(db_name).await,
+ }
+}
+
+/// Auto-detect best backend and construct
+#[cfg(target_arch = "wasm32")]
+pub async fn new_wasm_auto(db_name: &str) -> Result {
+ let backend = backend_detect::detect_best_backend().await;
+ new_wasm_with_backend(db_name, backend).await
+}
+```
+
+### 7. Sync Dispatch Changes
+
+```rust
+// wasm_vfs_sync.rs — modify vfs_sync_database()
+match storage.backend {
+ StorageBackend::IndexedDB => {
+ // existing persist_to_indexeddb_event_based() call
+ }
+ StorageBackend::Opfs => {
+ wasm_opfs::persist_to_opfs(&db_name, blocks).await?;
+ }
+ StorageBackend::Hybrid => {
+ // Blocks → OPFS, metadata → IDB (parallel)
+ hybrid_store::hybrid_persist(&db_name, blocks, metadata, commit_marker).await?;
+ }
+}
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Foundation (~3-4 days)
+- [x] Add `StorageBackend` enum to `block_storage.rs`
+- [x] Add `opfs` and `hybrid` feature flags to `Cargo.toml`
+- [x] Create `backend_detect.rs` with OPFS feature detection
+- [x] Create `wasm_opfs.rs` scaffold with function signatures
+- [x] Wire up `mod.rs` with new modules
+
+### Phase 2: OPFS Backend (~4-5 days)
+- [x] Implement `persist_to_opfs()` using a wasm-bindgen JS bridge for `SyncAccessHandle`
+- [x] Implement `restore_from_opfs()` as the active reload path — read all blocks back into GLOBAL_STORAGE
+- [x] Implement `delete_blocks_from_opfs()` and `delete_all_from_opfs()`
+- [x] Unit test with browser runner (Playwright)
+- [x] Benchmark harness: explicit Hybrid vs IndexedDB at 100/1000/10000 durable workloads (`BENCHMARK_SWEEP=1`)
+
+### Phase 3: Hybrid Mode (~3-4 days)
+- [x] Create `hybrid_store.rs` orchestrator
+- [x] Implement `hybrid_persist()` — OPFS blocks + IDB metadata in parallel
+- [x] Implement `hybrid_restore()` — cross-validate checksums on load
+- [x] OPFS recovery: detect orphan files, reconcile with IDB metadata
+
+### Phase 4: Integration (~2-3 days)
+- [x] Modify `constructors.rs` — `new_wasm_with_backend()`, `new_wasm_auto()`
+- [x] Modify `wasm_vfs_sync.rs` — backend-aware sync dispatch
+- [x] Modify `sync_operations.rs` — backend-aware flush
+- [x] Modify export / import / awaited sync paths — finish the remaining OPFS-aware export/reload cleanup
+- [x] Modify recovery path — OPFS orphan reconciliation
+- [x] Expose `StorageBackend` choice in WASM API (`Database::newDatabaseWithBackend()`)
+
+### Phase 5: Testing & Docs (~2 days)
+- [x] E2E tests: hybrid persist → close → reload → hybrid restore → verify data
+- [x] E2E tests: OPFS unavailable → graceful IDB fallback
+- [x] E2E tests: hybrid crash recovery (kill mid-persist)
+- [x] Update README with OPFS/hybrid documentation
+- [x] Benchmark report
+
+---
+
+## Mobile Impact
+
+**No storage-backend change.** All OPFS code remains gated behind `#[cfg(target_arch = "wasm32")]`, so mobile continues to use `rusqlite` + `fs_persist` on real filesystems.
+
+Follow-up validation by `npiesco` exercised the complete locked UniFFI host suite. It corrected mobile import schema qualification so an empty destination cannot resolve `DROP TABLE` against the attached backup, replaced blocking per-database locks with async-aware Tokio mutexes, refreshed the mobile lockfile to AbsurderSQL 0.1.26, and finished with 67/67 tests plus warning-free clippy.
+
+| Platform | Storage Backend | Changed? |
+|----------|----------------|----------|
+| Browser (WASM) | IDB → **Hybrid (OPFS+IDB)** | Yes |
+| Android | rusqlite → `/data/data/{pkg}/files/` | No |
+| iOS | rusqlite → `~/Documents/` | No |
+| Native CLI | rusqlite → local filesystem | No |
+
+---
+
+## `web-sys` Bindings Needed
+
+The OPFS API requires these `web-sys` features in `Cargo.toml`:
+
+```toml
+[dependencies.web-sys]
+features = [
+ # Existing features...
+ # New for OPFS:
+ "StorageManager",
+ "FileSystemDirectoryHandle",
+ "FileSystemFileHandle",
+ "FileSystemSyncAccessHandle",
+ "FileSystemGetFileOptions",
+ "FileSystemGetDirectoryOptions",
+ "FileSystemRemoveOptions",
+]
+```
+
+**Note:** `FileSystemSyncAccessHandle` may not be in `web-sys` yet (it was added to the spec relatively recently). If missing, use `js_sys::Reflect` + `JsValue` to call the methods manually, or use `wasm-bindgen`'s `#[wasm_bindgen]` extern blocks to declare the bindings inline. fewfs's `src/opfs/bridge.rs` has reference code for this approach.
+
+---
+
+## Reference Code Provenance
+
+These sources informed the original design, but are not vendored or imported by AbsurderSQL.
+
+| Source | File | Relevance |
+|--------|------|-----------|
+| **fewfs** | `opfs/opfs_store.rs` | OPFS `SyncAccessHandle` block read/write in Rust/WASM |
+| **fewfs** | `opfs/hybrid.rs` | Hybrid OPFS+IDB orchestration pattern |
+| **fewfs** | `opfs/bridge.rs` | `web-sys` / `js_sys` OPFS API bindings |
+| **fewfs** | `storage/types.rs` | `BlockReader`/`BlockWriter`/`Manifest` traits |
+| **fewfs** | `idb/idb_store.rs` | IDB block store (for comparison with our `wasm_indexeddb.rs`) |
+| **fewfs** | `idb/leader.rs` | Leader election (we already have our own) |
+| **fewfs** | `bindings/shim.js` | Runtime OPFS detection (JS reference for `backend_detect.rs`) |
+| **duckcells** | `app/opfs-cache.ts` | OPFS as a cache layer (TypeScript reference) |
+
+---
+
+## Resolved Decisions
+
+1. **OPFS layout:** the shipped browser implementation uses a single-file OPFS block store in `src/storage/wasm_opfs.rs`.
+2. **Sync persistence model:** writes stay buffered in `GLOBAL_STORAGE` and persist on `x_sync`, awaited sync paths, export, and auto-sync rather than every `x_write`.
+3. **Bindings approach:** OPFS support is bridged through the current wasm-bindgen/JS bridge in `src/storage/wasm_opfs.rs`, so separate `web-sys` coverage is no longer a release blocker.
+4. **Worker behavior:** `Hybrid` / `OPFS` are worker-oriented browser backends; main-thread browser usage should use IndexedDB directly or rely on `newDatabaseAuto()` to fall back automatically.
+5. **Benchmark follow-up:** the formal 100/1000/10000 durable workload sweep is implemented behind `BENCHMARK_SWEEP=1`; measured results should only be recorded after running it on a named machine/browser.
diff --git a/examples/absurder-benchmark-worker.js b/examples/absurder-benchmark-worker.js
new file mode 100644
index 00000000..d60c53fa
--- /dev/null
+++ b/examples/absurder-benchmark-worker.js
@@ -0,0 +1,98 @@
+import init, { Database } from '../pkg/absurder_sql.js';
+
+let initPromise = null;
+
+async function ensureInit() {
+ if (!initPromise) {
+ initPromise = init();
+ }
+ await initPromise;
+}
+
+function buildInsertValues(startId, numRows, testData) {
+ const values = [];
+ for (let index = 0; index < numRows; index += 1) {
+ values.push(`(${startId + index}, '${testData}')`);
+ }
+ return values.join(', ');
+}
+
+async function runBenchmark(dbName, backendName, numRows, batchSize, rowSize) {
+ const db = await Database.newDatabaseWithBackend(dbName, backendName);
+ db.allowNonLeaderWrites(true);
+
+ await db.execute('PRAGMA journal_mode=MEMORY');
+ await db.execute('PRAGMA page_size=8192');
+ await db.execute('CREATE TABLE IF NOT EXISTS benchmark (id INTEGER PRIMARY KEY, data TEXT)');
+
+ const testData = 'x'.repeat(rowSize);
+
+ const insertStart = performance.now();
+ for (let start = 0; start < numRows; start += batchSize) {
+ const count = Math.min(batchSize, numRows - start);
+ const values = buildInsertValues(start + 1, count, testData);
+ await db.execute(`INSERT INTO benchmark VALUES ${values}`);
+ }
+ await db.sync();
+ const insertTime = performance.now() - insertStart;
+
+ const readStart = performance.now();
+ await db.execute('SELECT * FROM benchmark');
+ const readTime = performance.now() - readStart;
+
+ const updateStart = performance.now();
+ await db.execute(`UPDATE benchmark SET data = '${testData}updated' WHERE id <= ${Math.floor(numRows / 2)}`);
+ await db.sync();
+ const updateTime = performance.now() - updateStart;
+
+ const deleteStart = performance.now();
+ await db.execute(`DELETE FROM benchmark WHERE id <= ${Math.floor(numRows / 4)}`);
+ await db.sync();
+ const deleteTime = performance.now() - deleteStart;
+
+ await db.execute('DROP TABLE benchmark');
+ await db.sync();
+
+ const selectedBackend = db.getStorageBackend();
+ await db.close();
+
+ return {
+ selectedBackend,
+ metrics: {
+ insert: insertTime,
+ read: readTime,
+ update: updateTime,
+ delete: deleteTime,
+ },
+ };
+}
+
+self.onmessage = async (event) => {
+ const { id, type, dbName, backendName, numRows, batchSize, rowSize } = event.data;
+
+ if (type !== 'runBenchmark') {
+ self.postMessage({
+ id,
+ success: false,
+ error: `Unknown message type: ${type}`,
+ });
+ return;
+ }
+
+ try {
+ await ensureInit();
+ const result = await runBenchmark(dbName, backendName, numRows, batchSize, rowSize);
+ self.postMessage({
+ id,
+ success: true,
+ ...result,
+ });
+ } catch (error) {
+ self.postMessage({
+ id,
+ success: false,
+ error: error?.message ?? String(error),
+ stack: error?.stack ?? null,
+ });
+ }
+};
diff --git a/examples/benchmark.html b/examples/benchmark.html
index 52b73e49..8d0138e6 100644
--- a/examples/benchmark.html
+++ b/examples/benchmark.html
@@ -3,7 +3,7 @@
- SQLite IndexedDB Benchmark - Full Comparison
+ SQLite Storage Backend Benchmark - Full Comparison
-
SQLite IndexedDB Performance Benchmark
-
+
SQLite Storage Backend Performance Benchmark
+
- Comparing: AbsurderSQL (persistent SQLite + IndexedDB VFS), absurd-sql (persistent SQLite + IndexedDB), and raw IndexedDB
- All implementations use persistent IndexedDB storage. AbsurderSQL uses our optimized BlockStorage (20x faster reads!).
- NOTE: Each benchmark run now cleans up IndexedDB completely to ensure consistent performance between runs.
+ Comparing: AbsurderSQL IndexedDB, AbsurderSQL Hybrid (OPFS + IndexedDB metadata), absurd-sql, and raw IndexedDB
+ AbsurderSQL write timings include an explicit sync() so the benchmark captures durable backend persistence instead of only in-memory SQLite work.
+ Hybrid runs in a Worker with an explicit Hybrid backend so OPFS is actually exercised.