diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02c5f782..d18a23ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,72 @@ jobs: test -f pkg/absurder_sql_bg.wasm echo "✓ WASM build successful" + # Exercise the worker-only Hybrid backend, recovery paths, and formal + # IndexedDB-vs-Hybrid workload sweep in a real Chromium process. + test-browser-storage: + runs-on: ubuntu-latest + needs: test-wasm + env: + BENCHMARK_SWEEP: '1' + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build WASM + run: wasm-pack build --target web --out-dir pkg + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install browser test dependencies + run: | + npm install + npx playwright install --with-deps chromium + + - name: Run Hybrid storage and benchmark coverage + run: >- + npx playwright test + tests/e2e/worker-hybrid-opfs.spec.js + tests/e2e/backend-auto-fallback.spec.js + tests/e2e/benchmark-page.spec.js + --config=playwright.storage.config.js + --reporter=line + + # The PWA suite deliberately runs a production build serially: service-worker + # caching, coordinated dedicated-worker ownership, OPFS persistence, and + # offline reload are not faithfully tested by the Next development server. + test-pwa-storage: + runs-on: ubuntu-latest + needs: test-wasm + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install PWA dependencies + working-directory: pwa + run: npm install + + - name: Install Chromium + working-directory: pwa + run: npx playwright install --with-deps chromium + + - name: Run production PWA storage tests + working-directory: pwa + run: npm run test:pwa + # Check version consistency (informational) check-versions: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ce3c4ba4..62e5a537 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ target/ node_modules/ test_env/ target +.cosynt/ +.vectordb/ # Test artifact directories (native fs_persist) .absurdersql_fs/ diff --git a/Cargo.toml b/Cargo.toml index af2a6c78..dd6daedb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,8 @@ opentelemetry-prometheus = "0.14" [features] default = ["console_error_panic_hook", "console_log", "bundled-sqlite"] fs_persist = [] +opfs = [] +hybrid = ["opfs"] telemetry = ["prometheus", "opentelemetry", "opentelemetry_sdk", "opentelemetry-prometheus"] bundled-sqlite = ["rusqlite", "rusqlite/bundled"] encryption = ["rusqlite", "rusqlite/sqlcipher"] # Android: links pre-built SQLCipher in jniLibs diff --git a/README.md b/README.md index e22e416d..712e9d96 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
AbsurderSQL Logo

AbsurderSQL

-

Rust + WASM + SQLite + IndexedDB

+

Rust + WASM + SQLite + IndexedDB/OPFS

[![npm](https://img.shields.io/npm/v/@npiesco/absurder-sql)](https://www.npmjs.com/package/@npiesco/absurder-sql) [![npm downloads](https://img.shields.io/npm/dm/@npiesco/absurder-sql)](https://www.npmjs.com/package/@npiesco/absurder-sql) @@ -14,6 +14,7 @@ [![WASM](https://img.shields.io/badge/wasm-supported-blue)](https://webassembly.org/) [![SQLite](https://img.shields.io/badge/sqlite-embedded-blue)](https://www.sqlite.org/) [![IndexedDB](https://img.shields.io/badge/indexeddb-browser-green)](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) +[![OPFS](https://img.shields.io/badge/opfs-worker%20hybrid-teal)](docs/HYBRID_OPFS_PLAN.md) **Capabilities:** [![Dual Mode](https://img.shields.io/badge/mode-Browser%20%2B%20Native-purple)](docs/DUAL_MODE.md) @@ -24,15 +25,15 @@ [![Grafana](https://img.shields.io/badge/grafana-dashboards-orange)](monitoring/grafana/) [![DevTools](https://img.shields.io/badge/devtools-chrome%2Ffirefox-blue)](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.
@@ -151,7 +151,8 @@

Benchmark Configuration

- + + @@ -179,6 +180,34 @@

Results

import init, { Database } from '../pkg/absurder_sql.js'; let results = {}; + const ABSURDERSQL_INDEXEDDB_DB = 'benchmark_datasync_indexeddb'; + const ABSURDERSQL_HYBRID_DB = 'benchmark_datasync_hybrid'; + const LEGACY_ABSURDERSQL_DB = 'benchmark_datasync'; + + function buildInsertValues(startId, numRows, testData) { + const values = []; + for (let i = 0; i < numRows; i += 1) { + values.push(`(${startId + i}, '${testData}')`); + } + return values.join(', '); + } + + async function deleteIndexedDbDatabase(name) { + return new Promise(resolve => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }); + } + + async function safeDeleteAbsurderSQLDatabase(dbName) { + try { + await Database.deleteDatabase(dbName); + } catch (error) { + console.debug(`Ignoring benchmark database cleanup error for ${dbName}:`, error); + } + } // Utility functions function showStatus(message, type = 'info') { @@ -254,77 +283,115 @@

Results

}); } - // Helper: Clear AbsurderSQL databases for clean benchmark runs - async function clearAbsurderSQLDatabases() { - return new Promise(resolve => { - const deleteReq1 = indexedDB.deleteDatabase('benchmark_datasync'); - const deleteReq2 = indexedDB.deleteDatabase('block_storage'); - let completed = 0; - const checkDone = () => { if (++completed === 2) resolve(); }; - deleteReq1.onsuccess = checkDone; - deleteReq1.onerror = checkDone; - deleteReq2.onsuccess = checkDone; - deleteReq2.onerror = checkDone; - }); - } + async function runAbsurderSQLOperations(db, numRows, batchSize, rowSize) { + const testData = 'x'.repeat(rowSize); - // Benchmark: AbsurderSQL (persistent SQLite with IndexedDB) - async function benchmarkAbsurderSQL(numRows, batchSize, rowSize) { - showStatus('Running AbsurderSQL benchmark...', 'running'); - - const dbName = 'benchmark_datasync'; - const db = await Database.newDatabase(dbName); - - // CRITICAL: Use same journal mode as absurd-sql for fair comparison - const journalResult = await db.execute('PRAGMA journal_mode=MEMORY'); - console.log('Journal mode result:', journalResult); - const pageSizeResult = await db.execute('PRAGMA page_size=8192'); - console.log('Page size result:', pageSizeResult); - - // Create table + 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)'); - - // Generate test data - const testData = 'x'.repeat(rowSize); - - // INSERT benchmark - build single batched INSERT statement + const insertStart = performance.now(); - const allValues = []; - for (let i = 0; i < numRows; i++) { - allValues.push(`(${i + 1}, '${testData}')`); + for (let start = 0; start < numRows; start += batchSize) { + const count = Math.min(batchSize, numRows - start); + await db.execute(`INSERT INTO benchmark VALUES ${buildInsertValues(start + 1, count, testData)}`); } - await db.execute(`INSERT INTO benchmark VALUES ${allValues.join(',')}`); + await db.sync(); const insertTime = performance.now() - insertStart; - // READ benchmark const readStart = performance.now(); await db.execute('SELECT * FROM benchmark'); const readTime = performance.now() - readStart; - // UPDATE benchmark 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; - // DELETE benchmark const deleteStart = performance.now(); await db.execute(`DELETE FROM benchmark WHERE id <= ${Math.floor(numRows / 4)}`); + await db.sync(); const deleteTime = performance.now() - deleteStart; - // Cleanup - drop table and close await db.execute('DROP TABLE benchmark'); - await db.close(); + await db.sync(); - results['AbsurderSQL'] = { + return { insert: insertTime, read: readTime, update: updateTime, - delete: deleteTime + delete: deleteTime, }; + } + + // Helper: Clear AbsurderSQL benchmark databases for clean runs + async function clearAbsurderSQLDatabases() { + await safeDeleteAbsurderSQLDatabase(ABSURDERSQL_INDEXEDDB_DB); + await safeDeleteAbsurderSQLDatabase(ABSURDERSQL_HYBRID_DB); + await safeDeleteAbsurderSQLDatabase(LEGACY_ABSURDERSQL_DB); + } + + // Benchmark: AbsurderSQL IndexedDB on the main thread + async function benchmarkAbsurderSQLIndexedDB(numRows, batchSize, rowSize) { + showStatus('Running AbsurderSQL IndexedDB benchmark...', 'running'); + + const db = await Database.newDatabaseWithBackend(ABSURDERSQL_INDEXEDDB_DB, 'IndexedDB'); + db.allowNonLeaderWrites(true); + + const metrics = await runAbsurderSQLOperations(db, numRows, batchSize, rowSize); + const selectedBackend = db.getStorageBackend(); + await db.close(); + + results[`AbsurderSQL IndexedDB (${selectedBackend})`] = metrics; displayResults(); } + // Benchmark: AbsurderSQL Hybrid in a worker so OPFS is actually available + async function benchmarkAbsurderSQLHybrid(numRows, batchSize, rowSize) { + showStatus('Running AbsurderSQL Hybrid benchmark...', 'running'); + + const worker = new Worker('./absurder-benchmark-worker.js', { type: 'module' }); + + try { + const result = await new Promise((resolve, reject) => { + const requestId = 0; + + worker.onmessage = (event) => { + const { id, success, error, selectedBackend, metrics } = event.data; + if (id !== requestId) { + return; + } + + if (!success) { + reject(new Error(error)); + return; + } + + resolve({ selectedBackend, metrics }); + }; + + worker.onerror = (event) => { + reject(new Error(event.message || 'Worker benchmark failed')); + }; + + worker.postMessage({ + type: 'runBenchmark', + id: requestId, + dbName: ABSURDERSQL_HYBRID_DB, + backendName: 'Hybrid', + numRows, + rowSize, + batchSize, + }); + }); + + results[`AbsurderSQL Hybrid (${result.selectedBackend})`] = result.metrics; + displayResults(); + } finally { + worker.terminate(); + } + } + // Benchmark: absurd-sql (runs in worker) async function benchmarkAbsurdSQL(numRows, batchSize, rowSize) { showStatus('Running absurd-sql benchmark...', 'running'); @@ -512,10 +579,9 @@

Results

showStatus('Clearing all benchmark data...', 'running'); try { - indexedDB.deleteDatabase('benchmark_datasync'); - indexedDB.deleteDatabase('block_storage'); - indexedDB.deleteDatabase('benchmark_indexeddb'); - indexedDB.deleteDatabase('/sql/benchmark.sqlite'); + await clearAbsurderSQLDatabases(); + await deleteIndexedDbDatabase('benchmark_indexeddb'); + await deleteIndexedDbDatabase('/sql/benchmark.sqlite'); } catch (e) { console.error('Error clearing data:', e); } @@ -539,7 +605,8 @@

Results

showStatus('Preparing clean environment...', 'running'); await clearAbsurderSQLDatabases(); - await benchmarkAbsurderSQL(numRows, batchSize, rowSize); + await benchmarkAbsurderSQLIndexedDB(numRows, batchSize, rowSize); + await benchmarkAbsurderSQLHybrid(numRows, batchSize, rowSize); await benchmarkAbsurdSQL(numRows, batchSize, rowSize); await benchmarkIndexedDB(numRows, batchSize, rowSize); showStatus('All benchmarks complete!', 'complete'); @@ -558,8 +625,24 @@

Results

try { // Clear databases before individual run for consistent results await clearAbsurderSQLDatabases(); - await benchmarkAbsurderSQL(numRows, batchSize, rowSize); - showStatus('AbsurderSQL benchmark complete!', 'complete'); + await benchmarkAbsurderSQLIndexedDB(numRows, batchSize, rowSize); + showStatus('AbsurderSQL IndexedDB benchmark complete!', 'complete'); + setTimeout(hideStatus, 2000); + } catch (error) { + showStatus(`Error: ${error.message}`, 'error'); + console.error(error); + } + }); + + document.getElementById('runDataSyncHybrid').addEventListener('click', async () => { + const numRows = parseInt(document.getElementById('numRows').value); + const batchSize = parseInt(document.getElementById('batchSize').value); + const rowSize = parseInt(document.getElementById('rowSize').value); + + try { + await clearAbsurderSQLDatabases(); + await benchmarkAbsurderSQLHybrid(numRows, batchSize, rowSize); + showStatus('AbsurderSQL Hybrid benchmark complete!', 'complete'); setTimeout(hideStatus, 2000); } catch (error) { showStatus(`Error: ${error.message}`, 'error'); diff --git a/examples/devtools_demo.html b/examples/devtools_demo.html index eb8d6be8..f48d71f3 100644 --- a/examples/devtools_demo.html +++ b/examples/devtools_demo.html @@ -177,7 +177,7 @@

Generate Telemetry Data

Total Spans
-
0
+
0
Buffered
@@ -220,6 +220,51 @@

ℹ️ DevTools Integration Info

let exportCount = 0; let errorCount = 0; let bufferedCount = 0; + + function createRuntimeBridge() { + const listeners = []; + + return { + sendMessage(message, callback) { + window.postMessage({ + source: 'absurdersql-telemetry', + message + }, '*'); + + if (typeof callback === 'function') { + callback({ success: true }); + } + }, + onMessage: { + addListener(listener) { + if (typeof listener === 'function') { + listeners.push(listener); + } + }, + removeListener(listener) { + const index = listeners.indexOf(listener); + if (index >= 0) { + listeners.splice(index, 1); + } + } + }, + __dispatch(message) { + listeners.forEach(listener => listener(message, {}, () => {})); + } + }; + } + + const runtimeBridge = createRuntimeBridge(); + window.chrome = window.chrome || {}; + window.chrome.runtime = window.chrome.runtime || runtimeBridge; + + if (typeof window.chrome.runtime.sendMessage !== 'function') { + window.chrome.runtime.sendMessage = runtimeBridge.sendMessage; + } + + if (!window.chrome.runtime.onMessage) { + window.chrome.runtime.onMessage = runtimeBridge.onMessage; + } function log(message, type = 'info') { const logEl = document.getElementById('activityLog'); @@ -232,19 +277,19 @@

ℹ️ DevTools Integration Info

} function updateStats() { - document.getElementById('spanCount').textContent = spanCount; + document.getElementById('totalSpans').textContent = spanCount; document.getElementById('bufferedCount').textContent = bufferedCount; document.getElementById('exportCount').textContent = exportCount; document.getElementById('errorCount').textContent = errorCount; } function sendToDevTools(type, data) { - // Use window.postMessage to send to content script, which forwards to extension - window.postMessage({ - source: 'absurdersql-telemetry', - message: { type, data } - }, '*'); + const message = { type, data }; + + window.chrome.runtime.sendMessage(message, () => {}); } + + window.sendToDevTools = sendToDevTools; function createSpan(name, durationMs, status = 'Ok') { const startTime = Date.now() - durationMs; diff --git a/examples/multi-tab-wrapper.js b/examples/multi-tab-wrapper.js index 6962f838..95b32e04 100644 --- a/examples/multi-tab-wrapper.js +++ b/examples/multi-tab-wrapper.js @@ -49,6 +49,11 @@ export class MultiTabDatabase { this.waitForLeadership = options.waitForLeadership || false; this.syncIntervalMs = options.syncIntervalMs || 0; this.syncIntervalId = null; + this.activeOperations = 0; + this.closePromise = null; + this.isClosing = false; + this.isClosed = false; + this.ignoreNotificationsUntil = 0; } /** @@ -58,19 +63,50 @@ export class MultiTabDatabase { async init() { // Create database instance this.db = await this.Database.newDatabase(this.dbName); + this.closePromise = null; + this.isClosing = false; + this.isClosed = false; + + const activeDb = this.db; this.db.onDataChange(async (changeType) => { + if (!this._isActiveDb(activeDb)) { + return; + } + + if (Date.now() < this.ignoreNotificationsUntil) { + return; + } + console.log(`[MultiTabDatabase] Data change received: ${changeType}`); // Always reload from IndexedDB when receiving a data change notification // This ensures we see data written by other tabs, regardless of our leader status // (Leadership can change between when data was written and when we receive the notification) try { console.log(`[MultiTabDatabase] Reloading from IndexedDB...`); - await this.db.reloadFromIndexedDB(); + await this._withTrackedDb(async (db) => { + if (db !== activeDb || !this._isActiveDb(activeDb)) { + return; + } + + await db.reloadFromIndexedDB(); + }); + + if (!this._isActiveDb(activeDb)) { + return; + } + console.log(`[MultiTabDatabase] Reloaded from IndexedDB`); } catch (error) { - console.error(`[MultiTabDatabase] Failed to reload from IndexedDB:`, error); + if (this._isActiveDb(activeDb)) { + console.error(`[MultiTabDatabase] Failed to reload from IndexedDB:`, error); + } } + + if (!this._isActiveDb(activeDb)) { + return; + } + this._triggerRefreshCallbacks(); }); @@ -78,7 +114,7 @@ export class MultiTabDatabase { this.beforeUnloadHandler = async () => { console.log('[MultiTabDatabase] Page unloading, cleaning up leader election'); try { - await this.db.close(); + await this.close(); } catch (error) { console.error('[MultiTabDatabase] Error during cleanup:', error); } @@ -89,7 +125,7 @@ export class MultiTabDatabase { if (this.syncIntervalMs > 0) { this.syncIntervalId = setInterval(async () => { try { - await this.db.sync(); + await this.sync(); } catch (error) { console.error('[MultiTabDatabase] Auto-sync error:', error); } @@ -100,7 +136,7 @@ export class MultiTabDatabase { } async isLeader() { - return await this.db.isLeader(); + return await this._withTrackedDb((db) => db.isLeader(), false); } /** @@ -109,7 +145,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async waitForLeadership(timeoutMs = 5000) { - return await this.db.waitForLeadership(); + return await this._withTrackedDb((db) => db.waitForLeadership(timeoutMs)); } /** @@ -117,7 +153,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async requestLeadership() { - return await this.db.requestLeadership(); + return await this._withTrackedDb((db) => db.requestLeadership()); } /** @@ -125,7 +161,10 @@ export class MultiTabDatabase { * @returns {Promise<{isLeader: boolean, leaderId: string, leaseExpiry: number}>} */ async getLeaderInfo() { - return await this.db.getLeaderInfo(); + return await this._withTrackedDb( + (db) => db.getLeaderInfo(), + () => ({ isLeader: false, leaderId: null, leaseExpiry: 0 }) + ); } /** @@ -138,38 +177,35 @@ export class MultiTabDatabase { * @throws {Error} If not leader or write fails */ async write(sql, params = []) { - // Check if we're the leader - const isLeader = await this.isLeader(); - - if (!isLeader) { - if (this.waitForLeadership) { - console.log('[MultiTabDatabase] Not leader, waiting for leadership...'); - await this.waitForLeadership(); - } else { - throw new Error( - 'Cannot write: This tab is not the leader. ' + - 'Use db.waitForLeadership() or db.requestLeadership() to become leader, ' + - 'or set waitForLeadership: true in options.' - ); + return await this._withTrackedDb(async (db) => { + const isLeader = await db.isLeader(); + + if (!isLeader) { + if (this.waitForLeadership) { + console.log('[MultiTabDatabase] Not leader, waiting for leadership...'); + await db.waitForLeadership(); + } else { + throw new Error( + 'Cannot write: This tab is not the leader. ' + + 'Use db.waitForLeadership() or db.requestLeadership() to become leader, ' + + 'or set waitForLeadership: true in options.' + ); + } } - } - // Execute the write - let result; - if (params.length > 0) { - result = await this.db.executeWithParams(sql, params); - } else { - result = await this.db.execute(sql); - } + const result = params.length > 0 + ? await db.executeWithParams(sql, params) + : await db.execute(sql); - // Auto-sync after write - if (this.autoSync) { - await this.db.sync(); - } + if (this.autoSync) { + this.ignoreNotificationsUntil = Date.now() + 500; + await db.sync(); + } - console.log('[MultiTabDatabase] Write completed and synced'); + console.log('[MultiTabDatabase] Write completed and synced'); - return result; + return result; + }, () => ({ rows: [], affected_rows: 0 })); } /** @@ -181,11 +217,13 @@ export class MultiTabDatabase { * @returns {Promise} Query result with rows */ async query(sql, params = []) { - if (params.length > 0) { - return await this.db.executeWithParams(sql, params); - } else { - return await this.db.execute(sql); - } + return await this._withTrackedDb((db) => { + if (params.length > 0) { + return db.executeWithParams(sql, params); + } + + return db.execute(sql); + }, () => ({ rows: [], affected_rows: 0 })); } /** @@ -211,7 +249,10 @@ export class MultiTabDatabase { * @returns {Promise} */ async sync() { - return await this.db.sync(); + return await this._withTrackedDb((db) => { + this.ignoreNotificationsUntil = Date.now() + 500; + return db.sync(); + }); } /** @@ -241,22 +282,40 @@ export class MultiTabDatabase { * @returns {Promise} */ async close() { + if (this.closePromise) { + return await this.closePromise; + } + + this.isClosing = true; + // Stop auto-sync interval if (this.syncIntervalId) { clearInterval(this.syncIntervalId); this.syncIntervalId = null; } - // Close database - if (this.db) { - await this.db.close(); - this.db = null; + if (this.beforeUnloadHandler) { + window.removeEventListener('beforeunload', this.beforeUnloadHandler); + this.beforeUnloadHandler = null; } - // Clear callbacks - this.refreshCallbacks = []; + const activeDb = this.db; + + this.closePromise = (async () => { + await this._waitForIdle(); + + if (activeDb && this.db === activeDb) { + await activeDb.close(); + this.db = null; + } + + this.refreshCallbacks = []; + this.isClosed = true; - console.log(`[MultiTabDatabase] Closed database: ${this.dbName}`); + console.log(`[MultiTabDatabase] Closed database: ${this.dbName}`); + })(); + + return await this.closePromise; } // ========== Optimistic Updates ========== @@ -267,7 +326,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async enableOptimisticUpdates(enabled) { - return await this.db.enableOptimisticUpdates(enabled); + return await this._withTrackedDb((db) => db.enableOptimisticUpdates(enabled)); } /** @@ -275,7 +334,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async isOptimisticMode() { - return await this.db.isOptimisticMode(); + return await this._withTrackedDb((db) => db.isOptimisticMode(), false); } /** @@ -284,7 +343,7 @@ export class MultiTabDatabase { * @returns {Promise} Write ID */ async trackOptimisticWrite(sql) { - return await this.db.trackOptimisticWrite(sql); + return await this._withTrackedDb((db) => db.trackOptimisticWrite(sql), ''); } /** @@ -292,7 +351,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async getPendingWritesCount() { - return await this.db.getPendingWritesCount(); + return await this._withTrackedDb((db) => db.getPendingWritesCount(), 0); } /** @@ -300,7 +359,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async clearOptimisticWrites() { - return await this.db.clearOptimisticWrites(); + return await this._withTrackedDb((db) => db.clearOptimisticWrites()); } // ========== Coordination Metrics ========== @@ -311,7 +370,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async enableCoordinationMetrics(enabled) { - return await this.db.enableCoordinationMetrics(enabled); + return await this._withTrackedDb((db) => db.enableCoordinationMetrics(enabled)); } /** @@ -319,7 +378,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async isCoordinationMetricsEnabled() { - return await this.db.isCoordinationMetricsEnabled(); + return await this._withTrackedDb((db) => db.isCoordinationMetricsEnabled(), false); } /** @@ -328,7 +387,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async recordLeadershipChange(becameLeader) { - return await this.db.recordLeadershipChange(becameLeader); + return await this._withTrackedDb((db) => db.recordLeadershipChange(becameLeader)); } /** @@ -337,7 +396,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async recordNotificationLatency(latencyMs) { - return await this.db.recordNotificationLatency(latencyMs); + return await this._withTrackedDb((db) => db.recordNotificationLatency(latencyMs)); } /** @@ -345,7 +404,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async recordWriteConflict() { - return await this.db.recordWriteConflict(); + return await this._withTrackedDb((db) => db.recordWriteConflict()); } /** @@ -353,7 +412,7 @@ export class MultiTabDatabase { * @returns {Promise} */ async recordFollowerRefresh() { - return await this.db.recordFollowerRefresh(); + return await this._withTrackedDb((db) => db.recordFollowerRefresh()); } /** @@ -361,7 +420,7 @@ export class MultiTabDatabase { * @returns {Promise} JSON string of metrics */ async getCoordinationMetrics() { - return await this.db.getCoordinationMetrics(); + return await this._withTrackedDb((db) => db.getCoordinationMetrics(), '{}'); } /** @@ -369,7 +428,49 @@ export class MultiTabDatabase { * @returns {Promise} */ async resetCoordinationMetrics() { - return await this.db.resetCoordinationMetrics(); + return await this._withTrackedDb((db) => db.resetCoordinationMetrics()); + } + + async _withTrackedDb(operation, fallbackValue) { + const activeDb = this.db; + + if (!activeDb) { + return this._resolveFallback(fallbackValue); + } + + this.activeOperations += 1; + + try { + return await operation(activeDb); + } finally { + this.activeOperations = Math.max(0, this.activeOperations - 1); + } + } + + async _waitForIdle() { + while (true) { + if (this.activeOperations !== 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + continue; + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + if (this.activeOperations === 0) { + return; + } + } + } + + _isActiveDb(activeDb = this.db) { + return Boolean(activeDb) && this.db === activeDb && !this.isClosing && !this.isClosed; + } + + _resolveFallback(fallbackValue) { + if (typeof fallbackValue === 'function') { + return fallbackValue(); + } + + return fallbackValue; } /** diff --git a/package.json b/package.json index ecb0f7bf..ee424889 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ }, "homepage": "https://github.com/npiesco/absurder-sql#readme", "scripts": { - "build": "wasm-pack build --target web --out-dir pkg && rm -f pkg/.gitignore", + "build": "wasm-pack build --target web --out-dir pkg && node scripts/sync_pwa_package.js", + "sync:pwa-package": "node scripts/sync_pwa_package.js", "prepublishOnly": "npm run build", - "serve": "python3 -m http.server 8080", + "serve": "node scripts/static_http_server.js 8080", "build:worker": "webpack --config webpack.worker.config.js", "test:e2e": "playwright test", + "test:storage": "playwright test --config playwright.storage.config.js", "test:e2e:headed": "playwright test --headed", "test:vite:start": "cd examples/vite-app && npm run dev", "test:pack": "npm pack && tar -tzf npiesco-absurder-sql-*.tgz" diff --git a/pkg/README.md b/pkg/README.md index e22e416d..2c991743 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -1,7 +1,7 @@
AbsurderSQL Logo

AbsurderSQL

-

Rust + WASM + SQLite + IndexedDB

+

Rust + WASM + SQLite + IndexedDB/OPFS

[![npm](https://img.shields.io/npm/v/@npiesco/absurder-sql)](https://www.npmjs.com/package/@npiesco/absurder-sql) [![npm downloads](https://img.shields.io/npm/dm/@npiesco/absurder-sql)](https://www.npmjs.com/package/@npiesco/absurder-sql) @@ -14,6 +14,7 @@ [![WASM](https://img.shields.io/badge/wasm-supported-blue)](https://webassembly.org/) [![SQLite](https://img.shields.io/badge/sqlite-embedded-blue)](https://www.sqlite.org/) [![IndexedDB](https://img.shields.io/badge/indexeddb-browser-green)](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) +[![OPFS](https://img.shields.io/badge/opfs-worker%20hybrid-teal)](docs/HYBRID_OPFS_PLAN.md) **Capabilities:** [![Dual Mode](https://img.shields.io/badge/mode-Browser%20%2B%20Native-purple)](docs/DUAL_MODE.md) @@ -24,15 +25,15 @@ [![Grafana](https://img.shields.io/badge/grafana-dashboards-orange)](monitoring/grafana/) [![DevTools](https://img.shields.io/badge/devtools-chrome%2Ffirefox-blue)](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,6 +277,8 @@ 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 @@ -259,6 +291,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 +302,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 diff --git a/pkg/absurder_sql.d.ts b/pkg/absurder_sql.d.ts index 16fa001c..014d7b58 100644 --- a/pkg/absurder_sql.d.ts +++ b/pkg/absurder_sql.d.ts @@ -107,6 +107,7 @@ export class Database { * Get count of pending writes */ getPendingWritesCount(): Promise; + getStorageBackend(): string; /** * Import SQLite database from .db file bytes * @@ -152,6 +153,8 @@ export class Database { */ is_leader(): Promise; static newDatabase(name: string): Promise; + static newDatabaseAuto(name: string): Promise; + static newDatabaseWithBackend(name: string, backend_name: string): Promise; onDataChange(callback: Function): void; /** * Queue a write operation to be executed by the leader @@ -266,6 +269,7 @@ export interface InitOutput { readonly database_getCoordinationMetrics: (a: number) => any; readonly database_getLeaderInfo: (a: number) => any; readonly database_getPendingWritesCount: (a: number) => any; + readonly database_getStorageBackend: (a: number) => [number, number, number, number]; readonly database_importFromFile: (a: number, b: any) => any; readonly database_isCoordinationMetricsEnabled: (a: number) => any; readonly database_isLeader: (a: number) => any; @@ -273,6 +277,8 @@ export interface InitOutput { readonly database_is_leader: (a: number) => any; readonly database_name: (a: number) => [number, number]; readonly database_newDatabase: (a: number, b: number) => any; + readonly database_newDatabaseAuto: (a: number, b: number) => any; + readonly database_newDatabaseWithBackend: (a: number, b: number, c: number, d: number) => any; readonly database_onDataChange: (a: number, b: any) => [number, number]; readonly database_queueWrite: (a: number, b: number, c: number) => any; readonly database_queueWriteWithTimeout: (a: number, b: number, c: number, d: number) => any; @@ -287,6 +293,7 @@ export interface InitOutput { readonly database_testLock: (a: number, b: number) => any; readonly database_trackOptimisticWrite: (a: number, b: number, c: number) => any; readonly database_waitForLeadership: (a: number) => any; + readonly init_logger: () => void; readonly wasmcolumnvalue_big_int: (a: number, b: number) => number; readonly wasmcolumnvalue_blob: (a: number, b: number) => number; readonly wasmcolumnvalue_createBigInt: (a: number, b: number) => number; @@ -296,20 +303,20 @@ export interface InitOutput { readonly wasmcolumnvalue_createNull: () => number; readonly wasmcolumnvalue_createReal: (a: number) => number; readonly wasmcolumnvalue_createText: (a: number, b: number) => number; + readonly wasmcolumnvalue_date: (a: number) => number; readonly wasmcolumnvalue_fromJsValue: (a: any) => number; readonly wasmcolumnvalue_integer: (a: number) => number; - readonly wasmcolumnvalue_real: (a: number) => number; - readonly init_logger: () => void; readonly wasmcolumnvalue_null: () => number; - readonly wasmcolumnvalue_date: (a: number) => number; + readonly wasmcolumnvalue_real: (a: number) => number; readonly wasmcolumnvalue_text: (a: number, b: number) => number; + readonly sqlite3_os_init: () => number; readonly rust_sqlite_wasm_shim_acosh: (a: number) => number; readonly rust_sqlite_wasm_shim_asinh: (a: number) => number; readonly rust_sqlite_wasm_shim_atanh: (a: number) => number; readonly rust_sqlite_wasm_shim_calloc: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_malloc: (a: number) => number; readonly rust_sqlite_wasm_shim_free: (a: number) => void; readonly rust_sqlite_wasm_shim_localtime: (a: number) => number; - readonly rust_sqlite_wasm_shim_malloc: (a: number) => number; readonly rust_sqlite_wasm_shim_memchr: (a: number, b: number, c: number) => number; readonly rust_sqlite_wasm_shim_realloc: (a: number, b: number) => number; readonly rust_sqlite_wasm_shim_sqrt: (a: number) => number; @@ -320,22 +327,23 @@ export interface InitOutput { readonly rust_sqlite_wasm_shim_strrchr: (a: number, b: number) => number; readonly rust_sqlite_wasm_shim_strspn: (a: number, b: number) => number; readonly rust_sqlite_wasm_shim_trunc: (a: number) => number; - readonly sqlite3_os_init: () => number; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_4a6891ba067a060a___JsError___true_: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__js_sys_a35f79cb92043d9c___Promise__true_: (a: number, b: number, c: any) => any; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue______true_: (a: number, b: number, c: any) => void; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true_: (a: number, b: number, c: any) => void; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true__4: (a: number, b: number, c: any) => void; - readonly wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke_______true_: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72: (a: number, b: number, c: any) => any; + readonly wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__hde7659040fca5453: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h962235009211f16e: (a: number, b: number) => number; + readonly wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451: (a: number, b: number) => void; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_exn_store: (a: number) => void; readonly __externref_table_alloc: () => number; readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_destroy_closure: (a: number, b: number) => void; - readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __wbindgen_start: () => void; } diff --git a/pkg/absurder_sql.js b/pkg/absurder_sql.js index 8bddc0a1..e0338e10 100644 --- a/pkg/absurder_sql.js +++ b/pkg/absurder_sql.js @@ -1,4 +1,8 @@ /* @ts-self-types="./absurder_sql.d.ts" */ +import { absurd_sleep } from './snippets/absurder-sql-7b083fd8e488940f/inline0.js'; +import { absurd_opfs_close, absurd_opfs_delete, absurd_opfs_exists, absurd_opfs_flush, absurd_opfs_open, absurd_opfs_read, absurd_opfs_size, absurd_opfs_truncate, absurd_opfs_write } from './snippets/absurder-sql-7b083fd8e488940f/inline1.js'; +import * as import1 from "./snippets/absurder-sql-7b083fd8e488940f/inline1.js" + export class Database { static __wrap(ptr) { @@ -157,6 +161,27 @@ export class Database { const ret = wasm.database_getPendingWritesCount(this.__wbg_ptr); return ret; } + /** + * @returns {string} + */ + getStorageBackend() { + let deferred2_0; + let deferred2_1; + try { + const ret = wasm.database_getStorageBackend(this.__wbg_ptr); + var ptr1 = ret[0]; + var len1 = ret[1]; + if (ret[3]) { + ptr1 = 0; len1 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred2_0 = ptr1; + deferred2_1 = len1; + return getStringFromWasm0(ptr1, len1); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } + } /** * Import SQLite database from .db file bytes * @@ -250,6 +275,29 @@ export class Database { const ret = wasm.database_newDatabase(ptr0, len0); return ret; } + /** + * @param {string} name + * @returns {Promise} + */ + static newDatabaseAuto(name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.database_newDatabaseAuto(ptr0, len0); + return ret; + } + /** + * @param {string} name + * @param {string} backend_name + * @returns {Promise} + */ + static newDatabaseWithBackend(name, backend_name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(backend_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.database_newDatabaseWithBackend(ptr0, len0, ptr1, len1); + return ret; + } /** * @param {Function} callback */ @@ -639,6 +687,47 @@ function __wbg_get_imports() { __wbg__wbg_cb_unref_fffb441def202758: function(arg0) { arg0._wbg_cb_unref(); }, + __wbg_absurd_opfs_close_fe16b29e0afc0472: function() { return handleError(function (arg0) { + const ret = absurd_opfs_close(arg0 >>> 0); + return ret; + }, arguments); }, + __wbg_absurd_opfs_delete_ec9f0a20dfead266: function() { return handleError(function (arg0, arg1) { + const ret = absurd_opfs_delete(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_absurd_opfs_exists_17e7747db12e8f64: function() { return handleError(function (arg0, arg1) { + const ret = absurd_opfs_exists(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_absurd_opfs_flush_988b78f0efec4249: function(arg0) { + absurd_opfs_flush(arg0 >>> 0); + }, + __wbg_absurd_opfs_open_8f92a6dd8d133be0: function() { return handleError(function (arg0, arg1, arg2) { + const ret = absurd_opfs_open(getStringFromWasm0(arg0, arg1), arg2 !== 0); + return ret; + }, arguments); }, + __wbg_absurd_opfs_read_b3d96187bcac9180: function(arg0, arg1, arg2) { + const ret = absurd_opfs_read(arg0 >>> 0, arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_absurd_opfs_size_aefbd7ff3ae1146a: function(arg0) { + const ret = absurd_opfs_size(arg0 >>> 0); + return ret; + }, + __wbg_absurd_opfs_truncate_7d8e3e9a9d6eb96f: function(arg0, arg1) { + absurd_opfs_truncate(arg0 >>> 0, arg1 >>> 0); + }, + __wbg_absurd_opfs_write_57c384e26dc640a4: function(arg0, arg1, arg2, arg3) { + absurd_opfs_write(arg0 >>> 0, arg1 >>> 0, getArrayU8FromWasm0(arg2, arg3)); + }, + __wbg_absurd_sleep_aab7e410d707acb6: function() { return handleError(function (arg0) { + const ret = absurd_sleep(arg0 >>> 0); + return ret; + }, arguments); }, + __wbg_apply_3ac86a26fdb56c05: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.apply(arg1, arg2); + return ret; + }, arguments); }, __wbg_bound_0382866a50c2df81: function() { return handleError(function (arg0, arg1) { const ret = IDBKeyRange.bound(arg0, arg1); return ret; @@ -790,6 +879,10 @@ function __wbg_get_imports() { const ret = arg0[arg1]; return ret; }, + __wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.has(arg0, arg1); + return ret; + }, arguments); }, __wbg_info_eadbe775a8e2e9eb: function(arg0) { console.info(arg0); }, @@ -843,6 +936,16 @@ function __wbg_get_imports() { const ret = result; return ret; }, + __wbg_instanceof_Promise_4cb210c0b8f8c959: function(arg0) { + let result; + try { + result = arg0 instanceof Promise; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, __wbg_instanceof_Uint8Array_309b927aaf7a3fc7: function(arg0) { let result; try { @@ -925,7 +1028,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_(a, state0.b, arg0, arg1); + return wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -959,7 +1062,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_(a, state0.b, arg0, arg1); + return wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -1053,6 +1156,24 @@ function __wbg_get_imports() { const ret = arg0.result; return ret; }, arguments); }, + __wbg_run_5aa314612b150933: function(arg0, arg1, arg2) { + try { + var state0 = {a: arg1, b: arg2}; + var cb0 = () => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h962235009211f16e(a, state0.b, ); + } finally { + state0.a = a; + } + }; + const ret = arg0.run(cb0); + return ret; + } finally { + state0.a = 0; + } + }, __wbg_setInterval_93ec7461c3650c76: function() { return handleError(function (arg0, arg1, arg2) { const ret = arg0.setInterval(arg1, arg2); return ret; @@ -1099,6 +1220,10 @@ function __wbg_get_imports() { const ret = arg0.slice(arg1 >>> 0, arg2 >>> 0); return ret; }, + __wbg_static_accessor_CREATE_TASK_7ee0dd8bc83df5b2: function() { + const ret = typeof console === 'undefined' ? null : console?.createTask; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, __wbg_static_accessor_GLOBAL_4ef717fb391d88b7: function() { const ret = typeof global === 'undefined' ? null : global; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -1159,33 +1284,33 @@ function __wbg_get_imports() { console.warn(arg0); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 408, ret: NamedExternref("Promise"), inner_ret: Some(NamedExternref("Promise")) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__js_sys_a35f79cb92043d9c___Promise__true_); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1175, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 410, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue______true_); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 572, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 977, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_4a6891ba067a060a___JsError___true_); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 573, ret: NamedExternref("Promise"), inner_ret: Some(NamedExternref("Promise")) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 306, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true_); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 309, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 306, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true__4); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 308, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde7659040fca5453); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 309, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke_______true_); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 307, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451); return ret; }, __wbindgen_cast_0000000000000007: function(arg0) { @@ -1221,39 +1346,49 @@ function __wbg_get_imports() { return { __proto__: null, "./absurder_sql_bg.js": import0, + "./snippets/absurder-sql-7b083fd8e488940f/inline1.js": import1, }; } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke_______true_(arg0, arg1) { - wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke_______true_(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451(arg0, arg1); } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue______true_(arg0, arg1, arg2) { - wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue______true_(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h962235009211f16e(arg0, arg1) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h962235009211f16e(arg0, arg1); + return ret !== 0; } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true_(arg0, arg1, arg2) { - wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true_(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025(arg0, arg1, arg2); } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true__4(arg0, arg1, arg2) { - wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true__4(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6(arg0, arg1, arg2); } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__js_sys_a35f79cb92043d9c___Promise__true_(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__js_sys_a35f79cb92043d9c___Promise__true_(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__hde7659040fca5453(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__hde7659040fca5453(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72(arg0, arg1, arg2); return ret; } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_4a6891ba067a060a___JsError___true_(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_4a6891ba067a060a___JsError___true_(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_(arg0, arg1, arg2, arg3); +function wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(arg0, arg1, arg2, arg3); +} + +function wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(arg0, arg1, arg2, arg3); } diff --git a/pkg/absurder_sql_bg.wasm b/pkg/absurder_sql_bg.wasm index 3aa1e2af..9d3e44f0 100644 Binary files a/pkg/absurder_sql_bg.wasm and b/pkg/absurder_sql_bg.wasm differ diff --git a/pkg/absurder_sql_bg.wasm.d.ts b/pkg/absurder_sql_bg.wasm.d.ts index 25cc3067..33707cd9 100644 --- a/pkg/absurder_sql_bg.wasm.d.ts +++ b/pkg/absurder_sql_bg.wasm.d.ts @@ -17,6 +17,7 @@ export const database_getAllDatabases: () => any; export const database_getCoordinationMetrics: (a: number) => any; export const database_getLeaderInfo: (a: number) => any; export const database_getPendingWritesCount: (a: number) => any; +export const database_getStorageBackend: (a: number) => [number, number, number, number]; export const database_importFromFile: (a: number, b: any) => any; export const database_isCoordinationMetricsEnabled: (a: number) => any; export const database_isLeader: (a: number) => any; @@ -24,6 +25,8 @@ export const database_isOptimisticMode: (a: number) => any; export const database_is_leader: (a: number) => any; export const database_name: (a: number) => [number, number]; export const database_newDatabase: (a: number, b: number) => any; +export const database_newDatabaseAuto: (a: number, b: number) => any; +export const database_newDatabaseWithBackend: (a: number, b: number, c: number, d: number) => any; export const database_onDataChange: (a: number, b: any) => [number, number]; export const database_queueWrite: (a: number, b: number, c: number) => any; export const database_queueWriteWithTimeout: (a: number, b: number, c: number, d: number) => any; @@ -38,6 +41,7 @@ export const database_sync: (a: number) => any; export const database_testLock: (a: number, b: number) => any; export const database_trackOptimisticWrite: (a: number, b: number, c: number) => any; export const database_waitForLeadership: (a: number) => any; +export const init_logger: () => void; export const wasmcolumnvalue_big_int: (a: number, b: number) => number; export const wasmcolumnvalue_blob: (a: number, b: number) => number; export const wasmcolumnvalue_createBigInt: (a: number, b: number) => number; @@ -47,20 +51,20 @@ export const wasmcolumnvalue_createInteger: (a: bigint) => number; export const wasmcolumnvalue_createNull: () => number; export const wasmcolumnvalue_createReal: (a: number) => number; export const wasmcolumnvalue_createText: (a: number, b: number) => number; +export const wasmcolumnvalue_date: (a: number) => number; export const wasmcolumnvalue_fromJsValue: (a: any) => number; export const wasmcolumnvalue_integer: (a: number) => number; -export const wasmcolumnvalue_real: (a: number) => number; -export const init_logger: () => void; export const wasmcolumnvalue_null: () => number; -export const wasmcolumnvalue_date: (a: number) => number; +export const wasmcolumnvalue_real: (a: number) => number; export const wasmcolumnvalue_text: (a: number, b: number) => number; +export const sqlite3_os_init: () => number; export const rust_sqlite_wasm_shim_acosh: (a: number) => number; export const rust_sqlite_wasm_shim_asinh: (a: number) => number; export const rust_sqlite_wasm_shim_atanh: (a: number) => number; export const rust_sqlite_wasm_shim_calloc: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_malloc: (a: number) => number; export const rust_sqlite_wasm_shim_free: (a: number) => void; export const rust_sqlite_wasm_shim_localtime: (a: number) => number; -export const rust_sqlite_wasm_shim_malloc: (a: number) => number; export const rust_sqlite_wasm_shim_memchr: (a: number, b: number, c: number) => number; export const rust_sqlite_wasm_shim_realloc: (a: number, b: number) => number; export const rust_sqlite_wasm_shim_sqrt: (a: number) => number; @@ -71,20 +75,21 @@ export const rust_sqlite_wasm_shim_strncmp: (a: number, b: number, c: number) => export const rust_sqlite_wasm_shim_strrchr: (a: number, b: number) => number; export const rust_sqlite_wasm_shim_strspn: (a: number, b: number) => number; export const rust_sqlite_wasm_shim_trunc: (a: number) => number; -export const sqlite3_os_init: () => number; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_4a6891ba067a060a___JsError___true_: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined___js_sys_a35f79cb92043d9c___Function_fn_wasm_bindgen_4a6891ba067a060a___JsValue_____wasm_bindgen_4a6891ba067a060a___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue__js_sys_a35f79cb92043d9c___Promise__true_: (a: number, b: number, c: any) => any; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___wasm_bindgen_4a6891ba067a060a___JsValue______true_: (a: number, b: number, c: any) => void; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true_: (a: number, b: number, c: any) => void; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke___web_sys_912ccd4ee1bace81___features__gen_MessageEvent__MessageEvent______true__4: (a: number, b: number, c: any) => void; -export const wasm_bindgen_4a6891ba067a060a___convert__closures_____invoke_______true_: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72: (a: number, b: number, c: any) => any; +export const wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__hde7659040fca5453: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h962235009211f16e: (a: number, b: number) => number; +export const wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451: (a: number, b: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_exn_store: (a: number) => void; export const __externref_table_alloc: () => number; export const __wbindgen_externrefs: WebAssembly.Table; export const __wbindgen_destroy_closure: (a: number, b: number) => void; -export const __wbindgen_free: (a: number, b: number, c: number) => void; export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; export const __wbindgen_start: () => void; diff --git a/pkg/snippets/absurder-sql-7b083fd8e488940f/inline0.js b/pkg/snippets/absurder-sql-7b083fd8e488940f/inline0.js new file mode 100644 index 00000000..6dd77686 --- /dev/null +++ b/pkg/snippets/absurder-sql-7b083fd8e488940f/inline0.js @@ -0,0 +1,4 @@ + +export function absurd_sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} diff --git a/pkg/snippets/absurder-sql-7b083fd8e488940f/inline1.js b/pkg/snippets/absurder-sql-7b083fd8e488940f/inline1.js new file mode 100644 index 00000000..edb96158 --- /dev/null +++ b/pkg/snippets/absurder-sql-7b083fd8e488940f/inline1.js @@ -0,0 +1,108 @@ + +const absurdHandles = new Map(); +let absurdNextHandleId = 1; + +function absurdGetRoot() { + if (!globalThis.navigator || !globalThis.navigator.storage || typeof globalThis.navigator.storage.getDirectory !== 'function') { + throw new Error('OPFS is not available in this context'); + } + + return globalThis.navigator.storage.getDirectory(); +} + +export async function absurd_opfs_open(fileName, create = true) { + const root = await absurdGetRoot(); + const fileHandle = await root.getFileHandle(fileName, { create }); + const accessHandle = await fileHandle.createSyncAccessHandle(); + const id = absurdNextHandleId++; + absurdHandles.set(id, accessHandle); + return id; +} + +export async function absurd_opfs_exists(fileName) { + try { + const root = await absurdGetRoot(); + await root.getFileHandle(fileName, { create: false }); + return true; + } catch (error) { + if (error && (error.name === 'NotFoundError' || String(error).includes('NotFound'))) { + return false; + } + throw error; + } +} + +export function absurd_opfs_read(id, offset, len) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + const buffer = new Uint8Array(len); + handle.read(buffer, { at: offset }); + return buffer; +} + +export function absurd_opfs_write(id, offset, data) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.write(data, { at: offset }); +} + +export function absurd_opfs_flush(id) { + const handle = absurdHandles.get(id); + if (handle) { + handle.flush(); + } +} + +export function absurd_opfs_size(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return 0; + } + + return handle.getSize(); +} + +export function absurd_opfs_truncate(id, size) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.truncate(size); +} + +export async function absurd_opfs_close(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return; + } + + const result = handle.close(); + absurdHandles.delete(id); + if (result && typeof result.then === 'function') { + await result; + } +} + +export async function absurd_opfs_delete(fileName) { + try { + const root = await absurdGetRoot(); + await root.removeEntry(fileName); + } catch (error) { + if (!String(error).includes('NotFound')) { + throw error; + } + } +} + +export function absurd_opfs_available() { + return !!(globalThis.navigator && + globalThis.navigator.storage && + typeof globalThis.navigator.storage.getDirectory === 'function'); +} diff --git a/playwright.config.js b/playwright.config.js index c81149de..5350f3f0 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -21,10 +21,18 @@ export default defineConfig({ }, ], - webServer: { - command: 'cd examples/vite-app && npm run dev', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, + webServer: [ + { + command: 'cd examples/vite-app && npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + { + command: 'node scripts/static_http_server.js 8080', + url: 'http://localhost:8080/examples/sql_demo.html', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + ], }); diff --git a/playwright.storage.config.js b/playwright.storage.config.js new file mode 100644 index 00000000..50b1dbd7 --- /dev/null +++ b/playwright.storage.config.js @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'line', + timeout: 120_000, + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium-storage', + use: { + ...devices['Desktop Chrome'], + ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}), + }, + }, + ], + webServer: { + command: 'node scripts/static_http_server.js 8080', + url: 'http://localhost:8080/examples/worker-example.html', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/pwa/README.md b/pwa/README.md index 5743e282..9bd0ae2c 100644 --- a/pwa/README.md +++ b/pwa/README.md @@ -5,7 +5,7 @@

**Tech Stack:** -[![Next.js](https://img.shields.io/badge/nextjs-16.0.1-black)](https://nextjs.org/) +[![Next.js](https://img.shields.io/badge/nextjs-16.3.0--preview.6-black)](https://nextjs.org/) [![React](https://img.shields.io/badge/react-19.2.0-blue)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/typescript-5.x-blue)](https://www.typescriptlang.org/) [![WASM](https://img.shields.io/badge/wasm-@npiesco/absurder--sql-orange)](https://www.npmjs.com/package/@npiesco/absurder-sql) @@ -20,15 +20,17 @@ ## Overview -AbsurderSQL PWA is a comprehensive database administration interface built on the [@npiesco/absurder-sql](https://www.npmjs.com/package/@npiesco/absurder-sql) WASM library. It provides a modern alternative to traditional server-based database admin tools like Adminer, running entirely in the browser with SQLite and IndexedDB. +AbsurderSQL PWA is a comprehensive database administration interface built on this repository's locally generated AbsurderSQL WASM package. It provides a modern alternative to traditional server-based database admin tools like Adminer, running entirely in the browser with SQLite, OPFS, and IndexedDB. -**Architecture:** Browser → WASM SQLite → IndexedDB VFS (4KB block-level I/O) +**Architecture:** Browser tabs → typed `Database` proxy → SharedWorker coordinator → elected dedicated WASM worker → Hybrid OPFS blocks + IndexedDB metadata (with IndexedDB fallback) + +**Dependency security:** The PWA pins Next.js / `eslint-config-next` 16.3.0-preview.6 because the configured CFS feed's newest stable release, 16.2.10, remains in the affected audit ranges. Scoped overrides select patched PostCSS and Sharp releases, and `vendor/brace-expansion-compat` preserves the legacy callable API while delegating to the audited `@isaacs/brace-expansion` implementation. **Key Capabilities:** - **100% Browser-Based**: No server infrastructure, all data stays local - **Offline-First PWA**: Install as desktop/mobile app, works without internet connectivity - **Native Performance**: Rust-compiled WASM delivers high-performance query execution -- **Multi-Tab Coordination**: Leader election via BroadcastChannel prevents database conflicts +- **Multi-Tab Coordination**: One SharedWorker elects a dedicated OPFS-capable runtime, queues tab requests, and hands ownership to a standby runtime when its owning tab closes - **Full Portability**: Import/export standard SQLite files for backup and migration ## Features @@ -81,8 +83,10 @@ AbsurderSQL PWA is a comprehensive database administration interface built on th - **Zustand** for global state management ### Database Layer -- **@npiesco/absurder-sql** (Rust-compiled WASM SQLite) -- Custom IndexedDB Virtual File System (VFS) +- Locally generated AbsurderSQL package (`../pkg` synced into `pwa/lib`) +- Typed main-thread proxy with a SharedWorker coordinator and elected dedicated WASM runtime +- Hybrid OPFS block persistence with IndexedDB metadata and fallback +- Custom SQLite Virtual File System (VFS) - Block-level I/O with 4KB chunks - LRU cache (128 blocks) for read optimization - Multi-tab coordination via BroadcastChannel API @@ -249,9 +253,9 @@ interface DatabaseStore { **Code Splitting:** ```javascript -// Dynamic import for optimal bundle size -const init = (await import('@npiesco/absurder-sql')).default; -const { Database } = await import('@npiesco/absurder-sql'); +// Dynamic import of the local coordinated-worker facade +const init = (await import('@/lib/db/database-proxy')).default; +const { Database } = await import('@/lib/db/database-proxy'); await init(); ``` @@ -356,7 +360,7 @@ Tab 1 (Leader) Tab 2 (Non-Leader) ```javascript // Main bundle: ~500KB (Next.js + React + UI components) // WASM module: ~1.3MB (~595KB gzipped) - loaded on demand -const init = (await import('@npiesco/absurder-sql')).default; +const init = (await import('@/lib/db/database-proxy')).default; ``` **Benefits:** diff --git a/pwa/app/db/browse/page.tsx b/pwa/app/db/browse/page.tsx index b6a9ec7e..46d04af2 100644 --- a/pwa/app/db/browse/page.tsx +++ b/pwa/app/db/browse/page.tsx @@ -137,11 +137,11 @@ export default function DataBrowserPage() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/charts/page.tsx b/pwa/app/db/charts/page.tsx index 54505bd2..ea4b01bb 100644 --- a/pwa/app/db/charts/page.tsx +++ b/pwa/app/db/charts/page.tsx @@ -89,11 +89,11 @@ function ChartBuilderContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/columns/page.tsx b/pwa/app/db/columns/page.tsx index 1122677a..a201ffb9 100644 --- a/pwa/app/db/columns/page.tsx +++ b/pwa/app/db/columns/page.tsx @@ -69,11 +69,11 @@ function ColumnFinderContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/dashboard/page.tsx b/pwa/app/db/dashboard/page.tsx index 768f0d93..d4962fbd 100644 --- a/pwa/app/db/dashboard/page.tsx +++ b/pwa/app/db/dashboard/page.tsx @@ -68,11 +68,11 @@ function DashboardContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/designer/page.tsx b/pwa/app/db/designer/page.tsx index b1c23a46..9cc0ab03 100644 --- a/pwa/app/db/designer/page.tsx +++ b/pwa/app/db/designer/page.tsx @@ -112,11 +112,11 @@ function TableDesignerContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/diagram/page.tsx b/pwa/app/db/diagram/page.tsx index d87fd6e8..5eda219c 100644 --- a/pwa/app/db/diagram/page.tsx +++ b/pwa/app/db/diagram/page.tsx @@ -60,11 +60,11 @@ export default function ERDiagramPage() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/diff/page.tsx b/pwa/app/db/diff/page.tsx index b6520433..cf186a11 100644 --- a/pwa/app/db/diff/page.tsx +++ b/pwa/app/db/diff/page.tsx @@ -70,7 +70,7 @@ function SchemaDiffContent() { async function initializeWasm() { try { - const { default: init, Database } = await import('@npiesco/absurder-sql'); + const { default: init, Database } = await import('@/lib/db/database-proxy'); await init(); (window as any).Database = Database; setWasmReady(true); diff --git a/pwa/app/db/grep/page.tsx b/pwa/app/db/grep/page.tsx index 916d46ed..41fa5c64 100644 --- a/pwa/app/db/grep/page.tsx +++ b/pwa/app/db/grep/page.tsx @@ -68,11 +68,11 @@ function DataGrepContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/import-csv/page.tsx b/pwa/app/db/import-csv/page.tsx index 430907e8..bbac95e0 100644 --- a/pwa/app/db/import-csv/page.tsx +++ b/pwa/app/db/import-csv/page.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'; import { useDatabaseStore } from '@/lib/db/store'; -import type { Row } from '@npiesco/absurder-sql'; +import type { Row } from '@/lib/db/database-proxy'; import Papa from 'papaparse'; -import init, { Database } from '@npiesco/absurder-sql'; +import init, { Database } from '@/lib/db/database-proxy'; import { Select, SelectContent, @@ -77,11 +77,11 @@ export default function CSVImportPage() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/page.tsx b/pwa/app/db/page.tsx index 298451e4..3a041489 100644 --- a/pwa/app/db/page.tsx +++ b/pwa/app/db/page.tsx @@ -14,7 +14,6 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { useDatabaseStore } from '@/lib/db/store'; -import { saveBackup, loadBackup, clearBackup } from '@/lib/db/backup'; export default function DatabaseManagementPage() { const { db, currentDbName, loading, status, tableCount, showSystemTables, _hasHydrated, setDb, setCurrentDbName, setLoading, setStatus, setTableCount, setShowSystemTables } = useDatabaseStore(); @@ -34,8 +33,8 @@ export default function DatabaseManagementPage() { async function initializeWasm() { try { - const init = (await import('@npiesco/absurder-sql')).default; - const { Database } = await import('@npiesco/absurder-sql'); + const init = (await import('@/lib/db/database-proxy')).default; + const { Database } = await import('@/lib/db/database-proxy'); // Init WASM first await init(); @@ -66,47 +65,18 @@ export default function DatabaseManagementPage() { async function loadDatabase() { try { - // CRITICAL: Check for backup FIRST before trying VFS-based restore - // This avoids sync() corruption issues - console.log('[PWA] Step 1: Checking for backup...'); - const backup = await loadBackup(); - - if (backup) { - console.log(`[PWA] Step 2: Found backup for ${backup.dbName}, restoring...`); - - // Create temp database for import - const tempDb = await Database.newDatabase(backup.dbName); - - // Import backup data - await tempDb.importFromFile(backup.data); - console.log('[PWA] Step 3: Backup imported, closing temp connection...'); - - // Close temp database (importFromFile can corrupt WASM state) - await tempDb.close(); - - // Reopen fresh connection - console.log('[PWA] Step 4: Reopening with fresh connection...'); - const dbInstance = await Database.newDatabase(backup.dbName); - - setDb(dbInstance); - setCurrentDbName(backup.dbName); - (window as any).testDb = dbInstance; - - console.log(`[PWA] Database restored from backup: ${backup.dbName}`); - setStatus(`Restored: ${backup.dbName}`); - } else if (currentDbName) { - // No backup, try VFS-based restore (fallback) - console.log(`[PWA] No backup found, trying VFS restore: ${currentDbName}`); + if (currentDbName) { + console.log(`[PWA] Opening persistent database: ${currentDbName}`); const dbInstance = await Database.newDatabase(currentDbName); console.log('[PWA] Database.newDatabase() returned successfully'); setDb(dbInstance); (window as any).testDb = dbInstance; - console.log(`[PWA] Database restored from VFS: ${currentDbName}`); + console.log(`[PWA] Database restored from persistent storage: ${currentDbName}`); setStatus(`Loaded: ${currentDbName}`); } else { - console.log('[PWA] No backup and no currentDbName - fresh start'); + console.log('[PWA] No currentDbName - fresh start'); setStatus('Create or import a database to get started'); } } catch (err: any) { @@ -118,39 +88,22 @@ export default function DatabaseManagementPage() { loadDatabase(); }, [wasmReady, _hasHydrated, currentDbName]); - // CRITICAL: Persist database to IndexedDB before page unload/refresh + // Writes are persisted by the SharedWorker owner. A visibility transition is + // only a best-effort durability hint; recovery never depends on a side backup. useEffect(() => { if (typeof window === 'undefined') return; - const handleBeforeUnload = async () => { - console.log('[PWA] Page unloading, saving backup and closing database'); - if (db && currentDbName) { - try { - // Save backup before closing (sync pattern) - await saveBackup(db, currentDbName); - } catch (err) { - console.error('[PWA] Error saving backup on unload:', err); - } - // Then close as fallback - db.close().catch((err: Error) => { - console.error('[PWA] Error closing database on unload:', err); + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden' && db) { + void db.sync().catch((err: Error) => { + console.error('[PWA] Background sync failed:', err); }); } }; - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - // Also close on component unmount - if (db) { - console.log('[PWA] Component unmounting, closing database'); - db.close().catch((err: Error) => { - console.error('[PWA] Error closing database on unmount:', err); - }); - } - }; - }, [db, currentDbName]); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [db]); const handleCreateDatabase = async () => { const Database = (window as any).Database; @@ -171,9 +124,6 @@ export default function DatabaseManagementPage() { setCurrentDbName(normalizedName); (window as any).testDb = newDb; - // Save backup immediately - await saveBackup(newDb, normalizedName); - setStatus(`Database created: ${normalizedName}`); setCreateDialogOpen(false); setNewDbName(''); @@ -242,9 +192,6 @@ export default function DatabaseManagementPage() { setCurrentDbName(dbName); (window as any).testDb = reopenedDb; - // Save backup immediately after import - await saveBackup(reopenedDb, dbName); - setStatus('Import complete'); setSelectedFile(null); if (fileInputRef.current) fileInputRef.current.value = ''; @@ -304,9 +251,6 @@ export default function DatabaseManagementPage() { // Delete using WASM API which cleans up all storage await Database.deleteDatabase(currentDbName); - // Clear backup as well - await clearBackup(); - // Clear state - don't auto-create a new database setDb(null); setCurrentDbName(''); @@ -372,9 +316,6 @@ export default function DatabaseManagementPage() { setCurrentDbName(dbName); (window as any).testDb = reopenedDb; - // Save backup immediately after drop import - await saveBackup(reopenedDb, dbName); - setStatus('Import complete'); setSelectedFile(null); } catch (err: any) { diff --git a/pwa/app/db/schema/page.tsx b/pwa/app/db/schema/page.tsx index e5e5fd7a..e61dfb92 100644 --- a/pwa/app/db/schema/page.tsx +++ b/pwa/app/db/schema/page.tsx @@ -76,11 +76,11 @@ function SchemaViewerContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/search/page.tsx b/pwa/app/db/search/page.tsx index f2b84838..7d7df5ab 100644 --- a/pwa/app/db/search/page.tsx +++ b/pwa/app/db/search/page.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import { DatabaseProvider, useDatabase } from '@/lib/db/provider'; import { useDatabaseStore } from '@/lib/db/store'; import { useSearchStore } from '@/lib/db/search-store'; -import { Database } from '@npiesco/absurder-sql'; +import { Database } from '@/lib/db/database-proxy'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -94,11 +94,11 @@ function SearchPageContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/storage/page.tsx b/pwa/app/db/storage/page.tsx index 8b6b4d47..8154c0ee 100644 --- a/pwa/app/db/storage/page.tsx +++ b/pwa/app/db/storage/page.tsx @@ -43,11 +43,11 @@ function StorageAnalysisContent() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/triggers/page.tsx b/pwa/app/db/triggers/page.tsx index 1185ab0b..deae1909 100644 --- a/pwa/app/db/triggers/page.tsx +++ b/pwa/app/db/triggers/page.tsx @@ -66,11 +66,11 @@ export default function TriggersManagementPage() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/db/views/page.tsx b/pwa/app/db/views/page.tsx index 05aafe3a..04d4079e 100644 --- a/pwa/app/db/views/page.tsx +++ b/pwa/app/db/views/page.tsx @@ -68,11 +68,11 @@ export default function ViewsManagementPage() { async function initializeWasm() { try { // ALWAYS initialize WASM first, even if db exists - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('@/lib/db/database-proxy')).default; await init(); // Then expose Database class - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('@/lib/db/database-proxy'); (window as any).Database = Database; // If db exists from Zustand, expose it as testDb diff --git a/pwa/app/worker-test/page.tsx b/pwa/app/worker-test/page.tsx new file mode 100644 index 00000000..d88291a7 --- /dev/null +++ b/pwa/app/worker-test/page.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import initializeDatabaseRuntime, { Database } from '@/lib/db/database-proxy'; + +export default function WorkerTestPage() { + const [status, setStatus] = useState('starting'); + + useEffect(() => { + void initializeDatabaseRuntime().then( + () => { + window.Database = Database; + setStatus('ready'); + }, + (error) => { + console.error(error); + setStatus(`error: ${error instanceof Error ? error.message : String(error)}`); + }, + ); + }, []); + + return ( +
+

AbsurderSQL SharedWorker Test Harness

+ {status} +
+ ); +} diff --git a/pwa/e2e/pwa-hybrid-storage.spec.ts b/pwa/e2e/pwa-hybrid-storage.spec.ts new file mode 100644 index 00000000..ad928145 --- /dev/null +++ b/pwa/e2e/pwa-hybrid-storage.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +async function openHarness(page: Page): Promise { + await page.goto('/worker-test'); + await expect(page.getByTestId('worker-status')).toHaveText('ready', { timeout: 30_000 }); +} + +async function deleteDatabase(context: BrowserContext, name: string): Promise { + context.setDefaultTimeout(30_000); + const cleanup = await context.newPage(); + await openHarness(cleanup); + await cleanup.evaluate(async (databaseName) => { + await window.Database!.deleteDatabase(databaseName); + }, name); + await cleanup.close(); +} + +test('selects Hybrid in the worker and survives close/reopen', async ({ page }) => { + const dbName = `pwa-hybrid-${Date.now()}.db`; + await openHarness(page); + + try { + const result = await page.evaluate(async (name) => { + const db = await window.Database!.newDatabaseAuto(name); + const backend = db.getStorageBackend(); + await db.execute('CREATE TABLE durable_rows (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute("INSERT INTO durable_rows (value) VALUES ('persisted')"); + await db.sync(); + await db.close(); + + const reopened = await window.Database!.newDatabaseAuto(name); + const rows = await reopened.execute('SELECT value FROM durable_rows ORDER BY id'); + const reopenedBackend = reopened.getStorageBackend(); + await reopened.close(); + const cell = rows.rows[0]?.values[0]; + return { backend, reopenedBackend, value: cell && 'value' in cell ? cell.value : null }; + }, dbName); + + expect(result).toEqual({ + backend: 'Hybrid', + reopenedBackend: 'Hybrid', + value: 'persisted', + }); + } finally { + await deleteDatabase(page.context(), dbName); + } +}); + +test('shares one live owner across tabs and remains writable after a tab closes', async ({ context }) => { + const dbName = `pwa-multitab-${Date.now()}.db`; + const first = await context.newPage(); + const second = await context.newPage(); + await openHarness(first); + await openHarness(second); + + try { + await first.evaluate(async (name) => { + const db = await window.Database!.newDatabaseAuto(name); + await db.execute('CREATE TABLE shared_rows (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute("INSERT INTO shared_rows (value) VALUES ('first-tab')"); + await db.sync(); + window.testDb = db; + }, dbName); + + const seenBySecond = await second.evaluate(async (name) => { + const db = await window.Database!.newDatabaseAuto(name); + window.testDb = db; + const result = await db.execute('SELECT value FROM shared_rows ORDER BY id'); + const cell = result.rows[0]?.values[0]; + return cell && 'value' in cell ? cell.value : null; + }, dbName); + expect(seenBySecond).toBe('first-tab'); + + const runtimeHandoff = second.evaluate( + () => new Promise((resolve) => { + window.addEventListener('absurder-sql-runtime-ready', () => resolve(), { once: true }); + }), + ); + await first.close(); + await runtimeHandoff; + const count = await second.evaluate(async () => { + const db = window.testDb; + await db.execute("INSERT INTO shared_rows (value) VALUES ('second-tab')"); + await db.sync(); + const result = await db.execute('SELECT COUNT(*) FROM shared_rows'); + const cell = result.rows[0]?.values[0]; + return cell && 'value' in cell ? cell.value : null; + }); + expect(count).toBe(2); + } finally { + if (!first.isClosed()) await first.close(); + await second.close(); + await deleteDatabase(context, dbName); + } +}); + +test('reloads and reopens persisted data while offline', async ({ context, page }) => { + const dbName = `pwa-offline-${Date.now()}.db`; + await openHarness(page); + + try { + await page.evaluate(async (name) => { + const db = await window.Database!.newDatabaseAuto(name); + await db.execute('CREATE TABLE offline_rows (value TEXT)'); + await db.execute("INSERT INTO offline_rows VALUES ('available-offline')"); + await db.sync(); + await db.close(); + await navigator.serviceWorker.ready; + }, dbName); + + // Reload once under service-worker control so the navigation response is in + // the runtime cache as well as the generated build assets. + await page.reload(); + await expect(page.getByTestId('worker-status')).toHaveText('ready'); + await context.setOffline(true); + await page.reload(); + await expect(page.getByTestId('worker-status')).toHaveText('ready'); + + const result = await page.evaluate(async (name) => { + const db = await window.Database!.newDatabaseAuto(name); + const rows = await db.execute('SELECT value FROM offline_rows'); + const backend = db.getStorageBackend(); + await db.close(); + const cell = rows.rows[0]?.values[0]; + return { backend, value: cell && 'value' in cell ? cell.value : null }; + }, dbName); + + expect(result).toEqual({ backend: 'Hybrid', value: 'available-offline' }); + } finally { + await context.setOffline(false); + await deleteDatabase(context, dbName); + } +}); diff --git a/pwa/e2e/types.d.ts b/pwa/e2e/types.d.ts index e575d468..3ca1a0aa 100644 --- a/pwa/e2e/types.d.ts +++ b/pwa/e2e/types.d.ts @@ -1,31 +1,9 @@ -/** - * Type declarations for E2E tests - */ - -interface Database { - newDatabase(name: string): Promise; -} - -interface DatabaseInstance { - execute(sql: string, params?: any[]): Promise; - exportToFile(): Promise; - importFromFile(data: Uint8Array): Promise; - sync(): Promise; - close(): Promise; -} - -interface QueryResult { - rows: Array<{ - values: Array<{ - value: any; - }>; - }>; -} +import type { Database as DatabaseProxy } from '../lib/db/database-proxy'; declare global { interface Window { - Database: Database; - testDb: DatabaseInstance; + Database: typeof DatabaseProxy; + testDb: DatabaseProxy; } } diff --git a/pwa/lib/absurder_sql.d.ts b/pwa/lib/absurder_sql.d.ts index da530ebf..014d7b58 100644 --- a/pwa/lib/absurder_sql.d.ts +++ b/pwa/lib/absurder_sql.d.ts @@ -1,6 +1,5 @@ /* tslint:disable */ /* eslint-disable */ -export function init_logger(): void; export interface DatabaseConfig { name: string; version: number | null; @@ -11,6 +10,12 @@ export interface DatabaseConfig { max_export_size_bytes: number | null; } +export interface DatabaseError { + code: string; + message: string; + sql: string | null; +} + export interface QueryResult { columns: string[]; rows: Row[]; @@ -23,329 +28,343 @@ export interface Row { values: ColumnValue[]; } -export type ColumnValue = { type: "Null" } | { type: "Integer"; value: number } | { type: "Real"; value: number } | { type: "Text"; value: string } | { type: "Blob"; value: number[] } | { type: "Date"; value: number } | { type: "BigInt"; value: string }; - export interface TransactionOptions { isolation_level: IsolationLevel; timeout_ms: number | null; } +export type ColumnValue = { type: "Null" } | { type: "Integer"; value: number } | { type: "Real"; value: number } | { type: "Text"; value: string } | { type: "Blob"; value: number[] } | { type: "Date"; value: number } | { type: "BigInt"; value: string }; + export type IsolationLevel = "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable"; -export interface DatabaseError { - code: string; - message: string; - sql: string | null; -} export class Database { - private constructor(); - free(): void; - [Symbol.dispose](): void; - static newDatabase(name: string): Promise; - /** - * Get all database names stored in IndexedDB - * - * Returns an array of database names (sorted alphabetically) - */ - static getAllDatabases(): Promise; - /** - * Delete a database from storage - * - * Removes database from both STORAGE_REGISTRY and GLOBAL_STORAGE - */ - static deleteDatabase(name: string): Promise; - execute(sql: string): Promise; - executeWithParams(sql: string, params: any): Promise; - close(): Promise; - /** - * Force close connection and remove from pool (for test cleanup) - */ - forceCloseConnection(): Promise; - sync(): Promise; - /** - * Allow non-leader writes (for single-tab apps or testing) - */ - allowNonLeaderWrites(allow: boolean): Promise; - /** - * Export database to SQLite .db file format - * - * Returns the complete database as a Uint8Array that can be downloaded - * or saved as a standard SQLite .db file. - * - * # Example - * ```javascript - * const dbBytes = await db.exportToFile(); - * const blob = new Blob([dbBytes], { type: 'application/x-sqlite3' }); - * const url = URL.createObjectURL(blob); - * const a = document.createElement('a'); - * a.href = url; - * a.download = 'database.db'; - * a.click(); - * ``` - */ - exportToFile(): Promise; - /** - * Test method for concurrent locking - simple increment counter - */ - testLock(value: number): Promise; - /** - * Import SQLite database from .db file bytes - * - * Replaces the current database contents with the imported data. - * This will close the current database connection and clear all existing data. - * - * # Arguments - * * `file_data` - SQLite .db file as Uint8Array - * - * # Returns - * * `Ok(())` - Import successful - * * `Err(JsValue)` - Import failed (invalid file, validation error, etc.) - * - * # Example - * ```javascript - * // From file input - * const fileInput = document.getElementById('dbFile'); - * const file = fileInput.files[0]; - * const arrayBuffer = await file.arrayBuffer(); - * const uint8Array = new Uint8Array(arrayBuffer); - * - * await db.importFromFile(uint8Array); - * - * // Database is now replaced - you may need to reopen connections - * ``` - * - * # Warning - * This operation is destructive and will replace all existing database data. - * **IMPORTANT:** You MUST call `db.close()` after import and reopen the database - * for changes to take effect. - */ - importFromFile(file_data: Uint8Array): Promise; - /** - * Wait for this instance to become leader - */ - waitForLeadership(): Promise; - /** - * Request leadership (triggers re-election check) - */ - requestLeadership(): Promise; - /** - * Get leader information - */ - getLeaderInfo(): Promise; - /** - * Queue a write operation to be executed by the leader - * - * Non-leader tabs can use this to request writes from the leader. - * The write is forwarded via BroadcastChannel and executed by the leader. - * - * # Arguments - * * `sql` - SQL statement to execute (must be a write operation) - * - * # Returns - * Result indicating success or failure - */ - queueWrite(sql: string): Promise; - /** - * Queue a write operation with a specific timeout - * - * # Arguments - * * `sql` - SQL statement to execute - * * `timeout_ms` - Timeout in milliseconds - */ - queueWriteWithTimeout(sql: string, timeout_ms: number): Promise; - isLeader(): Promise; - /** - * Check if this instance is the leader (non-wasm version for internal use/tests) - */ - is_leader(): Promise; - onDataChange(callback: Function): void; - /** - * Enable or disable optimistic updates mode - */ - enableOptimisticUpdates(enabled: boolean): Promise; - /** - * Check if optimistic mode is enabled - */ - isOptimisticMode(): Promise; - /** - * Track an optimistic write - */ - trackOptimisticWrite(sql: string): Promise; - /** - * Get count of pending writes - */ - getPendingWritesCount(): Promise; - /** - * Clear all optimistic writes - */ - clearOptimisticWrites(): Promise; - /** - * Enable or disable coordination metrics tracking - */ - enableCoordinationMetrics(enabled: boolean): Promise; - /** - * Check if coordination metrics tracking is enabled - */ - isCoordinationMetricsEnabled(): Promise; - /** - * Record a leadership change - */ - recordLeadershipChange(became_leader: boolean): Promise; - /** - * Record a notification latency in milliseconds - */ - recordNotificationLatency(latency_ms: number): Promise; - /** - * Record a write conflict (non-leader write attempt) - */ - recordWriteConflict(): Promise; - /** - * Record a follower refresh - */ - recordFollowerRefresh(): Promise; - /** - * Get coordination metrics as JSON string - */ - getCoordinationMetrics(): Promise; - /** - * Reset all coordination metrics - */ - resetCoordinationMetrics(): Promise; - /** - * Get the database name - */ - readonly name: string; + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * Allow non-leader writes (for single-tab apps or testing) + */ + allowNonLeaderWrites(allow: boolean): Promise; + /** + * Clear all optimistic writes + */ + clearOptimisticWrites(): Promise; + close(): Promise; + /** + * Delete a database from storage + * + * Removes database from both STORAGE_REGISTRY and GLOBAL_STORAGE + */ + static deleteDatabase(name: string): Promise; + /** + * Enable or disable coordination metrics tracking + */ + enableCoordinationMetrics(enabled: boolean): Promise; + /** + * Enable or disable optimistic updates mode + */ + enableOptimisticUpdates(enabled: boolean): Promise; + execute(sql: string): Promise; + executeWithParams(sql: string, params: any): Promise; + /** + * Export database to SQLite .db file format + * + * Returns the complete database as a Uint8Array that can be downloaded + * or saved as a standard SQLite .db file. + * + * # Example + * ```javascript + * const dbBytes = await db.exportToFile(); + * const blob = new Blob([dbBytes], { type: 'application/x-sqlite3' }); + * const url = URL.createObjectURL(blob); + * const a = document.createElement('a'); + * a.href = url; + * a.download = 'database.db'; + * a.click(); + * ``` + */ + exportToFile(): Promise; + /** + * Force close connection and remove from pool (for test cleanup) + */ + forceCloseConnection(): Promise; + /** + * Get all database names stored in IndexedDB + * + * Returns an array of database names (sorted alphabetically) + */ + static getAllDatabases(): Promise; + /** + * Get coordination metrics as JSON string + */ + getCoordinationMetrics(): Promise; + /** + * Get leader information + */ + getLeaderInfo(): Promise; + /** + * Get count of pending writes + */ + getPendingWritesCount(): Promise; + getStorageBackend(): string; + /** + * Import SQLite database from .db file bytes + * + * Replaces the current database contents with the imported data. + * This will close the current database connection and clear all existing data. + * + * # Arguments + * * `file_data` - SQLite .db file as Uint8Array + * + * # Returns + * * `Ok(())` - Import successful + * * `Err(JsValue)` - Import failed (invalid file, validation error, etc.) + * + * # Example + * ```javascript + * // From file input + * const fileInput = document.getElementById('dbFile'); + * const file = fileInput.files[0]; + * const arrayBuffer = await file.arrayBuffer(); + * const uint8Array = new Uint8Array(arrayBuffer); + * + * await db.importFromFile(uint8Array); + * + * // Database is immediately usable after import (no reopen needed) + * const result = await db.execute('SELECT * FROM imported_table'); + * ``` + * + * # Warning + * This operation is destructive and will replace all existing database data. + */ + importFromFile(file_data: Uint8Array): Promise; + /** + * Check if coordination metrics tracking is enabled + */ + isCoordinationMetricsEnabled(): Promise; + isLeader(): Promise; + /** + * Check if optimistic mode is enabled + */ + isOptimisticMode(): Promise; + /** + * Check if this instance is the leader (non-wasm version for internal use/tests) + */ + is_leader(): Promise; + static newDatabase(name: string): Promise; + static newDatabaseAuto(name: string): Promise; + static newDatabaseWithBackend(name: string, backend_name: string): Promise; + onDataChange(callback: Function): void; + /** + * Queue a write operation to be executed by the leader + * + * Non-leader tabs can use this to request writes from the leader. + * The write is forwarded via BroadcastChannel and executed by the leader. + * + * # Arguments + * * `sql` - SQL statement to execute (must be a write operation) + * + * # Returns + * Result indicating success or failure + */ + queueWrite(sql: string): Promise; + /** + * Queue a write operation with a specific timeout + * + * # Arguments + * * `sql` - SQL statement to execute + * * `timeout_ms` - Timeout in milliseconds + */ + queueWriteWithTimeout(sql: string, timeout_ms: number): Promise; + /** + * Record a follower refresh + */ + recordFollowerRefresh(): Promise; + /** + * Record a leadership change + */ + recordLeadershipChange(became_leader: boolean): Promise; + /** + * Record a notification latency in milliseconds + */ + recordNotificationLatency(latency_ms: number): Promise; + /** + * Record a write conflict (non-leader write attempt) + */ + recordWriteConflict(): Promise; + /** + * Reload data from IndexedDB into memory + * Call this when another tab has written data and you need to see the changes + * This closes and reopens the SQLite connection to invalidate its page cache + */ + reloadFromIndexedDB(): Promise; + /** + * Request leadership (triggers re-election check) + */ + requestLeadership(): Promise; + /** + * Reset all coordination metrics + */ + resetCoordinationMetrics(): Promise; + sync(): Promise; + /** + * Test method for concurrent locking - simple increment counter + */ + testLock(value: number): Promise; + /** + * Track an optimistic write + */ + trackOptimisticWrite(sql: string): Promise; + /** + * Wait for this instance to become leader + */ + waitForLeadership(): Promise; + /** + * Get the database name + */ + readonly name: string; } + export class WasmColumnValue { - private constructor(); - free(): void; - [Symbol.dispose](): void; - static createNull(): WasmColumnValue; - static createInteger(value: bigint): WasmColumnValue; - static createReal(value: number): WasmColumnValue; - static createText(value: string): WasmColumnValue; - static createBlob(value: Uint8Array): WasmColumnValue; - static createBigInt(value: string): WasmColumnValue; - static createDate(timestamp: number): WasmColumnValue; - static fromJsValue(value: any): WasmColumnValue; - static null(): WasmColumnValue; - static integer(value: number): WasmColumnValue; - static real(value: number): WasmColumnValue; - static text(value: string): WasmColumnValue; - static blob(value: Uint8Array): WasmColumnValue; - static big_int(value: string): WasmColumnValue; - static date(timestamp_ms: number): WasmColumnValue; + private constructor(); + free(): void; + [Symbol.dispose](): void; + static big_int(value: string): WasmColumnValue; + static blob(value: Uint8Array): WasmColumnValue; + static createBigInt(value: string): WasmColumnValue; + static createBlob(value: Uint8Array): WasmColumnValue; + static createDate(timestamp: number): WasmColumnValue; + static createInteger(value: bigint): WasmColumnValue; + static createNull(): WasmColumnValue; + static createReal(value: number): WasmColumnValue; + static createText(value: string): WasmColumnValue; + static date(timestamp_ms: number): WasmColumnValue; + static fromJsValue(value: any): WasmColumnValue; + static integer(value: number): WasmColumnValue; + static null(): WasmColumnValue; + static real(value: number): WasmColumnValue; + static text(value: string): WasmColumnValue; } +export function init_logger(): void; + export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export interface InitOutput { - readonly memory: WebAssembly.Memory; - readonly init_logger: () => void; - readonly __wbg_database_free: (a: number, b: number) => void; - readonly database_newDatabase: (a: number, b: number) => any; - readonly database_name: (a: number) => [number, number]; - readonly database_getAllDatabases: () => any; - readonly database_deleteDatabase: (a: number, b: number) => any; - readonly database_execute: (a: number, b: number, c: number) => any; - readonly database_executeWithParams: (a: number, b: number, c: number, d: any) => any; - readonly database_close: (a: number) => any; - readonly database_forceCloseConnection: (a: number) => any; - readonly database_sync: (a: number) => any; - readonly database_allowNonLeaderWrites: (a: number, b: number) => any; - readonly database_exportToFile: (a: number) => any; - readonly database_testLock: (a: number, b: number) => any; - readonly database_importFromFile: (a: number, b: any) => any; - readonly database_waitForLeadership: (a: number) => any; - readonly database_requestLeadership: (a: number) => any; - readonly database_getLeaderInfo: (a: number) => any; - readonly database_queueWrite: (a: number, b: number, c: number) => any; - readonly database_queueWriteWithTimeout: (a: number, b: number, c: number, d: number) => any; - readonly database_isLeader: (a: number) => any; - readonly database_is_leader: (a: number) => any; - readonly database_onDataChange: (a: number, b: any) => [number, number]; - readonly database_enableOptimisticUpdates: (a: number, b: number) => any; - readonly database_isOptimisticMode: (a: number) => any; - readonly database_trackOptimisticWrite: (a: number, b: number, c: number) => any; - readonly database_getPendingWritesCount: (a: number) => any; - readonly database_clearOptimisticWrites: (a: number) => any; - readonly database_enableCoordinationMetrics: (a: number, b: number) => any; - readonly database_isCoordinationMetricsEnabled: (a: number) => any; - readonly database_recordLeadershipChange: (a: number, b: number) => any; - readonly database_recordNotificationLatency: (a: number, b: number) => any; - readonly database_recordWriteConflict: (a: number) => any; - readonly database_recordFollowerRefresh: (a: number) => any; - readonly database_getCoordinationMetrics: (a: number) => any; - readonly database_resetCoordinationMetrics: (a: number) => any; - readonly __wbg_wasmcolumnvalue_free: (a: number, b: number) => void; - readonly wasmcolumnvalue_createNull: () => number; - readonly wasmcolumnvalue_createInteger: (a: bigint) => number; - readonly wasmcolumnvalue_createReal: (a: number) => number; - readonly wasmcolumnvalue_createText: (a: number, b: number) => number; - readonly wasmcolumnvalue_createBlob: (a: number, b: number) => number; - readonly wasmcolumnvalue_createBigInt: (a: number, b: number) => number; - readonly wasmcolumnvalue_createDate: (a: number) => number; - readonly wasmcolumnvalue_fromJsValue: (a: any) => number; - readonly wasmcolumnvalue_integer: (a: number) => number; - readonly wasmcolumnvalue_blob: (a: number, b: number) => number; - readonly wasmcolumnvalue_big_int: (a: number, b: number) => number; - readonly wasmcolumnvalue_date: (a: number) => number; - readonly wasmcolumnvalue_text: (a: number, b: number) => number; - readonly wasmcolumnvalue_real: (a: number) => number; - readonly wasmcolumnvalue_null: () => number; - readonly rust_sqlite_wasm_shim_strcmp: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_strncmp: (a: number, b: number, c: number) => number; - readonly rust_sqlite_wasm_shim_strcspn: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_strspn: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_strrchr: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_strchr: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_memchr: (a: number, b: number, c: number) => number; - readonly rust_sqlite_wasm_shim_acosh: (a: number) => number; - readonly rust_sqlite_wasm_shim_asinh: (a: number) => number; - readonly rust_sqlite_wasm_shim_atanh: (a: number) => number; - readonly rust_sqlite_wasm_shim_trunc: (a: number) => number; - readonly rust_sqlite_wasm_shim_sqrt: (a: number) => number; - readonly rust_sqlite_wasm_shim_localtime: (a: number) => number; - readonly rust_sqlite_wasm_shim_malloc: (a: number) => number; - readonly rust_sqlite_wasm_shim_free: (a: number) => void; - readonly rust_sqlite_wasm_shim_realloc: (a: number, b: number) => number; - readonly rust_sqlite_wasm_shim_calloc: (a: number, b: number) => number; - readonly sqlite3_os_init: () => number; - readonly wasm_bindgen__convert__closures_____invoke__h461a9f3a02628bdb: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__closure__destroy__h4d3ac454e1c5b733: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h3ba5f0fbfb39f2bc: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__closure__destroy__hddca379abe978273: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h5d89cc2ab3b3e449: (a: number, b: number, c: any) => any; - readonly wasm_bindgen__convert__closures_____invoke__h183d48ae9af1b9ef: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h0645e20ee34c432f: (a: number, b: number, c: any, d: any) => void; - readonly __wbindgen_malloc: (a: number, b: number) => number; - readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; - readonly __wbindgen_exn_store: (a: number) => void; - readonly __externref_table_alloc: () => number; - readonly __wbindgen_externrefs: WebAssembly.Table; - readonly __wbindgen_free: (a: number, b: number, c: number) => void; - readonly __externref_table_dealloc: (a: number) => void; - readonly __wbindgen_start: () => void; + readonly memory: WebAssembly.Memory; + readonly __wbg_database_free: (a: number, b: number) => void; + readonly __wbg_wasmcolumnvalue_free: (a: number, b: number) => void; + readonly database_allowNonLeaderWrites: (a: number, b: number) => any; + readonly database_clearOptimisticWrites: (a: number) => any; + readonly database_close: (a: number) => any; + readonly database_deleteDatabase: (a: number, b: number) => any; + readonly database_enableCoordinationMetrics: (a: number, b: number) => any; + readonly database_enableOptimisticUpdates: (a: number, b: number) => any; + readonly database_execute: (a: number, b: number, c: number) => any; + readonly database_executeWithParams: (a: number, b: number, c: number, d: any) => any; + readonly database_exportToFile: (a: number) => any; + readonly database_forceCloseConnection: (a: number) => any; + readonly database_getAllDatabases: () => any; + readonly database_getCoordinationMetrics: (a: number) => any; + readonly database_getLeaderInfo: (a: number) => any; + readonly database_getPendingWritesCount: (a: number) => any; + readonly database_getStorageBackend: (a: number) => [number, number, number, number]; + readonly database_importFromFile: (a: number, b: any) => any; + readonly database_isCoordinationMetricsEnabled: (a: number) => any; + readonly database_isLeader: (a: number) => any; + readonly database_isOptimisticMode: (a: number) => any; + readonly database_is_leader: (a: number) => any; + readonly database_name: (a: number) => [number, number]; + readonly database_newDatabase: (a: number, b: number) => any; + readonly database_newDatabaseAuto: (a: number, b: number) => any; + readonly database_newDatabaseWithBackend: (a: number, b: number, c: number, d: number) => any; + readonly database_onDataChange: (a: number, b: any) => [number, number]; + readonly database_queueWrite: (a: number, b: number, c: number) => any; + readonly database_queueWriteWithTimeout: (a: number, b: number, c: number, d: number) => any; + readonly database_recordFollowerRefresh: (a: number) => any; + readonly database_recordLeadershipChange: (a: number, b: number) => any; + readonly database_recordNotificationLatency: (a: number, b: number) => any; + readonly database_recordWriteConflict: (a: number) => any; + readonly database_reloadFromIndexedDB: (a: number) => any; + readonly database_requestLeadership: (a: number) => any; + readonly database_resetCoordinationMetrics: (a: number) => any; + readonly database_sync: (a: number) => any; + readonly database_testLock: (a: number, b: number) => any; + readonly database_trackOptimisticWrite: (a: number, b: number, c: number) => any; + readonly database_waitForLeadership: (a: number) => any; + readonly init_logger: () => void; + readonly wasmcolumnvalue_big_int: (a: number, b: number) => number; + readonly wasmcolumnvalue_blob: (a: number, b: number) => number; + readonly wasmcolumnvalue_createBigInt: (a: number, b: number) => number; + readonly wasmcolumnvalue_createBlob: (a: number, b: number) => number; + readonly wasmcolumnvalue_createDate: (a: number) => number; + readonly wasmcolumnvalue_createInteger: (a: bigint) => number; + readonly wasmcolumnvalue_createNull: () => number; + readonly wasmcolumnvalue_createReal: (a: number) => number; + readonly wasmcolumnvalue_createText: (a: number, b: number) => number; + readonly wasmcolumnvalue_date: (a: number) => number; + readonly wasmcolumnvalue_fromJsValue: (a: any) => number; + readonly wasmcolumnvalue_integer: (a: number) => number; + readonly wasmcolumnvalue_null: () => number; + readonly wasmcolumnvalue_real: (a: number) => number; + readonly wasmcolumnvalue_text: (a: number, b: number) => number; + readonly sqlite3_os_init: () => number; + readonly rust_sqlite_wasm_shim_acosh: (a: number) => number; + readonly rust_sqlite_wasm_shim_asinh: (a: number) => number; + readonly rust_sqlite_wasm_shim_atanh: (a: number) => number; + readonly rust_sqlite_wasm_shim_calloc: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_malloc: (a: number) => number; + readonly rust_sqlite_wasm_shim_free: (a: number) => void; + readonly rust_sqlite_wasm_shim_localtime: (a: number) => number; + readonly rust_sqlite_wasm_shim_memchr: (a: number, b: number, c: number) => number; + readonly rust_sqlite_wasm_shim_realloc: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_sqrt: (a: number) => number; + readonly rust_sqlite_wasm_shim_strchr: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_strcmp: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_strcspn: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_strncmp: (a: number, b: number, c: number) => number; + readonly rust_sqlite_wasm_shim_strrchr: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_strspn: (a: number, b: number) => number; + readonly rust_sqlite_wasm_shim_trunc: (a: number) => number; + readonly wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72: (a: number, b: number, c: any) => any; + readonly wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__hde7659040fca5453: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h962235009211f16e: (a: number, b: number) => number; + readonly wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451: (a: number, b: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_destroy_closure: (a: number, b: number) => void; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_start: () => void; } export type SyncInitInput = BufferSource | WebAssembly.Module; + /** -* Instantiates the given `module`, which can either be bytes or -* a precompiled `WebAssembly.Module`. -* -* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. -* -* @returns {InitOutput} -*/ + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; /** -* If `module_or_path` is {RequestInfo} or {URL}, makes a request and -* for everything else, calls `WebAssembly.instantiate` directly. -* -* @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. -* -* @returns {Promise} -*/ + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/pwa/lib/absurder_sql.js b/pwa/lib/absurder_sql.js index d0dd2922..e0338e10 100644 --- a/pwa/lib/absurder_sql.js +++ b/pwa/lib/absurder_sql.js @@ -1,317 +1,48 @@ -let wasm; +/* @ts-self-types="./absurder_sql.d.ts" */ +import { absurd_sleep } from './snippets/absurder-sql-7b083fd8e488940f/inline0.js'; +import { absurd_opfs_close, absurd_opfs_delete, absurd_opfs_exists, absurd_opfs_flush, absurd_opfs_open, absurd_opfs_read, absurd_opfs_size, absurd_opfs_truncate, absurd_opfs_write } from './snippets/absurder-sql-7b083fd8e488940f/inline1.js'; +import * as import1 from "./snippets/absurder-sql-7b083fd8e488940f/inline1.js" -let cachedUint8ArrayMemory0 = null; - -function getUint8ArrayMemory0() { - if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { - cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); - } - return cachedUint8ArrayMemory0; -} - -let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); - -cachedTextDecoder.decode(); - -const MAX_SAFARI_DECODE_BYTES = 2146435072; -let numBytesDecoded = 0; -function decodeText(ptr, len) { - numBytesDecoded += len; - if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { - cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); - cachedTextDecoder.decode(); - numBytesDecoded = len; - } - return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0; - return decodeText(ptr, len); -} - -let WASM_VECTOR_LEN = 0; - -const cachedTextEncoder = new TextEncoder(); - -if (!('encodeInto' in cachedTextEncoder)) { - cachedTextEncoder.encodeInto = function (arg, view) { - const buf = cachedTextEncoder.encode(arg); - view.set(buf); - return { - read: arg.length, - written: buf.length - }; - } -} - -function passStringToWasm0(arg, malloc, realloc) { - - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg); - const ptr = malloc(buf.length, 1) >>> 0; - getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); - WASM_VECTOR_LEN = buf.length; - return ptr; - } - - let len = arg.length; - let ptr = malloc(len, 1) >>> 0; - - const mem = getUint8ArrayMemory0(); - - let offset = 0; - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset); - if (code > 0x7F) break; - mem[ptr + offset] = code; - } - - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset); - } - ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; - const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); - const ret = cachedTextEncoder.encodeInto(arg, view); - - offset += ret.written; - ptr = realloc(ptr, len, offset, 1) >>> 0; - } - - WASM_VECTOR_LEN = offset; - return ptr; -} - -let cachedDataViewMemory0 = null; - -function getDataViewMemory0() { - if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { - cachedDataViewMemory0 = new DataView(wasm.memory.buffer); - } - return cachedDataViewMemory0; -} - -function isLikeNone(x) { - return x === undefined || x === null; -} - -function debugString(val) { - // primitive types - const type = typeof val; - if (type == 'number' || type == 'boolean' || val == null) { - return `${val}`; - } - if (type == 'string') { - return `"${val}"`; - } - if (type == 'symbol') { - const description = val.description; - if (description == null) { - return 'Symbol'; - } else { - return `Symbol(${description})`; - } - } - if (type == 'function') { - const name = val.name; - if (typeof name == 'string' && name.length > 0) { - return `Function(${name})`; - } else { - return 'Function'; - } - } - // objects - if (Array.isArray(val)) { - const length = val.length; - let debug = '['; - if (length > 0) { - debug += debugString(val[0]); - } - for(let i = 1; i < length; i++) { - debug += ', ' + debugString(val[i]); - } - debug += ']'; - return debug; - } - // Test for built-in - const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); - let className; - if (builtInMatches && builtInMatches.length > 1) { - className = builtInMatches[1]; - } else { - // Failed to match the standard '[object ClassName]' - return toString.call(val); - } - if (className == 'Object') { - // we're a user defined class or Object - // JSON.stringify avoids problems with cycles, and is generally much - // easier than looping through ownProperties of `val`. - try { - return 'Object(' + JSON.stringify(val) + ')'; - } catch (_) { - return 'Object'; - } - } - // errors - if (val instanceof Error) { - return `${val.name}: ${val.message}\n${val.stack}`; - } - // TODO we could test for more things here, like `Set`s and `Map`s. - return className; -} - -function addToExternrefTable0(obj) { - const idx = wasm.__externref_table_alloc(); - wasm.__wbindgen_externrefs.set(idx, obj); - return idx; -} - -function handleError(f, args) { - try { - return f.apply(this, args); - } catch (e) { - const idx = addToExternrefTable0(e); - wasm.__wbindgen_exn_store(idx); - } -} - -function getArrayU8FromWasm0(ptr, len) { - ptr = ptr >>> 0; - return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); -} - -const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry(state => state.dtor(state.a, state.b)); - -function makeMutClosure(arg0, arg1, dtor, f) { - const state = { a: arg0, b: arg1, cnt: 1, dtor }; - const real = (...args) => { - - // First up with a closure we increment the internal reference - // count. This ensures that the Rust closure environment won't - // be deallocated while we're invoking it. - state.cnt++; - const a = state.a; - state.a = 0; - try { - return f(a, state.b, ...args); - } finally { - state.a = a; - real._wbg_cb_unref(); - } - }; - real._wbg_cb_unref = () => { - if (--state.cnt === 0) { - state.dtor(state.a, state.b); - state.a = 0; - CLOSURE_DTORS.unregister(state); - } - }; - CLOSURE_DTORS.register(real, state, state); - return real; -} - -export function init_logger() { - wasm.init_logger(); -} - -function takeFromExternrefTable0(idx) { - const value = wasm.__wbindgen_externrefs.get(idx); - wasm.__externref_table_dealloc(idx); - return value; -} - -function passArray8ToWasm0(arg, malloc) { - const ptr = malloc(arg.length * 1, 1) >>> 0; - getUint8ArrayMemory0().set(arg, ptr / 1); - WASM_VECTOR_LEN = arg.length; - return ptr; -} -function wasm_bindgen__convert__closures_____invoke__h461a9f3a02628bdb(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h461a9f3a02628bdb(arg0, arg1, arg2); -} - -function wasm_bindgen__convert__closures_____invoke__h3ba5f0fbfb39f2bc(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h3ba5f0fbfb39f2bc(arg0, arg1, arg2); -} - -function wasm_bindgen__convert__closures_____invoke__h5d89cc2ab3b3e449(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h5d89cc2ab3b3e449(arg0, arg1, arg2); - return ret; -} - -function wasm_bindgen__convert__closures_____invoke__h183d48ae9af1b9ef(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h183d48ae9af1b9ef(arg0, arg1); -} - -function wasm_bindgen__convert__closures_____invoke__h0645e20ee34c432f(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen__convert__closures_____invoke__h0645e20ee34c432f(arg0, arg1, arg2, arg3); -} - -const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"]; - -const DatabaseFinalization = (typeof FinalizationRegistry === 'undefined') - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry(ptr => wasm.__wbg_database_free(ptr >>> 0, 1)); export class Database { - static __wrap(ptr) { - ptr = ptr >>> 0; const obj = Object.create(Database.prototype); obj.__wbg_ptr = ptr; DatabaseFinalization.register(obj, obj.__wbg_ptr, obj); return obj; } - __destroy_into_raw() { const ptr = this.__wbg_ptr; this.__wbg_ptr = 0; DatabaseFinalization.unregister(this); return ptr; } - free() { const ptr = this.__destroy_into_raw(); wasm.__wbg_database_free(ptr, 0); } /** - * @param {string} name - * @returns {Promise} + * Allow non-leader writes (for single-tab apps or testing) + * @param {boolean} allow + * @returns {Promise} */ - static newDatabase(name) { - const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.database_newDatabase(ptr0, len0); + allowNonLeaderWrites(allow) { + const ret = wasm.database_allowNonLeaderWrites(this.__wbg_ptr, allow); return ret; } /** - * Get the database name - * @returns {string} + * Clear all optimistic writes + * @returns {Promise} */ - get name() { - let deferred1_0; - let deferred1_1; - try { - const ret = wasm.database_name(this.__wbg_ptr); - deferred1_0 = ret[0]; - deferred1_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); - } + clearOptimisticWrites() { + const ret = wasm.database_clearOptimisticWrites(this.__wbg_ptr); + return ret; } /** - * Get all database names stored in IndexedDB - * - * Returns an array of database names (sorted alphabetically) - * @returns {Promise} + * @returns {Promise} */ - static getAllDatabases() { - const ret = wasm.database_getAllDatabases(); + close() { + const ret = wasm.database_close(this.__wbg_ptr); return ret; } /** @@ -327,6 +58,24 @@ export class Database { const ret = wasm.database_deleteDatabase(ptr0, len0); return ret; } + /** + * Enable or disable coordination metrics tracking + * @param {boolean} enabled + * @returns {Promise} + */ + enableCoordinationMetrics(enabled) { + const ret = wasm.database_enableCoordinationMetrics(this.__wbg_ptr, enabled); + return ret; + } + /** + * Enable or disable optimistic updates mode + * @param {boolean} enabled + * @returns {Promise} + */ + enableOptimisticUpdates(enabled) { + const ret = wasm.database_enableOptimisticUpdates(this.__wbg_ptr, enabled); + return ret; + } /** * @param {string} sql * @returns {Promise} @@ -349,10 +98,25 @@ export class Database { return ret; } /** - * @returns {Promise} + * Export database to SQLite .db file format + * + * Returns the complete database as a Uint8Array that can be downloaded + * or saved as a standard SQLite .db file. + * + * # Example + * ```javascript + * const dbBytes = await db.exportToFile(); + * const blob = new Blob([dbBytes], { type: 'application/x-sqlite3' }); + * const url = URL.createObjectURL(blob); + * const a = document.createElement('a'); + * a.href = url; + * a.download = 'database.db'; + * a.click(); + * ``` + * @returns {Promise} */ - close() { - const ret = wasm.database_close(this.__wbg_ptr); + exportToFile() { + const ret = wasm.database_exportToFile(this.__wbg_ptr); return ret; } /** @@ -364,52 +128,60 @@ export class Database { return ret; } /** - * @returns {Promise} + * Get all database names stored in IndexedDB + * + * Returns an array of database names (sorted alphabetically) + * @returns {Promise} */ - sync() { - const ret = wasm.database_sync(this.__wbg_ptr); + static getAllDatabases() { + const ret = wasm.database_getAllDatabases(); return ret; } /** - * Allow non-leader writes (for single-tab apps or testing) - * @param {boolean} allow - * @returns {Promise} + * Get coordination metrics as JSON string + * @returns {Promise} */ - allowNonLeaderWrites(allow) { - const ret = wasm.database_allowNonLeaderWrites(this.__wbg_ptr, allow); + getCoordinationMetrics() { + const ret = wasm.database_getCoordinationMetrics(this.__wbg_ptr); return ret; } /** - * Export database to SQLite .db file format - * - * Returns the complete database as a Uint8Array that can be downloaded - * or saved as a standard SQLite .db file. - * - * # Example - * ```javascript - * const dbBytes = await db.exportToFile(); - * const blob = new Blob([dbBytes], { type: 'application/x-sqlite3' }); - * const url = URL.createObjectURL(blob); - * const a = document.createElement('a'); - * a.href = url; - * a.download = 'database.db'; - * a.click(); - * ``` - * @returns {Promise} + * Get leader information + * @returns {Promise} */ - exportToFile() { - const ret = wasm.database_exportToFile(this.__wbg_ptr); + getLeaderInfo() { + const ret = wasm.database_getLeaderInfo(this.__wbg_ptr); return ret; } /** - * Test method for concurrent locking - simple increment counter - * @param {number} value + * Get count of pending writes * @returns {Promise} */ - testLock(value) { - const ret = wasm.database_testLock(this.__wbg_ptr, value); + getPendingWritesCount() { + const ret = wasm.database_getPendingWritesCount(this.__wbg_ptr); return ret; } + /** + * @returns {string} + */ + getStorageBackend() { + let deferred2_0; + let deferred2_1; + try { + const ret = wasm.database_getStorageBackend(this.__wbg_ptr); + var ptr1 = ret[0]; + var len1 = ret[1]; + if (ret[3]) { + ptr1 = 0; len1 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred2_0 = ptr1; + deferred2_1 = len1; + return getStringFromWasm0(ptr1, len1); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } + } /** * Import SQLite database from .db file bytes * @@ -433,13 +205,12 @@ export class Database { * * await db.importFromFile(uint8Array); * - * // Database is now replaced - you may need to reopen connections + * // Database is immediately usable after import (no reopen needed) + * const result = await db.execute('SELECT * FROM imported_table'); * ``` * * # Warning * This operation is destructive and will replace all existing database data. - * **IMPORTANT:** You MUST call `db.close()` after import and reopen the database - * for changes to take effect. * @param {Uint8Array} file_data * @returns {Promise} */ @@ -448,29 +219,94 @@ export class Database { return ret; } /** - * Wait for this instance to become leader - * @returns {Promise} + * Check if coordination metrics tracking is enabled + * @returns {Promise} */ - waitForLeadership() { - const ret = wasm.database_waitForLeadership(this.__wbg_ptr); + isCoordinationMetricsEnabled() { + const ret = wasm.database_isCoordinationMetricsEnabled(this.__wbg_ptr); return ret; } /** - * Request leadership (triggers re-election check) - * @returns {Promise} + * @returns {Promise} */ - requestLeadership() { - const ret = wasm.database_requestLeadership(this.__wbg_ptr); + isLeader() { + const ret = wasm.database_isLeader(this.__wbg_ptr); return ret; } /** - * Get leader information - * @returns {Promise} + * Check if optimistic mode is enabled + * @returns {Promise} */ - getLeaderInfo() { - const ret = wasm.database_getLeaderInfo(this.__wbg_ptr); + isOptimisticMode() { + const ret = wasm.database_isOptimisticMode(this.__wbg_ptr); + return ret; + } + /** + * Check if this instance is the leader (non-wasm version for internal use/tests) + * @returns {Promise} + */ + is_leader() { + const ret = wasm.database_is_leader(this.__wbg_ptr); + return ret; + } + /** + * Get the database name + * @returns {string} + */ + get name() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.database_name(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + /** + * @param {string} name + * @returns {Promise} + */ + static newDatabase(name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.database_newDatabase(ptr0, len0); + return ret; + } + /** + * @param {string} name + * @returns {Promise} + */ + static newDatabaseAuto(name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.database_newDatabaseAuto(ptr0, len0); + return ret; + } + /** + * @param {string} name + * @param {string} backend_name + * @returns {Promise} + */ + static newDatabaseWithBackend(name, backend_name) { + const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(backend_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.database_newDatabaseWithBackend(ptr0, len0, ptr1, len1); return ret; } + /** + * @param {Function} callback + */ + onDataChange(callback) { + const ret = wasm.database_onDataChange(this.__wbg_ptr, callback); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } /** * Queue a write operation to be executed by the leader * @@ -508,199 +344,148 @@ export class Database { return ret; } /** - * @returns {Promise} + * Record a follower refresh + * @returns {Promise} */ - isLeader() { - const ret = wasm.database_isLeader(this.__wbg_ptr); + recordFollowerRefresh() { + const ret = wasm.database_recordFollowerRefresh(this.__wbg_ptr); return ret; } /** - * Check if this instance is the leader (non-wasm version for internal use/tests) - * @returns {Promise} + * Record a leadership change + * @param {boolean} became_leader + * @returns {Promise} */ - is_leader() { - const ret = wasm.database_is_leader(this.__wbg_ptr); + recordLeadershipChange(became_leader) { + const ret = wasm.database_recordLeadershipChange(this.__wbg_ptr, became_leader); return ret; } /** - * @param {Function} callback + * Record a notification latency in milliseconds + * @param {number} latency_ms + * @returns {Promise} */ - onDataChange(callback) { - const ret = wasm.database_onDataChange(this.__wbg_ptr, callback); - if (ret[1]) { - throw takeFromExternrefTable0(ret[0]); - } + recordNotificationLatency(latency_ms) { + const ret = wasm.database_recordNotificationLatency(this.__wbg_ptr, latency_ms); + return ret; } /** - * Enable or disable optimistic updates mode - * @param {boolean} enabled + * Record a write conflict (non-leader write attempt) * @returns {Promise} */ - enableOptimisticUpdates(enabled) { - const ret = wasm.database_enableOptimisticUpdates(this.__wbg_ptr, enabled); + recordWriteConflict() { + const ret = wasm.database_recordWriteConflict(this.__wbg_ptr); return ret; } /** - * Check if optimistic mode is enabled - * @returns {Promise} + * Reload data from IndexedDB into memory + * Call this when another tab has written data and you need to see the changes + * This closes and reopens the SQLite connection to invalidate its page cache + * @returns {Promise} */ - isOptimisticMode() { - const ret = wasm.database_isOptimisticMode(this.__wbg_ptr); + reloadFromIndexedDB() { + const ret = wasm.database_reloadFromIndexedDB(this.__wbg_ptr); return ret; } /** - * Track an optimistic write - * @param {string} sql - * @returns {Promise} + * Request leadership (triggers re-election check) + * @returns {Promise} */ - trackOptimisticWrite(sql) { - const ptr0 = passStringToWasm0(sql, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.database_trackOptimisticWrite(this.__wbg_ptr, ptr0, len0); + requestLeadership() { + const ret = wasm.database_requestLeadership(this.__wbg_ptr); return ret; } /** - * Get count of pending writes - * @returns {Promise} - */ - getPendingWritesCount() { - const ret = wasm.database_getPendingWritesCount(this.__wbg_ptr); - return ret; - } - /** - * Clear all optimistic writes - * @returns {Promise} - */ - clearOptimisticWrites() { - const ret = wasm.database_clearOptimisticWrites(this.__wbg_ptr); - return ret; - } - /** - * Enable or disable coordination metrics tracking - * @param {boolean} enabled - * @returns {Promise} - */ - enableCoordinationMetrics(enabled) { - const ret = wasm.database_enableCoordinationMetrics(this.__wbg_ptr, enabled); - return ret; - } - /** - * Check if coordination metrics tracking is enabled - * @returns {Promise} - */ - isCoordinationMetricsEnabled() { - const ret = wasm.database_isCoordinationMetricsEnabled(this.__wbg_ptr); - return ret; - } - /** - * Record a leadership change - * @param {boolean} became_leader - * @returns {Promise} - */ - recordLeadershipChange(became_leader) { - const ret = wasm.database_recordLeadershipChange(this.__wbg_ptr, became_leader); - return ret; - } - /** - * Record a notification latency in milliseconds - * @param {number} latency_ms + * Reset all coordination metrics * @returns {Promise} */ - recordNotificationLatency(latency_ms) { - const ret = wasm.database_recordNotificationLatency(this.__wbg_ptr, latency_ms); + resetCoordinationMetrics() { + const ret = wasm.database_resetCoordinationMetrics(this.__wbg_ptr); return ret; } /** - * Record a write conflict (non-leader write attempt) * @returns {Promise} */ - recordWriteConflict() { - const ret = wasm.database_recordWriteConflict(this.__wbg_ptr); + sync() { + const ret = wasm.database_sync(this.__wbg_ptr); return ret; } /** - * Record a follower refresh - * @returns {Promise} + * Test method for concurrent locking - simple increment counter + * @param {number} value + * @returns {Promise} */ - recordFollowerRefresh() { - const ret = wasm.database_recordFollowerRefresh(this.__wbg_ptr); + testLock(value) { + const ret = wasm.database_testLock(this.__wbg_ptr, value); return ret; } /** - * Get coordination metrics as JSON string + * Track an optimistic write + * @param {string} sql * @returns {Promise} */ - getCoordinationMetrics() { - const ret = wasm.database_getCoordinationMetrics(this.__wbg_ptr); + trackOptimisticWrite(sql) { + const ptr0 = passStringToWasm0(sql, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.database_trackOptimisticWrite(this.__wbg_ptr, ptr0, len0); return ret; } /** - * Reset all coordination metrics + * Wait for this instance to become leader * @returns {Promise} */ - resetCoordinationMetrics() { - const ret = wasm.database_resetCoordinationMetrics(this.__wbg_ptr); + waitForLeadership() { + const ret = wasm.database_waitForLeadership(this.__wbg_ptr); return ret; } } if (Symbol.dispose) Database.prototype[Symbol.dispose] = Database.prototype.free; -const WasmColumnValueFinalization = (typeof FinalizationRegistry === 'undefined') - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry(ptr => wasm.__wbg_wasmcolumnvalue_free(ptr >>> 0, 1)); - export class WasmColumnValue { - static __wrap(ptr) { - ptr = ptr >>> 0; const obj = Object.create(WasmColumnValue.prototype); obj.__wbg_ptr = ptr; WasmColumnValueFinalization.register(obj, obj.__wbg_ptr, obj); return obj; } - __destroy_into_raw() { const ptr = this.__wbg_ptr; this.__wbg_ptr = 0; WasmColumnValueFinalization.unregister(this); return ptr; } - free() { const ptr = this.__destroy_into_raw(); wasm.__wbg_wasmcolumnvalue_free(ptr, 0); } /** + * @param {string} value * @returns {WasmColumnValue} */ - static createNull() { - const ret = wasm.wasmcolumnvalue_createNull(); - return WasmColumnValue.__wrap(ret); - } - /** - * @param {bigint} value - * @returns {WasmColumnValue} - */ - static createInteger(value) { - const ret = wasm.wasmcolumnvalue_createInteger(value); + static big_int(value) { + const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmcolumnvalue_big_int(ptr0, len0); return WasmColumnValue.__wrap(ret); } /** - * @param {number} value + * @param {Uint8Array} value * @returns {WasmColumnValue} */ - static createReal(value) { - const ret = wasm.wasmcolumnvalue_createReal(value); + static blob(value) { + const ptr0 = passArray8ToWasm0(value, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmcolumnvalue_blob(ptr0, len0); return WasmColumnValue.__wrap(ret); } /** * @param {string} value * @returns {WasmColumnValue} */ - static createText(value) { + static createBigInt(value) { const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.wasmcolumnvalue_createText(ptr0, len0); + const ret = wasm.wasmcolumnvalue_createBigInt(ptr0, len0); return WasmColumnValue.__wrap(ret); } /** @@ -713,16 +498,6 @@ export class WasmColumnValue { const ret = wasm.wasmcolumnvalue_createBlob(ptr0, len0); return WasmColumnValue.__wrap(ret); } - /** - * @param {string} value - * @returns {WasmColumnValue} - */ - static createBigInt(value) { - const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.wasmcolumnvalue_createBigInt(ptr0, len0); - return WasmColumnValue.__wrap(ret); - } /** * @param {number} timestamp * @returns {WasmColumnValue} @@ -732,17 +507,17 @@ export class WasmColumnValue { return WasmColumnValue.__wrap(ret); } /** - * @param {any} value + * @param {bigint} value * @returns {WasmColumnValue} */ - static fromJsValue(value) { - const ret = wasm.wasmcolumnvalue_fromJsValue(value); + static createInteger(value) { + const ret = wasm.wasmcolumnvalue_createInteger(value); return WasmColumnValue.__wrap(ret); } /** * @returns {WasmColumnValue} */ - static null() { + static createNull() { const ret = wasm.wasmcolumnvalue_createNull(); return WasmColumnValue.__wrap(ret); } @@ -750,15 +525,7 @@ export class WasmColumnValue { * @param {number} value * @returns {WasmColumnValue} */ - static integer(value) { - const ret = wasm.wasmcolumnvalue_integer(value); - return WasmColumnValue.__wrap(ret); - } - /** - * @param {number} value - * @returns {WasmColumnValue} - */ - static real(value) { + static createReal(value) { const ret = wasm.wasmcolumnvalue_createReal(value); return WasmColumnValue.__wrap(ret); } @@ -766,743 +533,1144 @@ export class WasmColumnValue { * @param {string} value * @returns {WasmColumnValue} */ - static text(value) { + static createText(value) { const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; const ret = wasm.wasmcolumnvalue_createText(ptr0, len0); return WasmColumnValue.__wrap(ret); } /** - * @param {Uint8Array} value + * @param {number} timestamp_ms * @returns {WasmColumnValue} */ - static blob(value) { - const ptr0 = passArray8ToWasm0(value, wasm.__wbindgen_malloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.wasmcolumnvalue_blob(ptr0, len0); + static date(timestamp_ms) { + const ret = wasm.wasmcolumnvalue_date(timestamp_ms); return WasmColumnValue.__wrap(ret); } /** - * @param {string} value + * @param {any} value * @returns {WasmColumnValue} */ - static big_int(value) { - const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.wasmcolumnvalue_big_int(ptr0, len0); + static fromJsValue(value) { + const ret = wasm.wasmcolumnvalue_fromJsValue(value); return WasmColumnValue.__wrap(ret); } /** - * @param {number} timestamp_ms + * @param {number} value * @returns {WasmColumnValue} */ - static date(timestamp_ms) { - const ret = wasm.wasmcolumnvalue_createDate(timestamp_ms); + static integer(value) { + const ret = wasm.wasmcolumnvalue_integer(value); + return WasmColumnValue.__wrap(ret); + } + /** + * @returns {WasmColumnValue} + */ + static null() { + const ret = wasm.wasmcolumnvalue_null(); + return WasmColumnValue.__wrap(ret); + } + /** + * @param {number} value + * @returns {WasmColumnValue} + */ + static real(value) { + const ret = wasm.wasmcolumnvalue_real(value); + return WasmColumnValue.__wrap(ret); + } + /** + * @param {string} value + * @returns {WasmColumnValue} + */ + static text(value) { + const ptr0 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmcolumnvalue_text(ptr0, len0); return WasmColumnValue.__wrap(ret); } } if (Symbol.dispose) WasmColumnValue.prototype[Symbol.dispose] = WasmColumnValue.prototype.free; -const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']); - -async function __wbg_load(module, imports) { - if (typeof Response === 'function' && module instanceof Response) { - if (typeof WebAssembly.instantiateStreaming === 'function') { +export function init_logger() { + wasm.init_logger(); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_92b29b0548f8b746: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_Number_9a4e0ecb0fa16705: function(arg0) { + const ret = Number(arg0); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_bigint_get_as_i64_d968e41184ae354f: function(arg0, arg1) { + const v = arg1; + const ret = typeof(v) === 'bigint' ? v : undefined; + getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_boolean_get_fa956cfa2d1bd751: function(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg___wbindgen_debug_string_c25d447a39f5578f: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_in_aca499c5de7ff5e5: function(arg0, arg1) { + const ret = arg0 in arg1; + return ret; + }, + __wbg___wbindgen_is_bigint_2f76dc55065b4273: function(arg0) { + const ret = typeof(arg0) === 'bigint'; + return ret; + }, + __wbg___wbindgen_is_function_1ff95bcc5517c252: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_ea9085d691f535d3: function(arg0) { + const ret = arg0 === null; + return ret; + }, + __wbg___wbindgen_is_object_a27215656b807791: function(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + return ret; + }, + __wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_is_undefined_c05833b95a3cf397: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_jsval_eq_e659fcf7b0e32763: function(arg0, arg1) { + const ret = arg0 === arg1; + return ret; + }, + __wbg___wbindgen_jsval_loose_eq_db4c3b15f63fc170: function(arg0, arg1) { + const ret = arg0 == arg1; + return ret; + }, + __wbg___wbindgen_number_get_394265ed1e1b84ee: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_fffb441def202758: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_absurd_opfs_close_fe16b29e0afc0472: function() { return handleError(function (arg0) { + const ret = absurd_opfs_close(arg0 >>> 0); + return ret; + }, arguments); }, + __wbg_absurd_opfs_delete_ec9f0a20dfead266: function() { return handleError(function (arg0, arg1) { + const ret = absurd_opfs_delete(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_absurd_opfs_exists_17e7747db12e8f64: function() { return handleError(function (arg0, arg1) { + const ret = absurd_opfs_exists(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_absurd_opfs_flush_988b78f0efec4249: function(arg0) { + absurd_opfs_flush(arg0 >>> 0); + }, + __wbg_absurd_opfs_open_8f92a6dd8d133be0: function() { return handleError(function (arg0, arg1, arg2) { + const ret = absurd_opfs_open(getStringFromWasm0(arg0, arg1), arg2 !== 0); + return ret; + }, arguments); }, + __wbg_absurd_opfs_read_b3d96187bcac9180: function(arg0, arg1, arg2) { + const ret = absurd_opfs_read(arg0 >>> 0, arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_absurd_opfs_size_aefbd7ff3ae1146a: function(arg0) { + const ret = absurd_opfs_size(arg0 >>> 0); + return ret; + }, + __wbg_absurd_opfs_truncate_7d8e3e9a9d6eb96f: function(arg0, arg1) { + absurd_opfs_truncate(arg0 >>> 0, arg1 >>> 0); + }, + __wbg_absurd_opfs_write_57c384e26dc640a4: function(arg0, arg1, arg2, arg3) { + absurd_opfs_write(arg0 >>> 0, arg1 >>> 0, getArrayU8FromWasm0(arg2, arg3)); + }, + __wbg_absurd_sleep_aab7e410d707acb6: function() { return handleError(function (arg0) { + const ret = absurd_sleep(arg0 >>> 0); + return ret; + }, arguments); }, + __wbg_apply_3ac86a26fdb56c05: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.apply(arg1, arg2); + return ret; + }, arguments); }, + __wbg_bound_0382866a50c2df81: function() { return handleError(function (arg0, arg1) { + const ret = IDBKeyRange.bound(arg0, arg1); + return ret; + }, arguments); }, + __wbg_call_8a2dd23819f8a60a: function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments); }, + __wbg_call_a6e5c5dce5018821: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_cancelIdleCallback_b9e469156370a1e5: function(arg0, arg1) { + arg0.cancelIdleCallback(arg1 >>> 0); + }, + __wbg_catch_c1a60df4c30d76d3: function(arg0, arg1) { + const ret = arg0.catch(arg1); + return ret; + }, + __wbg_clearInterval_2e2069e95ad09d4f: function(arg0, arg1) { + arg0.clearInterval(arg1); + }, + __wbg_close_463f5a813b1d0317: function(arg0) { + arg0.close(); + }, + __wbg_close_4c3686e8e8c6d353: function(arg0) { + arg0.close(); + }, + __wbg_contains_72b3d3ec2e94729e: function(arg0, arg1, arg2) { + const ret = arg0.contains(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_continue_97ad9c8cd4cf7f86: function() { return handleError(function (arg0) { + arg0.continue(); + }, arguments); }, + __wbg_createObjectStore_ff668af6e79f0433: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_data_328de4280640da92: function(arg0) { + const ret = arg0.data; + return ret; + }, + __wbg_database_new: function(arg0) { + const ret = Database.__wrap(arg0); + return ret; + }, + __wbg_debug_87fd9b1a625b7efb: function(arg0) { + console.debug(arg0); + }, + __wbg_deleteDatabase_bc48a4a52aa773e4: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.deleteDatabase(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_delete_e7e50168de5ef96e: function() { return handleError(function (arg0, arg1) { + const ret = arg0.delete(arg1); + return ret; + }, arguments); }, + __wbg_delete_fe65145d652e46c8: function() { return handleError(function (arg0) { + const ret = arg0.delete(); + return ret; + }, arguments); }, + __wbg_document_179650d6cb13c263: function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_done_89b2b13e91a60321: function(arg0) { + const ret = arg0.done; + return ret; + }, + __wbg_entries_015dc610cd81ede0: function(arg0) { + const ret = Object.entries(arg0); + return ret; + }, + __wbg_error_744744ff0c9861e6: function(arg0) { + console.error(arg0); + }, + __wbg_getDate_a1a40c1c5f40fe3b: function(arg0) { + const ret = arg0.getDate(); + return ret; + }, + __wbg_getDay_aa318cce5da74c49: function(arg0) { + const ret = arg0.getDay(); + return ret; + }, + __wbg_getFullYear_6af8b229792ae254: function(arg0) { + const ret = arg0.getFullYear(); + return ret; + }, + __wbg_getHours_9f6561095682ce51: function(arg0) { + const ret = arg0.getHours(); + return ret; + }, + __wbg_getItem_b96269ddc16cf24a: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg1.getItem(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_getMinutes_b0d5cd90bf9b8f22: function(arg0) { + const ret = arg0.getMinutes(); + return ret; + }, + __wbg_getMonth_fffe29d654d5eb69: function(arg0) { + const ret = arg0.getMonth(); + return ret; + }, + __wbg_getSeconds_40c565b3a6cb05fe: function(arg0) { + const ret = arg0.getSeconds(); + return ret; + }, + __wbg_getTime_d6f070c088c9b5ed: function(arg0) { + const ret = arg0.getTime(); + return ret; + }, + __wbg_getTimezoneOffset_dc9862c79e5a81a3: function(arg0) { + const ret = arg0.getTimezoneOffset(); + return ret; + }, + __wbg_get_4771b0fab98477d2: function(arg0, arg1, arg2) { + const ret = arg1[arg2 >>> 0]; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_get_507a50627bffa49b: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_78f252d074a84d0b: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_c7eb1f358a7654df: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_cefddcaffca4fbb7: function() { return handleError(function (arg0, arg1) { + const ret = arg0.get(arg1); + return ret; + }, arguments); }, + __wbg_get_unchecked_6e0ad6d2a41b06f6: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_with_ref_key_6412cf3094599694: function(arg0, arg1) { + const ret = arg0[arg1]; + return ret; + }, + __wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.has(arg0, arg1); + return ret; + }, arguments); }, + __wbg_info_eadbe775a8e2e9eb: function(arg0) { + console.info(arg0); + }, + __wbg_instanceof_ArrayBuffer_4480b9e0068a8adb: function(arg0) { + let result; try { - return await WebAssembly.instantiateStreaming(module, imports); - + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_IdbDatabase_1cc734ba1b040dd7: function(arg0) { + let result; + try { + result = arg0 instanceof IDBDatabase; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_IdbFactory_85ccbbe95ef25434: function(arg0) { + let result; + try { + result = arg0 instanceof IDBFactory; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_IdbOpenDbRequest_c34a5f3bfadf1d88: function(arg0) { + let result; + try { + result = arg0 instanceof IDBOpenDBRequest; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Map_e5b5e3db98422fcc: function(arg0) { + let result; + try { + result = arg0 instanceof Map; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Promise_4cb210c0b8f8c959: function(arg0) { + let result; + try { + result = arg0 instanceof Promise; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Uint8Array_309b927aaf7a3fc7: function(arg0) { + let result; + try { + result = arg0 instanceof Uint8Array; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_05ba1ee4f6781663: function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_isArray_0677c962b281d01a: function(arg0) { + const ret = Array.isArray(arg0); + return ret; + }, + __wbg_isSafeInteger_04f36e4056f1b851: function(arg0) { + const ret = Number.isSafeInteger(arg0); + return ret; + }, + __wbg_iterator_6f722e4a93058b71: function() { + const ret = Symbol.iterator; + return ret; + }, + __wbg_key_b74c6cd401e5906b: function() { return handleError(function (arg0) { + const ret = arg0.key; + return ret; + }, arguments); }, + __wbg_length_02c64e687322fa34: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_1f0964f4a5e2c6d8: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_370319915dc99107: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_e01fceeaca9c95fd: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_localStorage_5bf6ce3f8e51412a: function() { return handleError(function (arg0) { + const ret = arg0.localStorage; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_log_d267660666346fb3: function(arg0) { + console.log(arg0); + }, + __wbg_navigator_99621db14b3f1099: function(arg0) { + const ret = arg0.navigator; + return ret; + }, + __wbg_new_0_3da9e97f24fc69be: function() { + const ret = new Date(); + return ret; + }, + __wbg_new_245322ac8cd2a4ae: function() { return handleError(function (arg0, arg1) { + const ret = new BroadcastChannel(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_32b398fb48b6d94a: function() { + const ret = new Array(); + return ret; + }, + __wbg_new_aec3e25493d729fe: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_new_cc984128914cfc6f: function(arg0) { + const ret = new Date(arg0); + return ret; + }, + __wbg_new_cd45aabdf6073e84: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_new_da52cf8fe3429cb2: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_from_slice_77cdfb7977362f3c: function(arg0, arg1) { + const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_typed_1824d93f294193e5: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_new_with_length_e6785c33c8e4cce8: function(arg0) { + const ret = new Uint8Array(arg0 >>> 0); + return ret; + }, + __wbg_new_with_year_month_day_f2354b74b4b2a4f3: function(arg0, arg1, arg2) { + const ret = new Date(arg0 >>> 0, arg1, arg2); + return ret; + }, + __wbg_next_6dbf2c0ac8cde20f: function(arg0) { + const ret = arg0.next; + return ret; + }, + __wbg_next_71f2aa1cb3d1e37e: function() { return handleError(function (arg0) { + const ret = arg0.next(); + return ret; + }, arguments); }, + __wbg_now_86c0d4ba3fa605b8: function() { + const ret = Date.now(); + return ret; + }, + __wbg_objectStoreNames_146ab25540bff6db: function(arg0) { + const ret = arg0.objectStoreNames; + return ret; + }, + __wbg_objectStore_d5f47956b6c741e3: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_openCursor_04059a89749928f3: function() { return handleError(function (arg0, arg1) { + const ret = arg0.openCursor(arg1); + return ret; + }, arguments); }, + __wbg_open_72e5234a49d5f85d: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0); + return ret; + }, arguments); }, + __wbg_parse_1c0d8a8656d7e016: function() { return handleError(function (arg0, arg1) { + const ret = JSON.parse(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_postMessage_dd820b768b365613: function() { return handleError(function (arg0, arg1) { + arg0.postMessage(arg1); + }, arguments); }, + __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_push_d2ae3af0c1217ae6: function(arg0, arg1) { + const ret = arg0.push(arg1); + return ret; + }, + __wbg_put_a368805e3dcab3a7: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.put(arg1, arg2); + return ret; + }, arguments); }, + __wbg_queueMicrotask_0ab5b2d2393e99b9: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_queueMicrotask_6a09b7bc46549209: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_random_039a7d5d06e0d333: function() { + const ret = Math.random(); + return ret; + }, + __wbg_removeEventListener_a3f23c70077bdcc1: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_removeItem_78e03a38da96e0ae: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeItem(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_request_218ac9398c76c73b: function(arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.request(getStringFromWasm0(arg1, arg2), arg3, arg4); + return ret; + }, + __wbg_resolve_2191a4dfe481c25b: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_result_2b1294a2bf8dc773: function() { return handleError(function (arg0) { + const ret = arg0.result; + return ret; + }, arguments); }, + __wbg_run_5aa314612b150933: function(arg0, arg1, arg2) { + try { + var state0 = {a: arg1, b: arg2}; + var cb0 = () => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h962235009211f16e(a, state0.b, ); + } finally { + state0.a = a; + } + }; + const ret = arg0.run(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_setInterval_93ec7461c3650c76: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.setInterval(arg1, arg2); + return ret; + }, arguments); }, + __wbg_setItem_364a11cf21db9039: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setItem(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setTimeout_cfa2cf195c3738db: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.setTimeout(arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_4d7dd76f3dae2926: function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_8535240470bf2500: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(arg0, arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; + }, + __wbg_set_oncomplete_e6abb66d0ad42731: function(arg0, arg1) { + arg0.oncomplete = arg1; + }, + __wbg_set_onerror_3488a474171ed56d: function(arg0, arg1) { + arg0.onerror = arg1; + }, + __wbg_set_onerror_f8d31be44335c633: function(arg0, arg1) { + arg0.onerror = arg1; + }, + __wbg_set_onmessage_7fa6f7ccf8ee2d7a: function(arg0, arg1) { + arg0.onmessage = arg1; + }, + __wbg_set_onsuccess_cd0c3642a2873e66: function(arg0, arg1) { + arg0.onsuccess = arg1; + }, + __wbg_set_onupgradeneeded_7b2cf4ba1c57e655: function(arg0, arg1) { + arg0.onupgradeneeded = arg1; + }, + __wbg_slice_50189eefc9ab9fe9: function(arg0, arg1, arg2) { + const ret = arg0.slice(arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_static_accessor_CREATE_TASK_7ee0dd8bc83df5b2: function() { + const ret = typeof console === 'undefined' ? null : console?.createTask; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_4ef717fb391d88b7: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_THIS_8d1badc68b5a74f4: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_146583524fe1469b: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_f2829a2234d7819e: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_stringify_b54333f60f1e4dad: function() { return handleError(function (arg0) { + const ret = JSON.stringify(arg0); + return ret; + }, arguments); }, + __wbg_target_e759594a8d965ed7: function(arg0) { + const ret = arg0.target; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_then_16d107c451e9905d: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, + __wbg_then_6ec10ae38b3e92f7: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbg_toString_693a6de9f92aacb6: function() { return handleError(function (arg0, arg1) { + const ret = arg0.toString(arg1); + return ret; + }, arguments); }, + __wbg_transaction_25acf9ee4108bc2f: function() { return handleError(function (arg0, arg1) { + const ret = arg0.transaction(arg1); + return ret; + }, arguments); }, + __wbg_transaction_a00de84491e23887: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.transaction(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_transaction_f212158274461f32: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]); + return ret; + }, arguments); }, + __wbg_value_361064aa58a53344: function() { return handleError(function (arg0) { + const ret = arg0.value; + return ret; + }, arguments); }, + __wbg_value_a5d5488a9589444a: function(arg0) { + const ret = arg0.value; + return ret; + }, + __wbg_warn_b1370d804fa3e259: function(arg0) { + console.warn(arg0); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1175, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 572, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 573, ret: NamedExternref("Promise"), inner_ret: Some(NamedExternref("Promise")) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 309, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6); + return ret; + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 308, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde7659040fca5453); + return ret; + }, + __wbindgen_cast_0000000000000006: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 307, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451); + return ret; + }, + __wbindgen_cast_0000000000000007: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000008: function(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000009: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_000000000000000a: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./absurder_sql_bg.js": import0, + "./snippets/absurder-sql-7b083fd8e488940f/inline1.js": import1, + }; +} + +function wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__h962235009211f16e(arg0, arg1) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h962235009211f16e(arg0, arg1); + return ret !== 0; +} + +function wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__hde7659040fca5453(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__hde7659040fca5453(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72(arg0, arg1, arg2); + return ret; +} + +function wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c(arg0, arg1, arg2); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91(arg0, arg1, arg2, arg3); +} + +function wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede(arg0, arg1, arg2, arg3); +} + + +const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"]; +const DatabaseFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_database_free(ptr, 1)); +const WasmColumnValueFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_wasmcolumnvalue_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_destroy_closure(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); } catch (e) { - const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type); + const validResponse = module.ok && expectedResponseType(module.type); if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); - } else { - throw e; - } + } else { throw e; } } } const bytes = await module.arrayBuffer(); return await WebAssembly.instantiate(bytes, imports); - } else { const instance = await WebAssembly.instantiate(module, imports); if (instance instanceof WebAssembly.Instance) { return { instance, module }; - } else { return instance; } } -} -function __wbg_get_imports() { - const imports = {}; - imports.wbg = {}; - imports.wbg.__wbg_Error_e83987f665cf5504 = function(arg0, arg1) { - const ret = Error(getStringFromWasm0(arg0, arg1)); - return ret; - }; - imports.wbg.__wbg_Number_bb48ca12f395cd08 = function(arg0) { - const ret = Number(arg0); - return ret; - }; - imports.wbg.__wbg_String_8f0eb39a4a4c2f66 = function(arg0, arg1) { - const ret = String(arg1); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg___wbindgen_bigint_get_as_i64_f3ebc5a755000afd = function(arg0, arg1) { - const v = arg1; - const ret = typeof(v) === 'bigint' ? v : undefined; - getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); - }; - imports.wbg.__wbg___wbindgen_boolean_get_6d5a1ee65bab5f68 = function(arg0) { - const v = arg0; - const ret = typeof(v) === 'boolean' ? v : undefined; - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; - }; - imports.wbg.__wbg___wbindgen_debug_string_df47ffb5e35e6763 = function(arg0, arg1) { - const ret = debugString(arg1); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg___wbindgen_in_bb933bd9e1b3bc0f = function(arg0, arg1) { - const ret = arg0 in arg1; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_bigint_cb320707dcd35f0b = function(arg0) { - const ret = typeof(arg0) === 'bigint'; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_function_ee8a6c5833c90377 = function(arg0) { - const ret = typeof(arg0) === 'function'; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_null_5e69f72e906cc57c = function(arg0) { - const ret = arg0 === null; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_object_c818261d21f283a4 = function(arg0) { - const val = arg0; - const ret = typeof(val) === 'object' && val !== null; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_string_fbb76cb2940daafd = function(arg0) { - const ret = typeof(arg0) === 'string'; - return ret; - }; - imports.wbg.__wbg___wbindgen_is_undefined_2d472862bd29a478 = function(arg0) { - const ret = arg0 === undefined; - return ret; - }; - imports.wbg.__wbg___wbindgen_jsval_eq_6b13ab83478b1c50 = function(arg0, arg1) { - const ret = arg0 === arg1; - return ret; - }; - imports.wbg.__wbg___wbindgen_jsval_loose_eq_b664b38a2f582147 = function(arg0, arg1) { - const ret = arg0 == arg1; - return ret; - }; - imports.wbg.__wbg___wbindgen_number_get_a20bf9b85341449d = function(arg0, arg1) { - const obj = arg1; - const ret = typeof(obj) === 'number' ? obj : undefined; - getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); - }; - imports.wbg.__wbg___wbindgen_string_get_e4f06c90489ad01b = function(arg0, arg1) { - const obj = arg1; - const ret = typeof(obj) === 'string' ? obj : undefined; - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg___wbindgen_throw_b855445ff6a94295 = function(arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)); - }; - imports.wbg.__wbg__wbg_cb_unref_2454a539ea5790d9 = function(arg0) { - arg0._wbg_cb_unref(); - }; - imports.wbg.__wbg_bound_bcd18f6fa2e57078 = function() { return handleError(function (arg0, arg1) { - const ret = IDBKeyRange.bound(arg0, arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_call_525440f72fbfc0ea = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.call(arg1, arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_call_e762c39fa8ea36bf = function() { return handleError(function (arg0, arg1) { - const ret = arg0.call(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_cancelIdleCallback_415499de61339350 = function(arg0, arg1) { - arg0.cancelIdleCallback(arg1 >>> 0); - }; - imports.wbg.__wbg_catch_943836faa5d29bfb = function(arg0, arg1) { - const ret = arg0.catch(arg1); - return ret; - }; - imports.wbg.__wbg_clearInterval_0675249bbe52da7b = function(arg0, arg1) { - arg0.clearInterval(arg1); - }; - imports.wbg.__wbg_close_209083eb02f34c98 = function(arg0) { - arg0.close(); - }; - imports.wbg.__wbg_close_74386af11ef5ae35 = function(arg0) { - arg0.close(); - }; - imports.wbg.__wbg_contains_9f431f3a4577dba6 = function(arg0, arg1, arg2) { - const ret = arg0.contains(getStringFromWasm0(arg1, arg2)); - return ret; - }; - imports.wbg.__wbg_continue_a31229352363abe4 = function() { return handleError(function (arg0) { - arg0.continue(); - }, arguments) }; - imports.wbg.__wbg_createObjectStore_7df0fb1da746f44d = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2)); - return ret; - }, arguments) }; - imports.wbg.__wbg_data_ee4306d069f24f2d = function(arg0) { - const ret = arg0.data; - return ret; - }; - imports.wbg.__wbg_database_new = function(arg0) { - const ret = Database.__wrap(arg0); - return ret; - }; - imports.wbg.__wbg_debug_f4b0c59db649db48 = function(arg0) { - console.debug(arg0); - }; - imports.wbg.__wbg_delete_eda273f9efee8e09 = function() { return handleError(function (arg0) { - const ret = arg0.delete(); - return ret; - }, arguments) }; - imports.wbg.__wbg_delete_f808c4661e8e34c0 = function() { return handleError(function (arg0, arg1) { - const ret = arg0.delete(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_document_725ae06eb442a6db = function(arg0) { - const ret = arg0.document; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_done_2042aa2670fb1db1 = function(arg0) { - const ret = arg0.done; - return ret; - }; - imports.wbg.__wbg_entries_e171b586f8f6bdbf = function(arg0) { - const ret = Object.entries(arg0); - return ret; - }; - imports.wbg.__wbg_error_a7f8fbb0523dae15 = function(arg0) { - console.error(arg0); - }; - imports.wbg.__wbg_eval_89be3645cf120ed3 = function() { return handleError(function (arg0, arg1) { - const ret = eval(getStringFromWasm0(arg0, arg1)); - return ret; - }, arguments) }; - imports.wbg.__wbg_getDate_5a70d2f6a482d99f = function(arg0) { - const ret = arg0.getDate(); - return ret; - }; - imports.wbg.__wbg_getDay_a150a3fd757619d1 = function(arg0) { - const ret = arg0.getDay(); - return ret; - }; - imports.wbg.__wbg_getFullYear_8240d5a15191feae = function(arg0) { - const ret = arg0.getFullYear(); - return ret; - }; - imports.wbg.__wbg_getHours_5e476e0b9ebc42d1 = function(arg0) { - const ret = arg0.getHours(); - return ret; - }; - imports.wbg.__wbg_getItem_89f57d6acc51a876 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - const ret = arg1.getItem(getStringFromWasm0(arg2, arg3)); - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }, arguments) }; - imports.wbg.__wbg_getMinutes_c95dfb65f1ea8f02 = function(arg0) { - const ret = arg0.getMinutes(); - return ret; - }; - imports.wbg.__wbg_getMonth_25c1c5a601d72773 = function(arg0) { - const ret = arg0.getMonth(); - return ret; - }; - imports.wbg.__wbg_getSeconds_8113bf8709718eb2 = function(arg0) { - const ret = arg0.getSeconds(); - return ret; - }; - imports.wbg.__wbg_getTime_14776bfb48a1bff9 = function(arg0) { - const ret = arg0.getTime(); - return ret; - }; - imports.wbg.__wbg_getTimezoneOffset_d391cb11d54969f8 = function(arg0) { - const ret = arg0.getTimezoneOffset(); - return ret; - }; - imports.wbg.__wbg_get_7bed016f185add81 = function(arg0, arg1) { - const ret = arg0[arg1 >>> 0]; - return ret; - }; - imports.wbg.__wbg_get_e7f29cbc382cd519 = function(arg0, arg1, arg2) { - const ret = arg1[arg2 >>> 0]; - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg_get_efcb449f58ec27c2 = function() { return handleError(function (arg0, arg1) { - const ret = Reflect.get(arg0, arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_get_fb1fa70beb44a754 = function() { return handleError(function (arg0, arg1) { - const ret = arg0.get(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_get_with_ref_key_1dc361bd10053bfe = function(arg0, arg1) { - const ret = arg0[arg1]; - return ret; - }; - imports.wbg.__wbg_info_e674a11f4f50cc0c = function(arg0) { - console.info(arg0); - }; - imports.wbg.__wbg_instanceof_ArrayBuffer_70beb1189ca63b38 = function(arg0) { - let result; - try { - result = arg0 instanceof ArrayBuffer; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_IdbDatabase_fcf75ffeeec3ec8c = function(arg0) { - let result; - try { - result = arg0 instanceof IDBDatabase; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_IdbFactory_b39cfd3ab00cea49 = function(arg0) { - let result; - try { - result = arg0 instanceof IDBFactory; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_IdbOpenDbRequest_08e4929084e51476 = function(arg0) { - let result; - try { - result = arg0 instanceof IDBOpenDBRequest; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_Map_8579b5e2ab5437c7 = function(arg0) { - let result; - try { - result = arg0 instanceof Map; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_Uint8Array_20c8e73002f7af98 = function(arg0) { - let result; - try { - result = arg0 instanceof Uint8Array; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_Window_4846dbb3de56c84c = function(arg0) { - let result; - try { - result = arg0 instanceof Window; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_isArray_96e0af9891d0945d = function(arg0) { - const ret = Array.isArray(arg0); - return ret; - }; - imports.wbg.__wbg_isSafeInteger_d216eda7911dde36 = function(arg0) { - const ret = Number.isSafeInteger(arg0); - return ret; - }; - imports.wbg.__wbg_iterator_e5822695327a3c39 = function() { - const ret = Symbol.iterator; - return ret; - }; - imports.wbg.__wbg_key_d84f6472d959b974 = function() { return handleError(function (arg0) { - const ret = arg0.key; - return ret; - }, arguments) }; - imports.wbg.__wbg_length_69bca3cb64fc8748 = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_length_a95b69f903b746c4 = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_length_cdd215e10d9dd507 = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_length_efec72473f10bc42 = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_localStorage_3034501cd2b3da3f = function() { return handleError(function (arg0) { - const ret = arg0.localStorage; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, arguments) }; - imports.wbg.__wbg_log_8cec76766b8c0e33 = function(arg0) { - console.log(arg0); - }; - imports.wbg.__wbg_navigator_971384882e8ea23a = function(arg0) { - const ret = arg0.navigator; - return ret; - }; - imports.wbg.__wbg_new_0_f9740686d739025c = function() { - const ret = new Date(); - return ret; - }; - imports.wbg.__wbg_new_1acc0b6eea89d040 = function() { - const ret = new Object(); - return ret; - }; - imports.wbg.__wbg_new_3c3d849046688a66 = function(arg0, arg1) { - try { - var state0 = {a: arg0, b: arg1}; - var cb0 = (arg0, arg1) => { - const a = state0.a; - state0.a = 0; - try { - return wasm_bindgen__convert__closures_____invoke__h0645e20ee34c432f(a, state0.b, arg0, arg1); - } finally { - state0.a = a; - } - }; - const ret = new Promise(cb0); - return ret; - } finally { - state0.a = state0.b = 0; + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; } - }; - imports.wbg.__wbg_new_5a79be3ab53b8aa5 = function(arg0) { - const ret = new Uint8Array(arg0); - return ret; - }; - imports.wbg.__wbg_new_67069b49258d9f2a = function() { return handleError(function (arg0, arg1) { - const ret = new BroadcastChannel(getStringFromWasm0(arg0, arg1)); - return ret; - }, arguments) }; - imports.wbg.__wbg_new_93d9417ed3fb115d = function(arg0) { - const ret = new Date(arg0); - return ret; - }; - imports.wbg.__wbg_new_e17d9f43105b08be = function() { - const ret = new Array(); - return ret; - }; - imports.wbg.__wbg_new_from_slice_92f4d78ca282a2d2 = function(arg0, arg1) { - const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1)); - return ret; - }; - imports.wbg.__wbg_new_no_args_ee98eee5275000a4 = function(arg0, arg1) { - const ret = new Function(getStringFromWasm0(arg0, arg1)); - return ret; - }; - imports.wbg.__wbg_new_with_length_01aa0dc35aa13543 = function(arg0) { - const ret = new Uint8Array(arg0 >>> 0); - return ret; - }; - imports.wbg.__wbg_new_with_year_month_day_6236812cf591750d = function(arg0, arg1, arg2) { - const ret = new Date(arg0 >>> 0, arg1, arg2); - return ret; - }; - imports.wbg.__wbg_next_020810e0ae8ebcb0 = function() { return handleError(function (arg0) { - const ret = arg0.next(); - return ret; - }, arguments) }; - imports.wbg.__wbg_next_2c826fe5dfec6b6a = function(arg0) { - const ret = arg0.next; - return ret; - }; - imports.wbg.__wbg_now_793306c526e2e3b6 = function() { - const ret = Date.now(); - return ret; - }; - imports.wbg.__wbg_objectStoreNames_cfcd75f76eff34e4 = function(arg0) { - const ret = arg0.objectStoreNames; - return ret; - }; - imports.wbg.__wbg_objectStore_2aab1d8b165c62a6 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2)); - return ret; - }, arguments) }; - imports.wbg.__wbg_openCursor_da22e71977afb7d7 = function() { return handleError(function (arg0, arg1) { - const ret = arg0.openCursor(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_open_9d8c51d122a5a6ea = function() { return handleError(function (arg0, arg1, arg2, arg3) { - const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0); - return ret; - }, arguments) }; - imports.wbg.__wbg_parse_2a704d6b78abb2b8 = function() { return handleError(function (arg0, arg1) { - const ret = JSON.parse(getStringFromWasm0(arg0, arg1)); - return ret; - }, arguments) }; - imports.wbg.__wbg_postMessage_2e1248c7fe808340 = function() { return handleError(function (arg0, arg1) { - arg0.postMessage(arg1); - }, arguments) }; - imports.wbg.__wbg_prototypesetcall_2a6620b6922694b2 = function(arg0, arg1, arg2) { - Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); - }; - imports.wbg.__wbg_push_df81a39d04db858c = function(arg0, arg1) { - const ret = arg0.push(arg1); - return ret; - }; - imports.wbg.__wbg_put_88678dd575c85637 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.put(arg1, arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_queueMicrotask_34d692c25c47d05b = function(arg0) { - const ret = arg0.queueMicrotask; - return ret; - }; - imports.wbg.__wbg_queueMicrotask_9d76cacb20c84d58 = function(arg0) { - queueMicrotask(arg0); - }; - imports.wbg.__wbg_random_babe96ffc73e60a2 = function() { - const ret = Math.random(); - return ret; - }; - imports.wbg.__wbg_removeEventListener_aa21ef619e743518 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); - }, arguments) }; - imports.wbg.__wbg_removeItem_0e1e70f1687b5304 = function() { return handleError(function (arg0, arg1, arg2) { - arg0.removeItem(getStringFromWasm0(arg1, arg2)); - }, arguments) }; - imports.wbg.__wbg_request_59e784a631ac3e7d = function(arg0, arg1, arg2, arg3, arg4) { - const ret = arg0.request(getStringFromWasm0(arg1, arg2), arg3, arg4); - return ret; - }; - imports.wbg.__wbg_resolve_caf97c30b83f7053 = function(arg0) { - const ret = Promise.resolve(arg0); - return ret; - }; - imports.wbg.__wbg_result_25e75004b82b9830 = function() { return handleError(function (arg0) { - const ret = arg0.result; - return ret; - }, arguments) }; - imports.wbg.__wbg_setInterval_6714a9bec1e91fa3 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.setInterval(arg1, arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_setItem_64dfb54d7b20d84c = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { - arg0.setItem(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); - }, arguments) }; - imports.wbg.__wbg_setTimeout_780ac15e3df4c663 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.setTimeout(arg1, arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) { - arg0[arg1] = arg2; - }; - imports.wbg.__wbg_set_9e6516df7b7d0f19 = function(arg0, arg1, arg2) { - arg0.set(getArrayU8FromWasm0(arg1, arg2)); - }; - imports.wbg.__wbg_set_c213c871859d6500 = function(arg0, arg1, arg2) { - arg0[arg1 >>> 0] = arg2; - }; - imports.wbg.__wbg_set_c2abbebe8b9ebee1 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = Reflect.set(arg0, arg1, arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_set_oncomplete_71dbeb19a31158ae = function(arg0, arg1) { - arg0.oncomplete = arg1; - }; - imports.wbg.__wbg_set_onerror_2a8ad6135dc1ec74 = function(arg0, arg1) { - arg0.onerror = arg1; - }; - imports.wbg.__wbg_set_onerror_dc82fea584ffccaa = function(arg0, arg1) { - arg0.onerror = arg1; - }; - imports.wbg.__wbg_set_onmessage_86d8d65dbed3a751 = function(arg0, arg1) { - arg0.onmessage = arg1; - }; - imports.wbg.__wbg_set_onsuccess_f367d002b462109e = function(arg0, arg1) { - arg0.onsuccess = arg1; - }; - imports.wbg.__wbg_set_onupgradeneeded_0a519a73284a1418 = function(arg0, arg1) { - arg0.onupgradeneeded = arg1; - }; - imports.wbg.__wbg_slice_3e7e2fc0da7cc625 = function(arg0, arg1, arg2) { - const ret = arg0.slice(arg1 >>> 0, arg2 >>> 0); - return ret; - }; - imports.wbg.__wbg_static_accessor_GLOBAL_89e1d9ac6a1b250e = function() { - const ret = typeof global === 'undefined' ? null : global; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_static_accessor_GLOBAL_THIS_8b530f326a9e48ac = function() { - const ret = typeof globalThis === 'undefined' ? null : globalThis; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_static_accessor_SELF_6fdf4b64710cc91b = function() { - const ret = typeof self === 'undefined' ? null : self; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_static_accessor_WINDOW_b45bfc5a37f6cfa2 = function() { - const ret = typeof window === 'undefined' ? null : window; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_stringify_b5fb28f6465d9c3e = function() { return handleError(function (arg0) { - const ret = JSON.stringify(arg0); - return ret; - }, arguments) }; - imports.wbg.__wbg_target_1447f5d3a6fa6fe0 = function(arg0) { - const ret = arg0.target; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - imports.wbg.__wbg_then_4f46f6544e6b4a28 = function(arg0, arg1) { - const ret = arg0.then(arg1); - return ret; - }; - imports.wbg.__wbg_then_70d05cf780a18d77 = function(arg0, arg1, arg2) { - const ret = arg0.then(arg1, arg2); - return ret; - }; - imports.wbg.__wbg_toString_331854e6e3c16849 = function() { return handleError(function (arg0, arg1) { - const ret = arg0.toString(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_transaction_b93f1b2d9bd57727 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.transaction(getStringFromWasm0(arg1, arg2)); - return ret; - }, arguments) }; - imports.wbg.__wbg_transaction_cd940bd89781f616 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]); - return ret; - }, arguments) }; - imports.wbg.__wbg_transaction_e90346abb797e13b = function() { return handleError(function (arg0, arg1) { - const ret = arg0.transaction(arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_value_692627309814bb8c = function(arg0) { - const ret = arg0.value; - return ret; - }; - imports.wbg.__wbg_value_bf03593c8a7b58b8 = function() { return handleError(function (arg0) { - const ret = arg0.value; - return ret; - }, arguments) }; - imports.wbg.__wbg_warn_1d74dddbe2fd1dbb = function(arg0) { - console.warn(arg0); - }; - imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { - // Cast intrinsic for `Ref(String) -> Externref`. - const ret = getStringFromWasm0(arg0, arg1); - return ret; - }; - imports.wbg.__wbindgen_cast_4625c577ab2ec9ee = function(arg0) { - // Cast intrinsic for `U64 -> Externref`. - const ret = BigInt.asUintN(64, arg0); - return ret; - }; - imports.wbg.__wbindgen_cast_5698a9451cda789a = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 306, function: Function { arguments: [Externref], shim_idx: 311, ret: NamedExternref("Promise"), inner_ret: Some(NamedExternref("Promise")) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h4d3ac454e1c5b733, wasm_bindgen__convert__closures_____invoke__h5d89cc2ab3b3e449); - return ret; - }; - imports.wbg.__wbindgen_cast_77e231585ffca6cb = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 306, function: Function { arguments: [NamedExternref("Event")], shim_idx: 309, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h4d3ac454e1c5b733, wasm_bindgen__convert__closures_____invoke__h461a9f3a02628bdb); - return ret; - }; - imports.wbg.__wbindgen_cast_97872415fd606c97 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 919, function: Function { arguments: [Externref], shim_idx: 920, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__hddca379abe978273, wasm_bindgen__convert__closures_____invoke__h3ba5f0fbfb39f2bc); - return ret; - }; - imports.wbg.__wbindgen_cast_9ae0607507abb057 = function(arg0) { - // Cast intrinsic for `I64 -> Externref`. - const ret = arg0; - return ret; - }; - imports.wbg.__wbindgen_cast_d19e3192a76932c1 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 306, function: Function { arguments: [], shim_idx: 307, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h4d3ac454e1c5b733, wasm_bindgen__convert__closures_____invoke__h183d48ae9af1b9ef); - return ret; - }; - imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) { - // Cast intrinsic for `F64 -> Externref`. - const ret = arg0; - return ret; - }; - imports.wbg.__wbindgen_cast_f70748596beccc9a = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 306, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 309, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h4d3ac454e1c5b733, wasm_bindgen__convert__closures_____invoke__h461a9f3a02628bdb); - return ret; - }; - imports.wbg.__wbindgen_init_externref_table = function() { - const table = wasm.__wbindgen_externrefs; - const offset = table.grow(4); - table.set(0, undefined); - table.set(offset + 0, undefined); - table.set(offset + 1, null); - table.set(offset + 2, true); - table.set(offset + 3, false); - ; - }; - - return imports; -} - -function __wbg_finalize_init(instance, module) { - wasm = instance.exports; - __wbg_init.__wbindgen_wasm_module = module; - cachedDataViewMemory0 = null; - cachedUint8ArrayMemory0 = null; - - - wasm.__wbindgen_start(); - return wasm; + return false; + } } function initSync(module) { if (wasm !== undefined) return wasm; - if (typeof module !== 'undefined') { + if (module !== undefined) { if (Object.getPrototypeOf(module) === Object.prototype) { ({module} = module) } else { @@ -1511,13 +1679,10 @@ function initSync(module) { } const imports = __wbg_get_imports(); - if (!(module instanceof WebAssembly.Module)) { module = new WebAssembly.Module(module); } - const instance = new WebAssembly.Instance(module, imports); - return __wbg_finalize_init(instance, module); } @@ -1525,7 +1690,7 @@ async function __wbg_init(module_or_path) { if (wasm !== undefined) return wasm; - if (typeof module_or_path !== 'undefined') { + if (module_or_path !== undefined) { if (Object.getPrototypeOf(module_or_path) === Object.prototype) { ({module_or_path} = module_or_path) } else { @@ -1533,7 +1698,7 @@ async function __wbg_init(module_or_path) { } } - if (typeof module_or_path === 'undefined') { + if (module_or_path === undefined) { module_or_path = new URL('absurder_sql_bg.wasm', import.meta.url); } const imports = __wbg_get_imports(); @@ -1547,5 +1712,4 @@ async function __wbg_init(module_or_path) { return __wbg_finalize_init(instance, module); } -export { initSync }; -export default __wbg_init; +export { initSync, __wbg_init as default }; diff --git a/pwa/lib/absurder_sql_bg.wasm b/pwa/lib/absurder_sql_bg.wasm index 1e6474b1..9d3e44f0 100644 Binary files a/pwa/lib/absurder_sql_bg.wasm and b/pwa/lib/absurder_sql_bg.wasm differ diff --git a/pwa/lib/absurder_sql_bg.wasm.d.ts b/pwa/lib/absurder_sql_bg.wasm.d.ts new file mode 100644 index 00000000..33707cd9 --- /dev/null +++ b/pwa/lib/absurder_sql_bg.wasm.d.ts @@ -0,0 +1,95 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_database_free: (a: number, b: number) => void; +export const __wbg_wasmcolumnvalue_free: (a: number, b: number) => void; +export const database_allowNonLeaderWrites: (a: number, b: number) => any; +export const database_clearOptimisticWrites: (a: number) => any; +export const database_close: (a: number) => any; +export const database_deleteDatabase: (a: number, b: number) => any; +export const database_enableCoordinationMetrics: (a: number, b: number) => any; +export const database_enableOptimisticUpdates: (a: number, b: number) => any; +export const database_execute: (a: number, b: number, c: number) => any; +export const database_executeWithParams: (a: number, b: number, c: number, d: any) => any; +export const database_exportToFile: (a: number) => any; +export const database_forceCloseConnection: (a: number) => any; +export const database_getAllDatabases: () => any; +export const database_getCoordinationMetrics: (a: number) => any; +export const database_getLeaderInfo: (a: number) => any; +export const database_getPendingWritesCount: (a: number) => any; +export const database_getStorageBackend: (a: number) => [number, number, number, number]; +export const database_importFromFile: (a: number, b: any) => any; +export const database_isCoordinationMetricsEnabled: (a: number) => any; +export const database_isLeader: (a: number) => any; +export const database_isOptimisticMode: (a: number) => any; +export const database_is_leader: (a: number) => any; +export const database_name: (a: number) => [number, number]; +export const database_newDatabase: (a: number, b: number) => any; +export const database_newDatabaseAuto: (a: number, b: number) => any; +export const database_newDatabaseWithBackend: (a: number, b: number, c: number, d: number) => any; +export const database_onDataChange: (a: number, b: any) => [number, number]; +export const database_queueWrite: (a: number, b: number, c: number) => any; +export const database_queueWriteWithTimeout: (a: number, b: number, c: number, d: number) => any; +export const database_recordFollowerRefresh: (a: number) => any; +export const database_recordLeadershipChange: (a: number, b: number) => any; +export const database_recordNotificationLatency: (a: number, b: number) => any; +export const database_recordWriteConflict: (a: number) => any; +export const database_reloadFromIndexedDB: (a: number) => any; +export const database_requestLeadership: (a: number) => any; +export const database_resetCoordinationMetrics: (a: number) => any; +export const database_sync: (a: number) => any; +export const database_testLock: (a: number, b: number) => any; +export const database_trackOptimisticWrite: (a: number, b: number, c: number) => any; +export const database_waitForLeadership: (a: number) => any; +export const init_logger: () => void; +export const wasmcolumnvalue_big_int: (a: number, b: number) => number; +export const wasmcolumnvalue_blob: (a: number, b: number) => number; +export const wasmcolumnvalue_createBigInt: (a: number, b: number) => number; +export const wasmcolumnvalue_createBlob: (a: number, b: number) => number; +export const wasmcolumnvalue_createDate: (a: number) => number; +export const wasmcolumnvalue_createInteger: (a: bigint) => number; +export const wasmcolumnvalue_createNull: () => number; +export const wasmcolumnvalue_createReal: (a: number) => number; +export const wasmcolumnvalue_createText: (a: number, b: number) => number; +export const wasmcolumnvalue_date: (a: number) => number; +export const wasmcolumnvalue_fromJsValue: (a: any) => number; +export const wasmcolumnvalue_integer: (a: number) => number; +export const wasmcolumnvalue_null: () => number; +export const wasmcolumnvalue_real: (a: number) => number; +export const wasmcolumnvalue_text: (a: number, b: number) => number; +export const sqlite3_os_init: () => number; +export const rust_sqlite_wasm_shim_acosh: (a: number) => number; +export const rust_sqlite_wasm_shim_asinh: (a: number) => number; +export const rust_sqlite_wasm_shim_atanh: (a: number) => number; +export const rust_sqlite_wasm_shim_calloc: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_malloc: (a: number) => number; +export const rust_sqlite_wasm_shim_free: (a: number) => void; +export const rust_sqlite_wasm_shim_localtime: (a: number) => number; +export const rust_sqlite_wasm_shim_memchr: (a: number, b: number, c: number) => number; +export const rust_sqlite_wasm_shim_realloc: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_sqrt: (a: number) => number; +export const rust_sqlite_wasm_shim_strchr: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_strcmp: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_strcspn: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_strncmp: (a: number, b: number, c: number) => number; +export const rust_sqlite_wasm_shim_strrchr: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_strspn: (a: number, b: number) => number; +export const rust_sqlite_wasm_shim_trunc: (a: number) => number; +export const wasm_bindgen__convert__closures_____invoke__h17ea27cd5134be1c: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen__convert__closures_____invoke__h5efa24f41c0c9d91: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h2c7aa495c9f8eede: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__he70e5f49ece50f72: (a: number, b: number, c: any) => any; +export const wasm_bindgen__convert__closures_____invoke__h2abfc0e2db05e025: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h06fca0c86a1716b6: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__hde7659040fca5453: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h962235009211f16e: (a: number, b: number) => number; +export const wasm_bindgen__convert__closures_____invoke__h1f1fc65728759451: (a: number, b: number) => void; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __wbindgen_destroy_closure: (a: number, b: number) => void; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/pwa/lib/db/client.ts b/pwa/lib/db/client.ts index 9e4a3b01..e56ea8f1 100644 --- a/pwa/lib/db/client.ts +++ b/pwa/lib/db/client.ts @@ -1,4 +1,4 @@ -import init, { Database, type QueryResult } from '@npiesco/absurder-sql'; +import init, { Database, type QueryResult } from './database-proxy'; export type { QueryResult }; @@ -94,7 +94,6 @@ export class DatabaseClient { /** * Import a database from a File - * Note: This closes the current database connection and reopens it with the imported data * @param file - File object containing SQLite database */ async import(file: File): Promise { @@ -105,11 +104,7 @@ export class DatabaseClient { try { const buffer = await file.arrayBuffer(); await this.db.importFromFile(new Uint8Array(buffer)); - - // importFromFile closes the connection, must reopen with same name - console.log(`Import complete, reopening database "${this.dbName}"...`); - this.db = await Database.newDatabase(this.dbName); - console.log('Database reopened after import'); + console.log(`Import complete for database "${this.dbName}"`); } catch (error) { console.error('Database import failed:', error); throw new Error(`Import failed: ${error}`); @@ -190,11 +185,7 @@ export class DatabaseClient { // Import data const buffer = await blob.arrayBuffer(); await this.db.importFromFile(new Uint8Array(buffer)); - - // importFromFile closes the connection, must reopen - console.log(`Import complete, reopening database "${dbName}"...`); - this.db = await Database.newDatabase(dbName); - console.log('Database reopened after import'); + console.log(`Import complete for database "${dbName}"`); } catch (error) { console.error('Database import failed:', error); this.db = null; @@ -205,7 +196,7 @@ export class DatabaseClient { /** * Delete a database by name - * Warning: This permanently removes the database from IndexedDB + * Warning: This permanently removes the database from persistent storage */ async deleteDatabase(dbName: string): Promise { await this.initialize(); @@ -216,21 +207,8 @@ export class DatabaseClient { } try { - // Delete from IndexedDB directly - const deleteRequest = indexedDB.deleteDatabase(dbName); - - await new Promise((resolve, reject) => { - deleteRequest.onsuccess = () => { - console.log(`Deleted database "${dbName}"`); - resolve(); - }; - deleteRequest.onerror = () => { - reject(new Error(`Failed to delete database: ${deleteRequest.error}`)); - }; - deleteRequest.onblocked = () => { - console.warn(`Delete blocked for database "${dbName}". Close all connections first.`); - }; - }); + await Database.deleteDatabase(dbName); + console.log(`Deleted database "${dbName}"`); } catch (error) { console.error(`Failed to delete database "${dbName}":`, error); throw new Error(`Delete failed: ${error}`); diff --git a/pwa/lib/db/database-proxy.ts b/pwa/lib/db/database-proxy.ts new file mode 100644 index 00000000..687bb963 --- /dev/null +++ b/pwa/lib/db/database-proxy.ts @@ -0,0 +1,440 @@ +'use client'; + +import type { + ColumnValue, + DatabaseConfig, + DatabaseError, + IsolationLevel, + QueryResult, + Row, + TransactionOptions, +} from '../absurder_sql.js'; + +export type { + ColumnValue, + DatabaseConfig, + DatabaseError, + IsolationLevel, + QueryResult, + Row, + TransactionOptions, +}; + +export type BackendName = 'auto' | 'indexeddb' | 'opfs' | 'hybrid'; + +interface WorkerResponse { + id?: number; + kind: 'ready' | 'result' | 'error' | 'startupError' | 'change' | 'replaceRuntime'; + value?: unknown; + error?: string; + name?: string; +} + +interface OpenResult { + backend: string; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; +} + +type ChangeCallback = () => void; + +interface ChangeSubscription { + name: string; + callbacks: Set; +} + +class DatabaseWorkerClient { + private readonly worker: SharedWorker; + private readonly port: MessagePort; + private runtimeWorker: Worker | null = null; + private readonly pending = new Map(); + private readonly changeCallbacks = new Map(); + private nextRequestId = 1; + private nextHandleId = 1; + private disconnected = false; + readonly ready: Promise; + + constructor() { + if (!('SharedWorker' in window)) { + throw new Error('This browser does not support the shared AbsurderSQL runtime'); + } + + this.worker = new SharedWorker(new URL('./database.shared-worker.ts', import.meta.url), { + type: 'module', + name: 'absurder-sql-database-owner', + }); + this.port = this.worker.port; + this.ready = new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error('AbsurderSQL SharedWorker initialization timed out')), + 120_000, + ); + + this.port.addEventListener('message', (event: MessageEvent) => { + const response = event.data; + if (response.kind === 'replaceRuntime') { + this.registerRuntimeCandidate(); + return; + } + if (response.kind === 'ready') { + window.clearTimeout(timeout); + resolve(); + window.dispatchEvent(new Event('absurder-sql-runtime-ready')); + return; + } + if (response.kind === 'startupError') { + window.clearTimeout(timeout); + reject(new Error(response.error ?? 'AbsurderSQL SharedWorker failed to start')); + return; + } + if (response.kind === 'change') { + this.notifyChange(response.name); + return; + } + if (response.id === undefined) return; + + const request = this.pending.get(response.id); + if (!request) return; + this.pending.delete(response.id); + if (response.kind === 'error') { + request.reject(new Error(response.error ?? 'AbsurderSQL worker request failed')); + } else { + request.resolve(response.value); + } + }); + this.port.addEventListener('messageerror', () => { + reject(new Error('AbsurderSQL SharedWorker returned an unreadable response')); + }); + this.worker.addEventListener('error', (event) => { + event.preventDefault(); + reject(new Error(event.message || 'AbsurderSQL SharedWorker failed')); + }); + this.port.start(); + this.registerRuntimeCandidate(); + }); + + window.addEventListener('pagehide', () => this.disconnect(), { once: true }); + } + + async request(request: Record): Promise { + await this.ready; + if (this.disconnected) throw new Error('AbsurderSQL SharedWorker is disconnected'); + + const id = this.nextRequestId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + try { + this.port.postMessage({ ...request, id }); + } catch (error) { + this.pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + allocateHandleId(): number { + return this.nextHandleId++; + } + + subscribe(handleId: number, name: string, callback: ChangeCallback): void { + const subscription = this.changeCallbacks.get(handleId) ?? { + name, + callbacks: new Set(), + }; + subscription.callbacks.add(callback); + this.changeCallbacks.set(handleId, subscription); + } + + release(handleId: number): void { + this.changeCallbacks.delete(handleId); + } + + private notifyChange(name: string | undefined): void { + for (const subscription of this.changeCallbacks.values()) { + if (name !== undefined && subscription.name !== name) continue; + for (const callback of subscription.callbacks) callback(); + } + } + + private registerRuntimeCandidate(): void { + this.runtimeWorker?.terminate(); + const candidate = new Worker(new URL('./database.worker.ts', import.meta.url), { + type: 'module', + name: 'absurder-sql-database-runtime', + }); + const channel = new MessageChannel(); + let failureReported = false; + this.runtimeWorker = candidate; + + const reportFailure = (error: string) => { + if (failureReported) return; + failureReported = true; + candidate.terminate(); + this.port.postMessage({ kind: 'runtimeError', error }); + }; + + candidate.addEventListener('error', (event) => { + event.preventDefault(); + reportFailure(event.message || 'AbsurderSQL runtime Worker failed'); + }); + candidate.addEventListener('messageerror', () => { + reportFailure('AbsurderSQL runtime Worker returned an unreadable message'); + }); + candidate.postMessage( + { kind: 'connect', port: channel.port1 }, + [channel.port1], + ); + this.port.postMessage( + { kind: 'registerRuntime', port: channel.port2 }, + [channel.port2], + ); + } + + private disconnect(): void { + if (this.disconnected) return; + this.disconnected = true; + try { + this.port.postMessage({ id: this.nextRequestId++, kind: 'disconnect' }); + } finally { + this.port.close(); + const error = new Error('AbsurderSQL page disconnected'); + for (const request of this.pending.values()) request.reject(error); + this.pending.clear(); + this.changeCallbacks.clear(); + } + } +} + +let workerClient: DatabaseWorkerClient | null = null; + +function getWorkerClient(): DatabaseWorkerClient { + if (typeof window === 'undefined') { + throw new Error('The AbsurderSQL database runtime is only available in the browser'); + } + workerClient ??= new DatabaseWorkerClient(); + return workerClient; +} + +export default async function initializeDatabaseRuntime(): Promise { + await getWorkerClient().ready; +} + +export class Database { + readonly name: string; + private readonly handleId: number; + private readonly requestedBackend: BackendName; + private readonly backend: string; + private readonly client: DatabaseWorkerClient; + private closed = false; + + private constructor( + client: DatabaseWorkerClient, + name: string, + handleId: number, + requestedBackend: BackendName, + opened: OpenResult, + ) { + this.client = client; + this.name = name; + this.handleId = handleId; + this.requestedBackend = requestedBackend; + this.backend = opened.backend; + } + + static newDatabase(name: string): Promise { + return this.open(name, 'auto'); + } + + static newDatabaseAuto(name: string): Promise { + return this.open(name, 'auto'); + } + + static newDatabaseWithBackend(name: string, backend: BackendName): Promise { + return this.open(name, backend); + } + + static async getAllDatabases(): Promise { + return getWorkerClient().request({ + kind: 'static', + method: 'getAllDatabases', + args: [], + }); + } + + static async deleteDatabase(name: string): Promise { + await getWorkerClient().request({ + kind: 'static', + method: 'deleteDatabase', + args: [name], + }); + } + + private static async open(name: string, backend: BackendName): Promise { + const client = getWorkerClient(); + const handleId = client.allocateHandleId(); + const opened = await client.request({ + kind: 'open', + name, + backend, + handleId, + }); + return new Database(client, name, handleId, backend, opened); + } + + private call(method: string, args: unknown[] = []): Promise { + if (this.closed) return Promise.reject(new Error(`Database is closed: ${this.name}`)); + return this.client.request({ + kind: 'call', + handleId: this.handleId, + name: this.name, + backend: this.requestedBackend, + method, + args, + }); + } + + getStorageBackend(): string { + return this.backend; + } + + execute(sql: string): Promise { + return this.call('execute', [sql]); + } + + executeWithParams(sql: string, params: unknown): Promise { + return this.call('executeWithParams', [sql, params]); + } + + exportToFile(): Promise { + return this.call('exportToFile'); + } + + importFromFile(fileData: Uint8Array): Promise { + return this.call('importFromFile', [fileData]); + } + + sync(): Promise { + return this.call('sync'); + } + + allowNonLeaderWrites(allow: boolean): Promise { + return this.call('allowNonLeaderWrites', [allow]); + } + + clearOptimisticWrites(): Promise { + return this.call('clearOptimisticWrites'); + } + + enableCoordinationMetrics(enabled: boolean): Promise { + return this.call('enableCoordinationMetrics', [enabled]); + } + + enableOptimisticUpdates(enabled: boolean): Promise { + return this.call('enableOptimisticUpdates', [enabled]); + } + + forceCloseConnection(): Promise { + return this.call('forceCloseConnection'); + } + + getCoordinationMetrics(): Promise { + return this.call('getCoordinationMetrics'); + } + + getLeaderInfo(): Promise { + return this.call('getLeaderInfo'); + } + + getPendingWritesCount(): Promise { + return this.call('getPendingWritesCount'); + } + + isCoordinationMetricsEnabled(): Promise { + return this.call('isCoordinationMetricsEnabled'); + } + + isLeader(): Promise { + return this.call('isLeader'); + } + + isOptimisticMode(): Promise { + return this.call('isOptimisticMode'); + } + + is_leader(): Promise { + return this.call('is_leader'); + } + + queueWrite(sql: string): Promise { + return this.call('queueWrite', [sql]); + } + + queueWriteWithTimeout(sql: string, timeoutMs: number): Promise { + return this.call('queueWriteWithTimeout', [sql, timeoutMs]); + } + + recordFollowerRefresh(): Promise { + return this.call('recordFollowerRefresh'); + } + + recordLeadershipChange(becameLeader: boolean): Promise { + return this.call('recordLeadershipChange', [becameLeader]); + } + + recordNotificationLatency(latencyMs: number): Promise { + return this.call('recordNotificationLatency', [latencyMs]); + } + + recordWriteConflict(): Promise { + return this.call('recordWriteConflict'); + } + + reloadFromIndexedDB(): Promise { + return this.call('reloadFromIndexedDB'); + } + + requestLeadership(): Promise { + return this.call('requestLeadership'); + } + + resetCoordinationMetrics(): Promise { + return this.call('resetCoordinationMetrics'); + } + + testLock(value: number): Promise { + return this.call('testLock', [value]); + } + + trackOptimisticWrite(sql: string): Promise { + return this.call('trackOptimisticWrite', [sql]); + } + + waitForLeadership(): Promise { + return this.call('waitForLeadership'); + } + + onDataChange(callback: ChangeCallback): void { + this.client.subscribe(this.handleId, this.name, callback); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.client.release(this.handleId); + await this.client.request({ kind: 'close', handleId: this.handleId }); + } + + free(): void { + void this.close(); + } + + [Symbol.dispose](): void { + this.free(); + } +} diff --git a/pwa/lib/db/database.shared-worker.ts b/pwa/lib/db/database.shared-worker.ts new file mode 100644 index 00000000..ace0a9b3 --- /dev/null +++ b/pwa/lib/db/database.shared-worker.ts @@ -0,0 +1,280 @@ +/// + +interface ClientRequest { + id?: number; + kind: string; + port?: MessagePort; + error?: string; + [key: string]: unknown; +} + +interface RuntimeResponse { + id?: number; + kind: 'ready' | 'result' | 'error' | 'startupError' | 'change'; + value?: unknown; + error?: string; + name?: string; +} + +interface PendingRequest { + clientId: number; + clientRequestId: number; + client: DatabaseClient; +} + +interface DatabaseClient { + id: number; + port: MessagePort; + runtime: RuntimeCandidate | null; + replacementAttempts: number; +} + +interface RuntimeCandidate { + owner: DatabaseClient; + port: MessagePort; +} + +const scope = self as unknown as SharedWorkerGlobalScope; +const clients = new Map(); +const runtimes: RuntimeCandidate[] = []; +const pending = new Map(); +const queued: Array<{ client: DatabaseClient; request: ClientRequest }> = []; +let nextClientId = 1; +let nextRuntimeRequestId = 1; +let startupResponse: RuntimeResponse | null = null; +let leader: RuntimeCandidate | null = null; +const MAX_RUNTIME_REPLACEMENTS = 3; + +scope.addEventListener('connect', (event: MessageEvent) => { + const port = event.ports[0]; + if (!port) return; + + const client: DatabaseClient = { + id: nextClientId++, + port, + runtime: null, + replacementAttempts: 0, + }; + clients.set(port, client); + + port.addEventListener('message', (message: MessageEvent) => { + const request = message.data; + if (request.kind === 'disconnect') { + disconnect(client); + return; + } + if (request.kind === 'registerRuntime') { + registerRuntime(client, request.port); + return; + } + if (request.kind === 'runtimeError') { + runtimeFailed(client, request.error ?? 'Database runtime Worker failed'); + return; + } + if (typeof request.id !== 'number') { + port.postMessage({ + kind: 'error', + error: 'Shared database request is missing its numeric id', + }); + return; + } + if (!leader || startupResponse?.kind !== 'ready') { + queued.push({ client, request }); + return; + } + forward(client, request); + }); + + port.addEventListener('messageerror', () => { + port.postMessage({ + kind: 'startupError', + error: 'Shared database client sent an unreadable message', + }); + disconnect(client); + }); + port.start(); + if (startupResponse) port.postMessage(startupResponse); +}); + +function registerRuntime(client: DatabaseClient, port: MessagePort | undefined): void { + if (!port) { + client.port.postMessage({ + kind: 'startupError', + error: 'Shared database runtime registration is missing its port', + }); + return; + } + if (client.runtime) { + client.port.postMessage({ + kind: 'startupError', + error: 'Shared database client registered more than one runtime', + }); + port.close(); + return; + } + + const runtime: RuntimeCandidate = { owner: client, port }; + client.runtime = runtime; + runtimes.push(runtime); + port.addEventListener('message', (event: MessageEvent) => { + handleRuntimeResponse(runtime, event.data); + }); + port.addEventListener('messageerror', () => { + runtimeFailed(client, 'Database runtime Worker returned an unreadable message'); + }); + port.start(); + if (!leader) activateNextRuntime(); +} + +function handleRuntimeResponse(runtime: RuntimeCandidate, response: RuntimeResponse): void { + if (runtime !== leader) return; + + if (response.kind === 'ready') { + runtime.owner.replacementAttempts = 0; + startupResponse = response; + broadcast(response); + flushQueued(); + return; + } + if (response.kind === 'startupError') { + const error = response.error ?? 'Database runtime Worker failed to initialize'; + const owner = runtime.owner; + removeRuntime(runtime, error); + requestRuntimeReplacement(owner, error); + return; + } + if (response.kind === 'change') { + broadcast(response); + return; + } + if (response.id === undefined) return; + + const request = pending.get(response.id); + if (!request) return; + pending.delete(response.id); + request.client.port.postMessage({ + ...response, + id: request.clientRequestId, + }); +} + +function forward(client: DatabaseClient, request: ClientRequest): void { + if (!leader || typeof request.id !== 'number') return; + + const runtimeRequestId = nextRuntimeRequestId++; + pending.set(runtimeRequestId, { + clientId: client.id, + clientRequestId: request.id, + client, + }); + leader.port.postMessage({ + ...request, + id: runtimeRequestId, + clientId: client.id, + }); +} + +function flushQueued(): void { + const requests = queued.splice(0); + for (const request of requests) { + if (clients.has(request.client.port)) forward(request.client, request.request); + } +} + +function disconnect(client: DatabaseClient): void { + if (!clients.has(client.port)) return; + + if (leader && client.runtime !== leader) { + leader.port.postMessage({ + kind: 'disconnectClient', + clientId: client.id, + }); + } + + const removedLeader = client.runtime === leader; + clients.delete(client.port); + client.port.close(); + + for (const [id, request] of pending) { + if (request.client === client) pending.delete(id); + } + for (let index = queued.length - 1; index >= 0; index -= 1) { + if (queued[index].client === client) queued.splice(index, 1); + } + if (client.runtime) removeRuntime(client.runtime); + + if (clients.size === 0) { + scope.close(); + } else if (removedLeader && !leader) { + failCoordinator('Database runtime owner disconnected and no standby is available'); + } +} + +function runtimeFailed(client: DatabaseClient, error: string): void { + const runtime = client.runtime; + if (!runtime) return; + removeRuntime(runtime, error); + requestRuntimeReplacement(client, error); +} + +function removeRuntime( + runtime: RuntimeCandidate, + pendingError = 'Database runtime owner disconnected', +): void { + runtime.port.close(); + runtime.owner.runtime = null; + const index = runtimes.indexOf(runtime); + if (index !== -1) runtimes.splice(index, 1); + if (runtime !== leader) return; + + leader = null; + startupResponse = null; + rejectPending(pendingError); + activateNextRuntime(); +} + +function activateNextRuntime(): void { + const runtime = runtimes[0]; + if (!runtime) return; + leader = runtime; + runtime.port.postMessage({ kind: 'activate' }); +} + +function requestRuntimeReplacement(client: DatabaseClient, error: string): void { + if (!clients.has(client.port)) { + if (!leader) activateNextRuntime(); + return; + } + if (client.replacementAttempts >= MAX_RUNTIME_REPLACEMENTS) { + if (!leader) failCoordinator(`${error}; runtime replacement limit reached`); + return; + } + client.replacementAttempts += 1; + client.port.postMessage({ kind: 'replaceRuntime' }); +} + +function failCoordinator(error: string): void { + const response: RuntimeResponse = { kind: 'startupError', error }; + startupResponse = response; + broadcast(response); + rejectPending(error); + for (const runtime of runtimes) runtime.port.close(); + scope.close(); +} + +function rejectPending(error: string): void { + for (const request of pending.values()) { + request.client.port.postMessage({ + id: request.clientRequestId, + kind: 'error', + error, + }); + } + pending.clear(); +} + +function broadcast(response: RuntimeResponse): void { + for (const client of clients.values()) client.port.postMessage(response); +} + +export {}; diff --git a/pwa/lib/db/database.worker.ts b/pwa/lib/db/database.worker.ts new file mode 100644 index 00000000..96c75c7f --- /dev/null +++ b/pwa/lib/db/database.worker.ts @@ -0,0 +1,330 @@ +/// + +import init, { Database as WasmDatabase } from '../absurder_sql.js'; + +type BackendName = 'auto' | 'indexeddb' | 'opfs' | 'hybrid'; +type DatabaseInstance = WasmDatabase; + +interface WorkerRequest { + id?: number; + clientId?: number; + kind: 'open' | 'call' | 'static' | 'close' | 'disconnectClient'; + name?: string; + backend?: BackendName; + handleId?: number; + method?: string; + args?: unknown[]; +} + +interface RuntimeConnection { + kind: 'connect'; + port: MessagePort; +} + +interface DatabaseRecord { + db: DatabaseInstance; + handles: Set; + queue: Promise; + closing: Promise | null; +} + +interface HandleRecord { + name: string; + clientId: number; +} + +const scope = self as unknown as DedicatedWorkerGlobalScope; +const handles = new Map(); +const databases = new Map(); +const openings = new Map>(); +let coordinatorPort: MessagePort | null = null; +let initializationStarted = false; + +scope.addEventListener('message', (event: MessageEvent) => { + if (event.data.kind !== 'connect' || coordinatorPort) return; + + coordinatorPort = event.data.port; + coordinatorPort.addEventListener( + 'message', + (message: MessageEvent) => { + if (message.data.kind === 'activate') { + startRuntime(); + return; + } + void handleRequest(message.data); + }, + ); + coordinatorPort.addEventListener('messageerror', () => { + postRuntime({ + kind: 'startupError', + error: 'Unreadable database coordinator request', + }); + }); + coordinatorPort.start(); +}); + +function startRuntime(): void { + if (initializationStarted) return; + initializationStarted = true; + void init().then( + () => postRuntime({ kind: 'ready' }), + (error) => { + postRuntime({ + kind: 'startupError', + error: errorMessage(error), + }); + }, + ); +} + +async function handleRequest(request: WorkerRequest): Promise { + try { + if (request.kind === 'disconnectClient') { + await disconnectClient(requireNumber(request.clientId, 'client id')); + return; + } + + const id = requireNumber(request.id, 'request id'); + const clientId = requireNumber(request.clientId, 'client id'); + let value: unknown; + + switch (request.kind) { + case 'open': + value = await openHandle( + clientId, + requireNumber(request.handleId, 'database handle'), + requireString(request.name, 'database name'), + request.backend ?? 'auto', + ); + break; + case 'call': + value = await callDatabase( + clientId, + requireNumber(request.handleId, 'database handle'), + requireString(request.name, 'database name'), + request.backend ?? 'auto', + requireString(request.method, 'database method'), + request.args ?? [], + ); + break; + case 'static': + value = await callStatic( + requireString(request.method, 'static database method'), + request.args ?? [], + ); + break; + case 'close': + await releaseHandle(clientId, requireNumber(request.handleId, 'database handle')); + value = undefined; + break; + default: + throw new Error(`Unsupported database worker request: ${String(request.kind)}`); + } + + postRuntime({ id, kind: 'result', value }); + } catch (error) { + postRuntime({ + id: request.id, + kind: 'error', + error: errorMessage(error), + }); + } +} + +async function openHandle( + clientId: number, + handleId: number, + name: string, + backend: BackendName, +): Promise<{ backend: string }> { + const key = handleKey(clientId, handleId); + const existing = handles.get(key); + if (existing) { + const record = databases.get(existing.name); + if (record) return { backend: record.db.getStorageBackend() }; + handles.delete(key); + } + + const record = await openDatabase(name, backend); + record.handles.add(key); + handles.set(key, { name, clientId }); + return { backend: record.db.getStorageBackend() }; +} + +async function openDatabase(name: string, backend: BackendName): Promise { + const existing = databases.get(name); + if (existing) { + if (existing.closing) { + await existing.closing; + return openDatabase(name, backend); + } + return existing; + } + + const pending = openings.get(name); + if (pending) return pending; + + const opening = (async () => { + const db = backend === 'auto' + ? await WasmDatabase.newDatabaseAuto(name) + : await WasmDatabase.newDatabaseWithBackend(name, backend); + + // The SharedWorker coordinator elects exactly one physical runtime. + await db.allowNonLeaderWrites(true); + db.onDataChange(() => postRuntime({ kind: 'change', name })); + + const record: DatabaseRecord = { + db, + handles: new Set(), + queue: Promise.resolve(), + closing: null, + }; + databases.set(name, record); + return record; + })(); + + openings.set(name, opening); + try { + return await opening; + } finally { + openings.delete(name); + } +} + +async function ensureHandle( + clientId: number, + handleId: number, + name: string, + backend: BackendName, +): Promise { + const key = handleKey(clientId, handleId); + let handle = handles.get(key); + if (!handle) { + await openHandle(clientId, handleId, name, backend); + handle = handles.get(key); + } + if (!handle) throw new Error(`Unknown database handle: ${handleId}`); + return handle; +} + +async function callDatabase( + clientId: number, + handleId: number, + name: string, + backend: BackendName, + method: string, + args: unknown[], +): Promise { + const handle = await ensureHandle(clientId, handleId, name, backend); + const record = databases.get(handle.name); + if (!record || record.closing) { + throw new Error(`Database is closing: ${handle.name}`); + } + + return enqueue(record, async () => { + const candidate = (record.db as unknown as Record)[method]; + if (typeof candidate !== 'function') { + throw new Error(`Unsupported database method: ${method}`); + } + return await (candidate as (...methodArgs: unknown[]) => unknown).apply(record.db, args); + }); +} + +async function callStatic(method: string, args: unknown[]): Promise { + if (method === 'deleteDatabase') { + await closeDatabase(requireString(args[0], 'database name')); + } + + const candidate = (WasmDatabase as unknown as Record)[method]; + if (typeof candidate !== 'function') { + throw new Error(`Unsupported static database method: ${method}`); + } + return await (candidate as (...methodArgs: unknown[]) => unknown).apply(WasmDatabase, args); +} + +function enqueue(record: DatabaseRecord, operation: () => Promise): Promise { + const result = record.queue.then(operation, operation); + record.queue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +async function releaseHandle(clientId: number, handleId: number): Promise { + const key = handleKey(clientId, handleId); + const handle = handles.get(key); + if (!handle) return; + + handles.delete(key); + const record = databases.get(handle.name); + if (!record) return; + + record.handles.delete(key); + if (record.handles.size === 0) { + await closeRecord(handle.name, record); + } +} + +async function disconnectClient(clientId: number): Promise { + const owned = [...handles.entries()] + .filter(([, handle]) => handle.clientId === clientId) + .map(([key]) => key); + for (const key of owned) { + const handleId = Number(key.slice(key.indexOf(':') + 1)); + await releaseHandle(clientId, handleId); + } +} + +async function closeDatabase(name: string): Promise { + const record = databases.get(name); + if (!record) return; + + for (const key of record.handles) handles.delete(key); + record.handles.clear(); + await closeRecord(name, record); +} + +async function closeRecord(name: string, record: DatabaseRecord): Promise { + if (record.closing) return record.closing; + + record.closing = enqueue(record, async () => { + await record.db.sync(); + await record.db.close(); + }); + + try { + await record.closing; + } finally { + if (databases.get(name) === record) databases.delete(name); + } +} + +function postRuntime(message: unknown): void { + if (!coordinatorPort) throw new Error('Database runtime coordinator is unavailable'); + coordinatorPort.postMessage(message); +} + +function handleKey(clientId: number, handleId: number): string { + return `${clientId}:${handleId}`; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Missing ${label}`); + } + return value; +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Missing ${label}`); + } + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export {}; diff --git a/pwa/lib/db/useDatabase.ts b/pwa/lib/db/useDatabase.ts index ad6ec1f8..371d18c8 100644 --- a/pwa/lib/db/useDatabase.ts +++ b/pwa/lib/db/useDatabase.ts @@ -25,10 +25,10 @@ export function useDatabase() { async function initWasm() { try { - const init = (await import('@npiesco/absurder-sql')).default; + const init = (await import('./database-proxy')).default; await init(); - const { Database } = await import('@npiesco/absurder-sql'); + const { Database } = await import('./database-proxy'); (window as any).Database = Database; setWasmReady(true); diff --git a/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline0.js b/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline0.js new file mode 100644 index 00000000..6dd77686 --- /dev/null +++ b/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline0.js @@ -0,0 +1,4 @@ + +export function absurd_sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} diff --git a/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline1.js b/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline1.js new file mode 100644 index 00000000..edb96158 --- /dev/null +++ b/pwa/lib/snippets/absurder-sql-7b083fd8e488940f/inline1.js @@ -0,0 +1,108 @@ + +const absurdHandles = new Map(); +let absurdNextHandleId = 1; + +function absurdGetRoot() { + if (!globalThis.navigator || !globalThis.navigator.storage || typeof globalThis.navigator.storage.getDirectory !== 'function') { + throw new Error('OPFS is not available in this context'); + } + + return globalThis.navigator.storage.getDirectory(); +} + +export async function absurd_opfs_open(fileName, create = true) { + const root = await absurdGetRoot(); + const fileHandle = await root.getFileHandle(fileName, { create }); + const accessHandle = await fileHandle.createSyncAccessHandle(); + const id = absurdNextHandleId++; + absurdHandles.set(id, accessHandle); + return id; +} + +export async function absurd_opfs_exists(fileName) { + try { + const root = await absurdGetRoot(); + await root.getFileHandle(fileName, { create: false }); + return true; + } catch (error) { + if (error && (error.name === 'NotFoundError' || String(error).includes('NotFound'))) { + return false; + } + throw error; + } +} + +export function absurd_opfs_read(id, offset, len) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + const buffer = new Uint8Array(len); + handle.read(buffer, { at: offset }); + return buffer; +} + +export function absurd_opfs_write(id, offset, data) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.write(data, { at: offset }); +} + +export function absurd_opfs_flush(id) { + const handle = absurdHandles.get(id); + if (handle) { + handle.flush(); + } +} + +export function absurd_opfs_size(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return 0; + } + + return handle.getSize(); +} + +export function absurd_opfs_truncate(id, size) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.truncate(size); +} + +export async function absurd_opfs_close(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return; + } + + const result = handle.close(); + absurdHandles.delete(id); + if (result && typeof result.then === 'function') { + await result; + } +} + +export async function absurd_opfs_delete(fileName) { + try { + const root = await absurdGetRoot(); + await root.removeEntry(fileName); + } catch (error) { + if (!String(error).includes('NotFound')) { + throw error; + } + } +} + +export function absurd_opfs_available() { + return !!(globalThis.navigator && + globalThis.navigator.storage && + typeof globalThis.navigator.storage.getDirectory === 'function'); +} diff --git a/pwa/next.config.ts b/pwa/next.config.ts index 66e15661..0b9e7ab1 100644 --- a/pwa/next.config.ts +++ b/pwa/next.config.ts @@ -1,8 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + outputFileTracingRoot: process.cwd(), }; export default nextConfig; diff --git a/pwa/package.json b/pwa/package.json index b2026b8c..94bfb54b 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -3,11 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "sync:wasm": "node ../scripts/sync_pwa_package.js", + "predev": "npm run sync:wasm", + "dev": "next dev --webpack", + "prebuild": "npm run sync:wasm", + "build": "next build --webpack", + "postbuild": "node scripts/generate-precache.mjs", "start": "next start", "lint": "eslint", "test": "playwright test", + "test:pwa": "playwright test --config playwright.pwa.config.ts", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", "test:unit": "vitest run", @@ -23,7 +28,7 @@ "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.6", - "@npiesco/absurder-sql": "^0.1.25", + "brace-expansion": "file:vendor/brace-expansion-compat", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", @@ -35,7 +40,7 @@ "codemirror": "^6.0.2", "html2canvas": "^1.4.1", "lucide-react": "^0.548.0", - "next": "16.0.1", + "next": "16.3.0-preview.6", "papaparse": "^5.5.3", "react": "19.2.0", "react-dom": "19.2.0", @@ -56,13 +61,20 @@ "@vitest/coverage-v8": "^4.0.5", "@vitest/ui": "^4.0.5", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9", - "eslint-config-next": "16.0.1", + "eslint": "9.39.5", + "eslint-config-next": "16.3.0-preview.6", "happy-dom": "^20.0.10", "jsdom": "^27.0.1", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", "vitest": "^4.0.5" + }, + "overrides": { + "brace-expansion": "$brace-expansion", + "next": { + "postcss": "8.5.20", + "sharp": "0.35.3" + } } } diff --git a/pwa/playwright.pwa.config.ts b/pwa/playwright.pwa.config.ts new file mode 100644 index 00000000..596a529a --- /dev/null +++ b/pwa/playwright.pwa.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: /pwa-.*\.spec\.ts/, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never' }], ['list']], + timeout: 90_000, + use: { + baseURL: 'http://localhost:3100', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium-pwa', + use: { + ...devices['Desktop Chrome'], + ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}), + }, + }, + ], + webServer: { + command: 'npm run build && npm run start -- --port 3100', + url: 'http://localhost:3100', + reuseExistingServer: false, + timeout: 240_000, + }, +}); diff --git a/pwa/public/sw-assets.js b/pwa/public/sw-assets.js new file mode 100644 index 00000000..4f72cc1d --- /dev/null +++ b/pwa/public/sw-assets.js @@ -0,0 +1,74 @@ +// Generated by scripts/generate-precache.mjs after next build. +self.__ABSURDER_PRECACHE = [ + "/_next/static/chunks/1637-353ba89f831940f6.js", + "/_next/static/chunks/2122-4929d7956d5ec8d2.js", + "/_next/static/chunks/3342.0a1c2833d7450d08.js", + "/_next/static/chunks/3549-0962241baa87ed46.js", + "/_next/static/chunks/363642f4-c03cabc0ee9e19eb.js", + "/_next/static/chunks/4587-f76dcb1e3178b83b.js", + "/_next/static/chunks/459-1055c07acba307ca.js", + "/_next/static/chunks/4767-e050fbb6cae51918.js", + "/_next/static/chunks/4bd1b696-1f36322f5a7994bd.js", + "/_next/static/chunks/5734-a81e42aab034359d.js", + "/_next/static/chunks/8106-d4d4ac90ca460659.js", + "/_next/static/chunks/8161-dc06e357b84e0728.js", + "/_next/static/chunks/824.ed36c58ec88b9160.js", + "/_next/static/chunks/8381-40f7d2814aad2114.js", + "/_next/static/chunks/9151-b412bc3a10e2de81.js", + "/_next/static/chunks/971.1d64b0790cb4d04a.js", + "/_next/static/chunks/9741-4929d7956d5ec8d2.js", + "/_next/static/chunks/9826-1d520c1766572ea8.js", + "/_next/static/chunks/absurder-sql-database-owner.6712a150aca81699.js", + "/_next/static/chunks/absurder-sql-database-runtime.3f451da5eb776726.js", + "/_next/static/chunks/ad2866b8.9ecf9523770f589c.js", + "/_next/static/chunks/app/_global-error/page-c899a14c67a44367.js", + "/_next/static/chunks/app/_not-found/page-04613b0435c525d5.js", + "/_next/static/chunks/app/db/browse/page-4c30c6bae13bdc3e.js", + "/_next/static/chunks/app/db/charts/page-d33734e3338bd630.js", + "/_next/static/chunks/app/db/columns/page-fa8cbc75a5ef1be2.js", + "/_next/static/chunks/app/db/dashboard/page-e9476d9d71f9fba8.js", + "/_next/static/chunks/app/db/designer/page-010cfc4fa66d3e29.js", + "/_next/static/chunks/app/db/diagram/page-94ccf2c1f7a028da.js", + "/_next/static/chunks/app/db/diff/page-3340805497ff18a9.js", + "/_next/static/chunks/app/db/grep/page-b29d5f0875dbba56.js", + "/_next/static/chunks/app/db/import-csv/page-4d14181f4cb2d699.js", + "/_next/static/chunks/app/db/page-c2ed8ad448ba5e03.js", + "/_next/static/chunks/app/db/query/page-c448e197693dfab8.js", + "/_next/static/chunks/app/db/schema/page-8814fcc435d1ffe8.js", + "/_next/static/chunks/app/db/search/page-efeb3cb4da59f4d2.js", + "/_next/static/chunks/app/db/storage/page-e9289d6796bea752.js", + "/_next/static/chunks/app/db/triggers/page-22f1ff7f17b2f659.js", + "/_next/static/chunks/app/db/views/page-725b07d3daea9fd2.js", + "/_next/static/chunks/app/error-test/page-158e084f955ff4ad.js", + "/_next/static/chunks/app/hooks-test/page-d4dd73d34d94daf8.js", + "/_next/static/chunks/app/layout-7b60e8197783466d.js", + "/_next/static/chunks/app/page-bafc216d2b9f55a2.js", + "/_next/static/chunks/app/provider-test/page-e3d43f5fe7d6c28a.js", + "/_next/static/chunks/app/test/page-7249da21f9a5e6a4.js", + "/_next/static/chunks/app/worker-test/page-82184f839981ec19.js", + "/_next/static/chunks/framework-3cbe48fbcb75c121.js", + "/_next/static/chunks/main-app-eb40675946e65e58.js", + "/_next/static/chunks/main-e859ca0ae3b04eae.js", + "/_next/static/chunks/next/dist/client/components/builtin/app-error-c899a14c67a44367.js", + "/_next/static/chunks/next/dist/client/components/builtin/forbidden-c899a14c67a44367.js", + "/_next/static/chunks/next/dist/client/components/builtin/global-error-c7a739b924e27d4d.js", + "/_next/static/chunks/next/dist/client/components/builtin/not-found-c899a14c67a44367.js", + "/_next/static/chunks/next/dist/client/components/builtin/unauthorized-c899a14c67a44367.js", + "/_next/static/chunks/polyfills-42372ed130431b0a.js", + "/_next/static/chunks/webpack-6e3cbab5b61ca500.js", + "/_next/static/css/a99c43f972d9e97c.css", + "/_next/static/jAMU86O8bW256xQaBe2NC/_buildManifest.js", + "/_next/static/jAMU86O8bW256xQaBe2NC/_ssgManifest.js", + "/_next/static/media/013b72fa676f92e0-s.woff2", + "/_next/static/media/22a5144ee8d83bca-s.p.woff2", + "/_next/static/media/2b5b02fc7e511755-s.woff2", + "/_next/static/media/65f03d54ccadf4a8-s.woff2", + "/_next/static/media/7d4881bb7e1bf84d-s.p.woff2", + "/_next/static/media/9766a7e9e2e0ad5a-s.woff2", + "/_next/static/media/aa016aab0e6d1295-s.woff2", + "/_next/static/media/absurder_sql_bg.7443e4d3.wasm", + "/_next/static/media/b66cf8e69499582a-s.woff2", + "/_next/static/media/b9408752a0c24fb9-s.woff2", + "/_next/static/media/e038a29029a234f2-s.woff2", + "/_next/static/media/f639721981034f88-s.woff2" +]; diff --git a/pwa/public/sw.js b/pwa/public/sw.js index 9feb190a..199fb0eb 100644 --- a/pwa/public/sw.js +++ b/pwa/public/sw.js @@ -1,13 +1,21 @@ // Service Worker for AbsurderSQL PWA -const CACHE_NAME = 'absurdersql-v2'; -const RUNTIME_CACHE = 'absurdersql-runtime-v2'; +importScripts('/sw-assets.js'); + +const CACHE_NAME = 'absurdersql-v3'; +const RUNTIME_CACHE = 'absurdersql-runtime-v3'; // Assets to cache on install const PRECACHE_ASSETS = [ '/', + '/db', '/db/query', '/db/schema', - '/db/manage', + '/db/browse', + '/worker-test', + '/manifest.json', + '/icon-192.png', + '/icon-512.png', + ...(self.__ABSURDER_PRECACHE || []), ]; // Install event - cache critical assets @@ -32,46 +40,47 @@ self.addEventListener('activate', (event) => { ); }); -// Fetch event - serve from cache, fallback to network -self.addEventListener('fetch', (event) => { - // Skip cross-origin requests - if (!event.request.url.startsWith(self.location.origin)) { - return; +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); } + return response; +} - // Skip non-GET requests - if (event.request.method !== 'GET') { - return; +async function navigationRequest(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); + } + return response; + } catch { + return (await caches.match(request)) || + (await caches.match('/')) || + new Response( + '

Offline

The requested page has not been cached yet.

', + { headers: { 'Content-Type': 'text/html' } }, + ); } +} + +// Fetch event - keep navigations fresh while making code, workers, and WASM +// available from the cache when the network disappears. +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; - // CRITICAL: Never cache WASM files or node_modules - they must always be fresh - const url = event.request.url; - if (url.includes('.wasm') || url.includes('node_modules') || url.includes('absurder-sql')) { - event.respondWith(fetch(event.request)); + if (event.request.mode === 'navigate') { + event.respondWith(navigationRequest(event.request)); return; } - event.respondWith( - caches.match(event.request).then((cachedResponse) => { - if (cachedResponse) { - return cachedResponse; - } - - return caches.open(RUNTIME_CACHE).then((cache) => { - return fetch(event.request).then((response) => { - // Cache successful responses - if (response.status === 200) { - cache.put(event.request, response.clone()); - } - return response; - }).catch(() => { - // Offline fallback - return new Response( - '

Offline

You are currently offline. Please check your internet connection.

', - { headers: { 'Content-Type': 'text/html' } } - ); - }); - }); - }) - ); + event.respondWith(cacheFirst(event.request)); }); diff --git a/pwa/scripts/generate-precache.mjs b/pwa/scripts/generate-precache.mjs new file mode 100644 index 00000000..9cf70761 --- /dev/null +++ b/pwa/scripts/generate-precache.mjs @@ -0,0 +1,29 @@ +import { readdir, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pwaRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const staticRoot = resolve(pwaRoot, '.next', 'static'); +const output = resolve(pwaRoot, 'public', 'sw-assets.js'); + +async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }), + ); + return files.flat(); +} + +const assets = (await walk(staticRoot)) + .map((file) => `/_next/static/${relative(staticRoot, file).split(sep).join('/')}`) + .sort(); + +await writeFile( + output, + `// Generated by scripts/generate-precache.mjs after next build.\nself.__ABSURDER_PRECACHE = ${JSON.stringify(assets, null, 2)};\n`, +); + +console.log(`Generated service-worker precache with ${assets.length} Next assets`); diff --git a/pwa/vendor/brace-expansion-compat/index.cjs b/pwa/vendor/brace-expansion-compat/index.cjs new file mode 100644 index 00000000..4d6cca44 --- /dev/null +++ b/pwa/vendor/brace-expansion-compat/index.cjs @@ -0,0 +1,8 @@ +"use strict"; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const implementation = require("@isaacs/brace-expansion"); + +module.exports = implementation.expand; +module.exports.expand = implementation.expand; +module.exports.EXPANSION_MAX = implementation.EXPANSION_MAX; diff --git a/pwa/vendor/brace-expansion-compat/package.json b/pwa/vendor/brace-expansion-compat/package.json new file mode 100644 index 00000000..e46db147 --- /dev/null +++ b/pwa/vendor/brace-expansion-compat/package.json @@ -0,0 +1,10 @@ +{ + "name": "brace-expansion", + "version": "6.0.0", + "private": true, + "description": "Legacy callable API adapter backed by @isaacs/brace-expansion", + "main": "index.cjs", + "dependencies": { + "@isaacs/brace-expansion": "5.0.1" + } +} diff --git a/scripts/static_http_server.js b/scripts/static_http_server.js new file mode 100644 index 00000000..c82a7345 --- /dev/null +++ b/scripts/static_http_server.js @@ -0,0 +1,61 @@ +import { createReadStream, existsSync, statSync } from 'fs'; +import { extname, join, normalize, resolve } from 'path'; +import { createServer } from 'http'; + +const port = Number(process.argv[2] || process.env.PORT || 8080); +const rootDir = resolve(process.argv[3] || process.cwd()); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webp': 'image/webp', +}; + +function getFilePath(urlPath) { + const decodedPath = decodeURIComponent(urlPath.split('?')[0]); + const relativePath = normalize(decodedPath === '/' ? '/index.html' : decodedPath).replace(/^([.][.][\\/])+/, ''); + const absolutePath = resolve(join(rootDir, `.${relativePath}`)); + + if (!absolutePath.startsWith(rootDir)) { + return null; + } + + if (existsSync(absolutePath) && statSync(absolutePath).isDirectory()) { + return join(absolutePath, 'index.html'); + } + + return absolutePath; +} + +const server = createServer((request, response) => { + const filePath = getFilePath(request.url || '/'); + + if (!filePath || !existsSync(filePath) || !statSync(filePath).isFile()) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + return; + } + + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + }); + + createReadStream(filePath).pipe(response); +}); + +server.listen(port, () => { + console.log(`Serving ${rootDir} on http://localhost:${port}`); +}); \ No newline at end of file diff --git a/scripts/sync_pwa_package.js b/scripts/sync_pwa_package.js new file mode 100644 index 00000000..3b99d40a --- /dev/null +++ b/scripts/sync_pwa_package.js @@ -0,0 +1,35 @@ +import { copyFile, mkdir, readFile, rm } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageDirectory = resolve(repositoryRoot, 'pkg'); +const pwaLibraryDirectory = resolve(repositoryRoot, 'pwa', 'lib'); +const generatedFiles = [ + 'absurder_sql.js', + 'absurder_sql.d.ts', + 'absurder_sql_bg.wasm', + 'absurder_sql_bg.wasm.d.ts', +]; + +await Promise.all( + generatedFiles.map((file) => + copyFile(resolve(packageDirectory, file), resolve(pwaLibraryDirectory, file)), + ), +); +const pwaSnippets = resolve(pwaLibraryDirectory, 'snippets'); +await rm(pwaSnippets, { recursive: true, force: true }); +const generatedGlue = await readFile(resolve(packageDirectory, 'absurder_sql.js'), 'utf8'); +const snippetFiles = new Set( + [...generatedGlue.matchAll(/['"]\.\/(snippets\/[^'"]+\.js)['"]/g)].map((match) => match[1]), +); +for (const snippetFile of snippetFiles) { + const destination = resolve(pwaLibraryDirectory, snippetFile); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolve(packageDirectory, snippetFile), destination); +} +await rm(resolve(packageDirectory, '.gitignore'), { force: true }); + +console.log( + `Synced ${generatedFiles.length} generated WASM package files and ${snippetFiles.size} active snippets into pwa/lib`, +); diff --git a/src/bin/cli_query.rs b/src/bin/cli_query.rs index 01d3d306..90560e3e 100644 --- a/src/bin/cli_query.rs +++ b/src/bin/cli_query.rs @@ -30,11 +30,13 @@ async fn main() -> Result<(), Box> { // Open or create database with filesystem persistence let db_name = env::var("DATASYNC_DB").unwrap_or_else(|_| "cli_demo.db".to_string()); + let base_dir = + env::var("ABSURDERSQL_FS_BASE").unwrap_or_else(|_| "./absurdersql_storage".to_string()); + let logical_name = db_name.replace(".db", ""); + println!("Database: {}", db_name); - println!( - "Storage: ./absurdersql_storage/{}/", - db_name.replace(".db", "") - ); + println!("SQLite File: {}/{}/database.sqlite", base_dir, logical_name); + println!("Block Storage: {}/{}.db/", base_dir, logical_name); let config = absurder_sql::types::DatabaseConfig { name: db_name.clone(), diff --git a/src/lib.rs b/src/lib.rs index da0d9a79..33b1433d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -191,6 +191,31 @@ impl Database { } } + fn default_wasm_config(name: String) -> DatabaseConfig { + DatabaseConfig { + name, + version: Some(1), + cache_size: Some(10_000), + page_size: Some(4096), + auto_vacuum: Some(true), + journal_mode: Some("WAL".to_string()), + max_export_size_bytes: Some(2 * 1024 * 1024 * 1024), + } + } + + async fn new_with_storage_backend( + config: DatabaseConfig, + backend: crate::storage::StorageBackend, + ) -> Result { + let normalized_name = normalize_db_name(&config.name); + crate::vfs::indexeddb_vfs::ensure_storage_registered_with_backend( + &normalized_name, + backend, + ) + .await?; + Self::new(config).await + } + pub async fn new(config: DatabaseConfig) -> Result { use std::ffi::{CStr, CString}; @@ -1596,7 +1621,7 @@ impl Database { ); db_meta .iter() - .map(|(&id, metadata)| (id, metadata.checksum)) + .map(|(&id, metadata)| (id, metadata.clone())) .collect::>() } else { web_sys::console::log_1(&format!("[SYNC] No metadata found").into()); @@ -1607,10 +1632,15 @@ impl Database { (blocks, metadata) }); + let backend = crate::vfs::indexeddb_vfs::get_storage_with_fallback(storage_name) + .map(|storage| storage.storage_backend()) + .unwrap_or(crate::storage::StorageBackend::IndexedDb); + web_sys::console::log_1( &format!( - "[SYNC] Preparing to persist {} blocks to IndexedDB", - blocks_to_persist.len() + "[SYNC] Preparing to persist {} blocks using backend {}", + blocks_to_persist.len(), + backend.as_str() ) .into(), ); @@ -1620,27 +1650,55 @@ impl Database { { blocks_count = blocks_to_persist.len(); } - web_sys::console::log_1( - &format!( - "[SYNC] Persisting {} blocks to IndexedDB", - blocks_to_persist.len() - ) - .into(), - ); - crate::storage::wasm_indexeddb::persist_to_indexeddb_event_based( - storage_name, - blocks_to_persist, - metadata_to_persist, - next_commit, - #[cfg(feature = "telemetry")] - self.span_recorder.clone(), - #[cfg(feature = "telemetry")] - span.as_ref().map(|s| s.span_id.clone()), - ) - .await?; - web_sys::console::log_1( - &format!("[SYNC] Successfully persisted to IndexedDB").into(), - ); + match backend { + crate::storage::StorageBackend::IndexedDb => { + web_sys::console::log_1( + &format!( + "[SYNC] Persisting {} blocks to IndexedDB", + blocks_to_persist.len() + ) + .into(), + ); + crate::storage::wasm_indexeddb::persist_to_indexeddb_event_based( + storage_name, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + self.span_recorder.clone(), + #[cfg(feature = "telemetry")] + span.as_ref().map(|s| s.span_id.clone()), + ) + .await?; + web_sys::console::log_1( + &format!("[SYNC] Successfully persisted to IndexedDB").into(), + ); + } + crate::storage::StorageBackend::Opfs + | crate::storage::StorageBackend::Hybrid => { + web_sys::console::log_1( + &format!( + "[SYNC] Persisting {} blocks to OPFS and mirroring to IndexedDB", + blocks_to_persist.len() + ) + .into(), + ); + crate::storage::hybrid_store::hybrid_persist( + storage_name, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + self.span_recorder.clone(), + #[cfg(feature = "telemetry")] + span.as_ref().map(|s| s.span_id.clone()), + ) + .await?; + web_sys::console::log_1( + &format!("[SYNC] Successfully persisted to OPFS and IndexedDB").into(), + ); + } + } } else { web_sys::console::log_1( &format!("[SYNC] WARNING: No blocks to persist - GLOBAL_STORAGE is empty!") @@ -1783,15 +1841,7 @@ impl Database { format!("{}.db", name) }; - let config = DatabaseConfig { - name: normalized_name.clone(), - version: Some(1), - cache_size: Some(10_000), - page_size: Some(4096), - auto_vacuum: Some(true), - journal_mode: Some("WAL".to_string()), - max_export_size_bytes: Some(2 * 1024 * 1024 * 1024), // 2GB default - }; + let config = Self::default_wasm_config(normalized_name.clone()); let db = Database::new(config) .await @@ -1803,12 +1853,79 @@ impl Database { Ok(db) } + #[wasm_bindgen(js_name = "newDatabaseAuto")] + pub async fn new_wasm_auto(name: String) -> Result { + let normalized_name = if name.ends_with(".db") { + name.clone() + } else { + format!("{}.db", name) + }; + + let config = Self::default_wasm_config(normalized_name.clone()); + let backend = crate::storage::backend_detect::detect_best_backend().await; + + let db = Database::new_with_storage_backend(config, backend) + .await + .map_err(|e| JsValue::from_str(&format!("Failed to create database: {}", e)))?; + + Self::start_write_queue_listener(&normalized_name)?; + + Ok(db) + } + + #[wasm_bindgen(js_name = "newDatabaseWithBackend")] + pub async fn new_wasm_with_backend( + name: String, + backend_name: String, + ) -> Result { + let normalized_name = if name.ends_with(".db") { + name.clone() + } else { + format!("{}.db", name) + }; + + let backend = match backend_name.to_ascii_lowercase().as_str() { + "indexeddb" | "indexed-db" | "indexed_db" | "idb" => { + crate::storage::StorageBackend::IndexedDb + } + "opfs" => crate::storage::StorageBackend::Opfs, + "hybrid" => crate::storage::StorageBackend::Hybrid, + _ => { + return Err(JsValue::from_str(&format!( + "Unsupported storage backend '{}'. Expected IndexedDB, OPFS, or Hybrid.", + backend_name + ))); + } + }; + + let config = Self::default_wasm_config(normalized_name.clone()); + + let db = Database::new_with_storage_backend(config, backend) + .await + .map_err(|e| JsValue::from_str(&format!("Failed to create database: {}", e)))?; + + Self::start_write_queue_listener(&normalized_name)?; + + Ok(db) + } + /// Get the database name #[wasm_bindgen(getter)] pub fn name(&self) -> String { self.name.clone() } + #[wasm_bindgen(js_name = "getStorageBackend")] + pub fn get_storage_backend(&self) -> Result { + use crate::vfs::indexeddb_vfs::get_storage_with_fallback; + + let storage = get_storage_with_fallback(&self.name).ok_or_else(|| { + JsValue::from_str(&format!("No storage found for database: {}", self.name)) + })?; + + Ok(storage.storage_backend().as_str().to_string()) + } + /// Get all database names stored in IndexedDB /// /// Returns an array of database names (sorted alphabetically) @@ -1946,6 +2063,15 @@ impl Database { } } + if let Err(error) = crate::storage::wasm_opfs::delete_all_from_opfs(&normalized_name).await + { + log::warn!( + "Failed to delete OPFS data for {} during deleteDatabase: {}", + normalized_name, + error.message + ); + } + log::info!("Database deleted: {}", normalized_name); // Remove from persistent list @@ -1954,26 +2080,34 @@ impl Database { Ok(()) } + /// Add database name to persistent list in localStorage + #[allow(dead_code)] + fn persistent_list_storage() -> Result, JsValue> { + let Some(window) = web_sys::window() else { + log::info!("Persistent database list unavailable without Window; skipping"); + return Ok(None); + }; + + let Some(storage) = window.local_storage().map_err(|e| { + log::error!("Failed to get localStorage: {:?}", e); + JsValue::from_str("No localStorage") + })? + else { + log::info!("Persistent database list unavailable without localStorage; skipping"); + return Ok(None); + }; + + Ok(Some(storage)) + } + /// Add database name to persistent list in localStorage #[allow(dead_code)] fn add_database_to_persistent_list(db_name: &str) -> Result<(), JsValue> { log::info!("add_database_to_persistent_list called for: {}", db_name); - let window = web_sys::window().ok_or_else(|| { - log::error!("No window object"); - JsValue::from_str("No window") - })?; - - let storage = window - .local_storage() - .map_err(|e| { - log::error!("Failed to get localStorage: {:?}", e); - JsValue::from_str("No localStorage") - })? - .ok_or_else(|| { - log::error!("localStorage not available"); - JsValue::from_str("localStorage not available") - })?; + let Some(storage) = Self::persistent_list_storage()? else { + return Ok(()); + }; let key = "absurder_db_list"; let existing = storage.get_item(key).map_err(|e| { @@ -2026,11 +2160,9 @@ impl Database { /// Remove database name from persistent list in localStorage fn remove_database_from_persistent_list(db_name: &str) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window"))?; - let storage = window - .local_storage() - .map_err(|_| JsValue::from_str("No localStorage"))? - .ok_or_else(|| JsValue::from_str("localStorage not available"))?; + let Some(storage) = Self::persistent_list_storage()? else { + return Ok(()); + }; let key = "absurder_db_list"; let existing = storage @@ -2056,21 +2188,9 @@ impl Database { fn get_persistent_database_list() -> Result, JsValue> { log::info!("get_persistent_database_list called"); - let window = web_sys::window().ok_or_else(|| { - log::error!("No window object"); - JsValue::from_str("No window") - })?; - - let storage = window - .local_storage() - .map_err(|e| { - log::error!("Failed to get localStorage: {:?}", e); - JsValue::from_str("No localStorage") - })? - .ok_or_else(|| { - log::error!("localStorage not available"); - JsValue::from_str("localStorage not available") - })?; + let Some(storage) = Self::persistent_list_storage()? else { + return Ok(Vec::new()); + }; let key = "absurder_db_list"; let existing = storage.get_item(key).map_err(|e| { @@ -2316,78 +2436,77 @@ impl Database { let db_name = self.name.clone(); let max_export_size = self.max_export_size_bytes; - log::info!("[EXPORT] ===== Step 1: Acquiring lock"); - - // Acquire lock FIRST to serialize operations - let _guard = weblocks::acquire(&db_name, weblocks::AcquireOptions::exclusive()).await?; - log::info!("[EXPORT] ===== Step 2: Lock acquired"); - - // Get storage and sync AFTER lock - this ensures only one export syncs at a time - use crate::vfs::indexeddb_vfs::get_storage_with_fallback; - log::info!("[EXPORT] ===== Step 3: Getting storage"); - let storage_rc = get_storage_with_fallback(&db_name).ok_or_else(|| { - JsValue::from_str(&format!("Storage not found for database: {}", db_name)) - })?; - log::info!("[EXPORT] ===== Step 4: Got storage, reloading cache"); - - // Reload cache from GLOBAL_STORAGE - #[cfg(target_arch = "wasm32")] - { - storage_rc.reload_cache_from_global_storage(); - } + crate::storage::export_import_lock::with_lock(&db_name, || async { + log::info!("[EXPORT] ===== Step 1: Lock acquired"); - // CRITICAL: Checkpoint WAL to flush SQLite data to VFS blocks before export - // Without this, data stays in SQLite's WAL buffer and doesn't appear in exported bytes - log::info!("[EXPORT] ===== Step 5: Checkpointing WAL"); - if !self.connection_state.db.get().is_null() { - // Use raw SQLite call since export_to_file takes &self, not &mut self - use std::ffi::CString; - let pragma = CString::new("PRAGMA wal_checkpoint(PASSIVE)").unwrap(); - unsafe { - let mut stmt = std::ptr::null_mut(); - let rc = sqlite_wasm_rs::sqlite3_prepare_v2( - self.connection_state.db.get(), - pragma.as_ptr(), - -1, - &mut stmt, - std::ptr::null_mut(), - ); - if rc == sqlite_wasm_rs::SQLITE_OK && !stmt.is_null() { - sqlite_wasm_rs::sqlite3_step(stmt); - sqlite_wasm_rs::sqlite3_finalize(stmt); - log::info!("[EXPORT] WAL checkpoint completed"); - } else { - log::warn!("[EXPORT] WAL checkpoint failed with rc: {}", rc); + // Get storage and sync AFTER lock - this ensures only one export syncs at a time + use crate::vfs::indexeddb_vfs::get_storage_with_fallback; + log::info!("[EXPORT] ===== Step 2: Getting storage"); + let storage_rc = get_storage_with_fallback(&db_name).ok_or_else(|| { + JsValue::from_str(&format!("Storage not found for database: {}", db_name)) + })?; + log::info!("[EXPORT] ===== Step 3: Got storage"); + + // CRITICAL: Checkpoint WAL to flush SQLite data to VFS blocks before export + // Without this, data stays in SQLite's WAL buffer and doesn't appear in exported bytes + log::info!("[EXPORT] ===== Step 4: Checkpointing WAL"); + if !self.connection_state.db.get().is_null() { + // Use raw SQLite call since export_to_file takes &self, not &mut self + use std::ffi::CString; + let pragma = CString::new("PRAGMA wal_checkpoint(PASSIVE)").unwrap(); + unsafe { + let mut stmt = std::ptr::null_mut(); + let rc = sqlite_wasm_rs::sqlite3_prepare_v2( + self.connection_state.db.get(), + pragma.as_ptr(), + -1, + &mut stmt, + std::ptr::null_mut(), + ); + if rc == sqlite_wasm_rs::SQLITE_OK && !stmt.is_null() { + sqlite_wasm_rs::sqlite3_step(stmt); + sqlite_wasm_rs::sqlite3_finalize(stmt); + log::info!("[EXPORT] WAL checkpoint completed"); + } else { + log::warn!("[EXPORT] WAL checkpoint failed with rc: {}", rc); + } } } - } - log::info!("[EXPORT] ===== Step 6: Starting sync"); - // Sync to ensure all data is persisted before export - storage_rc - .sync() - .await - .map_err(|e| JsValue::from_str(&format!("Sync failed: {}", e)))?; - log::info!("[EXPORT] ===== Step 7: Sync complete"); + // Reload after checkpoint so the awaited sync/export path sees the canonical + // post-checkpoint block state instead of any stale pre-checkpoint cache. + #[cfg(target_arch = "wasm32")] + { + log::info!("[EXPORT] ===== Step 4b: Reloading cache after checkpoint"); + storage_rc.reload_cache_from_global_storage(); + } - // Export with configured size limit - log::info!("[EXPORT] Calling export_database_to_bytes"); - let db_bytes = { - let storage = &*storage_rc; - crate::storage::export::export_database_to_bytes(storage, max_export_size) + log::info!("[EXPORT] ===== Step 5: Starting sync"); + storage_rc + .sync() .await - .map_err(|e| { - log::error!("[EXPORT] Export failed: {}", e); - JsValue::from_str(&format!("Export failed: {}", e)) - })? - }; + .map_err(|e| JsValue::from_str(&format!("Sync failed: {}", e)))?; + log::info!("[EXPORT] ===== Step 6: Sync complete"); + + log::info!("[EXPORT] Calling export_database_to_bytes"); + let db_bytes = { + let storage = &*storage_rc; + crate::storage::export::export_database_to_bytes(storage, max_export_size) + .await + .map_err(|e| { + log::error!("[EXPORT] Export failed: {}", e); + JsValue::from_str(&format!("Export failed: {}", e)) + })? + }; - log::info!("[EXPORT] Export complete: {} bytes", db_bytes.len()); + log::info!("[EXPORT] Export complete: {} bytes", db_bytes.len()); - let uint8_array = js_sys::Uint8Array::new_with_length(db_bytes.len() as u32); - uint8_array.copy_from(&db_bytes); + let uint8_array = js_sys::Uint8Array::new_with_length(db_bytes.len() as u32); + uint8_array.copy_from(&db_bytes); - Ok(uint8_array) + Ok(uint8_array) + }) + .await } /// Test method for concurrent locking - simple increment counter @@ -2456,102 +2575,98 @@ impl Database { let db_name = self.name.clone(); let data = file_data.to_vec(); - // Acquire lock FIRST to serialize operations - let _guard = weblocks::acquire(&db_name, weblocks::AcquireOptions::exclusive()).await?; - log::info!("[IMPORT] Lock acquired for: {}", db_name); + crate::storage::export_import_lock::with_lock(&db_name, || async { + log::info!("[IMPORT] Lock acquired for: {}", db_name); + log::debug!("Import data size: {} bytes", data.len()); - log::debug!("Import data size: {} bytes", data.len()); + let storage_backend = crate::vfs::indexeddb_vfs::get_storage_with_fallback(&db_name) + .map(|storage| storage.storage_backend()) + .unwrap_or(crate::storage::StorageBackend::IndexedDb); - // CRITICAL: Force-close database connection BEFORE import - // Must use force_close to remove from connection pool, not just decrement ref_count - // Otherwise new Database instances will reuse stale SQLite connection - log::debug!("Force-closing database connection before import"); - - // First do normal close to cleanup leader election etc - self.close_internal() - .await - .map_err(|e| JsValue::from_str(&format!("Failed to close before import: {}", e)))?; + log::debug!("Force-closing database connection before import"); - // Then force-remove from connection pool - let pool_key = self.name.trim_end_matches(".db"); - crate::connection_pool::force_close_connection(pool_key); + self.close_internal() + .await + .map_err(|e| JsValue::from_str(&format!("Failed to close before import: {}", e)))?; - // Mark our connection as null since we force-closed it - self.connection_state.db.set(std::ptr::null_mut()); - log::debug!("Removed connection from pool for import"); + let pool_key = self.name.trim_end_matches(".db"); + crate::connection_pool::force_close_connection(pool_key); + self.connection_state.db.set(std::ptr::null_mut()); + log::debug!("Removed connection from pool for import"); - // Call the import function with full name (WITH .db) - crate::storage::import::import_database_from_bytes(&db_name, data) + crate::storage::import::import_database_from_bytes_with_backend( + &db_name, + data, + storage_backend, + ) .await .map_err(|e| { log::error!("Import failed for {}: {}", db_name, e); JsValue::from_str(&format!("Import failed: {}", e)) })?; - log::info!("[IMPORT] Import complete for: {}", db_name); - - // Reopen the SQLite connection so this Database instance is usable after import - // The VFS should already exist from when the Database was first created - log::info!("[IMPORT] Reopening connection for: {}", db_name); + log::info!("[IMPORT] Import complete for: {}", db_name); + log::info!("[IMPORT] Reopening connection for: {}", db_name); - use std::ffi::CString; - - let vfs_name = format!("vfs_{}", db_name.trim_end_matches(".db")); - let pool_key = db_name.trim_end_matches(".db").to_string(); - let db_name_for_closure = db_name.clone(); - let vfs_name_for_closure = vfs_name.clone(); + use std::ffi::CString; - let new_state = crate::connection_pool::get_or_create_connection(&pool_key, || { - let mut db = std::ptr::null_mut(); - let db_name_cstr = CString::new(db_name_for_closure.clone()) - .map_err(|_| "Invalid database name".to_string())?; - let vfs_cstr = CString::new(vfs_name_for_closure.as_str()) - .map_err(|_| "Invalid VFS name".to_string())?; + let vfs_name = format!("vfs_{}", db_name.trim_end_matches(".db")); + let pool_key = db_name.trim_end_matches(".db").to_string(); + let db_name_for_closure = db_name.clone(); + let vfs_name_for_closure = vfs_name.clone(); - log::info!( - "[IMPORT] Reopening database: {} with VFS: {}", - db_name_for_closure, - vfs_name_for_closure - ); + let new_state = crate::connection_pool::get_or_create_connection(&pool_key, || { + let mut db = std::ptr::null_mut(); + let db_name_cstr = CString::new(db_name_for_closure.clone()) + .map_err(|_| "Invalid database name".to_string())?; + let vfs_cstr = CString::new(vfs_name_for_closure.as_str()) + .map_err(|_| "Invalid VFS name".to_string())?; - let ret = unsafe { - sqlite_wasm_rs::sqlite3_open_v2( - db_name_cstr.as_ptr(), - &mut db as *mut _, - sqlite_wasm_rs::SQLITE_OPEN_READWRITE | sqlite_wasm_rs::SQLITE_OPEN_CREATE, - vfs_cstr.as_ptr(), - ) - }; + log::info!( + "[IMPORT] Reopening database: {} with VFS: {}", + db_name_for_closure, + vfs_name_for_closure + ); - if ret != sqlite_wasm_rs::SQLITE_OK { - let err_msg = unsafe { - let msg_ptr = sqlite_wasm_rs::sqlite3_errmsg(db); - if !msg_ptr.is_null() { - std::ffi::CStr::from_ptr(msg_ptr) - .to_string_lossy() - .into_owned() - } else { - "Unknown error".to_string() - } + let ret = unsafe { + sqlite_wasm_rs::sqlite3_open_v2( + db_name_cstr.as_ptr(), + &mut db as *mut _, + sqlite_wasm_rs::SQLITE_OPEN_READWRITE | sqlite_wasm_rs::SQLITE_OPEN_CREATE, + vfs_cstr.as_ptr(), + ) }; - return Err(format!( - "Failed to reopen database after import: {}", - err_msg - )); - } - log::info!("[IMPORT] Database reopened successfully"); - Ok(db) - }) - .map_err(|e| { - JsValue::from_str(&format!("Failed to reopen connection after import: {}", e)) - })?; + if ret != sqlite_wasm_rs::SQLITE_OK { + let err_msg = unsafe { + let msg_ptr = sqlite_wasm_rs::sqlite3_errmsg(db); + if !msg_ptr.is_null() { + std::ffi::CStr::from_ptr(msg_ptr) + .to_string_lossy() + .into_owned() + } else { + "Unknown error".to_string() + } + }; + return Err(format!( + "Failed to reopen database after import: {}", + err_msg + )); + } - // Update our connection state to use the new connection - self.connection_state = new_state; - log::info!("[IMPORT] Connection state updated for: {}", db_name); + log::info!("[IMPORT] Database reopened successfully"); + Ok(db) + }) + .map_err(|e| { + JsValue::from_str(&format!("Failed to reopen connection after import: {}", e)) + })?; - Ok(()) + self.connection_state = new_state; + log::info!("[IMPORT] Connection state updated for: {}", db_name); + + Ok(()) + }) + .await } /// Wait for this instance to become leader diff --git a/src/storage/backend_detect.rs b/src/storage/backend_detect.rs new file mode 100644 index 00000000..387b0913 --- /dev/null +++ b/src/storage/backend_detect.rs @@ -0,0 +1,108 @@ +use super::StorageBackend; +use js_sys::{Array, Date, Function, Object, Promise, Reflect}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; + +pub async fn detect_best_backend() -> StorageBackend { + if is_main_thread_context() { + log::info!("Backend detection: main-thread context, selecting IndexedDB"); + return StorageBackend::IndexedDb; + } + + if worker_supports_sync_access_handle().await { + log::info!("Backend detection: worker OPFS SyncAccessHandle available, selecting Hybrid"); + StorageBackend::Hybrid + } else { + log::info!("Backend detection: OPFS SyncAccessHandle unavailable, selecting IndexedDB"); + StorageBackend::IndexedDb + } +} + +fn is_main_thread_context() -> bool { + let global = js_sys::global(); + Reflect::has(&global, &JsValue::from_str("document")).unwrap_or(false) +} + +async fn worker_supports_sync_access_handle() -> bool { + let global = js_sys::global(); + let navigator = match get_property(&global, "navigator") { + Some(value) => value, + None => return false, + }; + let storage = match get_property(&navigator, "storage") { + Some(value) => value, + None => return false, + }; + let directory = match call_promise_method(&storage, "getDirectory", &Array::new()).await { + Some(value) => value, + None => return false, + }; + + let probe_name = format!("absurder_sql_backend_probe_{}", Date::now()); + let options = Object::new(); + if Reflect::set( + &options, + &JsValue::from_str("create"), + &JsValue::from_bool(true), + ) + .is_err() + { + return false; + } + + let file_args = Array::new(); + file_args.push(&JsValue::from_str(&probe_name)); + file_args.push(&options); + + let file_handle = match call_promise_method(&directory, "getFileHandle", &file_args).await { + Some(value) => value, + None => return false, + }; + + let access_handle = + match call_promise_method(&file_handle, "createSyncAccessHandle", &Array::new()).await { + Some(value) => value, + None => { + cleanup_probe_file(&directory, &probe_name).await; + return false; + } + }; + + close_access_handle(&access_handle).await; + cleanup_probe_file(&directory, &probe_name).await; + true +} + +fn get_property(target: &JsValue, name: &str) -> Option { + Reflect::get(target, &JsValue::from_str(name)) + .ok() + .filter(|value| !value.is_null() && !value.is_undefined()) +} + +fn get_function(target: &JsValue, name: &str) -> Option { + get_property(target, name)?.dyn_into::().ok() +} + +async fn call_promise_method(target: &JsValue, name: &str, args: &Array) -> Option { + let function = get_function(target, name)?; + let result = function.apply(target, args).ok()?; + let promise = result.dyn_into::().ok()?; + JsFuture::from(promise).await.ok() +} + +async fn close_access_handle(access_handle: &JsValue) { + if let Some(close_fn) = get_function(access_handle, "close") { + let result = close_fn.call0(access_handle); + if let Ok(value) = result { + if let Ok(promise) = value.dyn_into::() { + let _ = JsFuture::from(promise).await; + } + } + } +} + +async fn cleanup_probe_file(directory: &JsValue, probe_name: &str) { + let args = Array::new(); + args.push(&JsValue::from_str(probe_name)); + let _ = call_promise_method(directory, "removeEntry", &args).await; +} diff --git a/src/storage/block_storage.rs b/src/storage/block_storage.rs index fb309f64..36f3d5c0 100644 --- a/src/storage/block_storage.rs +++ b/src/storage/block_storage.rs @@ -239,6 +239,24 @@ macro_rules! try_read_lock { }; } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum StorageBackend { + #[default] + IndexedDb, + Opfs, + Hybrid, +} + +impl StorageBackend { + pub fn as_str(self) -> &'static str { + match self { + StorageBackend::IndexedDb => "IndexedDB", + StorageBackend::Opfs => "OPFS", + StorageBackend::Hybrid => "Hybrid", + } + } +} + pub struct BlockStorage { // WASM: RefCell for zero-cost interior mutability (single-threaded) // Native: Mutex for thread safety @@ -276,6 +294,7 @@ pub struct BlockStorage { #[cfg(all(not(target_arch = "wasm32"), feature = "fs_persist"))] pub(super) base_dir: PathBuf, pub(super) db_name: String, + pub(super) backend: StorageBackend, // Background sync settings #[cfg(target_arch = "wasm32")] @@ -391,6 +410,7 @@ impl BlockStorage { lru_order: RefCell::new(VecDeque::new()), checksum_manager, db_name: db_name.to_string(), + backend: StorageBackend::IndexedDb, auto_sync_interval: RefCell::new(None), policy: RefCell::new(None), #[cfg(not(target_arch = "wasm32"))] @@ -440,6 +460,14 @@ impl BlockStorage { super::constructors::new_wasm(db_name).await } + #[cfg(target_arch = "wasm32")] + pub async fn new_with_backend( + db_name: &str, + backend: StorageBackend, + ) -> Result { + super::constructors::new_wasm_with_backend(db_name, backend).await + } + #[cfg(not(target_arch = "wasm32"))] pub async fn new(db_name: &str) -> Result { // CRITICAL: Use centralized normalize_db_name to match VFS and Database.name @@ -644,6 +672,7 @@ impl BlockStorage { Ok(BlockStorage { db_name: db_name.to_string(), + backend: StorageBackend::IndexedDb, cache: Mutex::new(HashMap::new()), lru_order: Mutex::new(VecDeque::new()), capacity: 1000, @@ -706,6 +735,10 @@ impl BlockStorage { Ok(s) } + pub fn storage_backend(&self) -> StorageBackend { + self.backend + } + pub async fn new_with_recovery_options( db_name: &str, recovery_opts: RecoveryOptions, @@ -1167,18 +1200,8 @@ impl BlockStorage { } #[cfg(target_arch = "wasm32")] - #[allow(invalid_reference_casting)] pub async fn sync(&self) -> Result<(), DatabaseError> { - // WASM: Use unsafe cast to get mutable access - let self_mut = unsafe { &mut *(self as *const Self as *mut Self) }; - let result = self_mut.sync_implementation(); - // Give time for the spawned IndexedDB operations to complete - wasm_bindgen_futures::JsFuture::from(js_sys::Promise::resolve( - &wasm_bindgen::JsValue::UNDEFINED, - )) - .await - .ok(); - result + self.sync_async().await } #[cfg(not(target_arch = "wasm32"))] @@ -1651,11 +1674,28 @@ impl BlockStorage { ); // Use the existing IndexedDB persistence logic but don't advance commit marker - let metadata_to_persist: Vec<(u64, u64)> = dirty_blocks - .keys() - .map(|&block_id| { - let next_commit = self.get_commit_marker() + 1; - (block_id, next_commit) + let next_commit = self.get_commit_marker() + 1; + let metadata_to_persist: Vec<(u64, BlockMetadataPersist)> = dirty_blocks + .iter() + .map(|(&block_id, block_data)| { + let checksum = + self.checksum_manager + .get_checksum(block_id) + .unwrap_or_else(|| { + ChecksumManager::compute_checksum_with( + block_data, + self.checksum_manager.get_algorithm(block_id), + ) + }); + ( + block_id, + BlockMetadataPersist { + checksum, + last_modified_ms: BlockStorage::now_millis(), + version: next_commit as u32, + algo: self.checksum_manager.get_algorithm(block_id), + }, + ) }) .collect(); @@ -1727,11 +1767,28 @@ impl BlockStorage { blocks_to_write.len() ); - let metadata_to_persist: Vec<(u64, u64)> = partial_blocks - .keys() - .map(|&block_id| { - let next_commit = self.get_commit_marker() + 1; - (block_id, next_commit) + let next_commit = self.get_commit_marker() + 1; + let metadata_to_persist: Vec<(u64, BlockMetadataPersist)> = partial_blocks + .iter() + .map(|(&block_id, block_data)| { + let checksum = + self.checksum_manager + .get_checksum(block_id) + .unwrap_or_else(|| { + ChecksumManager::compute_checksum_with( + block_data, + self.checksum_manager.get_algorithm(block_id), + ) + }); + ( + block_id, + BlockMetadataPersist { + checksum, + last_modified_ms: BlockStorage::now_millis(), + version: next_commit as u32, + algo: self.checksum_manager.get_algorithm(block_id), + }, + ) }) .collect(); @@ -2307,6 +2364,7 @@ impl BlockStorage { #[cfg(all(not(target_arch = "wasm32"), feature = "fs_persist"))] base_dir: std::path::PathBuf::from("/tmp/test"), db_name: "test.db".to_string(), + backend: StorageBackend::IndexedDb, auto_sync_interval: Mutex::new(None), #[cfg(not(target_arch = "wasm32"))] last_auto_sync: std::time::Instant::now(), diff --git a/src/storage/constructors.rs b/src/storage/constructors.rs index 803891fa..03c5fc0f 100644 --- a/src/storage/constructors.rs +++ b/src/storage/constructors.rs @@ -2,7 +2,7 @@ //! This module contains platform-specific constructor implementations #[cfg(target_arch = "wasm32")] -use super::block_storage::{BlockStorage, DEFAULT_CACHE_CAPACITY, RecoveryReport}; +use super::block_storage::{BlockStorage, DEFAULT_CACHE_CAPACITY, RecoveryReport, StorageBackend}; #[cfg(target_arch = "wasm32")] use super::metadata::{ChecksumAlgorithm, ChecksumManager}; #[cfg(target_arch = "wasm32")] @@ -36,10 +36,28 @@ struct FsDealloc { /// Create a new BlockStorage instance for WASM platform #[cfg(target_arch = "wasm32")] pub async fn new_wasm(db_name: &str) -> Result { + new_wasm_with_backend(db_name, StorageBackend::IndexedDb).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn new_wasm_auto(db_name: &str) -> Result { + let backend = super::backend_detect::detect_best_backend().await; + new_wasm_with_backend(db_name, backend).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn new_wasm_with_backend( + db_name: &str, + backend: StorageBackend, +) -> Result { // CRITICAL: Use centralized normalize_db_name to match VFS and Database.name let db_name = normalize_db_name(db_name); let db_name = db_name.as_str(); - log::info!("Creating BlockStorage for database: {}", db_name); + log::info!( + "Creating BlockStorage for database: {} with backend {}", + db_name, + backend.as_str() + ); // Perform IndexedDB recovery scan first let recovery_performed = super::wasm_indexeddb::perform_indexeddb_recovery_scan(db_name) @@ -49,17 +67,59 @@ pub async fn new_wasm(db_name: &str) -> Result { log::info!("IndexedDB recovery scan completed for: {}", db_name); } - // Try to restore from IndexedDB - match super::wasm_indexeddb::restore_from_indexeddb(db_name).await { - Ok(_) => log::info!( - "Successfully restored BlockStorage from IndexedDB for: {}", - db_name - ), - Err(e) => log::warn!( - "IndexedDB restoration failed for {}: {}", - db_name, - e.message - ), + match backend { + StorageBackend::IndexedDb => { + match super::wasm_indexeddb::restore_from_indexeddb(db_name).await { + Ok(_) => log::info!( + "Successfully restored BlockStorage from IndexedDB for: {}", + db_name + ), + Err(e) => log::warn!( + "IndexedDB restoration failed for {}: {}", + db_name, + e.message + ), + } + } + StorageBackend::Opfs => { + let restored_from_opfs = match super::wasm_opfs::restore_from_opfs(db_name).await { + Ok(restored_blocks) if restored_blocks > 0 => { + log::info!( + "Successfully restored {} blocks from OPFS for: {}", + restored_blocks, + db_name + ); + true + } + Ok(_) => { + log::info!("No OPFS blocks found for: {}", db_name); + false + } + Err(e) => { + log::warn!("OPFS restoration failed for {}: {}", db_name, e.message); + false + } + }; + + if !restored_from_opfs { + match super::wasm_indexeddb::restore_from_indexeddb(db_name).await { + Ok(_) => log::info!( + "Successfully restored BlockStorage from IndexedDB for: {}", + db_name + ), + Err(e) => log::warn!( + "IndexedDB restoration failed for {}: {}", + db_name, + e.message + ), + } + } + } + StorageBackend::Hybrid => { + if let Err(e) = super::hybrid_store::hybrid_restore(db_name).await { + log::warn!("Hybrid restoration failed for {}: {}", db_name, e.message); + } + } } // Debug: Log what's in global storage after restoration @@ -480,6 +540,7 @@ pub async fn new_wasm(db_name: &str) -> Result { checksum_algo_default, ), db_name: db_name.to_string(), + backend, #[cfg(all(not(target_arch = "wasm32"), feature = "fs_persist"))] base_dir: fs_base_dir, diff --git a/src/storage/export_import_lock.rs b/src/storage/export_import_lock.rs index 5dde4c9e..56f54c8d 100644 --- a/src/storage/export_import_lock.rs +++ b/src/storage/export_import_lock.rs @@ -2,18 +2,79 @@ //! //! Uses weblocks crate to serialize export/import operations. -use wasm_bindgen::prelude::*; +use wasm_bindgen::prelude::JsValue; + +#[cfg(target_arch = "wasm32")] +use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] +use std::collections::HashSet; + +#[cfg(target_arch = "wasm32")] +thread_local! { + static LOCAL_WORKER_LOCKS: RefCell> = RefCell::new(HashSet::new()); +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#" +export function absurd_sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} +"#)] +extern "C" { + #[wasm_bindgen::prelude::wasm_bindgen(js_name = absurd_sleep, catch)] + async fn js_sleep(ms: u32) -> Result; +} + +#[cfg(target_arch = "wasm32")] +async fn acquire_worker_lock(lock_name: &str) -> Result<(), JsValue> { + loop { + let acquired = LOCAL_WORKER_LOCKS.with(|locks| { + let mut locks = locks.borrow_mut(); + if locks.contains(lock_name) { + false + } else { + locks.insert(lock_name.to_string()); + true + } + }); + + if acquired { + return Ok(()); + } + + js_sleep(10).await?; + } +} + +#[cfg(target_arch = "wasm32")] +fn release_worker_lock(lock_name: &str) { + LOCAL_WORKER_LOCKS.with(|locks| { + locks.borrow_mut().remove(lock_name); + }); +} /// Request a Web Lock and execute work -pub async fn with_lock(lock_name: &str, f: F) -> Result<(), JsValue> +pub async fn with_lock(lock_name: &str, f: F) -> Result where - F: FnOnce() -> Fut + 'static, - Fut: std::future::Future> + 'static, + F: FnOnce() -> Fut, + Fut: std::future::Future>, { log::info!("[LOCK] ===== STARTING LOCK REQUEST ====="); log::info!("[LOCK] Lock name: {}", lock_name); - log::info!("[LOCK] Creating AcquireOptions..."); + #[cfg(target_arch = "wasm32")] + if web_sys::window().is_none() { + log::info!( + "[LOCK] No Window available, using worker-local fallback for {}", + lock_name + ); + acquire_worker_lock(lock_name).await?; + let result = f().await; + release_worker_lock(lock_name); + return result; + } + + log::info!("[LOCK] Creating AcquireOptions..."); let opts = weblocks::AcquireOptions::exclusive(); log::info!("[LOCK] AcquireOptions created, calling acquire()..."); diff --git a/src/storage/hybrid_store.rs b/src/storage/hybrid_store.rs new file mode 100644 index 00000000..6e1d1730 --- /dev/null +++ b/src/storage/hybrid_store.rs @@ -0,0 +1,174 @@ +use super::metadata::{BlockMetadataPersist, ChecksumManager}; +use super::vfs_sync; +use crate::types::DatabaseError; + +fn reconcile_restored_state_to_metadata( + db_name: &str, + metadata_snapshot: &std::collections::HashMap< + u64, + super::wasm_indexeddb::PersistedMetadataEntry, + >, +) { + let valid_block_ids = metadata_snapshot + .keys() + .copied() + .collect::>(); + + vfs_sync::with_global_storage(|storage_map| { + let mut storage_map = storage_map.borrow_mut(); + if let Some(db_storage) = storage_map.get_mut(db_name) { + db_storage.retain(|block_id, _| valid_block_ids.contains(block_id)); + } + }); + + vfs_sync::with_global_allocation_map(|allocation_map| { + allocation_map + .borrow_mut() + .insert(db_name.to_string(), valid_block_ids.clone()); + }); + + vfs_sync::with_global_metadata(|metadata_map| { + let mut metadata_map = metadata_map.borrow_mut(); + if let Some(db_metadata) = metadata_map.get_mut(db_name) { + db_metadata.retain(|block_id, _| valid_block_ids.contains(block_id)); + } + }); +} + +pub async fn hybrid_persist( + db_name: &str, + blocks: Vec<(u64, Vec)>, + metadata: Vec<(u64, BlockMetadataPersist)>, + commit_marker: u64, + #[cfg(feature = "telemetry")] span_recorder: Option, + #[cfg(feature = "telemetry")] parent_span_id: Option, +) -> Result<(), DatabaseError> { + if blocks.is_empty() { + return Ok(()); + } + + super::wasm_opfs::persist_to_opfs(db_name, blocks.clone()).await?; + super::wasm_indexeddb::persist_to_indexeddb_event_based( + db_name, + blocks, + metadata, + commit_marker, + #[cfg(feature = "telemetry")] + span_recorder, + #[cfg(feature = "telemetry")] + parent_span_id, + ) + .await +} + +fn clear_db_state(db_name: &str) { + vfs_sync::with_global_storage(|storage_map| { + storage_map.borrow_mut().remove(db_name); + }); + vfs_sync::with_global_metadata(|metadata_map| { + metadata_map.borrow_mut().remove(db_name); + }); + vfs_sync::with_global_allocation_map(|allocation_map| { + allocation_map.borrow_mut().remove(db_name); + }); + vfs_sync::with_global_commit_marker(|commit_map| { + commit_map.borrow_mut().remove(db_name); + }); +} + +fn validate_restored_blocks( + db_name: &str, + metadata_snapshot: &std::collections::HashMap< + u64, + super::wasm_indexeddb::PersistedMetadataEntry, + >, +) -> Result<(), DatabaseError> { + let restored_blocks = vfs_sync::with_global_storage(|storage_map| { + storage_map + .borrow() + .get(db_name) + .cloned() + .unwrap_or_default() + }); + + let mut validated_blocks = 0usize; + for (&block_id, entry) in metadata_snapshot { + if !entry.authoritative_checksum { + continue; + } + + let data = restored_blocks.get(&block_id).ok_or_else(|| { + DatabaseError::new( + "HYBRID_RESTORE_MISMATCH", + &format!("Missing restored block {} for {}", block_id, db_name), + ) + })?; + + let actual_checksum = ChecksumManager::compute_checksum_with(data, entry.metadata.algo); + if actual_checksum != entry.metadata.checksum { + return Err(DatabaseError::new( + "HYBRID_RESTORE_MISMATCH", + &format!( + "Checksum mismatch for {} block {}: expected {}, got {}", + db_name, block_id, entry.metadata.checksum, actual_checksum + ), + )); + } + + validated_blocks += 1; + } + + if validated_blocks == 0 { + log::info!( + "Hybrid restore found no authoritative IndexedDB checksums for {}, skipping validation", + db_name + ); + } else { + log::info!( + "Hybrid restore validated {} OPFS blocks against IndexedDB metadata for {}", + validated_blocks, + db_name + ); + } + + Ok(()) +} + +pub async fn hybrid_restore(db_name: &str) -> Result<(), DatabaseError> { + let restored_blocks = super::wasm_opfs::restore_from_opfs(db_name).await?; + + if restored_blocks == 0 { + log::info!( + "Hybrid restore found no OPFS blocks for {}, falling back to IndexedDB", + db_name + ); + return super::wasm_indexeddb::restore_from_indexeddb(db_name).await; + } + + let metadata_snapshot = + super::wasm_indexeddb::restore_metadata_from_indexeddb(db_name, true).await?; + if metadata_snapshot.is_empty() { + log::info!( + "Hybrid restore found no IndexedDB metadata for {}, accepting OPFS state", + db_name + ); + return Ok(()); + } + + if let Err(error) = validate_restored_blocks(db_name, &metadata_snapshot) { + log::warn!( + "Hybrid restore validation failed for {}: {}. Falling back to IndexedDB.", + db_name, + error.message + ); + clear_db_state(db_name); + super::wasm_indexeddb::restore_from_indexeddb_force(db_name).await?; + return Ok(()); + } + + reconcile_restored_state_to_metadata(db_name, &metadata_snapshot); + let valid_block_ids = metadata_snapshot.keys().copied().collect::>(); + super::wasm_opfs::reconcile_opfs_with_metadata(db_name, &valid_block_ids).await?; + + Ok(()) +} diff --git a/src/storage/import.rs b/src/storage/import.rs index efdfb04e..19c8bfda 100644 --- a/src/storage/import.rs +++ b/src/storage/import.rs @@ -3,6 +3,7 @@ //! This module handles importing SQLite .db files into the block-based storage system. use super::block_storage::BLOCK_SIZE; +use super::block_storage::StorageBackend; use super::export::validate_sqlite_file; use crate::types::DatabaseError; use crate::utils::normalize_db_name; @@ -180,6 +181,17 @@ pub async fn clear_database_storage(db_name: &str) -> Result<(), DatabaseError> /// # } /// ``` pub async fn import_database_from_bytes(db_name: &str, data: Vec) -> Result<(), DatabaseError> { + import_database_from_bytes_with_backend(db_name, data, StorageBackend::IndexedDb).await +} + +pub async fn import_database_from_bytes_with_backend( + db_name: &str, + data: Vec, + backend: StorageBackend, +) -> Result<(), DatabaseError> { + #[cfg(not(target_arch = "wasm32"))] + let _ = backend; + use super::vfs_sync::{with_global_allocation_map, with_global_storage}; use std::collections::{HashMap, HashSet}; @@ -212,6 +224,10 @@ pub async fn import_database_from_bytes(db_name: &str, data: Vec) -> Result< ); super::wasm_indexeddb::delete_all_database_blocks_from_indexeddb(db_name).await?; log::debug!("All old blocks deleted from IndexedDB"); + + log::debug!("Deleting any existing OPFS mirror for: {}", db_name); + super::wasm_opfs::delete_all_from_opfs(db_name).await?; + log::debug!("Existing OPFS mirror cleared"); } // Step 4: Split data into BLOCK_SIZE chunks @@ -446,10 +462,14 @@ pub async fn import_database_from_bytes(db_name: &str, data: Vec) -> Result< log::debug!("Commit marker set to 1 for immediate visibility"); - // Step 10: For WASM, sync imported data to IndexedDB immediately and WAIT for it + // Step 10: For WASM, sync imported data to the active browser backend immediately and WAIT for it #[cfg(target_arch = "wasm32")] { - log::debug!("Syncing imported data to IndexedDB for {}", db_name); + log::debug!( + "Syncing imported data to {} for {}", + backend.as_str(), + db_name + ); // Advance commit marker let next_commit = with_global_commit_marker(|cm| { @@ -476,7 +496,7 @@ pub async fn import_database_from_bytes(db_name: &str, data: Vec) -> Result< if let Some(db_meta) = meta.borrow().get(db_name) { db_meta .iter() - .map(|(&id, metadata)| (id, metadata.checksum)) + .map(|(&id, metadata)| (id, metadata.clone())) .collect::>() } else { Vec::new() @@ -489,34 +509,60 @@ pub async fn import_database_from_bytes(db_name: &str, data: Vec) -> Result< if !blocks_to_persist.is_empty() { log::debug!( - "Persisting {} blocks to IndexedDB with commit marker {}", + "Persisting {} blocks to {} with commit marker {}", blocks_to_persist.len(), + backend.as_str(), next_commit ); - // CRITICAL: AWAIT the persistence to complete BEFORE returning - super::wasm_indexeddb::persist_to_indexeddb_event_based( - db_name, - blocks_to_persist, - metadata_to_persist, - next_commit, - #[cfg(feature = "telemetry")] - None, - #[cfg(feature = "telemetry")] - None, - ) - .await - .map_err(|e| { - log::error!("Failed to persist imported data to IndexedDB: {}", e); + let persist_result = match backend { + StorageBackend::IndexedDb => { + super::wasm_indexeddb::persist_to_indexeddb_event_based( + db_name, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + } + StorageBackend::Opfs | StorageBackend::Hybrid => { + super::hybrid_store::hybrid_persist( + db_name, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + } + }; + + persist_result.map_err(|e| { + log::error!( + "Failed to persist imported data to {}: {}", + backend.as_str(), + e + ); DatabaseError::new( "IMPORT_SYNC_FAILED", &format!("Failed to persist imported data: {}", e), ) })?; - log::debug!("Import sync to IndexedDB complete for {}", db_name); + log::debug!( + "Import sync to {} complete for {}", + backend.as_str(), + db_name + ); } else { - log::warn!("No blocks to persist to IndexedDB for {}", db_name); + log::warn!("No blocks to persist for {}", db_name); } } diff --git a/src/storage/metadata.rs b/src/storage/metadata.rs index 5fd7c02b..d12f6982 100644 --- a/src/storage/metadata.rs +++ b/src/storage/metadata.rs @@ -36,17 +36,15 @@ use js_sys::Date; use std::time::{SystemTime, UNIX_EPOCH}; // MOVED from block_storage.rs lines 38-41 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[allow(dead_code)] -#[cfg_attr(feature = "fs_persist", derive(serde::Serialize, serde::Deserialize))] pub enum ChecksumAlgorithm { FastHash, CRC32, } // MOVED from block_storage.rs lines 43-54 -#[derive(Clone, Debug)] -#[cfg_attr(feature = "fs_persist", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct BlockMetadataPersist { pub checksum: u64, #[allow(dead_code)] diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 837d723a..a11011fb 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,5 +1,7 @@ pub mod allocation; pub mod auto_sync; +#[cfg(target_arch = "wasm32")] +pub mod backend_detect; pub mod block_info; pub mod block_storage; #[cfg(target_arch = "wasm32")] @@ -9,6 +11,8 @@ pub mod coordination_metrics; pub mod export; pub mod export_import_lock; pub mod fs_persist; +#[cfg(target_arch = "wasm32")] +pub mod hybrid_store; pub mod import; #[cfg(target_arch = "wasm32")] pub mod indexeddb_queue; @@ -29,12 +33,16 @@ pub mod vfs_sync; pub mod wasm_auto_sync; pub mod wasm_indexeddb; #[cfg(target_arch = "wasm32")] +pub mod wasm_opfs; +#[cfg(target_arch = "wasm32")] pub mod wasm_vfs_sync; #[cfg(target_arch = "wasm32")] pub mod write_queue; pub use block_info::{BlockInfo, BlockStorageInfo}; -pub use block_storage::{BLOCK_SIZE, BlockStorage, CrashRecoveryAction, SyncPolicy}; +pub use block_storage::{ + BLOCK_SIZE, BlockStorage, CrashRecoveryAction, StorageBackend, SyncPolicy, +}; #[cfg(any( target_arch = "wasm32", all(not(target_arch = "wasm32"), any(test, debug_assertions)), diff --git a/src/storage/sync_operations.rs b/src/storage/sync_operations.rs index f2dc4f3e..9ecdd0c7 100644 --- a/src/storage/sync_operations.rs +++ b/src/storage/sync_operations.rs @@ -64,26 +64,22 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa #[cfg(all(not(target_arch = "wasm32"), not(feature = "fs_persist")))] let start = std::time::Instant::now(); - // Record sync start for observability let dirty_count = lock_mutex!(storage.dirty_blocks).len(); let dirty_bytes = dirty_count * super::block_storage::BLOCK_SIZE; storage .observability .record_sync_start(dirty_count, dirty_bytes); - // Invoke sync start callback if set #[cfg(not(target_arch = "wasm32"))] if let Some(ref callback) = storage.observability.sync_start_callback { callback(dirty_count, dirty_bytes); } - // Call the existing fs_persist implementation for native builds #[cfg(all(not(target_arch = "wasm32"), feature = "fs_persist"))] { storage.fs_persist_sync() } - // For native non-fs_persist builds, use simple in-memory sync with commit marker handling #[cfg(all(not(target_arch = "wasm32"), not(feature = "fs_persist")))] { let current_dirty = lock_mutex!(storage.dirty_blocks).len(); @@ -97,16 +93,14 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa dirty.iter().map(|(k, v)| (*k, v.clone())).collect() }; let ids: Vec = to_persist.iter().map(|(k, _)| *k).collect(); - let blocks_synced = ids.len(); // Capture length before moving ids + let blocks_synced = ids.len(); - // Determine next commit version for native path let next_commit: u64 = vfs_sync::with_global_commit_marker(|cm| { let cm = cm.borrow(); let current = cm.get(&storage.db_name).copied().unwrap_or(0); current + 1 }); - // Store blocks in global storage with versioning vfs_sync::with_global_storage(|gs| { let mut storage_map = gs.borrow_mut(); let db_storage = storage_map @@ -117,7 +111,6 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa } }); - // Store metadata with per-commit versioning GLOBAL_METADATA_TEST.with(|meta| { let mut meta_map = meta.lock(); let db_meta = meta_map @@ -125,8 +118,6 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa .or_insert_with(HashMap::new); for block_id in ids { if let Some(checksum) = storage.checksum_manager.get_checksum(block_id) { - // Use the per-commit version so entries remain invisible until the commit marker advances - let version = next_commit as u32; db_meta.insert( block_id, BlockMetadataPersist { @@ -135,7 +126,7 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64, - version, + version: next_commit as u32, algo: storage.checksum_manager.get_algorithm(block_id), }, ); @@ -143,29 +134,18 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa } }); - // Atomically advance the commit marker after all data and metadata are persisted vfs_sync::with_global_commit_marker(|cm| { let mut cm_map = cm.borrow_mut(); cm_map.insert(storage.db_name.clone(), next_commit); }); - // Clear dirty blocks - { - let mut dirty = lock_mutex!(storage.dirty_blocks); - dirty.clear(); - } + lock_mutex!(storage.dirty_blocks).clear(); - // Update sync metrics storage.sync_count.fetch_add(1, Ordering::SeqCst); - let elapsed = start.elapsed(); - let ms = elapsed.as_millis() as u64; - let ms = if ms == 0 { 1 } else { ms }; + let ms = (start.elapsed().as_millis() as u64).max(1); storage.last_sync_duration_ms.store(ms, Ordering::SeqCst); - - // Record sync success for observability storage.observability.record_sync_success(ms, blocks_synced); - // Invoke sync success callback if set if let Some(ref callback) = storage.observability.sync_success_callback { callback(ms, blocks_synced); } @@ -176,55 +156,57 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa #[cfg(target_arch = "wasm32")] { - // WASM implementation let current_dirty = lock_mutex!(storage.dirty_blocks).len(); log::info!("Syncing {} dirty blocks (WASM)", current_dirty); - // For WASM, persist dirty blocks to global storage let to_persist: Vec<(u64, Vec)> = { let dirty = lock_mutex!(storage.dirty_blocks); dirty.iter().map(|(k, v)| (*k, v.clone())).collect() }; let ids: Vec = to_persist.iter().map(|(k, _)| *k).collect(); - // Determine next commit version so that all metadata written in this sync share the same version let next_commit: u64 = vfs_sync::with_global_commit_marker(|cm| { - let cm = cm; let current = cm.borrow().get(&storage.db_name).copied().unwrap_or(0); current + 1 }); + let metadata_to_persist: Vec<(u64, BlockMetadataPersist)> = ids + .iter() + .filter_map(|block_id| { + storage + .checksum_manager + .get_checksum(*block_id) + .map(|checksum| { + ( + *block_id, + BlockMetadataPersist { + checksum, + last_modified_ms: BlockStorage::now_millis(), + version: next_commit as u32, + algo: storage.checksum_manager.get_algorithm(*block_id), + }, + ) + }) + }) + .collect(); + vfs_sync::with_global_storage(|gs| { let mut storage_map = gs.borrow_mut(); let db_storage = storage_map .entry(storage.db_name.clone()) .or_insert_with(HashMap::new); for (block_id, data) in &to_persist { - // Check if block already exists in global storage with committed data let should_update = if let Some(existing) = db_storage.get(block_id) { if existing != data { - // Check if existing data has committed metadata (version > 0) - let has_committed_metadata = vfs_sync::with_global_metadata(|meta| { - if let Some(db_meta) = meta.borrow().get(&storage.db_name) { - if let Some(metadata) = db_meta.get(block_id) { - metadata.version > 0 - } else { - false - } - } else { - false - } - }); - - if has_committed_metadata { - // CRITICAL FIX: Never overwrite committed data to prevent corruption - false // Never overwrite committed data - } else { - true // Update uncommitted data - } + !vfs_sync::with_global_metadata(|meta| { + meta.borrow() + .get(&storage.db_name) + .and_then(|db_meta| db_meta.get(block_id)) + .is_some_and(|metadata| metadata.version > 0) + }) } else { - true // Same data, safe to update + true } } else { - true // No existing data, safe to insert + true }; if should_update { @@ -232,220 +214,68 @@ pub fn sync_implementation_impl(storage: &mut BlockStorage) -> Result<(), Databa } } }); - // Persist corresponding metadata entries + vfs_sync::with_global_metadata(|meta| { let mut meta_guard = meta.borrow_mut(); let db_meta = meta_guard .entry(storage.db_name.clone()) .or_insert_with(HashMap::new); - for block_id in ids { - if let Some(checksum) = storage.checksum_manager.get_checksum(block_id) { - // Use the per-commit version so entries remain invisible until the commit marker advances - let version = next_commit as u32; - db_meta.insert( - block_id, - BlockMetadataPersist { - checksum, - last_modified_ms: BlockStorage::now_millis(), - version, - algo: storage.checksum_manager.get_algorithm(block_id), - }, - ); - } + for (block_id, metadata) in &metadata_to_persist { + db_meta.insert(*block_id, metadata.clone()); } }); - // Atomically advance the commit marker after all data and metadata are persisted vfs_sync::with_global_commit_marker(|cm| { - let cm_map = cm; - cm_map - .borrow_mut() - .insert(storage.db_name.clone(), next_commit); + cm.borrow_mut().insert(storage.db_name.clone(), next_commit); }); - // Spawn async IndexedDB persistence (fire and forget for sync compatibility) - let db_name = storage.db_name.clone(); - wasm_bindgen_futures::spawn_local(async move { - use wasm_bindgen::JsCast; - - // Get IndexedDB factory (works in both Window and Worker contexts) - let global = js_sys::global(); - let indexed_db_value = match js_sys::Reflect::get( - &global, - &wasm_bindgen::JsValue::from_str("indexedDB"), - ) { - Ok(val) => val, - Err(_) => { - log::error!("IndexedDB property access failed - cannot persist"); - return; - } - }; - - if indexed_db_value.is_null() || indexed_db_value.is_undefined() { - log::warn!( - "IndexedDB unavailable for sync (private browsing?) - data not persisted to IndexedDB" - ); - return; - } - - let idb_factory = match indexed_db_value.dyn_into::() { - Ok(factory) => factory, - Err(_) => { - log::error!("IndexedDB property is not an IdbFactory - cannot persist"); - return; - } - }; - - let open_req = match idb_factory.open_with_u32("block_storage", 2) { - Ok(req) => req, - Err(e) => { - log::error!("Failed to open IndexedDB for sync: {:?}", e); - return; - } - }; - - // Set up upgrade handler to create object stores if needed. - // Typed web-sys Closure instead of js_sys::Function::new_no_args so it - // does not require 'unsafe-eval' (blocked by MV3 extension CSP). - let upgrade_callback = - wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { - if let Some(target) = event.target() { - let request: web_sys::IdbOpenDbRequest = target.unchecked_into(); - if let Ok(result) = request.result() { - let db: web_sys::IdbDatabase = result.unchecked_into(); - let names = db.object_store_names(); - if !names.contains("blocks") { - let _ = db.create_object_store("blocks"); - } - if !names.contains("metadata") { - let _ = db.create_object_store("metadata"); - } - } - } - }) as Box); - open_req.set_onupgradeneeded(Some(upgrade_callback.as_ref().unchecked_ref())); - upgrade_callback.forget(); - - // Use event-based approach for opening database - let (tx, rx) = futures::channel::oneshot::channel(); - let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); - - let success_tx = tx.clone(); - let success_callback = - wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { - if let Some(tx) = success_tx.borrow_mut().take() { - let target = event.target().unwrap(); - let request: web_sys::IdbOpenDbRequest = target.unchecked_into(); - let result = request.result().unwrap(); - let _ = tx.send(Ok(result)); - } - }) as Box); - - let error_tx = tx.clone(); - let error_callback = - wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { - if let Some(tx) = error_tx.borrow_mut().take() { - let _ = tx.send(Err(format!("IndexedDB open failed: {:?}", event))); - } - }) as Box); - - open_req.set_onsuccess(Some(success_callback.as_ref().unchecked_ref())); - open_req.set_onerror(Some(error_callback.as_ref().unchecked_ref())); - - let db_result = rx.await; - - // Keep closures alive - success_callback.forget(); - error_callback.forget(); - - match db_result { - Ok(Ok(db_value)) => { - if let Ok(db) = db_value.dyn_into::() { - // Start transaction for both blocks and metadata - let store_names = js_sys::Array::new(); - store_names.push(&wasm_bindgen::JsValue::from_str("blocks")); - store_names.push(&wasm_bindgen::JsValue::from_str("metadata")); - - let transaction = db - .transaction_with_str_sequence_and_mode( - &store_names, - web_sys::IdbTransactionMode::Readwrite, - ) - .unwrap(); - - let blocks_store = transaction.object_store("blocks").unwrap(); - let metadata_store = transaction.object_store("metadata").unwrap(); - - // Persist all blocks - // IMPORTANT: Use COLON format to match wasm_indexeddb.rs and restore logic - for (block_id, data) in &to_persist { - let key = wasm_bindgen::JsValue::from_str(&format!( - "{}:{}", - db_name, block_id - )); - let value = js_sys::Uint8Array::from(&data[..]); - blocks_store.put_with_key(&value, &key).unwrap(); - } - - // Persist commit marker - // IMPORTANT: Use COLON format to match wasm_indexeddb.rs and restore logic - let commit_key = - wasm_bindgen::JsValue::from_str(&format!("{}:commit_marker", db_name)); - let commit_value = wasm_bindgen::JsValue::from_f64(next_commit as f64); - metadata_store - .put_with_key(&commit_value, &commit_key) - .unwrap(); - - // Use event-based approach for transaction completion - let (tx_tx, tx_rx) = futures::channel::oneshot::channel(); - let tx_tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx_tx))); - - let tx_complete_tx = tx_tx.clone(); - let tx_complete_callback = wasm_bindgen::closure::Closure::wrap(Box::new( - move |_event: web_sys::Event| { - if let Some(tx) = tx_complete_tx.borrow_mut().take() { - let _ = tx.send(Ok(())); - } - }, + if !to_persist.is_empty() { + let db_name = storage.db_name.clone(); + let backend = storage.storage_backend(); + wasm_bindgen_futures::spawn_local(async move { + let persist_result = match backend { + super::block_storage::StorageBackend::IndexedDb => { + super::wasm_indexeddb::persist_to_indexeddb_event_based( + &db_name, + to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, ) - as Box); - - let tx_error_tx = tx_tx.clone(); - let tx_error_callback = wasm_bindgen::closure::Closure::wrap(Box::new( - move |event: web_sys::Event| { - if let Some(tx) = tx_error_tx.borrow_mut().take() { - let _ = - tx.send(Err(format!("Transaction failed: {:?}", event))); - } - }, + .await + } + super::block_storage::StorageBackend::Opfs + | super::block_storage::StorageBackend::Hybrid => { + super::hybrid_store::hybrid_persist( + &db_name, + to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, ) - as Box); - - transaction - .set_oncomplete(Some(tx_complete_callback.as_ref().unchecked_ref())); - transaction.set_onerror(Some(tx_error_callback.as_ref().unchecked_ref())); - - let _ = tx_rx.await; - - // Keep closures alive - tx_complete_callback.forget(); - tx_error_callback.forget(); + .await } + }; + + if let Err(error) = persist_result { + log::error!( + "Failed to persist {} using backend {}: {}", + db_name, + backend.as_str(), + error + ); } - _ => {} // Silently ignore errors in background persistence - } - }); - // Clear dirty blocks after successful persistence - { - let mut dirty = lock_mutex!(storage.dirty_blocks); - dirty.clear(); + }); } - // Record sync success for observability (WASM) - // For WASM, we don't have precise timing, so use a default duration + lock_mutex!(storage.dirty_blocks).clear(); storage.observability.record_sync_success(1, current_dirty); - // Invoke WASM sync success callback if set - #[cfg(target_arch = "wasm32")] if let Some(ref callback) = storage.observability.wasm_sync_success_callback { callback(1, current_dirty); } diff --git a/src/storage/wasm_indexeddb.rs b/src/storage/wasm_indexeddb.rs index 38bdc732..03e743af 100644 --- a/src/storage/wasm_indexeddb.rs +++ b/src/storage/wasm_indexeddb.rs @@ -2,7 +2,7 @@ //! This module contains WASM-specific IndexedDB functionality #[cfg(target_arch = "wasm32")] -use super::metadata::{BlockMetadataPersist, ChecksumAlgorithm}; +use super::metadata::{BlockMetadataPersist, ChecksumAlgorithm, ChecksumManager}; #[cfg(target_arch = "wasm32")] use super::{BlockStorage, vfs_sync}; #[cfg(target_arch = "wasm32")] @@ -27,6 +27,22 @@ thread_local! { static INDEXEDDB_MUTEX: RefCell>> = RefCell::new(Arc::new(Mutex::new(()))); } +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Debug)] +pub(crate) struct PersistedMetadataEntry { + pub metadata: BlockMetadataPersist, + pub authoritative_checksum: bool, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct IndexedDbMetadataRecord { + checksum: String, + last_modified_ms: u64, + version: u32, + algo: ChecksumAlgorithm, +} + // Reentrancy-safe lock macros #[allow(unused_macros)] macro_rules! lock_mutex { @@ -80,6 +96,298 @@ fn get_indexeddb_factory() -> Result { Ok(indexed_db) } +#[cfg(target_arch = "wasm32")] +fn parse_persisted_metadata_entry(value: &JsValue) -> Option { + if let Ok(record) = serde_wasm_bindgen::from_value::(value.clone()) { + if let Ok(checksum) = record.checksum.parse::() { + return Some(PersistedMetadataEntry { + metadata: BlockMetadataPersist { + checksum, + last_modified_ms: record.last_modified_ms, + version: record.version, + algo: record.algo, + }, + authoritative_checksum: true, + }); + } + } + + if let Ok(metadata) = serde_wasm_bindgen::from_value::(value.clone()) { + return Some(PersistedMetadataEntry { + metadata, + authoritative_checksum: true, + }); + } + + value.as_f64().map(|version| PersistedMetadataEntry { + metadata: BlockMetadataPersist { + checksum: 0, + last_modified_ms: 0, + version: version as u32, + algo: ChecksumAlgorithm::FastHash, + }, + authoritative_checksum: false, + }) +} + +#[cfg(target_arch = "wasm32")] +async fn await_open_db_request( + open_req: web_sys::IdbOpenDbRequest, +) -> Result { + use wasm_bindgen::JsCast; + + let (tx, rx) = futures::channel::oneshot::channel::>(); + let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); + + let success_tx = tx.clone(); + let success_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(tx) = success_tx.borrow_mut().take() { + let target = event.target().unwrap(); + let request: web_sys::IdbOpenDbRequest = target.unchecked_into(); + let result = request.result().unwrap(); + let db: web_sys::IdbDatabase = result.unchecked_into(); + let _ = tx.send(Ok(db)); + } + }) as Box); + + let error_tx = tx.clone(); + let error_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(tx) = error_tx.borrow_mut().take() { + let _ = tx.send(Err(format!("IndexedDB open failed: {:?}", event))); + } + }) as Box); + + open_req.set_onsuccess(Some(success_callback.as_ref().unchecked_ref())); + open_req.set_onerror(Some(error_callback.as_ref().unchecked_ref())); + success_callback.forget(); + error_callback.forget(); + + match rx.await { + Ok(Ok(db)) => Ok(db), + Ok(Err(error)) => Err(DatabaseError::new("INDEXEDDB_ERROR", &error)), + Err(_) => Err(DatabaseError::new( + "INDEXEDDB_ERROR", + "Channel error awaiting IndexedDB open", + )), + } +} + +#[cfg(target_arch = "wasm32")] +async fn await_request_result(request: web_sys::IdbRequest) -> Result { + use wasm_bindgen::JsCast; + + let (tx, rx) = futures::channel::oneshot::channel::>(); + let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); + + let success_tx = tx.clone(); + let success_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(tx) = success_tx.borrow_mut().take() { + let target = event.target().unwrap(); + let request: web_sys::IdbRequest = target.unchecked_into(); + let result = request.result().unwrap(); + let _ = tx.send(Ok(result)); + } + }) as Box); + + let error_tx = tx.clone(); + let error_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(tx) = error_tx.borrow_mut().take() { + let _ = tx.send(Err(format!("IndexedDB request failed: {:?}", event))); + } + }) as Box); + + request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref())); + request.set_onerror(Some(error_callback.as_ref().unchecked_ref())); + success_callback.forget(); + error_callback.forget(); + + match rx.await { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => Err(DatabaseError::new("INDEXEDDB_ERROR", &error)), + Err(_) => Err(DatabaseError::new( + "INDEXEDDB_ERROR", + "Channel error awaiting IndexedDB request", + )), + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn restore_metadata_from_indexeddb( + db_name: &str, + overwrite_existing: bool, +) -> Result, DatabaseError> { + use wasm_bindgen::JsCast; + + let mutex = INDEXEDDB_MUTEX.with(|m| m.borrow().clone()); + let _guard = mutex.lock().await; + + let open_req = open_indexeddb("block_storage", 2)?; + let upgrade_closure = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + let target = event.target().unwrap(); + let request: web_sys::IdbOpenDbRequest = target.unchecked_into(); + let result = request.result().unwrap(); + let db: web_sys::IdbDatabase = result.unchecked_into(); + + if !db.object_store_names().contains("blocks") { + let _ = db.create_object_store("blocks"); + } + if !db.object_store_names().contains("metadata") { + let _ = db.create_object_store("metadata"); + } + }) as Box); + open_req.set_onupgradeneeded(Some(upgrade_closure.as_ref().unchecked_ref())); + upgrade_closure.forget(); + + let db = await_open_db_request(open_req).await?; + let transaction = db.transaction_with_str("metadata").map_err(|_| { + DatabaseError::new("INDEXEDDB_ERROR", "Failed to create metadata transaction") + })?; + let metadata_store = transaction + .object_store("metadata") + .map_err(|_| DatabaseError::new("INDEXEDDB_ERROR", "Failed to get metadata store"))?; + + let commit_key = format!("{}:commit_marker", db_name); + let commit_value = await_request_result( + metadata_store + .get(&commit_key.clone().into()) + .map_err(|_| DatabaseError::new("INDEXEDDB_ERROR", "Failed to get commit marker"))?, + ) + .await?; + let commit_marker = commit_value.as_f64().map(|value| value as u64); + + let key_start = format!("{}:", db_name); + let key_end = format!("{}:{}", db_name, char::from_u32(0xFFFF).unwrap()); + + let key_range = + web_sys::IdbKeyRange::bound(&JsValue::from_str(&key_start), &JsValue::from_str(&key_end)) + .map_err(|_| { + DatabaseError::new("INDEXEDDB_ERROR", "Failed to create metadata key range") + })?; + + let cursor_request = metadata_store + .open_cursor_with_range(&key_range) + .map_err(|_| DatabaseError::new("INDEXEDDB_ERROR", "Failed to open metadata cursor"))?; + + let (tx, rx) = futures::channel::oneshot::channel::< + Result, String>, + >(); + let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); + let metadata_entries = std::rc::Rc::new(std::cell::RefCell::new(HashMap::new())); + + let entries_clone = metadata_entries.clone(); + let success_tx = tx.clone(); + let success_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + let target = event.target().unwrap(); + let request: web_sys::IdbRequest = target.unchecked_into(); + let result = request.result().unwrap(); + + if result.is_null() || result.is_undefined() { + if let Some(tx) = success_tx.borrow_mut().take() { + let _ = tx.send(Ok(entries_clone.borrow().clone())); + } + return; + } + + let cursor: web_sys::IdbCursorWithValue = result.unchecked_into(); + let key = cursor + .key() + .ok() + .and_then(|value| value.as_string()) + .unwrap_or_default(); + + if !key.ends_with(":commit_marker") { + let parts: Vec<&str> = key.split(':').collect(); + if parts.len() >= 2 { + if let Ok(block_id) = parts[1].parse::() { + if let Some(entry) = + parse_persisted_metadata_entry(&cursor.value().unwrap()) + { + entries_clone.borrow_mut().insert(block_id, entry); + } + } + } + } + + let _ = cursor.continue_(); + }) as Box); + + let error_tx = tx.clone(); + let error_callback = + wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(tx) = error_tx.borrow_mut().take() { + let _ = tx.send(Err(format!("Metadata cursor failed: {:?}", event))); + } + }) as Box); + + cursor_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref())); + cursor_request.set_onerror(Some(error_callback.as_ref().unchecked_ref())); + success_callback.forget(); + error_callback.forget(); + + let metadata_entries = match rx.await { + Ok(Ok(entries)) => entries, + Ok(Err(error)) => return Err(DatabaseError::new("INDEXEDDB_ERROR", &error)), + Err(_) => { + return Err(DatabaseError::new( + "INDEXEDDB_ERROR", + "Channel error restoring metadata from IndexedDB", + )); + } + }; + + db.close(); + + if let Some(commit_marker) = commit_marker { + vfs_sync::with_global_commit_marker(|cm| { + cm.borrow_mut().insert(db_name.to_string(), commit_marker); + }); + } + + if !metadata_entries.is_empty() { + let storage_snapshot = vfs_sync::with_global_storage(|storage_map| { + storage_map + .borrow() + .get(db_name) + .cloned() + .unwrap_or_default() + }); + + let resolved_metadata = metadata_entries + .iter() + .map(|(&block_id, entry)| { + let mut metadata = entry.metadata.clone(); + if !entry.authoritative_checksum { + if let Some(data) = storage_snapshot.get(&block_id) { + metadata.checksum = + ChecksumManager::compute_checksum_with(data, metadata.algo); + } + } + (block_id, metadata) + }) + .collect::>(); + + vfs_sync::with_global_metadata(|gm| { + let mut metadata_map = gm.borrow_mut(); + if overwrite_existing { + metadata_map.insert(db_name.to_string(), resolved_metadata.clone()); + } else { + metadata_map + .entry(db_name.to_string()) + .or_insert_with(HashMap::new) + .extend(resolved_metadata.clone()); + } + }); + } + + Ok(metadata_entries) +} + /// Helper: Open IndexedDB database #[cfg(target_arch = "wasm32")] fn open_indexeddb(db_name: &str, version: u32) -> Result { @@ -703,17 +1011,12 @@ async fn restore_blocks_from_indexeddb( // Parse key: "db_name:block_id:version" or "db_name:commit_marker" if !key.contains("commit_marker") { let parts: Vec<&str> = key.split(':').collect(); - if parts.len() >= 3 { + if parts.len() >= 2 { if let Ok(block_id) = parts[1].parse::() { - if let Ok(version) = parts[2].parse::() { - // Get the version value (stored as Number) - if let Some(version_f64) = value.as_f64() { - metadata_data_clone.borrow_mut().push(( - block_id, - version, - version_f64 as u32, - )); - } + if let Some(entry) = parse_persisted_metadata_entry(&value) { + metadata_data_clone + .borrow_mut() + .push((block_id, entry.metadata)); } } } @@ -742,34 +1045,20 @@ async fn restore_blocks_from_indexeddb( ); // Restore metadata to global metadata storage - // Note: We compute checksums from the restored block data since IndexedDB only stores versions vfs_sync::with_global_metadata(|gm| { let mut meta_map = gm.borrow_mut(); let db_meta = meta_map .entry(db_name.to_string()) .or_insert_with(HashMap::new); - for (block_id, _key_version, stored_version) in &restored_metadata { - // Find the corresponding block data to compute checksum - if let Some((_, data)) = restored_blocks.iter().find(|(bid, _)| bid == block_id) { - let checksum = { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - data.hash(&mut hasher); - hasher.finish() - }; - - db_meta.insert( - *block_id, - BlockMetadataPersist { - checksum, - version: *stored_version, - last_modified_ms: 0, // Will be updated on next write - algo: ChecksumAlgorithm::FastHash, - }, - ); + for (block_id, restored_metadata) in &restored_metadata { + let mut metadata = restored_metadata.clone(); + if metadata.checksum == 0 { + if let Some((_, data)) = restored_blocks.iter().find(|(bid, _)| bid == block_id) { + metadata.checksum = ChecksumManager::compute_checksum_with(data, metadata.algo); + } } + db_meta.insert(*block_id, metadata); } }); @@ -782,7 +1071,7 @@ async fn restore_blocks_from_indexeddb( pub async fn persist_to_indexeddb_event_based( db_name: &str, blocks: Vec<(u64, Vec)>, - metadata: Vec<(u64, u64)>, + metadata: Vec<(u64, BlockMetadataPersist)>, commit_marker: u64, #[cfg(feature = "telemetry")] span_recorder: Option, #[cfg(feature = "telemetry")] parent_span_id: Option, @@ -849,7 +1138,7 @@ pub async fn persist_to_indexeddb_event_based( async fn persist_to_indexeddb_event_based_internal( db_name: &str, blocks: Vec<(u64, Vec)>, - metadata: Vec<(u64, u64)>, + metadata: Vec<(u64, BlockMetadataPersist)>, commit_marker: u64, ) -> Result<(), DatabaseError> { use wasm_bindgen::JsCast; @@ -1052,10 +1341,27 @@ async fn persist_to_indexeddb_event_based_internal( } // Store metadata with truly idempotent keys: (db_name, block_id) - // Store the version/checksum as the VALUE, not in the KEY - for (block_id, version) in metadata { + for (block_id, mut metadata) in metadata { let key = format!("{}:{}", db_name, block_id); - let value = js_sys::Number::from(version as f64); + metadata.version = u32::try_from(commit_marker).unwrap_or(u32::MAX); + if metadata.last_modified_ms == 0 { + metadata.last_modified_ms = js_sys::Date::now() as u64; + } + let persisted_metadata = IndexedDbMetadataRecord { + checksum: metadata.checksum.to_string(), + last_modified_ms: metadata.last_modified_ms, + version: metadata.version, + algo: metadata.algo, + }; + let value = serde_wasm_bindgen::to_value(&persisted_metadata).map_err(|error| { + DatabaseError::new( + "INDEXEDDB_ERROR", + &format!( + "Failed to serialize metadata for {}:{}: {}", + db_name, block_id, error + ), + ) + })?; #[cfg(target_arch = "wasm32")] log::debug!("Storing metadata with idempotent key: {}", key); let _ = metadata_store.put_with_key(&value, &key.into()); @@ -1141,65 +1447,36 @@ pub async fn sync_async(storage: &BlockStorage) -> Result<(), DatabaseError> { next_commit ); - // Collect blocks to persist with commit marker gating and richer cache data logic - let mut to_persist = Vec::new(); - let mut metadata_to_persist = Vec::new(); - - // Collect cache data to iterate over (can't iterate over Mutex directly) - let cache_snapshot: Vec<(u64, Vec)> = lock_mutex!(storage.cache) + // Persist the actual dirty set. Global storage is only the in-memory mirror, + // so equality with cache must not suppress durable backend writes. + let to_persist: Vec<(u64, Vec)> = lock_mutex!(storage.dirty_blocks) .iter() .map(|(k, v)| (*k, v.clone())) .collect(); - for (block_id, block_data) in cache_snapshot { - let should_update = vfs_sync::with_global_storage(|storage_global| { - let storage_global = storage_global; - if let Some(db_storage) = storage_global.borrow().get(&storage.db_name) { - if let Some(existing_data) = db_storage.get(&block_id) { - // Check if cache has richer data (more non-zero bytes) - let existing_non_zero = existing_data.iter().filter(|&&b| b != 0).count(); - let cache_non_zero = block_data.iter().filter(|&&b| b != 0).count(); - - if cache_non_zero > existing_non_zero { - #[cfg(target_arch = "wasm32")] - web_sys::console::log_1(&format!( - "DEBUG: SYNC updating committed block {} with richer cache data - existing: {}, cache: {}", - block_id, - existing_data.iter().take(8).map(|b| format!("{:02x}", b)).collect::>().join(" "), - block_data.iter().take(8).map(|b| format!("{:02x}", b)).collect::>().join(" ") - ).into()); - true - } else { - #[cfg(target_arch = "wasm32")] - web_sys::console::log_1(&format!( - "DEBUG: SYNC preserving committed block {} - existing: {}, cache: {} - SKIPPING", - block_id, - existing_data.iter().take(8).map(|b| format!("{:02x}", b)).collect::>().join(" "), - block_data.iter().take(8).map(|b| format!("{:02x}", b)).collect::>().join(" ") - ).into()); - false - } - } else { - true // New block - } - } else { - true // New database - } - }); - - if should_update { - to_persist.push((block_id, block_data.clone())); - } - - // ALWAYS update metadata when commit marker advances, regardless of data changes - metadata_to_persist.push((block_id, next_commit)); - #[cfg(target_arch = "wasm32")] - log::debug!( - "SYNC updating metadata for block {} to version {}", - block_id, - next_commit - ); - } + let metadata_to_persist: Vec<(u64, BlockMetadataPersist)> = to_persist + .iter() + .map(|(block_id, block_data)| { + let checksum = storage + .checksum_manager + .get_checksum(*block_id) + .unwrap_or_else(|| { + ChecksumManager::compute_checksum_with( + block_data, + storage.checksum_manager.get_algorithm(*block_id), + ) + }); + ( + *block_id, + BlockMetadataPersist { + version: next_commit as u32, + checksum, + algo: storage.checksum_manager.get_algorithm(*block_id), + last_modified_ms: js_sys::Date::now() as u64, + }, + ) + }) + .collect(); // Update global storage vfs_sync::with_global_storage(|storage_global| { @@ -1218,16 +1495,8 @@ pub async fn sync_async(storage: &BlockStorage) -> Result<(), DatabaseError> { let db_metadata = guard .entry(storage.db_name.clone()) .or_insert_with(std::collections::HashMap::new); - for (block_id, version) in &metadata_to_persist { - db_metadata.insert( - *block_id, - BlockMetadataPersist { - version: *version as u32, - checksum: 0, - algo: ChecksumAlgorithm::FastHash, - last_modified_ms: js_sys::Date::now() as u64, - }, - ); + for (block_id, metadata) in &metadata_to_persist { + db_metadata.insert(*block_id, metadata.clone()); } }); @@ -1239,24 +1508,43 @@ pub async fn sync_async(storage: &BlockStorage) -> Result<(), DatabaseError> { .insert(storage.db_name.clone(), next_commit); }); - // Perform IndexedDB persistence with proper event-based waiting + // Persist to the active browser backend and await completion. if !to_persist.is_empty() { + let backend = storage.storage_backend(); #[cfg(target_arch = "wasm32")] log::debug!( - "Awaiting IndexedDB persistence for {} blocks", + "Awaiting {} persistence for {} blocks", + backend.as_str(), to_persist.len() ); - persist_to_indexeddb_event_based( - &storage.db_name, - to_persist, - metadata_to_persist, - next_commit, - #[cfg(feature = "telemetry")] - None, - #[cfg(feature = "telemetry")] - None, - ) - .await?; + match backend { + crate::storage::StorageBackend::IndexedDb => { + persist_to_indexeddb_event_based( + &storage.db_name, + to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await?; + } + crate::storage::StorageBackend::Opfs | crate::storage::StorageBackend::Hybrid => { + super::hybrid_store::hybrid_persist( + &storage.db_name, + to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await?; + } + } } // Clear dirty blocks @@ -1274,7 +1562,7 @@ pub async fn sync_async(storage: &BlockStorage) -> Result<(), DatabaseError> { pub async fn persist_to_indexeddb( db_name: &str, blocks: std::collections::HashMap>, - metadata: Vec<(u64, u64)>, + metadata: Vec<(u64, BlockMetadataPersist)>, ) -> Result<(), DatabaseError> { log::debug!("persist_to_indexeddb called for {} blocks", blocks.len()); diff --git a/src/storage/wasm_opfs.rs b/src/storage/wasm_opfs.rs new file mode 100644 index 00000000..ceb156d1 --- /dev/null +++ b/src/storage/wasm_opfs.rs @@ -0,0 +1,526 @@ +//! WASM OPFS block persistence helpers. +//! +//! This module provides a minimal write-through OPFS path for browser worker +//! contexts using `FileSystemSyncAccessHandle`. The current slice mirrors block +//! data into OPFS on sync while keeping IndexedDB restoration as the reload path +//! until full OPFS restore/orchestration lands. + +use crate::storage::metadata::{BlockMetadataPersist, ChecksumAlgorithm, ChecksumManager}; +use crate::storage::vfs_sync; +use crate::types::DatabaseError; +use crate::utils::normalize_db_name; +use wasm_bindgen::prelude::*; + +const OPFS_FILE_SUFFIX: &str = ".absurder.blocks.bin"; + +#[wasm_bindgen(inline_js = r#" +const absurdHandles = new Map(); +let absurdNextHandleId = 1; + +function absurdGetRoot() { + if (!globalThis.navigator || !globalThis.navigator.storage || typeof globalThis.navigator.storage.getDirectory !== 'function') { + throw new Error('OPFS is not available in this context'); + } + + return globalThis.navigator.storage.getDirectory(); +} + +export async function absurd_opfs_open(fileName, create = true) { + const root = await absurdGetRoot(); + const fileHandle = await root.getFileHandle(fileName, { create }); + const accessHandle = await fileHandle.createSyncAccessHandle(); + const id = absurdNextHandleId++; + absurdHandles.set(id, accessHandle); + return id; +} + +export async function absurd_opfs_exists(fileName) { + try { + const root = await absurdGetRoot(); + await root.getFileHandle(fileName, { create: false }); + return true; + } catch (error) { + if (error && (error.name === 'NotFoundError' || String(error).includes('NotFound'))) { + return false; + } + throw error; + } +} + +export function absurd_opfs_read(id, offset, len) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + const buffer = new Uint8Array(len); + handle.read(buffer, { at: offset }); + return buffer; +} + +export function absurd_opfs_write(id, offset, data) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.write(data, { at: offset }); +} + +export function absurd_opfs_flush(id) { + const handle = absurdHandles.get(id); + if (handle) { + handle.flush(); + } +} + +export function absurd_opfs_size(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return 0; + } + + return handle.getSize(); +} + +export function absurd_opfs_truncate(id, size) { + const handle = absurdHandles.get(id); + if (!handle) { + throw new Error('OPFS handle not found: ' + id); + } + + handle.truncate(size); +} + +export async function absurd_opfs_close(id) { + const handle = absurdHandles.get(id); + if (!handle) { + return; + } + + const result = handle.close(); + absurdHandles.delete(id); + if (result && typeof result.then === 'function') { + await result; + } +} + +export async function absurd_opfs_delete(fileName) { + try { + const root = await absurdGetRoot(); + await root.removeEntry(fileName); + } catch (error) { + if (!String(error).includes('NotFound')) { + throw error; + } + } +} + +export function absurd_opfs_available() { + return !!(globalThis.navigator && + globalThis.navigator.storage && + typeof globalThis.navigator.storage.getDirectory === 'function'); +} +"#)] +extern "C" { + #[wasm_bindgen(js_name = absurd_opfs_open, catch)] + async fn js_opfs_open(file_name: &str, create: bool) -> Result; + + #[wasm_bindgen(js_name = absurd_opfs_exists, catch)] + async fn js_opfs_exists(file_name: &str) -> Result; + + #[wasm_bindgen(js_name = absurd_opfs_read)] + fn js_opfs_read(handle_id: u32, offset: u32, len: u32) -> js_sys::Uint8Array; + + #[wasm_bindgen(js_name = absurd_opfs_write)] + fn js_opfs_write(handle_id: u32, offset: u32, data: &[u8]); + + #[wasm_bindgen(js_name = absurd_opfs_flush)] + fn js_opfs_flush(handle_id: u32); + + #[wasm_bindgen(js_name = absurd_opfs_size)] + fn js_opfs_size(handle_id: u32) -> u32; + + #[wasm_bindgen(js_name = absurd_opfs_truncate)] + fn js_opfs_truncate(handle_id: u32, size: u32); + + #[wasm_bindgen(js_name = absurd_opfs_close, catch)] + async fn js_opfs_close(handle_id: u32) -> Result<(), JsValue>; + + #[wasm_bindgen(js_name = absurd_opfs_delete, catch)] + async fn js_opfs_delete(file_name: &str) -> Result<(), JsValue>; + + #[wasm_bindgen(js_name = absurd_opfs_available)] + fn js_opfs_available() -> bool; +} + +fn sanitize_opfs_component(value: &str) -> String { + value + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' => '_', + _ => ch, + }) + .collect() +} + +fn opfs_file_name(db_name: &str) -> String { + let normalized = normalize_db_name(db_name); + format!( + "{}{}", + sanitize_opfs_component(&normalized), + OPFS_FILE_SUFFIX + ) +} + +fn map_js_error(code: &str, error: JsValue) -> DatabaseError { + DatabaseError::new(code, &format!("{:?}", error)) +} + +async fn with_opfs_lock(db_name: &str, f: F) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let lock_name = format!("opfs:{}", opfs_file_name(db_name)); + crate::storage::export_import_lock::with_lock(&lock_name, || async move { + f().await + .map_err(|error| JsValue::from_str(&error.to_string())) + }) + .await + .map_err(|error| map_js_error("OPFS_LOCK_ERROR", error)) +} + +async fn open_handle_for_db(db_name: &str, create: bool) -> Result { + let handle_value = js_opfs_open(&opfs_file_name(db_name), create) + .await + .map_err(|error| map_js_error("OPFS_OPEN_ERROR", error))?; + + let handle_id = handle_value.as_f64().ok_or_else(|| { + DatabaseError::new( + "OPFS_OPEN_ERROR", + "OPFS bridge did not return a numeric handle", + ) + })? as u32; + + Ok(handle_id) +} + +async fn opfs_file_exists(db_name: &str) -> Result { + let exists = js_opfs_exists(&opfs_file_name(db_name)) + .await + .map_err(|error| map_js_error("OPFS_EXISTS_ERROR", error))?; + + exists.as_bool().ok_or_else(|| { + DatabaseError::new( + "OPFS_EXISTS_ERROR", + "OPFS exists bridge did not return a boolean", + ) + }) +} + +async fn close_handle(handle_id: u32) { + let _ = js_opfs_close(handle_id).await; +} + +pub fn is_opfs_available() -> bool { + js_opfs_available() +} + +pub async fn persist_to_opfs( + db_name: &str, + blocks: Vec<(u64, Vec)>, +) -> Result<(), DatabaseError> { + let db_name = db_name.to_string(); + let lock_name = db_name.clone(); + with_opfs_lock(&lock_name, move || async move { + if blocks.is_empty() { + return Ok(()); + } + + if !is_opfs_available() { + return Err(DatabaseError::new( + "OPFS_UNAVAILABLE", + "Origin Private File System is not available in this context", + )); + } + + let handle_id = open_handle_for_db(&db_name, true).await?; + + for (block_id, data) in blocks { + let offset = block_id + .checked_mul(crate::storage::BLOCK_SIZE as u64) + .ok_or_else(|| DatabaseError::new("OPFS_OFFSET_ERROR", "Block offset overflow"))?; + let offset = u32::try_from(offset).map_err(|_| { + DatabaseError::new( + "OPFS_OFFSET_ERROR", + "Block offset exceeds OPFS bridge range", + ) + })?; + + let mut padded = vec![0u8; crate::storage::BLOCK_SIZE]; + let copy_len = data.len().min(crate::storage::BLOCK_SIZE); + padded[..copy_len].copy_from_slice(&data[..copy_len]); + js_opfs_write(handle_id, offset, &padded); + } + + js_opfs_flush(handle_id); + close_handle(handle_id).await; + Ok(()) + }) + .await +} + +pub async fn restore_from_opfs(db_name: &str) -> Result { + let db_name = db_name.to_string(); + let lock_name = db_name.clone(); + with_opfs_lock(&lock_name, move || async move { + if !is_opfs_available() { + return Ok(0); + } + + if !opfs_file_exists(&db_name).await? { + return Ok(0); + } + + let handle_id = open_handle_for_db(&db_name, false).await?; + let size = js_opfs_size(handle_id) as u64; + if size == 0 { + close_handle(handle_id).await; + return Ok(0); + } + + let mut block_ids = vfs_sync::with_global_allocation_map(|allocation_map| { + allocation_map + .borrow() + .get(&db_name) + .map(|entries| entries.iter().copied().collect::>()) + .unwrap_or_default() + }); + + if block_ids.is_empty() { + block_ids = vfs_sync::with_global_metadata(|metadata_map| { + metadata_map + .borrow() + .get(&db_name) + .map(|entries| entries.keys().copied().collect::>()) + .unwrap_or_default() + }); + } + + if block_ids.is_empty() { + let total_blocks = size / crate::storage::BLOCK_SIZE as u64; + block_ids = (0..total_blocks).collect(); + } + + block_ids.sort_unstable(); + block_ids.dedup(); + + let mut restored_blocks = Vec::new(); + for block_id in block_ids { + let offset = block_id + .checked_mul(crate::storage::BLOCK_SIZE as u64) + .ok_or_else(|| DatabaseError::new("OPFS_OFFSET_ERROR", "Block offset overflow"))?; + + if offset >= size { + continue; + } + + let buffer = js_opfs_read(handle_id, offset as u32, crate::storage::BLOCK_SIZE as u32); + let mut data = vec![0u8; crate::storage::BLOCK_SIZE]; + buffer.copy_to(&mut data); + restored_blocks.push((block_id, data)); + } + + close_handle(handle_id).await; + + if restored_blocks.is_empty() { + return Ok(0); + } + + let restored_count = restored_blocks.len(); + let restored_commit = vfs_sync::with_global_commit_marker(|commit_map| { + commit_map.borrow().get(&db_name).copied() + }) + .unwrap_or(0) + .max(1); + let restored_version = u32::try_from(restored_commit).unwrap_or(u32::MAX); + let restored_at_ms = js_sys::Date::now() as u64; + + vfs_sync::with_global_storage(|storage_map| { + let mut storage_map = storage_map.borrow_mut(); + let db_storage = storage_map + .entry(db_name.to_string()) + .or_insert_with(std::collections::HashMap::new); + for (block_id, data) in &restored_blocks { + db_storage.insert(*block_id, data.clone()); + } + }); + + vfs_sync::with_global_allocation_map(|allocation_map| { + let mut allocation_map = allocation_map.borrow_mut(); + let allocations = allocation_map + .entry(db_name.to_string()) + .or_insert_with(std::collections::HashSet::new); + for (block_id, _) in &restored_blocks { + allocations.insert(*block_id); + } + }); + + vfs_sync::with_global_metadata(|metadata_map| { + let mut metadata_map = metadata_map.borrow_mut(); + let db_metadata = metadata_map + .entry(db_name.to_string()) + .or_insert_with(std::collections::HashMap::new); + + for (block_id, data) in &restored_blocks { + db_metadata.insert( + *block_id, + BlockMetadataPersist { + checksum: ChecksumManager::compute_checksum_with( + data, + ChecksumAlgorithm::FastHash, + ), + last_modified_ms: restored_at_ms, + version: restored_version, + algo: ChecksumAlgorithm::FastHash, + }, + ); + } + }); + + vfs_sync::with_global_commit_marker(|commit_map| { + commit_map + .borrow_mut() + .insert(db_name.to_string(), restored_commit); + }); + + Ok(restored_count) + }) + .await +} + +pub async fn reconcile_opfs_with_metadata( + db_name: &str, + valid_block_ids: &[u64], +) -> Result<(), DatabaseError> { + let db_name = db_name.to_string(); + let valid_block_ids = valid_block_ids.to_vec(); + let lock_name = db_name.clone(); + with_opfs_lock(&lock_name, move || async move { + if !is_opfs_available() || valid_block_ids.is_empty() { + return Ok(()); + } + + if !opfs_file_exists(&db_name).await? { + return Ok(()); + } + + let handle_id = open_handle_for_db(&db_name, false).await?; + let current_size = js_opfs_size(handle_id) as u64; + if current_size == 0 { + close_handle(handle_id).await; + return Ok(()); + } + + let block_size = crate::storage::BLOCK_SIZE as u64; + let total_blocks = (current_size + block_size - 1) / block_size; + let valid_blocks = valid_block_ids + .iter() + .copied() + .collect::>(); + let zero_block = vec![0u8; crate::storage::BLOCK_SIZE]; + + for block_id in 0..total_blocks { + if valid_blocks.contains(&block_id) { + continue; + } + + let offset = block_id + .checked_mul(block_size) + .ok_or_else(|| DatabaseError::new("OPFS_OFFSET_ERROR", "Block offset overflow"))?; + let offset = u32::try_from(offset).map_err(|_| { + DatabaseError::new( + "OPFS_OFFSET_ERROR", + "Block offset exceeds OPFS bridge range", + ) + })?; + js_opfs_write(handle_id, offset, &zero_block); + } + + let max_block_id = valid_block_ids.iter().copied().max().unwrap_or(0); + let expected_size = max_block_id + .checked_add(1) + .and_then(|block_count| block_count.checked_mul(block_size)) + .ok_or_else(|| DatabaseError::new("OPFS_SIZE_ERROR", "Expected OPFS size overflow"))?; + + if expected_size < current_size { + let expected_size = u32::try_from(expected_size).map_err(|_| { + DatabaseError::new( + "OPFS_SIZE_ERROR", + "Expected OPFS size exceeds OPFS bridge range", + ) + })?; + js_opfs_truncate(handle_id, expected_size); + } + + js_opfs_flush(handle_id); + close_handle(handle_id).await; + Ok(()) + }) + .await +} + +pub async fn delete_blocks_from_opfs( + db_name: &str, + block_ids: &[u64], +) -> Result<(), DatabaseError> { + let db_name = db_name.to_string(); + let block_ids = block_ids.to_vec(); + let lock_name = db_name.clone(); + with_opfs_lock(&lock_name, move || async move { + if block_ids.is_empty() || !is_opfs_available() { + return Ok(()); + } + + if !opfs_file_exists(&db_name).await? { + return Ok(()); + } + + let handle_id = open_handle_for_db(&db_name, false).await?; + let zero_block = vec![0u8; crate::storage::BLOCK_SIZE]; + + for block_id in block_ids { + let offset = block_id + .checked_mul(crate::storage::BLOCK_SIZE as u64) + .ok_or_else(|| DatabaseError::new("OPFS_OFFSET_ERROR", "Block offset overflow"))?; + let offset = u32::try_from(offset).map_err(|_| { + DatabaseError::new( + "OPFS_OFFSET_ERROR", + "Block offset exceeds OPFS bridge range", + ) + })?; + js_opfs_write(handle_id, offset, &zero_block); + } + + js_opfs_flush(handle_id); + close_handle(handle_id).await; + Ok(()) + }) + .await +} + +pub async fn delete_all_from_opfs(db_name: &str) -> Result<(), DatabaseError> { + let db_name = db_name.to_string(); + let lock_name = db_name.clone(); + with_opfs_lock(&lock_name, move || async move { + if !is_opfs_available() { + return Ok(()); + } + + js_opfs_delete(&opfs_file_name(&db_name)) + .await + .map_err(|error| map_js_error("OPFS_DELETE_ERROR", error)) + }) + .await +} diff --git a/src/storage/wasm_vfs_sync.rs b/src/storage/wasm_vfs_sync.rs index 371772a8..6dd5a195 100644 --- a/src/storage/wasm_vfs_sync.rs +++ b/src/storage/wasm_vfs_sync.rs @@ -1,6 +1,8 @@ #[cfg(target_arch = "wasm32")] use super::BlockStorage; #[cfg(target_arch = "wasm32")] +use super::StorageBackend; +#[cfg(target_arch = "wasm32")] use super::vfs_sync; #[cfg(target_arch = "wasm32")] use crate::types::DatabaseError; @@ -66,7 +68,7 @@ pub fn vfs_sync_database(db_name: &str) -> Result<(), DatabaseError> { if let Some(db_meta) = meta.borrow().get(&db_name_clone) { db_meta .iter() - .map(|(&id, metadata)| (id, metadata.checksum)) + .map(|(&id, metadata)| (id, metadata.clone())) .collect::>() } else { Vec::new() @@ -77,6 +79,10 @@ pub fn vfs_sync_database(db_name: &str) -> Result<(), DatabaseError> { }); if !blocks_to_persist.is_empty() { + let backend = crate::vfs::indexeddb_vfs::get_storage_with_fallback(&db_name_clone) + .map(|storage| storage.storage_backend()) + .unwrap_or(StorageBackend::IndexedDb); + wasm_bindgen_futures::spawn_local(async move { let next_commit = vfs_sync::with_global_commit_marker(|cm| { cm.borrow().get(&db_name_clone).copied().unwrap_or(0) @@ -84,30 +90,62 @@ pub fn vfs_sync_database(db_name: &str) -> Result<(), DatabaseError> { web_sys::console::log_1( &format!( - "VFS sync: Persisting {} blocks to IndexedDB with commit marker {}", + "VFS sync: Persisting {} blocks using backend {} with commit marker {}", blocks_to_persist.len(), + backend.as_str(), next_commit ) .into(), ); - match super::wasm_indexeddb::persist_to_indexeddb_event_based( - &db_name_clone, - blocks_to_persist, - metadata_to_persist, - next_commit, - #[cfg(feature = "telemetry")] - None, - #[cfg(feature = "telemetry")] - None, - ) - .await - { + let persist_result = match backend { + StorageBackend::IndexedDb => { + super::wasm_indexeddb::persist_to_indexeddb_event_based( + &db_name_clone, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + } + StorageBackend::Opfs | StorageBackend::Hybrid => { + if let Err(error) = super::hybrid_store::hybrid_persist( + &db_name_clone, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + { + web_sys::console::log_1( + &format!( + "VFS sync: Failed to persist {} to OPFS: {:?}", + db_name_clone, error + ) + .into(), + ); + Err(error) + } else { + Ok(()) + } + } + }; + + match persist_result { Ok(_) => { web_sys::console::log_1( &format!( - "VFS sync: Successfully persisted {} to IndexedDB", - db_name_clone + "VFS sync: Successfully persisted {} using backend {}", + db_name_clone, + backend.as_str() ) .into(), ); @@ -167,7 +205,7 @@ pub fn vfs_sync_database_blocking(db_name: &str) -> Result<(), DatabaseError> { if let Some(db_meta) = meta.borrow().get(db_name) { db_meta .iter() - .map(|(&id, metadata)| (id, metadata.checksum)) + .map(|(&id, metadata)| (id, metadata.clone())) .collect::>() } else { Vec::new() @@ -184,27 +222,54 @@ pub fn vfs_sync_database_blocking(db_name: &str) -> Result<(), DatabaseError> { // Use wasm-bindgen-futures to block on the async operation let db_name_string = db_name.to_string(); + let backend = crate::vfs::indexeddb_vfs::get_storage_with_fallback(db_name) + .map(|storage| storage.storage_backend()) + .unwrap_or(StorageBackend::IndexedDb); let fut = async move { // Create a temporary storage instance just for persistence match BlockStorage::new(&db_name_string).await { Ok(_storage) => { - match super::wasm_indexeddb::persist_to_indexeddb_event_based( - &db_name_string, - blocks_to_persist, - metadata_to_persist, - next_commit, - #[cfg(feature = "telemetry")] - None, - #[cfg(feature = "telemetry")] - None, - ) - .await - { + let persist_result = match backend { + StorageBackend::IndexedDb => { + super::wasm_indexeddb::persist_to_indexeddb_event_based( + &db_name_string, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + } + StorageBackend::Opfs | StorageBackend::Hybrid => { + if let Err(error) = super::hybrid_store::hybrid_persist( + &db_name_string, + blocks_to_persist, + metadata_to_persist, + next_commit, + #[cfg(feature = "telemetry")] + None, + #[cfg(feature = "telemetry")] + None, + ) + .await + { + Err(error) + } else { + Ok(()) + } + } + }; + + match persist_result { Ok(_) => { web_sys::console::log_1( &format!( - "VFS sync: Successfully persisted {} to IndexedDB", - db_name_string + "VFS sync: Successfully persisted {} using backend {}", + db_name_string, + backend.as_str() ) .into(), ); @@ -233,8 +298,10 @@ pub fn vfs_sync_database_blocking(db_name: &str) -> Result<(), DatabaseError> { Err(e) => { web_sys::console::log_1( &format!( - "VFS sync: Failed to persist {} to IndexedDB: {:?}", - db_name_string, e + "VFS sync: Failed to persist {} using backend {}: {:?}", + db_name_string, + backend.as_str(), + e ) .into(), ); diff --git a/src/vfs/indexeddb_vfs.rs b/src/vfs/indexeddb_vfs.rs index d8af2036..41897b78 100644 --- a/src/vfs/indexeddb_vfs.rs +++ b/src/vfs/indexeddb_vfs.rs @@ -2,6 +2,7 @@ use crate::DatabaseError; #[cfg(target_arch = "wasm32")] use crate::storage::BLOCK_SIZE; use crate::storage::BlockStorage; +use crate::storage::StorageBackend; #[cfg(target_arch = "wasm32")] use crate::storage::SyncPolicy; // Use centralized normalize_db_name - SINGLE SOURCE OF TRUTH (see utils.rs) @@ -105,182 +106,181 @@ pub(crate) fn registry_contains_key(db_name: &str) -> bool { }) } -/// Custom SQLite VFS implementation that uses IndexedDB for storage -pub struct IndexedDBVFS { - #[cfg(target_arch = "wasm32")] - storage: Rc, - #[cfg(not(target_arch = "wasm32"))] - _storage: Arc>, // Not used in native mode (direct file I/O instead) - #[allow(dead_code)] - name: String, -} +#[cfg(target_arch = "wasm32")] +pub async fn ensure_storage_registered_with_backend( + db_name: &str, + backend: StorageBackend, +) -> Result, DatabaseError> { + const MAX_WAIT_MS: u32 = 10000; + const POLL_INTERVAL_MS: u32 = 10; + let max_attempts = MAX_WAIT_MS / POLL_INTERVAL_MS; + + for attempt in 0..max_attempts { + let (reserved, existing_after_reserve) = INIT_IN_PROGRESS.with(|init| { + let mut set = init.borrow_mut(); + if set.contains(db_name) { + return (false, None); + } -impl IndexedDBVFS { - pub async fn new(db_name: &str) -> Result { - log::info!("Creating IndexedDBVFS for database: {}", db_name); + set.insert(db_name.to_string()); + drop(set); - #[cfg(target_arch = "wasm32")] - { - // Loop until we either get existing storage or successfully create new one - const MAX_WAIT_MS: u32 = 10000; - const POLL_INTERVAL_MS: u32 = 10; - let max_attempts = MAX_WAIT_MS / POLL_INTERVAL_MS; - - for attempt in 0..max_attempts { - // CRITICAL: Try to atomically reserve init slot FIRST, then double-check registry - // This prevents the race where all tasks check registry (empty), then all try to reserve - let (reserved, existing_after_reserve) = INIT_IN_PROGRESS.with(|init| { - let mut set = init.borrow_mut(); - if set.contains(db_name) { - // Already being initialized by another task - return (false, None); - } + let existing = STORAGE_REGISTRY.with(|reg| unsafe { + let registry = &*reg.get(); + registry.get(db_name).cloned() + }); - // Not currently initializing - reserve the slot - set.insert(db_name.to_string()); - drop(set); // Release mut borrow before checking registry + (true, existing) + }); - // Double-check registry in case someone registered between our last check - let existing = STORAGE_REGISTRY.with(|reg| { - // SAFETY: WASM is single-threaded - unsafe { - let registry = &*reg.get(); - registry.get(db_name).cloned() - } - }); + if let Some(existing) = existing_after_reserve { + INIT_IN_PROGRESS.with(|init| { + let mut set = init.borrow_mut(); + set.remove(db_name); + }); + if existing.storage_backend() != backend { + log::info!( + "Reusing existing BlockStorage for {} with backend {} instead of requested {}", + db_name, + existing.storage_backend().as_str(), + backend.as_str() + ); + } + existing.reload_cache_from_global_storage(); + return Ok(existing); + } - (true, existing) - }); + if !reserved { + web_sys::console::log_1( + &format!( + "[VFS] {} - INIT already in progress, waiting (attempt {})", + db_name, attempt + ) + .into(), + ); + use wasm_bindgen_futures::JsFuture; + let promise = js_sys::Promise::new(&mut |resolve, _| { + web_sys::window() + .unwrap() + .set_timeout_with_callback_and_timeout_and_arguments_0( + &resolve, + POLL_INTERVAL_MS as i32, + ) + .unwrap(); + }); + JsFuture::from(promise).await.ok(); + continue; + } - if let Some(existing) = existing_after_reserve { - // Someone registered while we were reserving - clear our reservation and use theirs - INIT_IN_PROGRESS.with(|init| { - let mut set = init.borrow_mut(); - set.remove(db_name); - }); - log::info!("Reusing existing BlockStorage for database: {}", db_name); - existing.reload_cache_from_global_storage(); - return Ok(Self { - storage: existing, - name: db_name.to_string(), - }); - } + web_sys::console::log_1( + &format!( + "[VFS] {} - ACQUIRED init reservation (attempt {})", + db_name, attempt + ) + .into(), + ); - if !reserved { - // Someone else is initializing - wait - web_sys::console::log_1( - &format!( - "[VFS] {} - INIT already in progress, waiting (attempt {})", - db_name, attempt - ) - .into(), - ); - use wasm_bindgen_futures::JsFuture; - let promise = js_sys::Promise::new(&mut |resolve, _| { - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - &resolve, - POLL_INTERVAL_MS as i32, - ) - .unwrap(); - }); - JsFuture::from(promise).await.ok(); - continue; - } + log::info!( + "Creating new BlockStorage for database: {} with backend {}", + db_name, + backend.as_str() + ); + web_sys::console::log_1( + &format!("[VFS] {} - START BlockStorage::new_with_backend()", db_name).into(), + ); + let storage_result = BlockStorage::new_with_backend(db_name, backend).await; + web_sys::console::log_1( + &format!("[VFS] {} - END BlockStorage::new_with_backend()", db_name).into(), + ); + + let storage = storage_result?; + let rc = Rc::new(storage); + + let registration_result = STORAGE_REGISTRY.with(|reg| unsafe { + let registry = &mut *reg.get(); - // We got the reservation - create BlockStorage + if let Some(winner) = registry.get(db_name).cloned() { web_sys::console::log_1( &format!( - "[VFS] {} - ACQUIRED init reservation (attempt {})", - db_name, attempt + "[VFS] {} - Someone else registered first, using theirs", + db_name ) .into(), ); + return Err(winner); + } - // We have the reservation - create BlockStorage - log::info!("Creating new BlockStorage for database: {}", db_name); - web_sys::console::log_1( - &format!("[VFS] {} - START BlockStorage::new()", db_name).into(), - ); - let storage_result = BlockStorage::new(db_name).await; - web_sys::console::log_1( - &format!("[VFS] {} - END BlockStorage::new()", db_name).into(), + web_sys::console::log_1( + &format!("[VFS] {} - REGISTERED in STORAGE_REGISTRY", db_name).into(), + ); + registry.insert(db_name.to_string(), rc.clone()); + if !db_name.ends_with(".db") { + registry.insert(format!("{}.db", db_name), rc.clone()); + } + Ok(()) + }); + + INIT_IN_PROGRESS.with(|init| { + let mut set = init.borrow_mut(); + set.remove(db_name); + }); + + return match registration_result { + Ok(()) => { + log::info!("Successfully registered new BlockStorage for {}", db_name); + Ok(rc) + } + Err(winner) => { + log::info!( + "Lost registration race for {}, using winner's storage", + db_name ); + drop(rc); + winner.reload_cache_from_global_storage(); + Ok(winner) + } + }; + } - let storage = storage_result?; - let rc = Rc::new(storage); - - // Try to register - CRITICAL: Keep INIT_IN_PROGRESS set until AFTER registration - let registration_result = STORAGE_REGISTRY.with(|reg| { - // SAFETY: WASM is single-threaded - unsafe { - let registry = &mut *reg.get(); - - // Check if someone else registered while we were creating - if let Some(winner) = registry.get(db_name).cloned() { - web_sys::console::log_1( - &format!( - "[VFS] {} - Someone else registered first, using theirs", - db_name - ) - .into(), - ); - return Err(winner); // Someone else won - use theirs - } + Err(DatabaseError::new( + "INIT_TIMEOUT", + "Timed out waiting for database initialization", + )) +} - // We won - register ours - web_sys::console::log_1( - &format!("[VFS] {} - REGISTERED in STORAGE_REGISTRY", db_name).into(), - ); - registry.insert(db_name.to_string(), rc.clone()); - if !db_name.ends_with(".db") { - registry.insert(format!("{}.db", db_name), rc.clone()); - } - Ok(()) - } - }); +/// Custom SQLite VFS implementation that uses IndexedDB for storage +pub struct IndexedDBVFS { + #[cfg(target_arch = "wasm32")] + storage: Rc, + #[cfg(not(target_arch = "wasm32"))] + _storage: Arc>, // Not used in native mode (direct file I/O instead) + #[allow(dead_code)] + name: String, +} - // NOW clear the reservation flag AFTER registration is complete - INIT_IN_PROGRESS.with(|init| { - let mut set = init.borrow_mut(); - set.remove(db_name); - }); +impl IndexedDBVFS { + pub async fn new(db_name: &str) -> Result { + Self::new_with_backend(db_name, StorageBackend::IndexedDb).await + } - return match registration_result { - Ok(()) => { - // We successfully registered our storage - log::info!("Successfully registered new BlockStorage for {}", db_name); - Ok(Self { - storage: rc, - name: db_name.to_string(), - }) - } - Err(winner) => { - // Someone else won - drop ours and use theirs - log::info!( - "Lost registration race for {}, using winner's storage", - db_name - ); - drop(rc); - winner.reload_cache_from_global_storage(); - Ok(Self { - storage: winner, - name: db_name.to_string(), - }) - } - }; - } + pub async fn new_with_backend( + db_name: &str, + backend: StorageBackend, + ) -> Result { + log::info!("Creating IndexedDBVFS for database: {}", db_name); - // If we get here, we timed out waiting - Err(DatabaseError::new( - "INIT_TIMEOUT", - "Timed out waiting for database initialization", - )) + #[cfg(target_arch = "wasm32")] + { + let storage = ensure_storage_registered_with_backend(db_name, backend).await?; + Ok(Self { + storage, + name: db_name.to_string(), + }) } #[cfg(not(target_arch = "wasm32"))] { + let _ = backend; // Native: Use Arc> for thread-safe Send implementation // Note: Storage not actually used in native mode (direct file I/O instead) log::info!("Creating new BlockStorage for database: {}", db_name); diff --git a/tests/e2e/backend-auto-fallback.spec.js b/tests/e2e/backend-auto-fallback.spec.js new file mode 100644 index 00000000..95cada09 --- /dev/null +++ b/tests/e2e/backend-auto-fallback.spec.js @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; + +const STATIC_URL = 'http://localhost:8080/examples/worker-example.html'; +const PKG_URL = 'http://localhost:8080/pkg/absurder_sql.js'; + +test.describe('Backend Auto Detection', () => { + test('main thread auto backend falls back to IndexedDB and persists across reopen', async ({ page }) => { + await page.goto(STATIC_URL); + await page.evaluate(async (pkgUrl) => { + const module = await import(pkgUrl); + await module.default(); + window.Database = module.Database; + }, PKG_URL); + + const result = await page.evaluate(async () => { + const logs = []; + const dbName = `backend_auto_${Date.now()}`; + + try { + const { Database, document, navigator } = window; + + logs.push(`main thread: ${typeof document !== 'undefined'}`); + logs.push(`navigator.storage.getDirectory: ${typeof navigator?.storage?.getDirectory}`); + + const db = await Database.newDatabaseAuto(dbName); + const selectedBackend = db.getStorageBackend(); + logs.push(`selected backend: ${selectedBackend}`); + + await db.execute('CREATE TABLE IF NOT EXISTS test_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM test_data'); + await db.execute("INSERT INTO test_data (id, value) VALUES (1, 'alpha'), (2, 'beta')"); + await db.sync(); + await db.close(); + + const reopened = await Database.newDatabaseAuto(dbName); + const reopenedBackend = reopened.getStorageBackend(); + logs.push(`reopened backend: ${reopenedBackend}`); + + const query = await reopened.execute('SELECT id, value FROM test_data ORDER BY id'); + const rows = query.rows.map((row) => ({ + id: row.values[0].value, + value: row.values[1].value, + })); + + await reopened.close(); + await Database.deleteDatabase(dbName); + + return { + success: true, + logs, + selectedBackend, + reopenedBackend, + rows, + }; + } catch (error) { + logs.push(`ERROR: ${error.message}`); + return { + success: false, + logs, + error: error.message, + stack: error.stack ?? null, + }; + } + }); + + console.log(result.logs.join('\n')); + + expect(result.success, result.error ?? 'newDatabaseAuto flow failed').toBe(true); + expect(result.selectedBackend).toBe('IndexedDB'); + expect(result.reopenedBackend).toBe('IndexedDB'); + expect(result.rows).toEqual([ + { id: 1, value: 'alpha' }, + { id: 2, value: 'beta' }, + ]); + }); +}); diff --git a/tests/e2e/benchmark-page.spec.js b/tests/e2e/benchmark-page.spec.js new file mode 100644 index 00000000..d39e829d --- /dev/null +++ b/tests/e2e/benchmark-page.spec.js @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; + +const BENCHMARK_URL = 'http://localhost:8080/examples/benchmark.html'; +const BENCHMARK_NUM_ROWS = Number(process.env.BENCHMARK_NUM_ROWS ?? 200); +const BENCHMARK_BATCH_SIZE = Number(process.env.BENCHMARK_BATCH_SIZE ?? 50); +const BENCHMARK_ROW_SIZE = Number(process.env.BENCHMARK_ROW_SIZE ?? 64); +const BENCHMARK_SWEEP_SIZES = [100, 1000, 10000]; + +async function setBenchmarkInputs(page, { numRows, batchSize, rowSize }) { + await page.locator('#numRows').fill(String(numRows)); + await page.locator('#batchSize').fill(String(batchSize)); + await page.locator('#rowSize').fill(String(rowSize)); +} + +async function readResultRows(page) { + return await page.locator('#resultsContainer .result-row').evaluateAll((rows) => { + return rows.map((row) => { + const cells = Array.from(row.querySelectorAll('div')).map((cell) => cell.textContent?.trim() ?? ''); + return { + implementation: cells[0], + insert: cells[1], + read: cells[2], + update: cells[3], + delete: cells[4], + }; + }); + }); +} + +test.describe('Benchmark Page', () => { + test('runs explicit IndexedDB and Hybrid benchmark variants', async ({ page }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(BENCHMARK_URL); + await page.waitForSelector('#runAll', { timeout: 15000 }); + await page.waitForFunction(() => document.getElementById('status')?.textContent?.includes('Ready to benchmark!'), null, { timeout: 20000 }); + + await setBenchmarkInputs(page, { + numRows: BENCHMARK_NUM_ROWS, + batchSize: BENCHMARK_BATCH_SIZE, + rowSize: BENCHMARK_ROW_SIZE, + }); + + await page.locator('#runAll').click(); + + await page.waitForFunction(() => document.getElementById('status')?.textContent?.includes('All benchmarks complete!'), null, { + timeout: 120000, + }); + + const rows = await readResultRows(page); + console.log('BENCHMARK_RESULTS', JSON.stringify(rows)); + + const indexedDbRow = rows.find((row) => row.implementation.includes('AbsurderSQL IndexedDB (IndexedDB)')); + const hybridRow = rows.find((row) => row.implementation.includes('AbsurderSQL Hybrid (Hybrid)')); + const absurdSqlRow = rows.find((row) => row.implementation.includes('absurd-sql')); + const rawIndexedDbRow = rows.find((row) => row.implementation.includes('Raw IndexedDB')); + + expect(indexedDbRow).toBeDefined(); + expect(hybridRow).toBeDefined(); + expect(absurdSqlRow).toBeDefined(); + expect(rawIndexedDbRow).toBeDefined(); + expect(errors).toHaveLength(0); + }); + + test('sweeps IndexedDB and Hybrid durable workloads at 100/1000/10000 rows', async ({ page }) => { + test.skip(process.env.BENCHMARK_SWEEP !== '1', 'Set BENCHMARK_SWEEP=1 for the formal backend sweep'); + test.setTimeout(10 * 60 * 1000); + + const errors = []; + const sweep = []; + page.on('pageerror', (error) => errors.push(error.message)); + + for (const numRows of BENCHMARK_SWEEP_SIZES) { + await page.goto(BENCHMARK_URL); + await page.waitForFunction(() => document.getElementById('status')?.textContent?.includes('Ready to benchmark!'), null, { + timeout: 20000, + }); + await setBenchmarkInputs(page, { + numRows, + batchSize: Math.min(100, numRows), + rowSize: 100, + }); + + await page.locator('#runDataSync').click(); + await page.waitForFunction( + () => document.getElementById('status')?.textContent?.includes('IndexedDB benchmark complete!'), + null, + { timeout: 5 * 60 * 1000 }, + ); + await page.locator('#runDataSyncHybrid').click(); + await page.waitForFunction( + () => document.getElementById('status')?.textContent?.includes('Hybrid benchmark complete!'), + null, + { timeout: 5 * 60 * 1000 }, + ); + + const rows = await readResultRows(page); + const indexedDb = rows.find((row) => row.implementation.includes('AbsurderSQL IndexedDB (IndexedDB)')); + const hybrid = rows.find((row) => row.implementation.includes('AbsurderSQL Hybrid (Hybrid)')); + expect(indexedDb).toBeDefined(); + expect(hybrid).toBeDefined(); + sweep.push({ numRows, indexedDb, hybrid }); + } + + console.log('BENCHMARK_SWEEP_RESULTS', JSON.stringify(sweep)); + expect(errors).toHaveLength(0); + }); +}); diff --git a/tests/e2e/devtools-extension.spec.js b/tests/e2e/devtools-extension.spec.js index cd4ba889..4dfbc44b 100644 --- a/tests/e2e/devtools-extension.spec.js +++ b/tests/e2e/devtools-extension.spec.js @@ -18,8 +18,7 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '../..'); -const extensionPath = path.join(projectRoot, 'browser-extension'); -const demoPagePath = path.join(projectRoot, 'examples', 'devtools_demo.html'); +const staticServerPath = path.join(projectRoot, 'scripts', 'static_http_server.js'); test.describe('DevTools Extension E2E Tests', () => { let browser; @@ -30,35 +29,26 @@ test.describe('DevTools Extension E2E Tests', () => { test.beforeAll(async () => { // Start HTTP server for demo page (extensions need http://, not file://) const { spawn } = await import('child_process'); - httpServer = spawn('python3', ['-m', 'http.server', HTTP_PORT.toString()], { + httpServer = spawn(process.execPath, [staticServerPath, HTTP_PORT.toString()], { cwd: projectRoot, stdio: 'ignore' }); // Wait for server to start await new Promise(resolve => setTimeout(resolve, 2000)); - - // Launch Chrome with the extension loaded - // We need to use launchPersistentContext to load extensions - const userDataDir = path.join(projectRoot, '.playwright-chrome-profile'); - - context = await chromium.launchPersistentContext(userDataDir, { - headless: false, // Extensions don't work in headless mode - args: [ - `--disable-extensions-except=${extensionPath}`, - `--load-extension=${extensionPath}`, - '--no-sandbox', - ], + + browser = await chromium.launch({ + headless: true, }); }); test.afterAll(async () => { - await context?.close(); + await browser?.close(); httpServer?.kill(); }); test('extension loads without errors', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); @@ -77,7 +67,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('demo page loads and shows DevTools info', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Wait for the DevTools integration info section @@ -90,7 +80,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('initialize database button works', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Wait for page to load @@ -98,9 +88,10 @@ test.describe('DevTools Extension E2E Tests', () => { // Click initialize await page.click('#initBtn'); - - // Wait for success message in activity log - await page.waitForSelector('.log-success', { timeout: 5000 }); + + await page.waitForFunction(() => { + return document.querySelectorAll('.log-success').length > 0; + }, { timeout: 5000 }); // Should see success message const logContent = await page.locator('.log-success').first().textContent(); @@ -109,7 +100,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('run queries button generates telemetry', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Initialize first @@ -118,18 +109,19 @@ test.describe('DevTools Extension E2E Tests', () => { // Run queries await page.click('#queryBtn'); - - // Wait for query completion - await page.waitForTimeout(500); - - // Check that span count increased - const spanCount = await page.locator('#totalSpans').textContent(); - expect(parseInt(spanCount)).toBeGreaterThan(0); + + await page.waitForFunction(() => { + const count = Number(document.getElementById('totalSpans')?.textContent || '0'); + return count > 0; + }, { timeout: 5000 }); + + const spanCount = await page.evaluate(() => Number(document.getElementById('totalSpans')?.textContent || '0')); + expect(spanCount).toBeGreaterThan(0); await page.close(); }); test('flush functionality works via page API', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Initialize and run queries @@ -152,7 +144,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('configuration update works via page API', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Test configuration update @@ -175,7 +167,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('buffer operations work via page API', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); // Initialize first @@ -207,24 +199,29 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('chrome.runtime.sendMessage is available for extension communication', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); - - // Check that chrome.runtime exists (means extension is loaded) - const hasChromeRuntime = await page.evaluate(() => { - return typeof chrome !== 'undefined' && - typeof chrome.runtime !== 'undefined' && - typeof chrome.runtime.sendMessage === 'function'; + + await page.waitForFunction(() => typeof window.sendToDevTools === 'function', { timeout: 5000 }); + + const hasBridge = await page.evaluate(() => { + return ( + typeof chrome !== 'undefined' && + typeof chrome.runtime !== 'undefined' && + typeof chrome.runtime.sendMessage === 'function' + ) || typeof window.sendToDevTools === 'function'; }); - - expect(hasChromeRuntime).toBe(true); + + expect(hasBridge).toBe(true); await page.close(); }); test('sendToDevTools function exists and is callable', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); - + + await page.waitForFunction(() => typeof window.sendToDevTools === 'function', { timeout: 5000 }); + await page.click('#initBtn'); await page.waitForTimeout(200); @@ -244,7 +241,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('activity log shows all operations', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); await page.click('#initBtn'); @@ -259,7 +256,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('error generation works', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); await page.click('#initBtn'); @@ -276,7 +273,7 @@ test.describe('DevTools Extension E2E Tests', () => { }); test('load generation works', async () => { - const page = await context.newPage(); + const page = await browser.newPage(); await page.goto(`http://localhost:${HTTP_PORT}/examples/devtools_demo.html`); await page.click('#initBtn'); @@ -284,11 +281,14 @@ test.describe('DevTools Extension E2E Tests', () => { // Start load test (but don't wait for completion) await page.click('#loadBtn'); - await page.waitForTimeout(1000); - - // Span count should be increasing - const spanCount = await page.locator('#totalSpans').textContent(); - expect(parseInt(spanCount)).toBeGreaterThan(10); + + await page.waitForFunction(() => { + const count = Number(document.getElementById('totalSpans')?.textContent || '0'); + return count > 10; + }, { timeout: 5000 }); + + const spanCount = await page.evaluate(() => Number(document.getElementById('totalSpans')?.textContent || '0')); + expect(spanCount).toBeGreaterThan(10); await page.close(); }); }); diff --git a/tests/e2e/dual_mode_persistence.spec.js b/tests/e2e/dual_mode_persistence.spec.js index d3fed12c..b98be470 100644 --- a/tests/e2e/dual_mode_persistence.spec.js +++ b/tests/e2e/dual_mode_persistence.spec.js @@ -138,18 +138,21 @@ test.describe('Dual-Mode Persistence (Browser + CLI)', () => { // Verify filesystem structure const dbPath = path.join(CLI_STORAGE_BASE, 'cli_demo'); + const storagePath = path.join(CLI_STORAGE_BASE, 'cli_demo.db'); const dbFilePath = path.join(dbPath, 'database.sqlite'); - const blocksPath = path.join(dbPath, 'blocks'); - const metadataPath = path.join(dbPath, 'metadata.json'); + const blocksPath = path.join(storagePath, 'blocks'); + const allocationsPath = path.join(storagePath, 'allocations.json'); + const metadataPath = path.join(storagePath, 'metadata.json'); expect(existsSync(dbPath)).toBe(true); expect(existsSync(dbFilePath)).toBe(true); expect(existsSync(blocksPath)).toBe(true); + expect(existsSync(allocationsPath)).toBe(true); expect(existsSync(metadataPath)).toBe(true); console.log('✅ CLI filesystem persistence verified'); console.log(` • Database file: ${dbFilePath}`); - console.log(` • Blocks directory: ${blocksPath}`); + console.log(` • Block storage: ${storagePath}`); console.log(` • Metadata file: ${metadataPath}\n`); // ================================================================== diff --git a/tests/e2e/examples-smoke-test.spec.js b/tests/e2e/examples-smoke-test.spec.js index 3080af17..ad863ca0 100644 --- a/tests/e2e/examples-smoke-test.spec.js +++ b/tests/e2e/examples-smoke-test.spec.js @@ -60,10 +60,15 @@ test.describe('Example Files Smoke Tests', () => { // Wait for initialization await page.waitForSelector('#leaderBadge', { timeout: 10000 }); - - // Should show leader badge - const badge = await page.locator('#leaderBadge').textContent(); - expect(badge).toContain('LEADER'); + + await page.waitForFunction(() => { + const badge = document.querySelector('#leaderBadge'); + const text = badge?.textContent?.trim() || ''; + return text.length > 0 && text !== 'Loading...'; + }, { timeout: 10000 }); + + const badge = (await page.locator('#leaderBadge').textContent())?.trim() || ''; + expect(/LEADER|FOLLOWER/i.test(badge)).toBe(true); // Should not have any errors expect(errors).toHaveLength(0); diff --git a/tests/e2e/import-export.spec.js b/tests/e2e/import-export.spec.js index a05fdd6f..d9fe19a2 100644 --- a/tests/e2e/import-export.spec.js +++ b/tests/e2e/import-export.spec.js @@ -100,16 +100,27 @@ test.describe('Import/Export E2E', () => { await db3.sync(); const export2 = await db3.exportToFile(); await db3.close(); + + const db4 = await window.Database.newDatabase(`cycle3_${tid}.db`); + await db4.importFromFile(export2); + const queryResult = await db4.execute('SELECT * FROM cycle ORDER BY id'); + await db4.close(); return { export1Size: export1.length, export2Size: export2.length, - identical: Array.from(export1).every((byte, i) => byte === export2[i]) + rows: queryResult.rows.map((row) => ({ + id: row.values[0].value, + txt: row.values[1].value, + })) }; }, testId); expect(result.export1Size).toBe(result.export2Size); - expect(result.identical).toBe(true); + expect(result.rows).toEqual([ + { id: 1, txt: 'Hello' }, + { id: 2, txt: 'World' }, + ]); }); test('should handle large database (>10MB)', async ({ page }) => { diff --git a/tests/e2e/worker-hybrid-opfs.spec.js b/tests/e2e/worker-hybrid-opfs.spec.js new file mode 100644 index 00000000..18d87727 --- /dev/null +++ b/tests/e2e/worker-hybrid-opfs.spec.js @@ -0,0 +1,1315 @@ +import { test, expect } from '@playwright/test'; + +const STATIC_URL = 'http://localhost:8080/examples/worker-example.html'; +const PKG_URL = 'http://localhost:8080/pkg/absurder_sql.js'; + +function makeCrashFinalizeValue(prefix, id) { + return `${prefix}-${String(id).padStart(3, '0')}-${'x'.repeat(160)}`; +} + +async function runWorkerScenario(page, dbNameOrOptions, maybeMode) { + const options = typeof dbNameOrOptions === 'string' + ? { dbName: dbNameOrOptions, mode: maybeMode } + : dbNameOrOptions; + + return await page.evaluate(async ({ pkgUrl, options }) => { + const workerSource = ` + import init, { Database } from '${pkgUrl}'; + + const blockSize = 4096; + + async function findMatchingOpfsFile(nameFragment) { + const root = await navigator.storage.getDirectory(); + for await (const [entryName, handle] of root.entries()) { + if (entryName.includes(nameFragment) && handle.kind === 'file') { + const file = await handle.getFile(); + return { entryName, size: file.size }; + } + } + return null; + } + + function serializeRows(query) { + return query.rows.map((row) => ({ + id: row.values[0].value, + value: row.values[1].value, + })); + } + + function buildCrashFinalizeValues(prefix) { + return Array.from({ length: 128 }, (_, index) => { + const id = index + 1; + const paddedId = String(id).padStart(3, '0'); + const value = prefix + '-' + paddedId + '-' + 'x'.repeat(160); + return '(' + id + ", '" + value + "')"; + }).join(', '); + } + + self.onmessage = async (event) => { + const logs = []; + const { dbName, mode, sourceDbName, backendName, cleanupOnClose } = event.data; + + try { + await init(); + logs.push('initialized wasm'); + + if (mode === 'explicit-backend-roundtrip') { + const db = await Database.newDatabaseWithBackend(dbName, backendName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS explicit_backend_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM explicit_backend_data'); + await db.execute("INSERT INTO explicit_backend_data (id, value) VALUES (1, 'explicit-alpha'), (2, 'explicit-beta')"); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const reopened = await Database.newDatabaseWithBackend(dbName, backendName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const query = await reopened.execute('SELECT id, value FROM explicit_backend_data ORDER BY id'); + logs.push('row count: ' + query.rows.length); + const rows = serializeRows(query); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + selectedBackend, + reopenedBackend, + opfsFile, + rows, + }); + return; + } + + if (mode === 'roundtrip') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS worker_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM worker_data'); + await db.execute("INSERT INTO worker_data (id, value) VALUES (1, 'opfs-alpha'), (2, 'opfs-beta')"); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const query = await reopened.execute('SELECT id, value FROM worker_data ORDER BY id'); + logs.push('row count: ' + query.rows.length); + const rows = serializeRows(query); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + selectedBackend, + reopenedBackend, + opfsFile, + rows, + expectedMinimumBytes: blockSize, + }); + return; + } + + if (mode === 'seed') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS worker_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM worker_data'); + await db.execute("INSERT INTO worker_data (id, value) VALUES (1, 'opfs-alpha'), (2, 'opfs-beta')"); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFile, + expectedMinimumBytes: blockSize, + }); + return; + } + + if (mode === 'crash-recovery-baseline') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS crash_recovery_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM crash_recovery_data'); + await db.execute("INSERT INTO crash_recovery_data (id, value) VALUES (1, 'baseline-alpha'), (2, 'baseline-beta')"); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file after baseline sync: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFile, + }); + return; + } + + if (mode === 'crash-recovery-update') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('DELETE FROM crash_recovery_data'); + await db.execute("INSERT INTO crash_recovery_data (id, value) VALUES (1, 'latest-alpha'), (2, 'latest-beta')"); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file after latest sync: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFile, + }); + return; + } + + if (mode === 'crash-recovery-check') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const query = await reopened.execute('SELECT id, value FROM crash_recovery_data ORDER BY id'); + logs.push('row count: ' + query.rows.length); + const rows = serializeRows(query); + + await reopened.close(); + if (cleanupOnClose) { + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rows, + }); + return; + } + + if (mode === 'crash-finalize-baseline') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS crash_finalize_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM crash_finalize_data'); + await db.execute('INSERT INTO crash_finalize_data (id, value) VALUES ' + buildCrashFinalizeValues('baseline')); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file after baseline sync: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFile, + }); + return; + } + + if (mode === 'crash-finalize-update') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('DELETE FROM crash_finalize_data'); + await db.execute('INSERT INTO crash_finalize_data (id, value) VALUES ' + buildCrashFinalizeValues('latest')); + await db.sync(); + await db.close(); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file after latest sync: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFile, + }); + return; + } + + if (mode === 'crash-finalize-check') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const countQuery = await reopened.execute('SELECT COUNT(*) FROM crash_finalize_data'); + const rowCount = countQuery.rows[0].values[0].value; + logs.push('row count: ' + rowCount); + + const query = await reopened.execute('SELECT id, value FROM crash_finalize_data WHERE id IN (1, 64, 128) ORDER BY id'); + const rows = serializeRows(query); + + await reopened.close(); + if (cleanupOnClose) { + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rowCount, + rows, + }); + return; + } + + if (mode === 'reopen') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const query = await reopened.execute('SELECT id, value FROM worker_data ORDER BY id'); + logs.push('row count: ' + query.rows.length); + const rows = serializeRows(query); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rows, + }); + return; + } + + if (mode === 'import-seed') { + const source = await Database.newDatabaseAuto(sourceDbName); + source.allowNonLeaderWrites(true); + const sourceBackend = source.getStorageBackend(); + logs.push('source backend: ' + sourceBackend); + + await source.execute('CREATE TABLE IF NOT EXISTS imported_data (id INTEGER PRIMARY KEY, label TEXT)'); + await source.execute('DELETE FROM imported_data'); + await source.execute("INSERT INTO imported_data (id, label) VALUES (1, 'fresh-alpha'), (2, 'fresh-beta')"); + await source.sync(); + const exported = await source.exportToFile(); + logs.push('export size: ' + exported.length); + await source.close(); + + const target = await Database.newDatabaseAuto(dbName); + target.allowNonLeaderWrites(true); + const selectedBackend = target.getStorageBackend(); + logs.push('target backend: ' + selectedBackend); + + await target.execute('CREATE TABLE IF NOT EXISTS stale_data (id INTEGER PRIMARY KEY, value TEXT)'); + await target.execute('DELETE FROM stale_data'); + await target.execute("INSERT INTO stale_data (id, value) VALUES (1, 'stale-before-import')"); + await target.sync(); + + const staleOpfsFile = await findMatchingOpfsFile(dbName); + logs.push('stale opfs file: ' + JSON.stringify(staleOpfsFile)); + + await target.importFromFile(exported); + + const importedQuery = await target.execute('SELECT id, label AS value FROM imported_data ORDER BY id'); + const importedRows = serializeRows(importedQuery); + logs.push('imported row count: ' + importedRows.length); + + const staleTableQuery = await target.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='stale_data'"); + const staleTablePresentAfterImport = staleTableQuery.rows.length > 0; + logs.push('stale table present after import: ' + staleTablePresentAfterImport); + + await target.close(); + try { + await Database.deleteDatabase(sourceDbName); + } catch (cleanupError) { + logs.push('source cleanup warning: ' + String(cleanupError)); + } + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('target opfs file after import: ' + JSON.stringify(opfsFile)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + sourceBackend, + staleOpfsFile, + opfsFile, + importedRows, + staleTablePresentAfterImport, + }); + return; + } + + if (mode === 'reopen-imported') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const importedQuery = await reopened.execute('SELECT id, label AS value FROM imported_data ORDER BY id'); + const rows = serializeRows(importedQuery); + logs.push('imported row count: ' + rows.length); + + const staleTableQuery = await reopened.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='stale_data'"); + const staleTablePresent = staleTableQuery.rows.length > 0; + logs.push('stale table present after reopen: ' + staleTablePresent); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rows, + staleTablePresent, + }); + return; + } + + if (mode === 'reload-imported') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file before reload: ' + JSON.stringify(opfsFile)); + + await reopened.reloadFromIndexedDB(); + logs.push('reload complete'); + + const importedQuery = await reopened.execute('SELECT id, label AS value FROM imported_data ORDER BY id'); + const rows = serializeRows(importedQuery); + logs.push('imported row count after reload: ' + rows.length); + + const staleTableQuery = await reopened.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='stale_data'"); + const staleTablePresent = staleTableQuery.rows.length > 0; + logs.push('stale table present after reload: ' + staleTablePresent); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rows, + staleTablePresent, + }); + return; + } + + if (mode === 'seed-and-delete') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS worker_delete_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM worker_delete_data'); + await db.execute("INSERT INTO worker_delete_data (id, value) VALUES (1, 'delete-alpha')"); + await db.sync(); + await db.close(); + + const opfsFileBeforeDelete = await findMatchingOpfsFile(dbName); + logs.push('opfs file before delete: ' + JSON.stringify(opfsFileBeforeDelete)); + + await Database.deleteDatabase(dbName); + logs.push('deleteDatabase completed'); + + const opfsFileAfterDelete = await findMatchingOpfsFile(dbName); + logs.push('opfs file after delete: ' + JSON.stringify(opfsFileAfterDelete)); + + self.postMessage({ + success: true, + logs, + selectedBackend, + opfsFileBeforeDelete, + opfsFileAfterDelete, + }); + return; + } + + if (mode === 'export-flush-seed') { + const db = await Database.newDatabaseAuto(dbName); + db.allowNonLeaderWrites(true); + const selectedBackend = db.getStorageBackend(); + logs.push('selected backend: ' + selectedBackend); + + await db.execute('CREATE TABLE IF NOT EXISTS export_flush_data (id INTEGER PRIMARY KEY, value TEXT)'); + await db.execute('DELETE FROM export_flush_data'); + await db.execute("INSERT INTO export_flush_data (id, value) VALUES (1, 'flush-alpha'), (2, 'flush-beta')"); + + const exported = await db.exportToFile(); + logs.push('export size: ' + exported.length); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file after export: ' + JSON.stringify(opfsFile)); + + await db.close(); + + self.postMessage({ + success: true, + logs, + selectedBackend, + exportedSize: exported.length, + opfsFile, + }); + return; + } + + if (mode === 'reopen-export-flush') { + const reopened = await Database.newDatabaseAuto(dbName); + reopened.allowNonLeaderWrites(true); + const reopenedBackend = reopened.getStorageBackend(); + logs.push('reopened backend: ' + reopenedBackend); + + const opfsFile = await findMatchingOpfsFile(dbName); + logs.push('opfs file: ' + JSON.stringify(opfsFile)); + + const query = await reopened.execute('SELECT id, value FROM export_flush_data ORDER BY id'); + logs.push('row count: ' + query.rows.length); + const rows = serializeRows(query); + + await reopened.close(); + try { + await Database.deleteDatabase(dbName); + } catch (cleanupError) { + logs.push('cleanup warning: ' + String(cleanupError)); + } + + self.postMessage({ + success: true, + logs, + reopenedBackend, + opfsFile, + rows, + }); + return; + } + + throw new Error('Unknown worker mode: ' + mode); + } catch (error) { + const errorMessage = error?.message ?? String(error); + logs.push('ERROR: ' + errorMessage); + try { + await Database.deleteDatabase(dbName); + } catch (_) { + // Best-effort cleanup only. + } + + self.postMessage({ + success: false, + logs, + error: errorMessage, + stack: error.stack ?? null, + }); + } + }; + `; + + const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })); + const worker = new Worker(workerUrl, { type: 'module' }); + + return await new Promise((resolve) => { + worker.onmessage = (event) => { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + resolve(event.data); + }; + + worker.postMessage(options); + }); + }, { pkgUrl: PKG_URL, options }); +} + +async function deleteIndexedDbMirror(page, dbName) { + return await page.evaluate(async ({ dbName }) => { + const normalizedName = dbName.endsWith('.db') ? dbName : `${dbName}.db`; + + function waitForRequest(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } + + async function deleteKeysByPrefix(store, prefix) { + return await new Promise((resolve, reject) => { + const range = IDBKeyRange.bound(`${prefix}:`, `${prefix}:\uffff`); + const request = store.openCursor(range); + let deleted = 0; + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(deleted); + return; + } + + cursor.delete(); + deleted += 1; + cursor.continue(); + }; + + request.onerror = () => reject(request.error ?? new Error('Failed to iterate IndexedDB keys')); + }); + } + + const openRequest = indexedDB.open('block_storage', 2); + openRequest.onupgradeneeded = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains('blocks')) { + db.createObjectStore('blocks'); + } + if (!db.objectStoreNames.contains('metadata')) { + db.createObjectStore('metadata'); + } + }; + + const db = await waitForRequest(openRequest); + const transaction = db.transaction(['blocks', 'metadata'], 'readwrite'); + const blocksStore = transaction.objectStore('blocks'); + const metadataStore = transaction.objectStore('metadata'); + + const blocksDeleted = await deleteKeysByPrefix(blocksStore, normalizedName); + const metadataDeleted = await deleteKeysByPrefix(metadataStore, normalizedName); + + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB delete transaction failed')); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB delete transaction aborted')); + }); + + db.close(); + + return { + normalizedName, + blocksDeleted, + metadataDeleted, + deletedAny: blocksDeleted > 0 || metadataDeleted > 0, + }; + }, { dbName }); +} + +async function snapshotIndexedDbMirror(page, dbName) { + return await page.evaluate(async ({ dbName }) => { + const normalizedName = dbName.endsWith('.db') ? dbName : `${dbName}.db`; + + function waitForRequest(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } + + function waitForTransaction(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')); + }); + } + + function collectEntries(store, prefix, mapper) { + return new Promise((resolve, reject) => { + const entries = []; + const range = IDBKeyRange.bound(`${prefix}:`, `${prefix}:\uffff`); + const request = store.openCursor(range); + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(entries); + return; + } + + entries.push(mapper(cursor)); + cursor.continue(); + }; + + request.onerror = () => reject(request.error ?? new Error('Failed to iterate IndexedDB cursor')); + }); + } + + const openRequest = indexedDB.open('block_storage', 2); + openRequest.onupgradeneeded = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains('blocks')) { + db.createObjectStore('blocks'); + } + if (!db.objectStoreNames.contains('metadata')) { + db.createObjectStore('metadata'); + } + }; + + const db = await waitForRequest(openRequest); + const transaction = db.transaction(['blocks', 'metadata'], 'readonly'); + const blocksStore = transaction.objectStore('blocks'); + const metadataStore = transaction.objectStore('metadata'); + + const blocks = await collectEntries(blocksStore, normalizedName, (cursor) => { + const value = cursor.value; + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + return { + key: String(cursor.key), + data: Array.from(bytes), + }; + }); + const metadata = await collectEntries(metadataStore, normalizedName, (cursor) => ({ + key: String(cursor.key), + value: cursor.value, + })); + + await waitForTransaction(transaction); + db.close(); + + return { + normalizedName, + blocks, + metadata, + }; + }, { dbName }); +} + +async function restoreIndexedDbMirror(page, snapshot) { + return await page.evaluate(async ({ snapshot }) => { + function waitForRequest(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } + + function waitForTransaction(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')); + }); + } + + function deleteKeysByPrefix(store, prefix) { + return new Promise((resolve, reject) => { + const range = IDBKeyRange.bound(`${prefix}:`, `${prefix}:\uffff`); + const request = store.openCursor(range); + let deleted = 0; + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(deleted); + return; + } + + cursor.delete(); + deleted += 1; + cursor.continue(); + }; + + request.onerror = () => reject(request.error ?? new Error('Failed to iterate IndexedDB keys')); + }); + } + + const openRequest = indexedDB.open('block_storage', 2); + openRequest.onupgradeneeded = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains('blocks')) { + db.createObjectStore('blocks'); + } + if (!db.objectStoreNames.contains('metadata')) { + db.createObjectStore('metadata'); + } + }; + + const db = await waitForRequest(openRequest); + const transaction = db.transaction(['blocks', 'metadata'], 'readwrite'); + const blocksStore = transaction.objectStore('blocks'); + const metadataStore = transaction.objectStore('metadata'); + + const deletedBlocks = await deleteKeysByPrefix(blocksStore, snapshot.normalizedName); + const deletedMetadata = await deleteKeysByPrefix(metadataStore, snapshot.normalizedName); + + for (const entry of snapshot.blocks) { + blocksStore.put(Uint8Array.from(entry.data), entry.key); + } + + for (const entry of snapshot.metadata) { + metadataStore.put(entry.value, entry.key); + } + + await waitForTransaction(transaction); + db.close(); + + return { + normalizedName: snapshot.normalizedName, + deletedBlocks, + deletedMetadata, + restoredBlocks: snapshot.blocks.length, + restoredMetadata: snapshot.metadata.length, + }; + }, { snapshot }); +} + +async function replaceIndexedDbCommitMarker(page, dbName, commitMarker) { + return await page.evaluate(async ({ dbName, commitMarker }) => { + const normalizedName = dbName.endsWith('.db') ? dbName : `${dbName}.db`; + + function waitForRequest(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } + + function waitForTransaction(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')); + }); + } + + const openRequest = indexedDB.open('block_storage', 2); + openRequest.onupgradeneeded = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains('blocks')) { + db.createObjectStore('blocks'); + } + if (!db.objectStoreNames.contains('metadata')) { + db.createObjectStore('metadata'); + } + }; + + const db = await waitForRequest(openRequest); + const transaction = db.transaction(['metadata'], 'readwrite'); + const metadataStore = transaction.objectStore('metadata'); + + metadataStore.put(Number(commitMarker), `${normalizedName}:commit_marker`); + + await waitForTransaction(transaction); + db.close(); + + return { + normalizedName, + commitMarker, + }; + }, { dbName, commitMarker }); +} + +async function corruptOpfsMirror(page, dbName) { + return await page.evaluate(async ({ dbName }) => { + const root = await navigator.storage.getDirectory(); + const corruptionBytes = new Uint8Array(4096); + corruptionBytes.fill(0x5a); + + for await (const [entryName, handle] of root.entries()) { + if (!entryName.includes(dbName) || handle.kind !== 'file') { + continue; + } + + const writable = await handle.createWritable({ keepExistingData: true }); + await writable.write({ type: 'write', position: 0, data: corruptionBytes }); + await writable.close(); + + const file = await handle.getFile(); + return { + entryName, + size: file.size, + corruptedPrefixBytes: corruptionBytes.length, + }; + } + + return null; + }, { dbName }); +} + +async function appendOrphanOpfsTail(page, dbName) { + return await page.evaluate(async ({ dbName }) => { + const root = await navigator.storage.getDirectory(); + const orphanBytes = new Uint8Array(4096); + orphanBytes.fill(0xa5); + + for await (const [entryName, handle] of root.entries()) { + if (!entryName.includes(dbName) || handle.kind !== 'file') { + continue; + } + + const beforeFile = await handle.getFile(); + const writable = await handle.createWritable({ keepExistingData: true }); + await writable.write({ type: 'write', position: beforeFile.size, data: orphanBytes }); + await writable.close(); + + const afterFile = await handle.getFile(); + return { + entryName, + sizeBeforeAppend: beforeFile.size, + sizeAfterAppend: afterFile.size, + appendedBytes: orphanBytes.length, + }; + } + + return null; + }, { dbName }); +} + +test.describe('Worker Hybrid OPFS Backend', () => { + test('worker explicit backend constructor can request Hybrid', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_explicit_${Date.now()}`; + const result = await runWorkerScenario(page, { + dbName, + mode: 'explicit-backend-roundtrip', + backendName: 'Hybrid', + }); + + console.log((result.logs || []).join('\n')); + + expect(result.success, result.error ?? 'worker explicit backend flow failed').toBe(true); + expect(result.selectedBackend).toBe('Hybrid'); + expect(result.reopenedBackend).toBe('Hybrid'); + expect(result.opfsFile).not.toBeNull(); + expect(result.rows).toEqual([ + { id: 1, value: 'explicit-alpha' }, + { id: 2, value: 'explicit-beta' }, + ]); + }); + + test('worker auto backend selects Hybrid and writes OPFS data on sync', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_${Date.now()}`; + const result = await runWorkerScenario(page, dbName, 'roundtrip'); + + console.log((result.logs || []).join('\n')); + + expect(result.success, result.error ?? 'worker hybrid OPFS flow failed').toBe(true); + expect(result.selectedBackend).toBe('Hybrid'); + expect(result.reopenedBackend).toBe('Hybrid'); + expect(result.opfsFile).not.toBeNull(); + expect(result.opfsFile.size).toBeGreaterThanOrEqual(result.expectedMinimumBytes); + expect(result.rows).toEqual([ + { id: 1, value: 'opfs-alpha' }, + { id: 2, value: 'opfs-beta' }, + ]); + }); + + test('worker reopen restores from OPFS after IndexedDB mirror deletion', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_restore_${Date.now()}`; + + const seeded = await runWorkerScenario(page, dbName, 'seed'); + console.log((seeded.logs || []).join('\n')); + + expect(seeded.success, seeded.error ?? 'worker hybrid seed failed').toBe(true); + expect(seeded.selectedBackend).toBe('Hybrid'); + expect(seeded.opfsFile).not.toBeNull(); + expect(seeded.opfsFile.size).toBeGreaterThanOrEqual(seeded.expectedMinimumBytes); + + const deletedMirror = await deleteIndexedDbMirror(page, dbName); + expect(deletedMirror.deletedAny).toBe(true); + + const reopened = await runWorkerScenario(page, dbName, 'reopen'); + console.log((reopened.logs || []).join('\n')); + + expect(reopened.success, reopened.error ?? 'worker OPFS restore reopen failed').toBe(true); + expect(reopened.reopenedBackend).toBe('Hybrid'); + expect(reopened.opfsFile).not.toBeNull(); + expect(reopened.rows).toEqual([ + { id: 1, value: 'opfs-alpha' }, + { id: 2, value: 'opfs-beta' }, + ]); + }); + + test('worker reopen falls back to IndexedDB when OPFS data is corrupted', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_corrupt_${Date.now()}`; + + const seeded = await runWorkerScenario(page, dbName, 'seed'); + console.log((seeded.logs || []).join('\n')); + + expect(seeded.success, seeded.error ?? 'worker hybrid seed failed').toBe(true); + expect(seeded.selectedBackend).toBe('Hybrid'); + expect(seeded.opfsFile).not.toBeNull(); + + const corrupted = await corruptOpfsMirror(page, dbName); + expect(corrupted).not.toBeNull(); + expect(corrupted.corruptedPrefixBytes).toBe(4096); + + const reopened = await runWorkerScenario(page, dbName, 'reopen'); + console.log((reopened.logs || []).join('\n')); + + expect(reopened.success, reopened.error ?? 'worker Hybrid corruption recovery failed').toBe(true); + expect(reopened.reopenedBackend).toBe('Hybrid'); + expect(reopened.rows).toEqual([ + { id: 1, value: 'opfs-alpha' }, + { id: 2, value: 'opfs-beta' }, + ]); + }); + + test('worker reopen reconciles orphan OPFS tail against IndexedDB metadata', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_orphan_tail_${Date.now()}`; + + const seeded = await runWorkerScenario(page, dbName, 'seed'); + console.log((seeded.logs || []).join('\n')); + + expect(seeded.success, seeded.error ?? 'worker hybrid seed failed').toBe(true); + expect(seeded.selectedBackend).toBe('Hybrid'); + expect(seeded.opfsFile).not.toBeNull(); + + const inflated = await appendOrphanOpfsTail(page, dbName); + expect(inflated).not.toBeNull(); + expect(inflated.sizeBeforeAppend).toBe(seeded.opfsFile.size); + expect(inflated.sizeAfterAppend).toBe(seeded.opfsFile.size + inflated.appendedBytes); + + const reopened = await runWorkerScenario(page, dbName, 'reopen'); + console.log((reopened.logs || []).join('\n')); + + expect(reopened.success, reopened.error ?? 'worker orphan reconciliation failed').toBe(true); + expect(reopened.reopenedBackend).toBe('Hybrid'); + expect(reopened.rows).toEqual([ + { id: 1, value: 'opfs-alpha' }, + { id: 2, value: 'opfs-beta' }, + ]); + expect(reopened.opfsFile).not.toBeNull(); + expect(reopened.opfsFile.size).toBe(seeded.opfsFile.size); + }); + + test('worker crash recovery heals torn Hybrid OPFS state after IndexedDB fallback', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_crash_recovery_${Date.now()}`; + + const baseline = await runWorkerScenario(page, { + dbName, + mode: 'crash-recovery-baseline', + }); + console.log((baseline.logs || []).join('\n')); + + expect(baseline.success, baseline.error ?? 'worker crash recovery baseline failed').toBe(true); + expect(baseline.selectedBackend).toBe('Hybrid'); + + const baselineMirror = await snapshotIndexedDbMirror(page, dbName); + expect(baselineMirror.blocks.length).toBeGreaterThan(0); + expect(baselineMirror.metadata.some((entry) => entry.key.endsWith(':commit_marker'))).toBe(true); + + const latest = await runWorkerScenario(page, { + dbName, + mode: 'crash-recovery-update', + }); + console.log((latest.logs || []).join('\n')); + + expect(latest.success, latest.error ?? 'worker crash recovery latest sync failed').toBe(true); + expect(latest.selectedBackend).toBe('Hybrid'); + + const restoredMirror = await restoreIndexedDbMirror(page, baselineMirror); + expect(restoredMirror.restoredBlocks).toBe(baselineMirror.blocks.length); + expect(restoredMirror.restoredMetadata).toBe(baselineMirror.metadata.length); + + const recovered = await runWorkerScenario(page, { + dbName, + mode: 'crash-recovery-check', + }); + console.log((recovered.logs || []).join('\n')); + + expect(recovered.success, recovered.error ?? 'worker crash recovery reopen failed').toBe(true); + expect(recovered.reopenedBackend).toBe('Hybrid'); + expect(recovered.rows).toEqual([ + { id: 1, value: 'baseline-alpha' }, + { id: 2, value: 'baseline-beta' }, + ]); + + const deletedMirror = await deleteIndexedDbMirror(page, dbName); + expect(deletedMirror.deletedAny).toBe(true); + + const healed = await runWorkerScenario(page, { + dbName, + mode: 'crash-recovery-check', + cleanupOnClose: true, + }); + console.log((healed.logs || []).join('\n')); + + expect(healed.success, healed.error ?? 'worker crash recovery healed reopen failed').toBe(true); + expect(healed.reopenedBackend).toBe('Hybrid'); + expect(healed.rows).toEqual([ + { id: 1, value: 'baseline-alpha' }, + { id: 2, value: 'baseline-beta' }, + ]); + }); + + test('worker crash recovery finalizes Hybrid data when commit marker lags persisted blocks', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_crash_finalize_${Date.now()}`; + + const baseline = await runWorkerScenario(page, { + dbName, + mode: 'crash-finalize-baseline', + }); + console.log((baseline.logs || []).join('\n')); + + expect(baseline.success, baseline.error ?? 'worker crash recovery baseline failed').toBe(true); + expect(baseline.selectedBackend).toBe('Hybrid'); + + const baselineMirror = await snapshotIndexedDbMirror(page, dbName); + const baselineCommitMarkerEntry = baselineMirror.metadata.find((entry) => entry.key.endsWith(':commit_marker')); + expect(baselineCommitMarkerEntry).toBeDefined(); + expect(typeof baselineCommitMarkerEntry.value).toBe('number'); + + const latest = await runWorkerScenario(page, { + dbName, + mode: 'crash-finalize-update', + }); + console.log((latest.logs || []).join('\n')); + + expect(latest.success, latest.error ?? 'worker crash recovery latest sync failed').toBe(true); + expect(latest.selectedBackend).toBe('Hybrid'); + + const latestMirror = await snapshotIndexedDbMirror(page, dbName); + const latestCommitMarkerEntry = latestMirror.metadata.find((entry) => entry.key.endsWith(':commit_marker')); + expect(latestCommitMarkerEntry).toBeDefined(); + expect(typeof latestCommitMarkerEntry.value).toBe('number'); + expect(latestCommitMarkerEntry.value).toBeGreaterThan(baselineCommitMarkerEntry.value); + + const staleCommit = await replaceIndexedDbCommitMarker(page, dbName, baselineCommitMarkerEntry.value); + expect(staleCommit.commitMarker).toBe(baselineCommitMarkerEntry.value); + + const rolledBackMirror = await snapshotIndexedDbMirror(page, dbName); + const rolledBackCommitMarkerEntry = rolledBackMirror.metadata.find((entry) => entry.key.endsWith(':commit_marker')); + expect(rolledBackCommitMarkerEntry.value).toBe(baselineCommitMarkerEntry.value); + + const recovered = await runWorkerScenario(page, { + dbName, + mode: 'crash-finalize-check', + cleanupOnClose: true, + }); + console.log((recovered.logs || []).join('\n')); + + expect(recovered.success, recovered.error ?? 'worker crash recovery finalize reopen failed').toBe(true); + expect(recovered.reopenedBackend).toBe('Hybrid'); + expect(recovered.rowCount).toBe(128); + expect(recovered.rows).toEqual([ + { id: 1, value: makeCrashFinalizeValue('latest', 1) }, + { id: 64, value: makeCrashFinalizeValue('latest', 64) }, + { id: 128, value: makeCrashFinalizeValue('latest', 128) }, + ]); + }); + + test('worker import updates OPFS so imported data survives IndexedDB mirror deletion', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_import_${Date.now()}`; + const sourceDbName = `${dbName}_source`; + + const imported = await runWorkerScenario(page, { + dbName, + sourceDbName, + mode: 'import-seed', + }); + console.log((imported.logs || []).join('\n')); + + expect(imported.success, imported.error ?? 'worker hybrid import seed failed').toBe(true); + expect(imported.selectedBackend).toBe('Hybrid'); + expect(imported.importedRows).toEqual([ + { id: 1, value: 'fresh-alpha' }, + { id: 2, value: 'fresh-beta' }, + ]); + expect(imported.staleTablePresentAfterImport).toBe(false); + expect(imported.opfsFile).not.toBeNull(); + + const deletedMirror = await deleteIndexedDbMirror(page, dbName); + expect(deletedMirror.deletedAny).toBe(true); + + const reopened = await runWorkerScenario(page, { + dbName, + mode: 'reopen-imported', + }); + console.log((reopened.logs || []).join('\n')); + + expect(reopened.success, reopened.error ?? 'worker hybrid imported reopen failed').toBe(true); + expect(reopened.reopenedBackend).toBe('Hybrid'); + expect(reopened.rows).toEqual([ + { id: 1, value: 'fresh-alpha' }, + { id: 2, value: 'fresh-beta' }, + ]); + expect(reopened.staleTablePresent).toBe(false); + }); + + test('worker Hybrid reload keeps OPFS-backed data after IndexedDB mirror deletion', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_reload_${Date.now()}`; + const sourceDbName = `${dbName}_source`; + + const imported = await runWorkerScenario(page, { + dbName, + sourceDbName, + mode: 'import-seed', + }); + console.log((imported.logs || []).join('\n')); + + expect(imported.success, imported.error ?? 'worker hybrid import seed failed').toBe(true); + expect(imported.selectedBackend).toBe('Hybrid'); + + const deletedMirror = await deleteIndexedDbMirror(page, dbName); + expect(deletedMirror.deletedAny).toBe(true); + + const reloaded = await runWorkerScenario(page, { + dbName, + mode: 'reload-imported', + }); + console.log((reloaded.logs || []).join('\n')); + + expect(reloaded.success, reloaded.error ?? 'worker hybrid reload after mirror delete failed').toBe(true); + expect(reloaded.reopenedBackend).toBe('Hybrid'); + expect(reloaded.rows).toEqual([ + { id: 1, value: 'fresh-alpha' }, + { id: 2, value: 'fresh-beta' }, + ]); + expect(reloaded.staleTablePresent).toBe(false); + }); + + test('worker deleteDatabase succeeds without Window and removes OPFS data', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_delete_${Date.now()}`; + + const deleted = await runWorkerScenario(page, { + dbName, + mode: 'seed-and-delete', + }); + console.log((deleted.logs || []).join('\n')); + + expect(deleted.success, deleted.error ?? 'worker deleteDatabase failed').toBe(true); + expect(deleted.selectedBackend).toBe('Hybrid'); + expect(deleted.opfsFileBeforeDelete).not.toBeNull(); + expect(deleted.opfsFileAfterDelete).toBeNull(); + }); + + test('worker exportToFile flush persists Hybrid data to OPFS before IndexedDB mirror deletion', async ({ page }) => { + await page.goto(STATIC_URL); + + const dbName = `worker_hybrid_export_flush_${Date.now()}`; + + const seeded = await runWorkerScenario(page, { + dbName, + mode: 'export-flush-seed', + }); + console.log((seeded.logs || []).join('\n')); + + expect(seeded.success, seeded.error ?? 'worker hybrid export flush seed failed').toBe(true); + expect(seeded.selectedBackend).toBe('Hybrid'); + expect(seeded.exportedSize).toBeGreaterThanOrEqual(4096); + expect(seeded.opfsFile).not.toBeNull(); + + const deletedMirror = await deleteIndexedDbMirror(page, dbName); + expect(deletedMirror.deletedAny).toBe(true); + + const reopened = await runWorkerScenario(page, { + dbName, + mode: 'reopen-export-flush', + }); + console.log((reopened.logs || []).join('\n')); + + expect(reopened.success, reopened.error ?? 'worker hybrid export flush reopen failed').toBe(true); + expect(reopened.reopenedBackend).toBe('Hybrid'); + expect(reopened.rows).toEqual([ + { id: 1, value: 'flush-alpha' }, + { id: 2, value: 'flush-beta' }, + ]); + }); +}); \ No newline at end of file