diff --git a/.config/nextest.toml b/.config/nextest.toml
index 560508e..9d1b709 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -5,6 +5,13 @@
#
# Run with: cargo nextest run -p nodedb-lite
+experimental = ["setup-scripts"]
+
+[scripts.setup.build-origin]
+command = { command-line = "scripts/ensure-origin.sh", relative-to = "workspace-root" }
+slow-timeout = { period = "600s", terminate-after = 1 }
+capture-stderr = true
+
[profile.default]
# Hard ceiling per test. Anything above this is a bug, not a slow test.
slow-timeout = { period = "30s", terminate-after = 4 }
@@ -39,10 +46,18 @@ binary(/concurrency/)
| binary(/e2e/)
| binary(/integration/)
| binary(/compensation/)
+| binary(/sync_interop/)
+| binary(/sync_load/)
+| binary(/sql_parity/)
+| binary(/definition_sync_interop/)
'''
test-group = 'heavy'
threads-required = 'num-test-threads'
+[[profile.default.scripts]]
+filter = 'binary(/sync_interop/) | binary(/sync_load/) | binary(/definition_sync_interop/) | binary(/sql_parity/)'
+setup = 'build-origin'
+
[test-groups]
heavy = { max-threads = 1 }
@@ -52,3 +67,7 @@ fail-fast = false
[profile.ci.junit]
path = "junit.xml"
+
+[[profile.ci.scripts]]
+filter = 'binary(/sync_interop/) | binary(/sync_load/) | binary(/definition_sync_interop/) | binary(/sql_parity/)'
+setup = 'build-origin'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5d5c0a9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,23 @@
+# CI — thin wrapper that calls the reusable test workflow.
+
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened, ready_for_review]
+ workflow_dispatch:
+ workflow_call:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ if: github.event.pull_request.draft == false
+ name: Test Suite
+ uses: ./.github/workflows/test.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8c63b08
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,249 @@
+name: Release
+run-name: Release ${{ github.ref_name }}
+
+on:
+ push:
+ tags:
+ - "v*"
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ # ── Validate tag ─────────────────────────────────────────────────────────────
+ validate-version:
+ name: Validate Version Tag
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ is_full_release: ${{ steps.version.outputs.is_full_release }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Validate tag against Cargo.toml
+ id: version
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ TAG_VERSION="${TAG#v}"
+
+ CARGO_VERSION=$(cargo metadata --no-deps --format-version=1 \
+ | jq -r '.packages[] | select(.name == "nodedb-lite") | .version')
+ CARGO_BASE=$(echo "$CARGO_VERSION" | grep -oP '^\d+\.\d+\.\d+')
+
+ echo "Tag version: $TAG_VERSION"
+ echo "Cargo.toml version: $CARGO_VERSION"
+ echo "Cargo.toml base: $CARGO_BASE"
+
+ if [[ ! "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-[a-zA-Z]+\.[0-9]+)?$ ]]; then
+ echo "::error::Invalid tag format '$TAG'. Expected: vX.Y.Z or vX.Y.Z-label.N"
+ exit 1
+ fi
+
+ TAG_BASE="${BASH_REMATCH[1]}"
+
+ if [[ "$TAG_BASE" != "$CARGO_BASE" ]]; then
+ echo "::error::Base version mismatch! Tag '$TAG_BASE' != Cargo.toml '$CARGO_BASE'"
+ exit 1
+ fi
+
+ # Full release = no hyphen suffix (v0.1.0, not v0.1.0-beta.1)
+ if [[ "$TAG_VERSION" == *-* ]]; then
+ echo "is_full_release=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "is_full_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT"
+
+ # ── CI gate ──────────────────────────────────────────────────────────────────
+ ci:
+ name: CI Gate
+ needs: validate-version
+ uses: ./.github/workflows/ci.yml
+
+ # ── WASM gate ────────────────────────────────────────────────────────────────
+ wasm:
+ name: WASM Gate
+ needs: validate-version
+ uses: ./.github/workflows/wasm.yml
+
+ # ── Publish crates to crates.io ──────────────────────────────────────────────
+ # Requires secret: CARGO_REGISTRY_TOKEN
+ # Tier 1: nodedb-lite (no internal Lite deps).
+ # Tier 2: nodedb-lite-ffi, nodedb-lite-wasm (both depend on nodedb-lite).
+ # is_published / wait_for polling mirrors the nodedb workspace pattern so
+ # a re-run after a failed publish never double-publishes an already-indexed
+ # crate.
+ publish-crates:
+ name: Publish to crates.io
+ needs: [validate-version, ci, wasm]
+ runs-on: ubuntu-latest
+ environment: crates.io
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install system deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ cmake clang libclang-dev pkg-config protobuf-compiler perl
+
+ - name: Set version from tag
+ run: |
+ VERSION="${{ needs.validate-version.outputs.version }}"
+ CURRENT=$(cargo metadata --no-deps --format-version=1 \
+ | jq -r '.packages[] | select(.name == "nodedb-lite") | .version')
+ if [[ "$VERSION" != "$CURRENT" ]]; then
+ sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml
+
+ # For pre-release versions, pin internal dep requirements so
+ # semver "0.1.0" doesn't fail to match "0.1.0-beta.1".
+ if [[ "$VERSION" == *-* ]]; then
+ sed -i -E 's/(nodedb-lite = \{ [^}]*version = )"[^"]*"/\1"='"$VERSION"'"/' Cargo.toml
+ echo "Updated internal dep versions to =$VERSION"
+ fi
+
+ echo "Updated workspace version: $CURRENT -> $VERSION"
+ else
+ echo "Version already matches, no change needed"
+ fi
+
+ - name: Publish crates
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: |
+ TIER1="nodedb-lite"
+ TIER2="nodedb-lite-ffi nodedb-lite-wasm"
+
+ is_published() {
+ curl -sf \
+ -H "User-Agent: nodedb-lite-ci (github.com/NodeDB-Lab/nodedb-lite)" \
+ "https://crates.io/api/v1/crates/$1/$2" > /dev/null 2>&1
+ }
+
+ wait_for() {
+ local crate="$1" version="$2"
+ echo -n " Waiting for $crate@$version..."
+ for i in $(seq 1 30); do
+ if is_published "$crate" "$version"; then
+ echo " ready"
+ return 0
+ fi
+ sleep 5
+ done
+ echo " timed out!"
+ return 1
+ }
+
+ publish_tier() {
+ local tier_name="$1"; shift
+ local crates=("$@")
+ local need_wait=()
+
+ echo "::group::Tier: $tier_name"
+ for crate in "${crates[@]}"; do
+ VERSION=$(cargo metadata --no-deps --format-version=1 \
+ | jq -r --arg name "$crate" '.packages[] | select(.name == $name) | .version')
+ if is_published "$crate" "$VERSION"; then
+ echo " $crate@$VERSION already published — skipping"
+ else
+ echo " Publishing $crate@$VERSION..."
+ cargo publish -p "$crate" --allow-dirty --no-verify
+ need_wait+=("$crate:$VERSION")
+ fi
+ done
+
+ for entry in "${need_wait[@]}"; do
+ wait_for "${entry%%:*}" "${entry##*:}"
+ done
+ echo "::endgroup::"
+ }
+
+ publish_tier "1 (no internal Lite deps)" $TIER1
+ publish_tier "2 (depends on nodedb-lite)" $TIER2
+
+ # ── Publish npm package ──────────────────────────────────────────────────────
+ # Requires secret: NPM_TOKEN
+ publish-npm:
+ name: Publish to npm
+ needs: [validate-version, ci, wasm, publish-crates]
+ runs-on: ubuntu-latest
+ environment: npm
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ 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
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ registry-url: https://registry.npmjs.org
+
+ - name: Build WASM release
+ run: wasm-pack build --target web --release nodedb-lite-wasm
+
+ - name: Rewrite pkg/package.json metadata
+ working-directory: nodedb-lite-wasm/pkg
+ run: |
+ node -e "
+ const fs = require('fs');
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg.name = '@nodedb/lite';
+ pkg.version = '${{ needs.validate-version.outputs.version }}';
+ pkg.license = 'Apache-2.0';
+ pkg.repository = { type: 'git', url: 'https://github.com/NodeDB-Lab/nodedb-lite' };
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ "
+
+ - name: Publish to npm
+ working-directory: nodedb-lite-wasm/pkg
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ if [[ "${{ needs.validate-version.outputs.is_full_release }}" == "true" ]]; then
+ npm publish --access public
+ else
+ npm publish --access public --tag beta
+ fi
+
+ # ── Create GitHub Release ─────────────────────────────────────────────────────
+ github-release:
+ name: Create GitHub Release
+ needs: [validate-version, publish-crates, publish-npm]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ needs.validate-version.outputs.version }}
+ name: NodeDB Lite ${{ needs.validate-version.outputs.version }}
+ generate_release_notes: true
+ draft: false
+ prerelease: ${{ contains(needs.validate-version.outputs.version, '-') }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..a18851b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,80 @@
+# Reusable test workflow: fmt, clippy, native test suite, and wasm32 check.
+#
+# Called by:
+# - ci.yml (PR checks)
+# - release.yml (pre-publish gate)
+#
+# Lite is a separate workspace from Origin. It consumes the nodedb-* shared
+# crates from crates.io. Local development uses `.cargo/config.toml`
+# [patch.crates-io] to resolve to a sibling Origin checkout; CI uses the
+# published versions.
+
+name: Test
+
+on:
+ workflow_call:
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ lint:
+ name: Lint & Check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: lite-workspace
+ - name: Install system deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ cmake clang libclang-dev pkg-config protobuf-compiler perl
+ - name: Check formatting
+ run: cargo fmt --all -- --check
+ - name: Run clippy
+ run: cargo clippy --workspace --all-targets --profile ci -- -D warnings
+ - name: Check wasm32 target
+ run: cargo check -p nodedb-lite-wasm --target wasm32-unknown-unknown
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: lite-workspace
+ - name: Install system deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ cmake clang libclang-dev pkg-config protobuf-compiler perl
+ - name: Install cargo-nextest
+ uses: taiki-e/install-action@v2
+ with:
+ tool: nextest
+ - name: Run tests
+ run: |
+ cargo nextest run \
+ --workspace \
+ --cargo-profile ci --profile ci \
+ --no-fail-fast
+ - name: Upload JUnit report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: junit-report
+ path: target/nextest/ci/junit.xml
+ if-no-files-found: ignore
diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml
index 7cd7ac5..9aa31ac 100644
--- a/.github/workflows/wasm.yml
+++ b/.github/workflows/wasm.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
+ workflow_call:
concurrency:
group: wasm-${{ github.ref }}
@@ -32,7 +33,7 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
prefix-key: wasm
- workspaces: nodedb-lite -> nodedb-lite/target
+ shared-key: lite-wasm
- name: Restore wasm-pack cache
uses: actions/cache@v4
@@ -44,13 +45,29 @@ jobs:
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Check workspace for wasm32 target
- working-directory: nodedb-lite
run: cargo check --workspace --target wasm32-unknown-unknown
- name: Build WASM release
- working-directory: nodedb-lite
run: wasm-pack build --target web --release nodedb-lite-wasm
- - name: Run WASM tests
- working-directory: nodedb-lite
+ - name: Rewrite pkg/package.json metadata
+ working-directory: nodedb-lite-wasm/pkg
+ run: |
+ node -e "
+ const fs = require('fs');
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg.name = '@nodedb/lite';
+ pkg.version = '0.1.0';
+ pkg.license = 'Apache-2.0';
+ pkg.repository = { type: 'git', url: 'https://github.com/NodeDB-Lab/nodedb-lite' };
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ "
+
+ - name: Run WASM node tests
run: wasm-pack test --node nodedb-lite-wasm
+
+ - name: Install Chrome for browser tests
+ uses: browser-actions/setup-chrome@v1
+
+ - name: Run WASM browser tests (headless Chrome)
+ run: wasm-pack test --headless --chrome nodedb-lite-wasm
diff --git a/.gitignore b/.gitignore
index bf16fdf..bb29956 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,8 @@ dmypy.json
.gradle/
.cargo/
+
+# WASM build artifacts — generated by wasm-pack, not committed
+nodedb-lite-wasm/pkg/
+
+.claude
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5614b9a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,19 @@
+# Changelog
+
+All notable changes to NodeDB Lite are documented in this file.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+NodeDB Lite uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+---
+
+## [0.1.0] - 2026-05-23
+
+> First public release of NodeDB Lite. Ready for pilot integration with
+> NodeDB Origin and embedded use on Linux, macOS, Windows, Android, and
+> the browser. We welcome feedback before the 1.0 stable release.
+> Versions prior to 0.1.0 were internal iterations.
+
+---
+
+[0.1.0]: https://github.com/NodeDB-Lab/nodedb-lite/releases/tag/v0.1.0
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..8e830ee
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,7 @@
+# Code of Conduct
+
+This project adopts the Contributor Covenant, version 2.1. The full text is published at:
+
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+
+Reports of unacceptable behavior may be sent to: conduct@nodedb.io (address pending org confirmation).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2d80ff1
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,62 @@
+# Contributing to NodeDB Lite
+
+Thank you for your interest in contributing.
+
+## Repository layout
+
+| Crate | Description |
+|---|---|
+| `nodedb-lite` | Core embedded Rust library |
+| `nodedb-lite-ffi` | C FFI bindings (cbindgen, Kotlin/JNI for Android) |
+| `nodedb-lite-wasm` | JavaScript/TypeScript bindings via wasm-bindgen |
+
+## Building
+
+```bash
+# Check all crates compile
+cargo check --workspace
+
+# Rust core
+cargo build -p nodedb-lite
+
+# C FFI (requires cbindgen)
+cargo build -p nodedb-lite-ffi
+
+# WASM (requires wasm-pack)
+wasm-pack build --target web nodedb-lite-wasm
+```
+
+## Running tests
+
+```bash
+# Rust unit and integration tests
+cargo nextest run -p nodedb-lite
+
+# FFI tests
+cargo nextest run -p nodedb-lite-ffi
+
+# WASM tests (headless browser required)
+wasm-pack test --headless --firefox nodedb-lite-wasm
+```
+
+Always use `cargo nextest run`, not `cargo test`. The test suite relies on nextest's
+per-test isolation and retry configuration in `.config/nextest.toml`.
+
+## Before opening a pull request
+
+- [ ] `cargo fmt --all` — no formatting diffs
+- [ ] `cargo clippy --workspace --all-targets -- -D warnings` — no warnings
+- [ ] `cargo nextest run -p nodedb-lite` — all tests green
+- [ ] New public API has at least one integration test in `tests/`
+- [ ] No `.unwrap()` calls in library code — propagate errors with `?`
+- [ ] Files stay under 500 lines; split by concern if needed
+
+## Commit messages
+
+Conventional commits are encouraged: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
+Use the imperative mood in the subject line ("Add X", "Fix Y", not "Added X").
+Keep the subject under 72 characters. Reference the relevant issue number if one exists.
+
+## Code of conduct
+
+This project follows the [Contributor Covenant 2.1](./CODE_OF_CONDUCT.md).
diff --git a/Cargo.toml b/Cargo.toml
index 9148000..539c944 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,13 +1,9 @@
[workspace]
-members = [
- "nodedb-lite",
- "nodedb-lite-ffi",
- "nodedb-lite-wasm",
-]
+members = ["nodedb-lite", "nodedb-lite-ffi", "nodedb-lite-wasm"]
resolver = "2"
[workspace.package]
-version = "0.0.6"
+version = "0.1.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
@@ -17,20 +13,21 @@ homepage = "https://nodedb.dev"
[workspace.dependencies]
# Internal
-nodedb-lite = { path = "nodedb-lite", version = "0.0.6", default-features = false }
-nodedb-types = { version = "0.0.6" }
-nodedb-client = { version = "0.0.6" }
-nodedb-codec = { version = "0.0.6" }
-nodedb-crdt = { version = "0.0.6" }
-nodedb-query = { version = "0.0.6" }
-nodedb-spatial = { version = "0.0.6" }
-nodedb-graph = { version = "0.0.6" }
-nodedb-vector = { version = "0.0.6" }
-nodedb-fts = { version = "0.0.6" }
-nodedb-strict = { version = "0.0.6" }
-nodedb-columnar = { version = "0.0.6" }
-nodedb-sql = { version = "0.0.6" }
-nodedb-array = { version = "0.0.6" }
+nodedb-lite = { path = "nodedb-lite", version = "0.1.0", default-features = false }
+nodedb-types = { version = "0.3" }
+nodedb-client = { version = "0.3" }
+nodedb-codec = { version = "0.3" }
+nodedb-crdt = { version = "0.3" }
+nodedb-query = { version = "0.3" }
+nodedb-spatial = { version = "0.3" }
+nodedb-graph = { version = "0.3" }
+nodedb-vector = { version = "0.3" }
+nodedb-fts = { version = "0.3" }
+nodedb-strict = { version = "0.3" }
+nodedb-columnar = { version = "0.3" }
+nodedb-sql = { version = "0.3" }
+nodedb-array = { version = "0.3" }
+nodedb-physical = { version = "0.3" }
# Async
tokio = { version = "1" }
@@ -46,11 +43,12 @@ tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sonic-rs = "0.5"
-zerompk = { version = "0.4", features = ["std", "derive"] }
+zerompk = { version = "0.5", features = ["std", "derive"] }
# Storage
-redb = "2"
+pagedb = { version = "0", default-features = false }
roaring = "0.11"
+lru = "0.12"
arrow = { version = "58", default-features = false, features = ["ipc"] }
crc32c = "0.6"
@@ -58,7 +56,10 @@ crc32c = "0.6"
loro = "1"
# WASM runtime
-wasmtime = { version = "30", default-features = false, features = ["cranelift", "runtime"] }
+wasmtime = { version = "30", default-features = false, features = [
+ "cranelift",
+ "runtime",
+] }
# Crypto
aes-gcm = "0.10"
@@ -69,6 +70,7 @@ getrandom = { version = "0.3", features = ["std"] }
# Testing
tempfile = "3"
rust_decimal = { version = "1", features = ["serde-with-str"] }
+tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
# WASM bindings
wasm-bindgen = "0.2"
@@ -80,3 +82,8 @@ debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
+
+[profile.ci]
+inherits = "dev"
+debug = false
+incremental = false
diff --git a/README.md b/README.md
index 7ff369c..475d57b 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
- Status
+ Release Status
·
Platforms
·
@@ -27,14 +27,14 @@
-
+
-
+
@@ -47,20 +47,31 @@
NodeDB Lite replaces the usual SQLite + vector sidecar + ad hoc cache + custom sync layer stack with one embedded engine. Local reads stay in-process, writes remain available offline, and the same application code can later sync to NodeDB Origin without a rewrite.
-## Status
+## Release Status
+
+NodeDB Lite is in **public beta** as of **v0.1.0 (2026-05-23)**. All engines listed in [`docs/lite-support-matrix.md`](docs/lite-support-matrix.md) are feature-complete and covered by tests. The public surface — the `NodeDb` trait, the supported SQL plan variants, the C FFI ABI, and the WASM / npm bindings — is stable; clients written against 0.1.0 will keep working through 1.0.
+
+**v0.1.0 — Beta (today).** Use it for embedded workloads and for piloting Lite ↔ Origin sync. The public surface is stable; expect internal changes (pagedb layout, on-disk index format, sync-protocol internals) between minor releases. Patch and minor bumps will land as needed.
-NodeDB Lite is currently in development and is not released yet.
+**v1.0.0 — Production-ready (target: 2026-07-23).** What 1.0 guarantees:
-The immediate focus is NodeDB Origin through `v0.1.0`. Once Origin reaches `v0.1.0`, development focus shifts back to NodeDB Lite for packaging, platform hardening, and release work.
+- **API & SQL stability** — semver from 1.0 onward. No breaking changes to the `NodeDb` trait, the supported SQL plan variants, or the C FFI / WASM ABIs within a major.
+- **Sync protocol stability** — the Lite ↔ Origin WebSocket wire frames frozen at the version Origin ships in its 1.0.
+- **On-disk format stability** — no breaking migrations within 1.x. Forward-compatible upgrades only.
+- **Platform parity** — iOS FFI built and tested against a macOS environment; Android JNI packaging fully gated.
+- **Performance SLAs** — published p99 targets per engine, regression-gated in CI.
+- **Security audit** — third-party audit completed and findings remediated before 1.0 ships.
-Until then, this repository should be treated as active development, not a published/stable product.
+Pre-1.0 versions may change internals between releases — that work is critical-path hardening (persistence layouts, sync coordination, platform packaging) that has to be exercised in real production conditions before we put a stability stamp on it. The public API, SQL surface, and sync wire frames won't break; everything underneath is fair game until 1.0.
+
+> **Note:** This release track applies to **NodeDB Lite** only. [NodeDB Origin](https://github.com/NodeDB-Lab/nodedb), [`ndb` CLI](https://github.com/NodeDB-Lab/nodedb-cli), and [NodeDB Studio](https://github.com/NodeDB-Lab/nodedb-studio) are versioned independently on their own tracks.
## Why NodeDB Lite
- **One embedded engine, not a stitched-together client stack.** Vectors, graph, documents, full-text, timeseries, key-value, and other NodeDB data models run in one runtime with shared storage and one query surface.
- **Built for offline-first.** Every write is captured as a CRDT delta locally, then merged to Origin when the network comes back.
- **Same API as Origin.** The `NodeDb` trait is identical across Lite and server deployments, so moving from on-device to remote is a connection decision, not an architecture rewrite.
-- **Edge-ready.** Linux, macOS, Windows, Android, iOS, and browser/WASM support from the same product line.
+- **Edge-ready.** Linux, macOS, Windows, Android, and browser/WASM in `0.1.0`; iOS lands before `1.0`.
## When to Use
@@ -72,57 +83,65 @@ Until then, this repository should be treated as active development, not a publi
## Platforms
-| Platform | Crate | Backend | Size |
-| -------- | ------------------ | ----------------------- | --------- |
-| Linux | `nodedb-lite` | redb (file-backed) | Native |
-| macOS | `nodedb-lite` | redb (file-backed) | Native |
-| Windows | `nodedb-lite` | redb (file-backed) | Native |
-| Android | `nodedb-lite-ffi` | redb + C FFI + Kotlin/JNI | Native |
-| iOS | `nodedb-lite-ffi` | redb + C FFI (cbindgen) | Native |
-| Browser | `nodedb-lite-wasm` | redb (in-memory + OPFS) | Target: < 10 MB |
-
-## Planned Packages
+| Platform | Crate | Backend | Size |
+| ----------------------------------------- | ------------------ | ------------------------- | ------------------------------------------------------------------ |
+| Linux | `nodedb-lite` | pagedb (file-backed) | Native |
+| macOS | `nodedb-lite` | pagedb (file-backed) | Native |
+| Windows | `nodedb-lite` | pagedb (file-backed) | Native |
+| Android | `nodedb-lite-ffi` | pagedb + C FFI + Kotlin/JNI | Native |
+| iOS _(in progress — not in 0.1.0)_ | `nodedb-lite-ffi` | pagedb + C FFI (cbindgen) | Native _(requires macOS build environment — not yet built/tested)_ |
+| Browser | `nodedb-lite-wasm` | pagedb (in-memory + OPFS) | Target: < 10 MB |
-NodeDB Lite is not published yet. The package names below reflect the intended release targets:
+## Packages
```bash
-# Rust (planned)
+# Rust
cargo add nodedb-lite
-# JavaScript / TypeScript (WASM, planned)
+# JavaScript / TypeScript (browser + Node)
npm install @nodedb/lite
```
## Quick Start
-API shape preview while the project is still in development:
+The Rust crate API in `0.1.0`:
```rust
-use nodedb_lite::NodeDbLite;
+use nodedb_lite::{NodeDbLite, PagedbStorageMem};
use nodedb_client::NodeDb;
-let db = NodeDbLite::open("./my-app-data").await?;
+// Open an in-memory database (peer_id uniquely identifies this device/replica):
+let storage = PagedbStorageMem::open_in_memory().await?;
+let db = NodeDbLite::open(storage, 1u64).await?;
// Insert a document
-db.execute("CREATE COLLECTION notes").await?;
-db.execute("INSERT INTO notes { title: 'Hello', body: 'World' }").await?;
+db.execute_sql("CREATE COLLECTION notes", &[]).await?;
+
+// Put a document via the typed API
+use nodedb_types::document::Document;
+let mut doc = Document::new("n1");
+doc.set("title", "Hello".into());
+db.document_put("notes", doc).await?;
// Vector search
-db.execute("CREATE COLLECTION articles ENGINE vector DIMENSION 384").await?;
-db.vector_search("articles", &embedding, 10).await?;
+db.vector_insert("articles", "a1", &embedding, None).await?;
+// vector_search(collection, query, k, filter, allowed_ids)
+let results = db.vector_search("articles", &embedding, 10, None, None).await?;
// Graph traversal
-db.execute("MATCH (a)-[:KNOWS*1..3]->(b) WHERE a.name = 'Alice' RETURN b").await?;
+use nodedb_types::id::NodeId;
+let start = NodeId::try_new("alice")?;
+let subgraph = db.graph_traverse("social", &start, 3, None).await?;
```
## Same API, Any Runtime
-The `NodeDb` trait is identical across Lite and Origin. Application code doesn't change:
+The `NodeDb` trait is identical across Lite and Origin. Application code doesn't change for the operations both implementations expose — Origin offers more (full SQL, Array DDL/DML, vector quantization, distributed search); see [`docs/lite-support-matrix.md`](docs/lite-support-matrix.md) for the exact Lite surface.
```rust
// Works with both NodeDbLite (in-process) and NodeDbRemote (over network)
async fn search(db: &dyn NodeDb, query: &[f32]) -> Result> {
- db.vector_search("articles", query, 10).await
+ db.vector_search("articles", query, 10, None, None).await
}
```
@@ -133,7 +152,7 @@ Moving from embedded to server is a connection string change, not a rewrite.
Every write produces a delta. Deltas sync to Origin over WebSocket when online. Multiple devices converge regardless of operation order.
```
-Offline: App writes locally -> Loro generates delta -> delta persisted to redb
+Offline: App writes locally -> Loro generates delta -> delta persisted to pagedb
Reconnect: Device opens WebSocket -> sends vector clock + accumulated deltas
Cloud: Origin validates (RLS, UNIQUE, FK) -> merges -> pushes back missed changes
Conflict: Rejected deltas -> dead-letter queue + CompensationHint -> device handles
@@ -148,31 +167,41 @@ Converged: Device and cloud share identical Loro state hash
- **Multi-model locally** -- Vector, graph, document, full-text, timeseries, key-value, and more, all in-process with no network
- **Sub-millisecond reads** -- Hot data lives in memory indexes (HNSW, CSR, Loro)
-- **Full SQL** -- Same SQL as Origin. Window functions, CTEs, subqueries, JOINs.
+- **SQL** -- Supports a documented subset of NodeDB's SQL surface; see [SQL support](#sql-support) below.
- **Encryption at rest** -- AES-256-GCM + Argon2id key derivation
- **Memory governance** -- Per-engine budgets, pressure levels, LRU eviction
+## SQL support
+
+NodeDB Lite parses SQL via `nodedb-sql` and executes plans directly against local engines.
+8 of 44 `SqlPlan` variants are executed in `0.1.0`: `ConstantResult`, `Scan` (partial),
+`PointGet`, `Insert`, `Upsert`, `Update`, `Delete`, and `Truncate`. JOIN, aggregates, CTE,
+window functions, vector/FTS/spatial SQL, and all Array DDL/DML variants return
+`LiteError::Unsupported`. The regression gate is `tests/sql_matrix.rs`.
+
+See [docs/lite-support-matrix.md](docs/lite-support-matrix.md) for the full engine, SQL, and sync support matrix.
+
## Performance
-| Metric | Target |
-| ------------------------------------- | ------------- |
-| Vector search (1K vectors, 384d, k=5) | < 1ms p99 |
-| Graph BFS (10K edges, 2 hops) | < 1ms p99 |
-| Document get | < 0.1ms |
-| Cold start (10K vectors + 100K edges) | < 500ms |
-| Sync round-trip (single delta) | < 200ms |
-| WASM bundle | < 10 MB |
-| Mobile memory | < 100 MB |
+| Metric | Target |
+| ------------------------------------- | --------- |
+| Vector search (1K vectors, 384d, k=5) | < 1ms p99 |
+| Graph BFS (10K edges, 2 hops) | < 1ms p99 |
+| Document get | < 0.1ms |
+| Cold start (10K vectors + 100K edges) | < 500ms |
+| Sync round-trip (single delta) | < 200ms |
+| WASM bundle | < 10 MB |
+| Mobile memory | < 100 MB |
## Workspace
This repository contains three crates:
-| Crate | Description |
-| ------------------ | -------------------------------------------------------- |
-| `nodedb-lite` | Core embedded database library |
-| `nodedb-lite-ffi` | C FFI bindings for iOS/Android (cbindgen, Kotlin/JNI) |
-| `nodedb-lite-wasm` | JavaScript/TypeScript bindings via wasm-bindgen |
+| Crate | Description |
+| ------------------ | ----------------------------------------------------- |
+| `nodedb-lite` | Core embedded database library |
+| `nodedb-lite-ffi` | C FFI bindings for Android (cbindgen, Kotlin/JNI); iOS lands before 1.0 |
+| `nodedb-lite-wasm` | JavaScript/TypeScript bindings via wasm-bindgen |
## Building from Source
@@ -205,9 +234,15 @@ nodedb-vector = { path = "../nodedb/nodedb-vector" }
nodedb-fts = { path = "../nodedb/nodedb-fts" }
nodedb-strict = { path = "../nodedb/nodedb-strict" }
nodedb-columnar = { path = "../nodedb/nodedb-columnar" }
+nodedb-array = { path = "../nodedb/nodedb-array" }
nodedb-sql = { path = "../nodedb/nodedb-sql" }
+nodedb-physical = { path = "../nodedb/nodedb-physical" }
```
## License
-Apache-2.0. See [LICENSE](LICENSE) for details.
+**Apache-2.0** — see [LICENSE](LICENSE). NodeDB Lite is fully open source, with
+**no BSL anywhere in its dependency tree**: it builds only on NodeDB's Apache-2.0
+engine and foundation crates. The Business Source License applies _only_ to the
+[NodeDB Origin](https://github.com/NodeDB-Lab/nodedb) server — never to Lite.
+Embed it anywhere: apps, browsers (WASM/OPFS), mobile, desktop, edge.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..cbd44be
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,37 @@
+# Security Policy
+
+## Supported versions
+
+| Version | Supported |
+| ------- | --------- |
+| 0.1.x | Yes |
+
+## Reporting a vulnerability
+
+Please **do not** open a public GitHub issue for security vulnerabilities.
+
+Report security issues by email to **security@nodedb.io**. If you receive no acknowledgement within 72 hours, follow up by opening a GitHub issue marked `[security]` with no vulnerability details included.
+
+## Disclosure process
+
+NodeDB follows a 90-day coordinated disclosure default:
+
+1. Reporter sends details to `security@nodedb.io`.
+2. Maintainers acknowledge within 72 hours and begin investigation.
+3. A fix is prepared in a private branch.
+4. Reporter is notified when the fix is ready and a release date is set.
+5. Fix is released and a CVE (if applicable) is published simultaneously.
+6. Reporter is credited in the release notes unless they prefer to remain anonymous.
+
+If 90 days pass without a fix, reporters are free to publish their findings.
+
+## Scope
+
+NodeDB Lite is an embedded library. The primary security boundaries are:
+
+- Encryption at rest (AES-256-GCM + Argon2id key derivation)
+- CRDT sync transport (TLS required on production endpoints)
+- C FFI memory safety (cbindgen-generated bindings)
+- WASM sandbox isolation
+
+Out of scope: issues in `redb`, `loro`, or other upstream dependencies that are tracked by those projects directly.
diff --git a/docs/lite-support-matrix.md b/docs/lite-support-matrix.md
new file mode 100644
index 0000000..3ea2375
--- /dev/null
+++ b/docs/lite-support-matrix.md
@@ -0,0 +1,103 @@
+# NodeDB Lite 0.1.0 Support Matrix
+
+What ships in `0.1.0` — bindings, engines, SQL surface, and Lite-to-Origin
+sync, with file and test evidence pinned to the current tree.
+
+---
+
+## Bindings
+
+| Binding | Evidence |
+| ------------------------------- | --------------------------------------------------------------------------------- |
+| Rust crate (`nodedb-lite`) | Full workspace test suite green |
+| C FFI (`nodedb-lite-ffi`) | `nodedb-lite-ffi/tests/` |
+| WASM crate (`nodedb-lite-wasm`) | `cargo check --target wasm32-unknown-unknown` clean; browser + Node tests via CI |
+| npm `@nodedb/lite` | Published from the WASM crate on tag release (`release.yml` → `publish-npm`) |
+| Android JNI | Rust cross-compiles for `aarch64-linux-android`; no automated Android packaging gate yet |
+
+iOS bindings require a macOS build environment and are not part of `0.1.0`.
+See `docs/lite.md` for the current iOS status.
+
+---
+
+## Engines
+
+| Engine | What works | Evidence |
+| --------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| Strict document | Binary-tuple rows, schema, CRUD, secondary indexes, Arrow mapping, full row scan | `tests/strict_document.rs`, `tests/sql_parity/strict.rs` |
+| Document (schemaless) | Loro CRDT documents over redb, point-get, scan, insert/upsert/update/delete | `tests/document.rs`, `tests/sql_parity/document.rs` |
+| Columnar | Memtable + segment store, per-column codecs, flush, compaction, full row scan | `tests/columnar.rs`, `tests/sql_parity/columnar.rs` |
+| Timeseries | Backed by `ColumnarEngine` with `ColumnarProfile::Timeseries`; insert and row scan | `tests/sql_parity/timeseries.rs` |
+| Vector | HNSW + FP32 top-k ANN via `vector_search` | `tests/vector_engine_gate.rs`, `tests/sync_interop_vector.rs` |
+| Graph | Collection-scoped CSR adjacency, edge insert/delete, BFS traversal, shortest path, stats | `tests/graph_engine_gate.rs` |
+| Full-text | BM25 top-k over `nodedb-fts` with persistent index; reopens without rebuild | `tests/fts_persistence.rs` |
+| Spatial | Persistent R-tree, bbox / nearest / OGC predicates | `tests/spatial_engine_gate.rs` |
+| Key-value | `kv_put` / `kv_get` / `kv_delete`, TTL via `kv_put_with_ttl`, range scan, compact-expired | `tests/kv_engine_gate.rs`, `tests/kv_ttl_and_range.rs` |
+| Array | Tile-based ND store with catalog, manifest, memtable, segments, retention; sync receive wired | `tests/array.rs`, `tests/array_sync_interop_real.rs` |
+
+Vector quantization, filtered search, and distributed modes are NodeDB
+Origin features that Lite does not implement; query them remotely.
+HTAP materialized-view routing on top of the columnar engine is not
+exercised by the 0.1.0 gate tests.
+
+---
+
+## SQL
+
+SQL parses via `nodedb-sql` and executes against the local engines through
+the same planner Origin uses. The `SqlPlan` variants executed in 0.1.0:
+
+- `ConstantResult`
+- `Scan` — schemaless, strict, columnar, timeseries (full row scan; no WHERE pushdown beyond id)
+- `PointGet` — single-key lookup
+- `Insert` — with duplicate-key check
+- `Upsert` — maps to the CRDT upsert path
+- `Update` — literal-value assignments by key list
+- `Delete` — by key list
+- `Truncate`
+
+The regression gate is `tests/sql_matrix.rs`.
+
+`SqlPlan` variants that the parser accepts but Lite does not execute
+return `LiteError::Unsupported`. They are listed in the per-variant
+matrix and include all Array DDL/DML variants, JOIN, aggregates, CTEs,
+recursive queries, hybrid / multivec / spatial search variants, and the
+specialized `TimeseriesScan` / `TimeseriesIngest` / `VectorSearch` /
+`TextSearch` plans. Run those queries against Origin directly.
+
+---
+
+## Lite ↔ Origin sync
+
+Sync runs over WebSocket against a `NodeDbServer` exposing the Lite sync
+endpoint. Each engine has dedicated wire frames; the receive path is
+wired both ways. Cross-repo gate tests boot a real Origin process and
+drive a `NodeDbLite` instance against it.
+
+| Capability | Wire frames | Gate test |
+| --------------------------- | -------------------------------------------------------------------- | ---------------------------------------- |
+| Handshake + vector clock | `Handshake` / `HandshakeAck` | `tests/sync_interop_handshake.rs` |
+| Delta push / ack / reject | `DeltaPush` / `DeltaAck` / `DeltaReject` (with `CompensationHint`) | `tests/sync_interop_delta.rs` |
+| Reconnect + replay dedup | `Resume` / `Snapshot` / sequence-gap re-sync | `tests/sync_interop_resume.rs` |
+| Shape subscriptions | `ShapeSubscribe` / `ShapeSnapshot` / `ShapeDelta` | `tests/sync_interop_shape.rs` |
+| Definition sync (DDL) | `DefinitionSync` (`0x70`) | `tests/definition_sync_interop.rs` |
+| Array sync | `ArrayDelta` (`0x90`), `ArrayDeltaBatch` (`0x91`), ack (`0x95`) | `tests/array_sync_interop_real.rs` |
+| Columnar insert sync | `ColumnarInsert` (`0xA0`), `ColumnarInsertAck` (`0xA1`) | `tests/sync_interop_columnar.rs` |
+| Vector insert / delete sync | `VectorInsert` (`0xA2`/`0xA3`), `VectorDelete` (`0xA4`/`0xA5`) | `tests/sync_interop_vector.rs` |
+| FTS index / delete sync | `FtsIndex` (`0xA6`/`0xA7`), `FtsDelete` (`0xA8`/`0xA9`) | `tests/sync_interop_fts.rs` |
+| Spatial insert / delete sync| `SpatialInsert` (`0xAA`/`0xAB`), `SpatialDelete` (`0xAC`/`0xAD`) | `tests/sync_interop_spatial.rs` |
+| Timeseries insert sync | Shares the `ColumnarInsert` (`0xA0`) frame | `tests/sync_interop_timeseries.rs` |
+
+A documented public version of the sync wire contract (wire version,
+vector-clock encoding, frame catalogue) will land in `docs/` before 1.0.
+
+---
+
+## Notes on Origin parity
+
+Lite is the embedded surface of NodeDB. Where Origin offers more — vector
+quantization, distributed vector search, full SQL parity including JOIN
+and aggregates, Array DDL/DML, OGC `SpatialScan` over arbitrary shapes,
+or KV breadth like sorted-range with secondary predicates — Lite expects
+applications to issue those queries against Origin directly via the
+remote `NodeDb` client.
diff --git a/docs/lite.md b/docs/lite.md
index e67eac9..89a31b3 100644
--- a/docs/lite.md
+++ b/docs/lite.md
@@ -12,12 +12,14 @@ NodeDB-Lite is a fully capable embedded database for edge devices — phones, ta
## Platforms
-| Platform | Backend | Binary Size |
-| --------------------- | ------------------------- | ------------------------------------------------------------------------------------ |
-| Linux, macOS, Windows | redb (file-backed) | Native |
-| iOS _(in progress)_ | redb + C FFI (cbindgen) | Native _(requires macOS build environment — Rust compiles but not yet built/tested)_ |
-| Android | redb + C FFI + Kotlin/JNI | Native |
-| Browser (WASM) | redb (in-memory + OPFS) | ~4.5 MB |
+| Platform | Backend | Binary Size |
+| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------ |
+| Linux, macOS, Windows | redb (file-backed) | Native |
+| iOS _(in progress — not in 0.1.0)_ | redb + C FFI (cbindgen) | Native _(requires macOS build environment — not yet built/tested)_ |
+| Android | redb + C FFI + Kotlin/JNI | Native |
+| Browser (WASM) | redb (in-memory + OPFS) | ~4.5 MB |
+
+For the full release posture of each surface and engine, see [lite-support-matrix.md](./lite-support-matrix.md).
## Key Features
@@ -28,11 +30,11 @@ NodeDB-Lite is a fully capable embedded database for edge devices — phones, ta
- **Conflict resolution** — Declarative per-collection policies. SQL constraints (UNIQUE, FK) enforced on Origin at sync time with typed compensation hints back to the device.
- **Encryption at rest** — AES-256-GCM + Argon2id key derivation
- **Memory governance** — Per-engine budgets, pressure levels, LRU eviction
-- **Full SQL** — Same SQL via DataFusion as Origin. Window functions, CTEs, subqueries, JOINs.
+- **SQL** — Supports a documented subset of NodeDB's SQL surface. See the SQL compatibility matrix for the full list of supported plan types; complex queries (JOIN, CTE, window functions, aggregates) run against Origin via the remote `NodeDb` client.
## Same API, Any Runtime
-The `NodeDb` trait is identical across Lite and Origin. Application code doesn't change:
+The `NodeDb` trait is identical across Lite and Origin. Application code doesn't change for the operations both implementations expose; Origin offers more (full SQL, Array DDL/DML, vector quantization, distributed search). See the support matrix for the Lite-side surface.
```rust
// Works with both NodeDbLite (in-process) and NodeDbRemote (over network)
diff --git a/nodedb-lite-ffi/include/nodedb_lite.h b/nodedb-lite-ffi/include/nodedb_lite.h
index 50871cf..a106dd0 100644
--- a/nodedb-lite-ffi/include/nodedb_lite.h
+++ b/nodedb-lite-ffi/include/nodedb_lite.h
@@ -25,6 +25,9 @@
* Opaque handle to a NodeDB-Lite database.
*
* Created by `nodedb_open`, freed by `nodedb_close`.
+ *
+ * `_tmpdir` is `Some` when the database was opened with the `:memory:` path.
+ * The directory is deleted when the handle is dropped.
*/
typedef struct NodeDbNodeDbHandle NodeDbNodeDbHandle;
@@ -35,25 +38,43 @@ typedef struct NodeDbNodeDbHandle NodeDbNodeDbHandle;
* The caller must call `nodedb_close` to free the handle.
*
* # Safety
- * `path` must be a valid null-terminated UTF-8 string.
+ * - `path` must be a valid null-terminated UTF-8 string.
+ * - `passphrase` must be NULL or a valid null-terminated UTF-8 string.
+ *
+ * Encryption convention:
+ * - `passphrase` is NULL and `path` is `":memory:"` → `Encryption::Plaintext` (volatile data, safe).
+ * - `passphrase` is NULL and `path` is a real path → returns NULL (silent plaintext persistent
+ * storage is refused; pass an empty string to opt out explicitly).
+ * - `passphrase` is `""` (empty string) → `Encryption::Plaintext` (explicit conscious opt-out).
+ * - `passphrase` is a non-empty string → `Encryption::passphrase(passphrase)`.
+ * - `passphrase` is non-NULL but invalid UTF-8 → returns NULL.
*/
-struct NodeDbNodeDbHandle *nodedb_open(const char *path, uint64_t peer_id);
+struct NodeDbNodeDbHandle *nodedb_open(const char *path,
+ uint64_t peer_id,
+ const char *passphrase);
/**
* Open or create a NodeDB-Lite database with an explicit memory budget.
*
* # Safety
- * `path` must be a valid null-terminated UTF-8 string.
+ * - `path` must be a valid null-terminated UTF-8 string.
+ * - `passphrase` must be NULL or a valid null-terminated UTF-8 string.
+ *
+ * See `nodedb_open` for the passphrase/encryption convention.
+ * `memory_mb` of 0 uses the default memory budget.
*/
struct NodeDbNodeDbHandle *nodedb_open_with_config(const char *path,
uint64_t peer_id,
- uint64_t memory_mb);
+ uint64_t memory_mb,
+ const char *passphrase);
/**
* Close a NodeDB-Lite database and free the handle.
*
* # Safety
- * `handle` must be a valid pointer returned by `nodedb_open`, or NULL (no-op).
+ * `handle` must be a token returned by `nodedb_open`, or NULL/0 (no-op).
+ * The token is a `u64` id packed into a pointer-width integer; it is never
+ * dereferenced as a raw pointer.
*/
void nodedb_close(struct NodeDbNodeDbHandle *handle);
@@ -272,6 +293,7 @@ int32_t nodedb_document_delete(struct NodeDbNodeDbHandle *handle,
/**
* Full-text search (BM25). Results written as JSON array to `out_json`.
*
+ * `field` is the document field to search (e.g. `"body"`).
* `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
*
* # Safety
@@ -279,6 +301,7 @@ int32_t nodedb_document_delete(struct NodeDbNodeDbHandle *handle,
*/
int32_t nodedb_text_search(struct NodeDbNodeDbHandle *handle,
const char *collection,
+ const char *field,
const char *query,
uintptr_t top_k,
char **out_json);
@@ -294,41 +317,46 @@ int32_t nodedb_text_search(struct NodeDbNodeDbHandle *handle,
int32_t nodedb_execute_sql(struct NodeDbNodeDbHandle *handle, const char *sql, char **out_json);
/**
- * Insert a directed graph edge.
+ * Insert a directed graph edge into `collection`.
*
* # Safety
* All pointer parameters must be valid null-terminated UTF-8.
*/
int32_t nodedb_graph_insert_edge(struct NodeDbNodeDbHandle *handle,
+ const char *collection,
const char *from,
const char *to,
const char *edge_type);
/**
- * Delete a graph edge by ID.
+ * Delete a graph edge by ID from `collection`.
*
- * Edge ID format: "src--label-->dst" (as returned by graph_insert_edge).
+ * Edge ID format: length-prefixed form as returned by `graph_insert_edge`
+ * Display (`"{src_len}:{src}|{label_len}:{label}|{dst_len}:{dst}|{seq}"`).
*
* # Safety
- * `edge_id` must be valid null-terminated UTF-8.
+ * All pointer parameters must be valid null-terminated UTF-8.
*/
-int32_t nodedb_graph_delete_edge(struct NodeDbNodeDbHandle *handle, const char *edge_id);
+int32_t nodedb_graph_delete_edge(struct NodeDbNodeDbHandle *handle,
+ const char *collection,
+ const char *edge_id);
/**
- * Traverse the graph from a start node. Results written as JSON to `out_json`.
+ * Traverse the graph from a start node in `collection`. Results written as JSON to `out_json`.
*
* `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
*
* # Safety
- * `start` must be valid UTF-8. `out_json` must not be null.
+ * All pointer parameters must be valid UTF-8. `out_json` must not be null.
*/
int32_t nodedb_graph_traverse(struct NodeDbNodeDbHandle *handle,
+ const char *collection,
const char *start,
uint8_t depth,
char **out_json);
/**
- * Find the shortest path between two nodes. Results written as JSON to `out_json`.
+ * Find the shortest path between two nodes in `collection`. Results written as JSON to `out_json`.
*
* Returns `NODEDB_OK` with a JSON array of node IDs, or `"null"` if no path exists.
* `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
@@ -337,6 +365,7 @@ int32_t nodedb_graph_traverse(struct NodeDbNodeDbHandle *handle,
* All pointer parameters must be valid. `out_json` must not be null.
*/
int32_t nodedb_graph_shortest_path(struct NodeDbNodeDbHandle *handle,
+ const char *collection,
const char *from,
const char *to,
uint8_t max_depth,
@@ -346,7 +375,10 @@ int32_t nodedb_graph_shortest_path(struct NodeDbNodeDbHandle *handle,
* Insert a vector into a collection.
*
* # Safety
- * All pointer parameters must be valid. `embedding` must point to `dim` floats.
+ * All pointer parameters must be valid. `embedding` must be non-null, properly
+ * aligned for `f32`, and valid for exactly `dim` elements for the entire duration
+ * of this call. No runtime length validation is possible; passing a mismatched
+ * `dim` or a dangling pointer is immediate undefined behaviour.
*/
int32_t nodedb_vector_insert(struct NodeDbNodeDbHandle *handle,
const char *collection,
@@ -360,7 +392,10 @@ int32_t nodedb_vector_insert(struct NodeDbNodeDbHandle *handle,
* `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
*
* # Safety
- * `query` must point to `dim` valid floats. `out_json` must not be null.
+ * `query` must be non-null, properly aligned for `f32`, and valid for exactly `dim`
+ * elements for the entire duration of this call. No runtime length validation is
+ * possible; passing a mismatched `dim` or a dangling pointer is immediate undefined
+ * behaviour. `out_json` must not be null.
*/
int32_t nodedb_vector_search(struct NodeDbNodeDbHandle *handle,
const char *collection,
diff --git a/nodedb-lite-ffi/src/ffi_array.rs b/nodedb-lite-ffi/src/ffi_array.rs
index 3a781b9..34ef86c 100644
--- a/nodedb-lite-ffi/src/ffi_array.rs
+++ b/nodedb-lite-ffi/src/ffi_array.rs
@@ -8,8 +8,8 @@
use std::os::raw::c_char;
use crate::{
- NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, handle_ref,
- ptr_to_str,
+ NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, ffi_guard,
+ handle_ref, ptr_to_str,
};
// ─── helpers ─────────────────────────────────────────────────────────────────
@@ -32,7 +32,12 @@ unsafe fn write_msgpack_out(bytes: Vec, out_buf: *mut *mut u8, out_len: *mut
/// Decode a msgpack byte slice from a raw C pointer + length pair.
///
/// Returns `None` if the pointer is null, len is zero, or deserialization fails.
-/// `ptr` must point to at least `len` valid bytes (caller's responsibility).
+///
+/// # Safety contract (callers must uphold)
+/// When `ptr` is non-null and `len > 0`, `ptr` must be non-null, properly aligned
+/// for `u8`, and valid for exactly `len` bytes for the entire duration of this call.
+/// No runtime length validation beyond the null/zero check is possible; passing a
+/// mismatched `len` or a dangling pointer is immediate undefined behaviour.
fn decode_msgpack(ptr: *const u8, len: usize) -> Option
where
T: for<'a> zerompk::FromMessagePack<'a>,
@@ -63,22 +68,24 @@ pub unsafe extern "C" fn ndb_array_create(
schema_msgpack: *const u8,
schema_len: usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(schema) =
- decode_msgpack::(schema_msgpack, schema_len)
- else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(schema) =
+ decode_msgpack::(schema_msgpack, schema_len)
+ else {
+ return NODEDB_ERR_FAILED;
+ };
- match h.db.create_array(name_str, schema) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h.rt.block_on(h.db.create_array(name_str, schema)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Write a cell into array `name` at `coord`.
@@ -101,41 +108,43 @@ pub unsafe extern "C" fn ndb_array_put_cell(
valid_from_ms: i64,
valid_until_ms: i64,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(coord) = decode_msgpack::>(
- coord_msgpack,
- coord_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
- let Some(attrs) = decode_msgpack::>(
- payload_msgpack,
- payload_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(coord) = decode_msgpack::>(
+ coord_msgpack,
+ coord_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let Some(attrs) = decode_msgpack::>(
+ payload_msgpack,
+ payload_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_put_cell(
- name_str,
- coord,
- attrs,
- system_from_ms,
- valid_from_ms,
- valid_until_ms,
- ) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h.rt.block_on(h.db.array_put_cell(
+ name_str,
+ coord,
+ attrs,
+ system_from_ms,
+ valid_from_ms,
+ valid_until_ms,
+ )) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Slice query: return all live cells whose coordinates fall within `ranges`.
@@ -161,38 +170,40 @@ pub unsafe extern "C" fn ndb_array_slice(
out_buf: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- if out_buf.is_null() || out_len.is_null() {
- return NODEDB_ERR_NULL;
- }
- let Some(ranges) = decode_msgpack::>>(
- ranges_msgpack,
- ranges_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_buf.is_null() || out_len.is_null() {
+ return NODEDB_ERR_NULL;
+ }
+ let Some(ranges) = decode_msgpack::>>(
+ ranges_msgpack,
+ ranges_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
- let as_of = if has_as_of != 0 {
- Some(as_of_system_ms)
- } else {
- None
- };
+ let as_of = if has_as_of != 0 {
+ Some(as_of_system_ms)
+ } else {
+ None
+ };
- match h.db.array_slice(name_str, ranges, as_of) {
- Ok(cells) => {
- let encoded = match zerompk::to_msgpack_vec(&cells) {
- Ok(b) => b,
- Err(_) => return NODEDB_ERR_FAILED,
- };
- unsafe { write_msgpack_out(encoded, out_buf, out_len) }
+ match h.rt.block_on(h.db.array_slice(name_str, ranges, as_of)) {
+ Ok(cells) => {
+ let encoded = match zerompk::to_msgpack_vec(&cells) {
+ Ok(b) => b,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ unsafe { write_msgpack_out(encoded, out_buf, out_len) }
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Read the most recent live payload for `coord` at or before `as_of_system_ms`.
@@ -219,45 +230,50 @@ pub unsafe extern "C" fn ndb_array_read_coord(
out_buf: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- if out_buf.is_null() || out_len.is_null() {
- return NODEDB_ERR_NULL;
- }
- let Some(coord) = decode_msgpack::>(
- coord_msgpack,
- coord_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_buf.is_null() || out_len.is_null() {
+ return NODEDB_ERR_NULL;
+ }
+ let Some(coord) = decode_msgpack::>(
+ coord_msgpack,
+ coord_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
- let as_of = if has_as_of != 0 {
- Some(as_of_system_ms)
- } else {
- None
- };
+ let as_of = if has_as_of != 0 {
+ Some(as_of_system_ms)
+ } else {
+ None
+ };
- match h.db.array_read_coord(name_str, &coord, as_of) {
- Ok(Some(cell)) => {
- let encoded = match zerompk::to_msgpack_vec(&cell) {
- Ok(b) => b,
- Err(_) => return NODEDB_ERR_FAILED,
- };
- unsafe { write_msgpack_out(encoded, out_buf, out_len) }
- }
- Ok(None) => {
- unsafe {
- *out_buf = std::ptr::null_mut();
- *out_len = 0;
+ match h
+ .rt
+ .block_on(h.db.array_read_coord(name_str, &coord, as_of))
+ {
+ Ok(Some(cell)) => {
+ let encoded = match zerompk::to_msgpack_vec(&cell) {
+ Ok(b) => b,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ unsafe { write_msgpack_out(encoded, out_buf, out_len) }
}
- NODEDB_OK
+ Ok(None) => {
+ unsafe {
+ *out_buf = std::ptr::null_mut();
+ *out_len = 0;
+ }
+ NODEDB_OK
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Soft-delete a cell by writing a tombstone at the current system time.
@@ -273,28 +289,33 @@ pub unsafe extern "C" fn ndb_array_delete_cell(
coord_msgpack: *const u8,
coord_len: usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(coord) = decode_msgpack::>(
- coord_msgpack,
- coord_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(coord) = decode_msgpack::>(
+ coord_msgpack,
+ coord_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_delete_cell(name_str, coord, system_from_ms) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h
+ .rt
+ .block_on(h.db.array_delete_cell(name_str, coord, system_from_ms))
+ {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// GDPR erasure: permanently remove cell content at `coord`.
@@ -313,26 +334,31 @@ pub unsafe extern "C" fn ndb_array_gdpr_erase_cell(
coord_msgpack: *const u8,
coord_len: usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(name_str) = ptr_to_str(name) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(coord) = decode_msgpack::>(
- coord_msgpack,
- coord_len,
- ) else {
- return NODEDB_ERR_FAILED;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(name_str) = ptr_to_str(name) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(coord) = decode_msgpack::>(
+ coord_msgpack,
+ coord_len,
+ ) else {
+ return NODEDB_ERR_FAILED;
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_gdpr_erase_cell(name_str, coord, system_from_ms) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h
+ .rt
+ .block_on(h.db.array_gdpr_erase_cell(name_str, coord, system_from_ms))
+ {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
diff --git a/nodedb-lite-ffi/src/ffi_document.rs b/nodedb-lite-ffi/src/ffi_document.rs
index 9eb6628..dea1a97 100644
--- a/nodedb-lite-ffi/src/ffi_document.rs
+++ b/nodedb-lite-ffi/src/ffi_document.rs
@@ -6,7 +6,7 @@ use nodedb_client::NodeDb;
use crate::{
NODEDB_ERR_FAILED, NODEDB_ERR_NOT_FOUND, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK,
- NodeDbHandle, handle_ref, ptr_to_str, write_c_string,
+ NodeDbHandle, ffi_guard, handle_ref, ptr_to_str, write_c_string,
};
/// Get a document by ID. Result written as JSON to `out_json`.
@@ -24,27 +24,29 @@ pub unsafe extern "C" fn nodedb_document_get(
id: *const c_char,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(id) = ptr_to_str(id) else {
- return NODEDB_ERR_UTF8;
- };
- if out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(id) = ptr_to_str(id) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
- match h.rt.block_on(h.db.document_get(collection, id)) {
- Ok(Some(doc)) => {
- let json_str = sonic_rs::to_string(&doc).unwrap_or_else(|_| "{}".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h.rt.block_on(h.db.document_get(collection, id)) {
+ Ok(Some(doc)) => {
+ let json_str = sonic_rs::to_string(&doc).unwrap_or_else(|_| "{}".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Ok(None) => NODEDB_ERR_NOT_FOUND,
+ Err(_) => NODEDB_ERR_FAILED,
}
- Ok(None) => NODEDB_ERR_NOT_FOUND,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Put (insert or update) a document. Body is a JSON string.
@@ -62,35 +64,37 @@ pub unsafe extern "C" fn nodedb_document_put(
json_body: *const c_char,
out_id: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(json_str) = ptr_to_str(json_body) else {
- return NODEDB_ERR_UTF8;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(json_str) = ptr_to_str(json_body) else {
+ return NODEDB_ERR_UTF8;
+ };
- let mut doc: nodedb_types::Document = match sonic_rs::from_str(json_str) {
- Ok(d) => d,
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ let mut doc: nodedb_types::Document = match sonic_rs::from_str(json_str) {
+ Ok(d) => d,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- if doc.id.is_empty() {
- doc.id = nodedb_types::id_gen::uuid_v7();
- }
+ if doc.id.is_empty() {
+ doc.id = nodedb_types::id_gen::uuid_v7();
+ }
- match h.rt.block_on(h.db.document_put(collection, doc.clone())) {
- Ok(()) => {
- if !out_id.is_null() {
- // write_c_string failure is non-fatal here: the put succeeded.
- let _ = unsafe { write_c_string(out_id, doc.id) };
+ match h.rt.block_on(h.db.document_put(collection, doc.clone())) {
+ Ok(()) => {
+ if !out_id.is_null() {
+ // write_c_string failure is non-fatal here: the put succeeded.
+ let _ = unsafe { write_c_string(out_id, doc.id) };
+ }
+ NODEDB_OK
}
- NODEDB_OK
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Delete a document by ID.
@@ -103,23 +107,26 @@ pub unsafe extern "C" fn nodedb_document_delete(
collection: *const c_char,
id: *const c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(id) = ptr_to_str(id) else {
- return NODEDB_ERR_UTF8;
- };
- match h.rt.block_on(h.db.document_delete(collection, id)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(id) = ptr_to_str(id) else {
+ return NODEDB_ERR_UTF8;
+ };
+ match h.rt.block_on(h.db.document_delete(collection, id)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Full-text search (BM25). Results written as JSON array to `out_json`.
///
+/// `field` is the document field to search (e.g. `"body"`).
/// `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
///
/// # Safety
@@ -128,39 +135,47 @@ pub unsafe extern "C" fn nodedb_document_delete(
pub unsafe extern "C" fn nodedb_text_search(
handle: *mut NodeDbHandle,
collection: *const c_char,
+ field: *const c_char,
query: *const c_char,
top_k: usize,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(query_str) = ptr_to_str(query) else {
- return NODEDB_ERR_UTF8;
- };
- if out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(field_str) = ptr_to_str(field) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(query_str) = ptr_to_str(query) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
- match h.rt.block_on(h.db.text_search(
- collection,
- query_str,
- top_k,
- nodedb_types::TextSearchParams::default(),
- )) {
- Ok(results) => {
- let json_items: Vec = results
- .iter()
- .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
- .collect();
- let json_str = serde_json::to_string(&json_items).unwrap_or_else(|_| "[]".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h.rt.block_on(h.db.text_search(
+ collection,
+ field_str,
+ query_str,
+ top_k,
+ nodedb_types::TextSearchParams::default(),
+ None,
+ )) {
+ Ok(results) => {
+ let json_items: Vec = results
+ .iter()
+ .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
+ .collect();
+ let json_str = serde_json::to_string(&json_items).unwrap_or_else(|_| "[]".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Execute a SQL query. Results written as JSON to `out_json`.
@@ -175,26 +190,28 @@ pub unsafe extern "C" fn nodedb_execute_sql(
sql: *const c_char,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(sql_str) = ptr_to_str(sql) else {
- return NODEDB_ERR_UTF8;
- };
- if out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(sql_str) = ptr_to_str(sql) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
- match h.rt.block_on(h.db.execute_sql(sql_str, &[])) {
- Ok(result) => {
- let json = serde_json::json!({
- "columns": result.columns,
- "rows": result.rows,
- "rows_affected": result.rows_affected,
- });
- let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h.rt.block_on(h.db.execute_sql(sql_str, &[])) {
+ Ok(result) => {
+ let json = serde_json::json!({
+ "columns": result.columns,
+ "rows": result.rows,
+ "rows_affected": result.rows_affected,
+ });
+ let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
diff --git a/nodedb-lite-ffi/src/ffi_graph.rs b/nodedb-lite-ffi/src/ffi_graph.rs
index bacb602..055ac2f 100644
--- a/nodedb-lite-ffi/src/ffi_graph.rs
+++ b/nodedb-lite-ffi/src/ffi_graph.rs
@@ -1,121 +1,156 @@
//! Graph engine FFI functions.
use std::os::raw::c_char;
+use std::str::FromStr as _;
use nodedb_client::NodeDb;
use crate::{
- NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, handle_ref,
- ptr_to_str, write_c_string,
+ NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, ffi_guard,
+ handle_ref, ptr_to_str, write_c_string,
};
-/// Insert a directed graph edge.
+/// Insert a directed graph edge into `collection`.
///
/// # Safety
/// All pointer parameters must be valid null-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_graph_insert_edge(
handle: *mut NodeDbHandle,
+ collection: *const c_char,
from: *const c_char,
to: *const c_char,
edge_type: *const c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(from) = ptr_to_str(from) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(to) = ptr_to_str(to) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(edge_type) = ptr_to_str(edge_type) else {
- return NODEDB_ERR_UTF8;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(from) = ptr_to_str(from) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(to) = ptr_to_str(to) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(edge_type) = ptr_to_str(edge_type) else {
+ return NODEDB_ERR_UTF8;
+ };
- let from_id = nodedb_types::id::NodeId::new(from);
- let to_id = nodedb_types::id::NodeId::new(to);
+ let from_id = match nodedb_types::id::NodeId::try_new(from) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ let to_id = match nodedb_types::id::NodeId::try_new(to) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- match h
- .rt
- .block_on(h.db.graph_insert_edge(&from_id, &to_id, edge_type, None))
- {
- Ok(_) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h
+ .rt
+ .block_on(h.db.graph_insert_edge(collection, &from_id, &to_id, edge_type, None))
+ {
+ Ok(_) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
-/// Delete a graph edge by ID.
+/// Delete a graph edge by ID from `collection`.
///
-/// Edge ID format: "src--label-->dst" (as returned by graph_insert_edge).
+/// Edge ID format: length-prefixed form as returned by `graph_insert_edge`
+/// Display (`"{src_len}:{src}|{label_len}:{label}|{dst_len}:{dst}|{seq}"`).
///
/// # Safety
-/// `edge_id` must be valid null-terminated UTF-8.
+/// All pointer parameters must be valid null-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_graph_delete_edge(
handle: *mut NodeDbHandle,
+ collection: *const c_char,
edge_id: *const c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(edge_id_str) = ptr_to_str(edge_id) else {
- return NODEDB_ERR_UTF8;
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(edge_id_str) = ptr_to_str(edge_id) else {
+ return NODEDB_ERR_UTF8;
+ };
- let eid = nodedb_types::id::EdgeId::new(edge_id_str);
- match h.rt.block_on(h.db.graph_delete_edge(&eid)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ let eid = match nodedb_types::id::EdgeId::from_str(edge_id_str) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ match h.rt.block_on(h.db.graph_delete_edge(collection, &eid)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
-/// Traverse the graph from a start node. Results written as JSON to `out_json`.
+/// Traverse the graph from a start node in `collection`. Results written as JSON to `out_json`.
///
/// `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
///
/// # Safety
-/// `start` must be valid UTF-8. `out_json` must not be null.
+/// All pointer parameters must be valid UTF-8. `out_json` must not be null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_graph_traverse(
handle: *mut NodeDbHandle,
+ collection: *const c_char,
start: *const c_char,
depth: u8,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(start) = ptr_to_str(start) else {
- return NODEDB_ERR_UTF8;
- };
- if out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(start) = ptr_to_str(start) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
- let start_id = nodedb_types::id::NodeId::new(start);
+ let start_id = match nodedb_types::id::NodeId::try_new(start) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- match h.rt.block_on(h.db.graph_traverse(&start_id, depth, None)) {
- Ok(subgraph) => {
- let json = serde_json::json!({
- "nodes": subgraph.nodes.iter().map(|n| serde_json::json!({
- "id": n.id.as_str(),
- "depth": n.depth,
- })).collect::>(),
- "edges": subgraph.edges.iter().map(|e| serde_json::json!({
- "from": e.from.as_str(),
- "to": e.to.as_str(),
- "label": e.label,
- })).collect::>(),
- });
- let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h
+ .rt
+ .block_on(h.db.graph_traverse(collection, &start_id, depth, None))
+ {
+ Ok(subgraph) => {
+ let json = serde_json::json!({
+ "nodes": subgraph.nodes.iter().map(|n| serde_json::json!({
+ "id": n.id.as_str(),
+ "depth": n.depth,
+ })).collect::>(),
+ "edges": subgraph.edges.iter().map(|e| serde_json::json!({
+ "from": e.from.as_str(),
+ "to": e.to.as_str(),
+ "label": e.label,
+ })).collect::>(),
+ });
+ let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
-/// Find the shortest path between two nodes. Results written as JSON to `out_json`.
+/// Find the shortest path between two nodes in `collection`. Results written as JSON to `out_json`.
///
/// Returns `NODEDB_OK` with a JSON array of node IDs, or `"null"` if no path exists.
/// `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
@@ -125,37 +160,49 @@ pub unsafe extern "C" fn nodedb_graph_traverse(
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_graph_shortest_path(
handle: *mut NodeDbHandle,
+ collection: *const c_char,
from: *const c_char,
to: *const c_char,
max_depth: u8,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(from) = ptr_to_str(from) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(to) = ptr_to_str(to) else {
- return NODEDB_ERR_UTF8;
- };
- if out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(from) = ptr_to_str(from) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(to) = ptr_to_str(to) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
- let from_id = nodedb_types::id::NodeId::new(from);
- let to_id = nodedb_types::id::NodeId::new(to);
+ let from_id = match nodedb_types::id::NodeId::try_new(from) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ let to_id = match nodedb_types::id::NodeId::try_new(to) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- match h
- .rt
- .block_on(h.db.graph_shortest_path(&from_id, &to_id, max_depth, None))
- {
- Ok(Some(path)) => {
- let node_ids: Vec<&str> = path.iter().map(|n| n.as_str()).collect();
- let json_str = sonic_rs::to_string(&node_ids).unwrap_or_else(|_| "[]".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h
+ .rt
+ .block_on(h.db.graph_shortest_path(collection, &from_id, &to_id, max_depth, None))
+ {
+ Ok(Some(path)) => {
+ let node_ids: Vec<&str> = path.iter().map(|n| n.as_str()).collect();
+ let json_str = sonic_rs::to_string(&node_ids).unwrap_or_else(|_| "[]".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Ok(None) => unsafe { write_c_string(out_json, "null".to_string()) },
+ Err(_) => NODEDB_ERR_FAILED,
}
- Ok(None) => unsafe { write_c_string(out_json, "null".to_string()) },
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
diff --git a/nodedb-lite-ffi/src/ffi_vector.rs b/nodedb-lite-ffi/src/ffi_vector.rs
index 4a56804..8f4a1d7 100644
--- a/nodedb-lite-ffi/src/ffi_vector.rs
+++ b/nodedb-lite-ffi/src/ffi_vector.rs
@@ -5,14 +5,17 @@ use std::os::raw::c_char;
use nodedb_client::NodeDb;
use crate::{
- NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, handle_ref,
- ptr_to_str, write_c_string,
+ NODEDB_ERR_FAILED, NODEDB_ERR_NULL, NODEDB_ERR_UTF8, NODEDB_OK, NodeDbHandle, ffi_guard,
+ handle_ref, ptr_to_str, write_c_string,
};
/// Insert a vector into a collection.
///
/// # Safety
-/// All pointer parameters must be valid. `embedding` must point to `dim` floats.
+/// All pointer parameters must be valid. `embedding` must be non-null, properly
+/// aligned for `f32`, and valid for exactly `dim` elements for the entire duration
+/// of this call. No runtime length validation is possible; passing a mismatched
+/// `dim` or a dangling pointer is immediate undefined behaviour.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_vector_insert(
handle: *mut NodeDbHandle,
@@ -21,24 +24,26 @@ pub unsafe extern "C" fn nodedb_vector_insert(
embedding: *const f32,
dim: usize,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(id) = ptr_to_str(id) else {
- return NODEDB_ERR_UTF8;
- };
- if embedding.is_null() || dim == 0 {
- return NODEDB_ERR_NULL;
- }
- let emb = unsafe { std::slice::from_raw_parts(embedding, dim) };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(id) = ptr_to_str(id) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if embedding.is_null() || dim == 0 {
+ return NODEDB_ERR_NULL;
+ }
+ let emb = unsafe { std::slice::from_raw_parts(embedding, dim) };
- match h.rt.block_on(h.db.vector_insert(collection, id, emb, None)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h.rt.block_on(h.db.vector_insert(collection, id, emb, None)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Search for the k nearest vectors. Results are written as JSON to `out_json`.
@@ -46,7 +51,10 @@ pub unsafe extern "C" fn nodedb_vector_insert(
/// `*out_json` is only written on success. The caller must free via `nodedb_free_string`.
///
/// # Safety
-/// `query` must point to `dim` valid floats. `out_json` must not be null.
+/// `query` must be non-null, properly aligned for `f32`, and valid for exactly `dim`
+/// elements for the entire duration of this call. No runtime length validation is
+/// possible; passing a mismatched `dim` or a dangling pointer is immediate undefined
+/// behaviour. `out_json` must not be null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_vector_search(
handle: *mut NodeDbHandle,
@@ -56,28 +64,33 @@ pub unsafe extern "C" fn nodedb_vector_search(
k: usize,
out_json: *mut *mut c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- if query.is_null() || dim == 0 || out_json.is_null() {
- return NODEDB_ERR_NULL;
- }
- let q = unsafe { std::slice::from_raw_parts(query, dim) };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ if query.is_null() || dim == 0 || out_json.is_null() {
+ return NODEDB_ERR_NULL;
+ }
+ let q = unsafe { std::slice::from_raw_parts(query, dim) };
- match h.rt.block_on(h.db.vector_search(collection, q, k, None)) {
- Ok(results) => {
- let json_items: Vec = results
- .iter()
- .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
- .collect();
- let json_str = serde_json::to_string(&json_items).unwrap_or_else(|_| "[]".into());
- unsafe { write_c_string(out_json, json_str) }
+ match h
+ .rt
+ .block_on(h.db.vector_search(collection, q, k, None, None))
+ {
+ Ok(results) => {
+ let json_items: Vec = results
+ .iter()
+ .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
+ .collect();
+ let json_str = serde_json::to_string(&json_items).unwrap_or_else(|_| "[]".into());
+ unsafe { write_c_string(out_json, json_str) }
+ }
+ Err(_) => NODEDB_ERR_FAILED,
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ })
}
/// Delete a vector by ID.
@@ -90,17 +103,19 @@ pub unsafe extern "C" fn nodedb_vector_delete(
collection: *const c_char,
id: *const c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(collection) = ptr_to_str(collection) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(id) = ptr_to_str(id) else {
- return NODEDB_ERR_UTF8;
- };
- match h.rt.block_on(h.db.vector_delete(collection, id)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(collection) = ptr_to_str(collection) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(id) = ptr_to_str(id) else {
+ return NODEDB_ERR_UTF8;
+ };
+ match h.rt.block_on(h.db.vector_delete(collection, id)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
diff --git a/nodedb-lite-ffi/src/handle_registry.rs b/nodedb-lite-ffi/src/handle_registry.rs
new file mode 100644
index 0000000..6281c3b
--- /dev/null
+++ b/nodedb-lite-ffi/src/handle_registry.rs
@@ -0,0 +1,181 @@
+//! Validated id→Arc registry for `NodeDbHandle`.
+//!
+//! Instead of round-tripping raw `Box::into_raw` pointers through C/Kotlin as
+//! integers, every open database is stored in a global `HashMap>`
+//! keyed by a monotonically increasing `u64` id. The opaque token exposed to
+//! callers is that integer id cast to `*mut NodeDbHandle`; on 64-bit targets
+//! (arm64 / x86_64) the pointer width is 64 bits so no information is lost.
+//!
+//! The core UAF fix: `get` clones the `Arc` out from under the read-lock
+//! before returning, so an in-flight operation keeps the handle alive even if
+//! another thread calls `nodedb_close` / `remove` concurrently.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, OnceLock, RwLock};
+
+use crate::NodeDbHandle;
+
+// ── id allocator ─────────────────────────────────────────────────────────────
+
+/// Monotonic counter for handle ids. Starts at 1; id 0 is the invalid token.
+static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+
+// ── registry ─────────────────────────────────────────────────────────────────
+
+static REGISTRY: OnceLock>>> = OnceLock::new();
+
+fn registry() -> &'static RwLock>> {
+ REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
+}
+
+// ── public API ───────────────────────────────────────────────────────────────
+
+/// Store `handle` in the registry and return a non-zero opaque id for it.
+pub(crate) fn insert(handle: NodeDbHandle) -> u64 {
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ // id 0 is the invalid sentinel; the counter starts at 1 so this should
+ // never wrap in practice, but guard defensively.
+ debug_assert!(id != 0, "handle id wrapped to zero — impossibly many opens");
+ let arc = Arc::new(handle);
+ let mut map = registry().write().unwrap_or_else(|e| e.into_inner());
+ map.insert(id, arc);
+ id
+}
+
+/// Clone the `Arc` for `id` out of the registry.
+///
+/// Returns `None` for id 0 (invalid sentinel) or unknown ids.
+/// The returned `Arc` keeps the handle alive even if `remove` is called
+/// concurrently from another thread.
+pub(crate) fn get(id: u64) -> Option> {
+ if id == 0 {
+ return None;
+ }
+ let map = registry().read().unwrap_or_else(|e| e.into_inner());
+ map.get(&id).cloned()
+}
+
+/// Remove `id` from the registry, dropping the stored `Arc`.
+///
+/// If no other `Arc` clones are live the `NodeDbHandle` is freed here;
+/// otherwise it remains alive until the last clone is dropped.
+///
+/// Returns `false` for id 0 or ids that are not present.
+pub(crate) fn remove(id: u64) -> bool {
+ if id == 0 {
+ return false;
+ }
+ let mut map = registry().write().unwrap_or_else(|e| e.into_inner());
+ map.remove(&id).is_some()
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+ use std::sync::{Arc, RwLock};
+
+ // The registry's correctness does not depend on the stored type being
+ // NodeDbHandle — constructing one requires real storage. We exercise all
+ // invariants using a local HashMap> that mirrors the
+ // production registry exactly, then verify the live id-allocator separately.
+
+ fn make_reg() -> RwLock>> {
+ RwLock::new(HashMap::new())
+ }
+
+ fn reg_insert(reg: &RwLock>>, id: u64, v: u64) {
+ reg.write().unwrap().insert(id, Arc::new(v));
+ }
+
+ fn reg_get(reg: &RwLock>>, id: u64) -> Option> {
+ if id == 0 {
+ return None;
+ }
+ reg.read().unwrap().get(&id).cloned()
+ }
+
+ fn reg_remove(reg: &RwLock>>, id: u64) -> bool {
+ if id == 0 {
+ return false;
+ }
+ reg.write().unwrap().remove(&id).is_some()
+ }
+
+ // ── id allocator ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn id_allocator_returns_nonzero_distinct_ids() {
+ use std::sync::atomic::Ordering;
+ let id1 = super::NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ let id2 = super::NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ assert_ne!(id1, 0);
+ assert_ne!(id2, 0);
+ assert_ne!(id1, id2);
+ }
+
+ // ── lookup ───────────────────────────────────────────────────────────────
+
+ #[test]
+ fn get_valid_is_some() {
+ let r = make_reg();
+ reg_insert(&r, 42, 100);
+ assert!(reg_get(&r, 42).is_some());
+ }
+
+ #[test]
+ fn get_zero_is_none() {
+ let r = make_reg();
+ reg_insert(&r, 1, 1);
+ assert!(reg_get(&r, 0).is_none());
+ }
+
+ #[test]
+ fn get_unknown_is_none() {
+ let r = make_reg();
+ assert!(reg_get(&r, 999).is_none());
+ }
+
+ // ── remove ───────────────────────────────────────────────────────────────
+
+ #[test]
+ fn after_remove_get_is_none() {
+ let r = make_reg();
+ reg_insert(&r, 7, 7);
+ assert!(reg_remove(&r, 7));
+ assert!(reg_get(&r, 7).is_none());
+ }
+
+ #[test]
+ fn double_remove_second_returns_false() {
+ let r = make_reg();
+ reg_insert(&r, 3, 3);
+ assert!(reg_remove(&r, 3));
+ assert!(!reg_remove(&r, 3));
+ }
+
+ #[test]
+ fn remove_zero_returns_false() {
+ let r = make_reg();
+ assert!(!reg_remove(&r, 0));
+ }
+
+ // ── UAF regression: Arc clone keeps value alive across remove ────────────
+
+ #[test]
+ fn cloned_arc_survives_concurrent_remove() {
+ let r = make_reg();
+ reg_insert(&r, 5, 55);
+ // Simulate an in-flight operation: clone the Arc before the close path
+ // calls remove.
+ let in_flight = reg_get(&r, 5).expect("present before remove");
+ // Close path removes the entry from the registry.
+ assert!(reg_remove(&r, 5));
+ // Registry no longer holds it.
+ assert!(reg_get(&r, 5).is_none());
+ // But the in-flight clone is still valid — no use-after-free.
+ assert_eq!(*in_flight, 55);
+ }
+}
diff --git a/nodedb-lite-ffi/src/jni_bridge/array.rs b/nodedb-lite-ffi/src/jni_bridge/array.rs
index f8d7635..761e11f 100644
--- a/nodedb-lite-ffi/src/jni_bridge/array.rs
+++ b/nodedb-lite-ffi/src/jni_bridge/array.rs
@@ -2,18 +2,37 @@
//!
//! Byte arrays carry zerompk-encoded payloads. The Kotlin layer
//! serialises / deserialises using the same zerompk codec.
+//!
+//! ## JNI local-reference ownership
+//! `JByteArray::into_raw()` (and `JString::into_raw()`) returns a JNI local
+//! reference as the native method's return value. Returning that raw handle
+//! directly from the `extern "system"` fn is the correct, safe pattern — the
+//! JVM takes ownership on return. These raw handles must NOT be stored for use
+//! after the method returns; storing them as global refs would leak memory.
use jni::JNIEnv;
use jni::objects::{JByteArray, JObject, JString};
use jni::sys::{jbyteArray, jint, jlong};
-use super::super::{NODEDB_ERR_FAILED, NODEDB_OK};
+use super::super::{NODEDB_ERR_FAILED, NODEDB_OK, ffi_guard};
use super::core::get_handle;
fn jbytearray_to_vec(env: &mut JNIEnv, arr: &JByteArray) -> Option> {
- let len = env.get_array_length(arr).ok()? as usize;
+ let len = match env.get_array_length(arr) {
+ Ok(l) => l as usize,
+ Err(_) => {
+ let _ = env.exception_clear();
+ return None;
+ }
+ };
let mut buf = vec![0i8; len];
- env.get_byte_array_region(arr, 0, &mut buf).ok()?;
+ match env.get_byte_array_region(arr, 0, &mut buf) {
+ Ok(()) => {}
+ Err(_) => {
+ let _ = env.exception_clear();
+ return None;
+ }
+ }
Some(buf.into_iter().map(|b| b as u8).collect())
}
@@ -28,25 +47,31 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArrayCreate(
name: JString,
schema_msgpack: JByteArray,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let Some(schema_bytes) = jbytearray_to_vec(&mut env, &schema_msgpack) else {
- return NODEDB_ERR_FAILED;
- };
- let schema = match zerompk::from_msgpack::(&schema_bytes) {
- Ok(s) => s,
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let Some(schema_bytes) = jbytearray_to_vec(&mut env, &schema_msgpack) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let schema = match zerompk::from_msgpack::(&schema_bytes)
+ {
+ Ok(s) => s,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- match h.db.create_array(&name_str, schema) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h.rt.block_on(h.db.create_array(&name_str, schema)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Write a cell into array `name` at `coord`.
@@ -64,48 +89,54 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArrayPutCell(
valid_from_ms: jlong,
valid_until_ms: jlong,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
- return NODEDB_ERR_FAILED;
- };
- let Some(payload_bytes) = jbytearray_to_vec(&mut env, &payload_msgpack) else {
- return NODEDB_ERR_FAILED;
- };
- let coord = match zerompk::from_msgpack::>(
- &coord_bytes,
- ) {
- Ok(c) => c,
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let attrs = match zerompk::from_msgpack::>(
- &payload_bytes,
- ) {
- Ok(a) => a,
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let Some(payload_bytes) = jbytearray_to_vec(&mut env, &payload_msgpack) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let coord = match zerompk::from_msgpack::>(
+ &coord_bytes,
+ ) {
+ Ok(c) => c,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ let attrs = match zerompk::from_msgpack::<
+ Vec,
+ >(&payload_bytes)
+ {
+ Ok(a) => a,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_put_cell(
- &name_str,
- coord,
- attrs,
- system_from_ms,
- valid_from_ms,
- valid_until_ms,
- ) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h.rt.block_on(h.db.array_put_cell(
+ &name_str,
+ coord,
+ attrs,
+ system_from_ms,
+ valid_from_ms,
+ valid_until_ms,
+ )) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Slice query returning zerompk-encoded `Vec` as a byte array.
@@ -121,48 +152,57 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArraySlice(
ranges_msgpack: JByteArray,
as_of_ms: jlong,
) -> jbyteArray {
- let h = match get_handle(handle) {
- Some(h) => h,
- None => return std::ptr::null_mut(),
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
- let Some(ranges_bytes) = jbytearray_to_vec(&mut env, &ranges_msgpack) else {
- return std::ptr::null_mut();
- };
- let ranges = match zerompk::from_msgpack::>>(
- &ranges_bytes,
- ) {
- Ok(r) => r,
- Err(_) => return std::ptr::null_mut(),
- };
+ ffi_guard(std::ptr::null_mut(), || {
+ let h = match get_handle(handle) {
+ Some(h) => h,
+ None => return std::ptr::null_mut(),
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let Some(ranges_bytes) = jbytearray_to_vec(&mut env, &ranges_msgpack) else {
+ return std::ptr::null_mut();
+ };
+ let ranges = match zerompk::from_msgpack::>>(
+ &ranges_bytes,
+ ) {
+ Ok(r) => r,
+ Err(_) => return std::ptr::null_mut(),
+ };
- let as_of = if as_of_ms == i64::MAX {
- None
- } else {
- Some(as_of_ms)
- };
+ let as_of = if as_of_ms == i64::MAX {
+ None
+ } else {
+ Some(as_of_ms)
+ };
- let cells = match h.db.array_slice(&name_str, ranges, as_of) {
- Ok(c) => c,
- Err(_) => return std::ptr::null_mut(),
- };
- let encoded = match zerompk::to_msgpack_vec(&cells) {
- Ok(b) => b,
- Err(_) => return std::ptr::null_mut(),
- };
- let signed: Vec = encoded.into_iter().map(|b| b as i8).collect();
- match env.new_byte_array(signed.len() as i32) {
- Ok(arr) => {
- if env.set_byte_array_region(&arr, 0, &signed).is_err() {
- return std::ptr::null_mut();
+ let cells = match h.rt.block_on(h.db.array_slice(&name_str, ranges, as_of)) {
+ Ok(c) => c,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ let encoded = match zerompk::to_msgpack_vec(&cells) {
+ Ok(b) => b,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ let signed: Vec = encoded.into_iter().map(|b| b as i8).collect();
+ match env.new_byte_array(signed.len() as i32) {
+ Ok(arr) => {
+ if env.set_byte_array_region(&arr, 0, &signed).is_err() {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ arr.into_raw()
+ }
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
}
- arr.into_raw()
}
- Err(_) => std::ptr::null_mut(),
- }
+ })
}
/// Read a single cell payload as zerompk-encoded `CellPayload`, or null if not found.
@@ -178,51 +218,63 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArrayReadCoord(
coord_msgpack: JByteArray,
as_of_ms: jlong,
) -> jbyteArray {
- let h = match get_handle(handle) {
- Some(h) => h,
- None => return std::ptr::null_mut(),
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
- let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
- return std::ptr::null_mut();
- };
- let coord = match zerompk::from_msgpack::>(
- &coord_bytes,
- ) {
- Ok(c) => c,
- Err(_) => return std::ptr::null_mut(),
- };
+ ffi_guard(std::ptr::null_mut(), || {
+ let h = match get_handle(handle) {
+ Some(h) => h,
+ None => return std::ptr::null_mut(),
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
+ return std::ptr::null_mut();
+ };
+ let coord = match zerompk::from_msgpack::>(
+ &coord_bytes,
+ ) {
+ Ok(c) => c,
+ Err(_) => return std::ptr::null_mut(),
+ };
- let as_of = if as_of_ms == i64::MAX {
- None
- } else {
- Some(as_of_ms)
- };
+ let as_of = if as_of_ms == i64::MAX {
+ None
+ } else {
+ Some(as_of_ms)
+ };
- let cell = match h.db.array_read_coord(&name_str, &coord, as_of) {
- Ok(c) => c,
- Err(_) => return std::ptr::null_mut(),
- };
- let Some(payload) = cell else {
- return std::ptr::null_mut();
- };
- let encoded = match zerompk::to_msgpack_vec(&payload) {
- Ok(b) => b,
- Err(_) => return std::ptr::null_mut(),
- };
- let signed: Vec = encoded.into_iter().map(|b| b as i8).collect();
- match env.new_byte_array(signed.len() as i32) {
- Ok(arr) => {
- if env.set_byte_array_region(&arr, 0, &signed).is_err() {
- return std::ptr::null_mut();
+ let cell = match h
+ .rt
+ .block_on(h.db.array_read_coord(&name_str, &coord, as_of))
+ {
+ Ok(c) => c,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ let Some(payload) = cell else {
+ return std::ptr::null_mut();
+ };
+ let encoded = match zerompk::to_msgpack_vec(&payload) {
+ Ok(b) => b,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ let signed: Vec = encoded.into_iter().map(|b| b as i8).collect();
+ match env.new_byte_array(signed.len() as i32) {
+ Ok(arr) => {
+ if env.set_byte_array_region(&arr, 0, &signed).is_err() {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ arr.into_raw()
+ }
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
}
- arr.into_raw()
}
- Err(_) => std::ptr::null_mut(),
- }
+ })
}
/// Soft-delete a cell (tombstone at current system time).
@@ -236,32 +288,40 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArrayDeleteCell(
name: JString,
coord_msgpack: JByteArray,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
- return NODEDB_ERR_FAILED;
- };
- let coord = match zerompk::from_msgpack::>(
- &coord_bytes,
- ) {
- Ok(c) => c,
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let coord = match zerompk::from_msgpack::>(
+ &coord_bytes,
+ ) {
+ Ok(c) => c,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_delete_cell(&name_str, coord, system_from_ms) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h
+ .rt
+ .block_on(h.db.array_delete_cell(&name_str, coord, system_from_ms))
+ {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// GDPR erasure: permanently remove cell content.
@@ -275,30 +335,38 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeArrayGdprEraseCell(
name: JString,
coord_msgpack: JByteArray,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let name_str: String = match env.get_string(&name) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
- return NODEDB_ERR_FAILED;
- };
- let coord = match zerompk::from_msgpack::>(
- &coord_bytes,
- ) {
- Ok(c) => c,
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let name_str: String = match env.get_string(&name) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let Some(coord_bytes) = jbytearray_to_vec(&mut env, &coord_msgpack) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let coord = match zerompk::from_msgpack::>(
+ &coord_bytes,
+ ) {
+ Ok(c) => c,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
- let system_from_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as i64)
- .unwrap_or(0);
+ let system_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0);
- match h.db.array_gdpr_erase_cell(&name_str, coord, system_from_ms) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ match h
+ .rt
+ .block_on(h.db.array_gdpr_erase_cell(&name_str, coord, system_from_ms))
+ {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
diff --git a/nodedb-lite-ffi/src/jni_bridge/core.rs b/nodedb-lite-ffi/src/jni_bridge/core.rs
index c410fa8..ab238d4 100644
--- a/nodedb-lite-ffi/src/jni_bridge/core.rs
+++ b/nodedb-lite-ffi/src/jni_bridge/core.rs
@@ -1,19 +1,30 @@
//! JNI bridge — Kotlin/Android native method implementations.
//!
//! Uses jni 0.21 API (stable, widely used in Android Rust projects).
+//!
+//! ## JNI local-reference ownership
+//! `JString::into_raw()` (and similar `JObject`-wrapping types) returns a JNI
+//! local reference as the native method's return value. Returning that raw
+//! handle directly from the `extern "system"` fn is the correct, safe pattern
+//! — the JVM takes ownership on return. These raw handles must NOT be stored
+//! for use after the method returns; promoting them to global refs would leak.
use jni::JNIEnv;
use jni::objects::JFloatArray;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jint, jlong, jstring};
-use super::super::{NODEDB_ERR_FAILED, NODEDB_OK, NodeDbHandle};
+use std::sync::Arc;
+
+use super::super::{NODEDB_ERR_FAILED, NODEDB_OK, NodeDbHandle, ffi_guard};
-pub(super) fn get_handle(ptr: jlong) -> Option<&'static NodeDbHandle> {
- if ptr == 0 {
- return None;
- }
- Some(unsafe { &*(ptr as *const NodeDbHandle) })
+/// Look up the handle for an opaque token returned by `nativeOpen`.
+///
+/// Returns a cloned `Arc` — the handle stays alive for the duration of the
+/// call even if `nativeClose` is called concurrently from another thread.
+/// Token 0 and unknown tokens both return `None`.
+pub(super) fn get_handle(ptr: jlong) -> Option> {
+ crate::handle_registry::get(ptr as u64)
}
#[unsafe(no_mangle)]
@@ -22,17 +33,46 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_00024Companion_nativeOpen
_class: JClass,
path: JString,
peer_id: jlong,
+ passphrase: JString,
) -> jlong {
- let path: String = match env.get_string(&path) {
- Ok(s) => s.into(),
- Err(_) => return 0,
- };
- let path_c = match std::ffi::CString::new(path) {
- Ok(c) => c,
- Err(_) => return 0,
- };
- let handle = unsafe { super::super::nodedb_open(path_c.as_ptr(), peer_id as u64) };
- handle as jlong
+ ffi_guard(0, || {
+ let path: String = match env.get_string(&path) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return 0;
+ }
+ };
+ let path_c = match std::ffi::CString::new(path) {
+ Ok(c) => c,
+ Err(_) => return 0,
+ };
+
+ // `passphrase` is a nullable JString. Convert to an Option so we can pass
+ // a raw pointer (NULL when the JVM passed null) to the C convention in nodedb_open.
+ let passphrase_cstring: Option = if passphrase.is_null() {
+ None
+ } else {
+ let s: String = match env.get_string(&passphrase) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return 0;
+ }
+ };
+ match std::ffi::CString::new(s) {
+ Ok(c) => Some(c),
+ Err(_) => return 0,
+ }
+ };
+ let passphrase_ptr = passphrase_cstring
+ .as_ref()
+ .map_or(std::ptr::null(), |c| c.as_ptr());
+
+ let handle =
+ unsafe { super::super::nodedb_open(path_c.as_ptr(), peer_id as u64, passphrase_ptr) };
+ handle as jlong
+ })
}
#[unsafe(no_mangle)]
@@ -41,9 +81,11 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeClose(
_obj: JObject,
handle: jlong,
) {
- if handle != 0 {
- unsafe { super::super::nodedb_close(handle as *mut NodeDbHandle) };
- }
+ ffi_guard((), || {
+ if handle != 0 {
+ unsafe { super::super::nodedb_close(handle as *mut NodeDbHandle) };
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -52,13 +94,15 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeFlush(
_obj: JObject,
handle: jlong,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- match h.rt.block_on(h.db.flush()) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ match h.rt.block_on(h.db.flush()) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -71,35 +115,47 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeVectorInsert(
embedding: JFloatArray,
_dim: jint,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let id: String = match env.get_string(&id) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let id: String = match env.get_string(&id) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
- let len = match env.get_array_length(&embedding) {
- Ok(l) => l as usize,
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let mut buf = vec![0.0f32; len];
- if env.get_float_array_region(&embedding, 0, &mut buf).is_err() {
- return NODEDB_ERR_FAILED;
- }
+ let len = match env.get_array_length(&embedding) {
+ Ok(l) => l as usize,
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let mut buf = vec![0.0f32; len];
+ if env.get_float_array_region(&embedding, 0, &mut buf).is_err() {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
- use nodedb_client::NodeDb;
- match h
- .rt
- .block_on(h.db.vector_insert(&collection, &id, &buf, None))
- {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ use nodedb_client::NodeDb;
+ match h
+ .rt
+ .block_on(h.db.vector_insert(&collection, &id, &buf, None))
+ {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -112,42 +168,55 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeVectorSearch(
_dim: jint,
k: jint,
) -> jstring {
- let h = match get_handle(handle) {
- Some(h) => h,
- None => return std::ptr::null_mut(),
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
- let len = match env.get_array_length(&query) {
- Ok(l) => l as usize,
- Err(_) => return std::ptr::null_mut(),
- };
- let mut buf = vec![0.0f32; len];
- if env.get_float_array_region(&query, 0, &mut buf).is_err() {
- return std::ptr::null_mut();
- }
+ ffi_guard(std::ptr::null_mut(), || {
+ let h = match get_handle(handle) {
+ Some(h) => h,
+ None => return std::ptr::null_mut(),
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let len = match env.get_array_length(&query) {
+ Ok(l) => l as usize,
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let mut buf = vec![0.0f32; len];
+ if env.get_float_array_region(&query, 0, &mut buf).is_err() {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
- use nodedb_client::NodeDb;
- let results = match h
- .rt
- .block_on(h.db.vector_search(&collection, &buf, k as usize, None))
- {
- Ok(r) => r,
- Err(_) => return std::ptr::null_mut(),
- };
+ use nodedb_client::NodeDb;
+ let results =
+ match h
+ .rt
+ .block_on(h.db.vector_search(&collection, &buf, k as usize, None, None))
+ {
+ Ok(r) => r,
+ Err(_) => return std::ptr::null_mut(),
+ };
- let json: Vec = results
- .iter()
- .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
- .collect();
- let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "[]".into());
+ let json: Vec = results
+ .iter()
+ .map(|r| serde_json::json!({"id": r.id, "distance": r.distance}))
+ .collect();
+ let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "[]".into());
- match env.new_string(&json_str) {
- Ok(s) => s.into_raw(),
- Err(_) => std::ptr::null_mut(),
- }
+ match env.new_string(&json_str) {
+ Ok(s) => s.into_raw(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
+ }
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -158,22 +227,30 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeVectorDelete(
collection: JString,
id: JString,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let id: String = match env.get_string(&id) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- use nodedb_client::NodeDb;
- match h.rt.block_on(h.db.vector_delete(&collection, &id)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let id: String = match env.get_string(&id) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ use nodedb_client::NodeDb;
+ match h.rt.block_on(h.db.vector_delete(&collection, &id)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -181,36 +258,61 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeGraphInsertEdge(
mut env: JNIEnv,
_obj: JObject,
handle: jlong,
+ collection: JString,
from: JString,
to: JString,
edge_type: JString,
) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let from: String = match env.get_string(&from) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let to: String = match env.get_string(&to) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let edge_type: String = match env.get_string(&edge_type) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let from: String = match env.get_string(&from) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let to: String = match env.get_string(&to) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let edge_type: String = match env.get_string(&edge_type) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
- use nodedb_client::NodeDb;
- let from_id = nodedb_types::id::NodeId::new(&from);
- let to_id = nodedb_types::id::NodeId::new(&to);
- match h
- .rt
- .block_on(h.db.graph_insert_edge(&from_id, &to_id, &edge_type, None))
- {
- Ok(_) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ use nodedb_client::NodeDb;
+ let from_id = match nodedb_types::id::NodeId::try_new(from) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ let to_id = match nodedb_types::id::NodeId::try_new(to) {
+ Ok(id) => id,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+ match h
+ .rt
+ .block_on(h.db.graph_insert_edge(&collection, &from_id, &to_id, &edge_type, None))
+ {
+ Ok(_) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
#[unsafe(no_mangle)]
@@ -218,167 +320,55 @@ pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeGraphTraverse(
mut env: JNIEnv,
_obj: JObject,
handle: jlong,
+ collection: JString,
start: JString,
depth: jint,
) -> jstring {
- let h = match get_handle(handle) {
- Some(h) => h,
- None => return std::ptr::null_mut(),
- };
- let start: String = match env.get_string(&start) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
-
- use nodedb_client::NodeDb;
- let start_id = nodedb_types::id::NodeId::new(&start);
- let subgraph = match h
- .rt
- .block_on(h.db.graph_traverse(&start_id, depth as u8, None))
- {
- Ok(sg) => sg,
- Err(_) => return std::ptr::null_mut(),
- };
+ ffi_guard(std::ptr::null_mut(), || {
+ let h = match get_handle(handle) {
+ Some(h) => h,
+ None => return std::ptr::null_mut(),
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let start: String = match env.get_string(&start) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
- let json = serde_json::json!({
- "nodes": subgraph.nodes.iter().map(|n| serde_json::json!({"id": n.id.as_str(), "depth": n.depth})).collect::>(),
- "edges": subgraph.edges.iter().map(|e| serde_json::json!({"from": e.from.as_str(), "to": e.to.as_str(), "label": e.label})).collect::>(),
- });
- let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
- match env.new_string(&json_str) {
- Ok(s) => s.into_raw(),
- Err(_) => std::ptr::null_mut(),
- }
-}
+ use nodedb_client::NodeDb;
+ let start_id = match nodedb_types::id::NodeId::try_new(start) {
+ Ok(id) => id,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ let subgraph =
+ match h
+ .rt
+ .block_on(h.db.graph_traverse(&collection, &start_id, depth as u8, None))
+ {
+ Ok(sg) => sg,
+ Err(_) => return std::ptr::null_mut(),
+ };
-#[unsafe(no_mangle)]
-pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentGet(
- mut env: JNIEnv,
- _obj: JObject,
- handle: jlong,
- collection: JString,
- id: JString,
-) -> jstring {
- let h = match get_handle(handle) {
- Some(h) => h,
- None => return std::ptr::null_mut(),
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
- let id: String = match env.get_string(&id) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
-
- use nodedb_client::NodeDb;
- match h.rt.block_on(h.db.document_get(&collection, &id)) {
- Ok(Some(doc)) => {
- let json_str = sonic_rs::to_string(&doc).unwrap_or_else(|_| "{}".into());
- match env.new_string(&json_str) {
- Ok(s) => s.into_raw(),
- Err(_) => std::ptr::null_mut(),
+ let json = serde_json::json!({
+ "nodes": subgraph.nodes.iter().map(|n| serde_json::json!({"id": n.id.as_str(), "depth": n.depth})).collect::>(),
+ "edges": subgraph.edges.iter().map(|e| serde_json::json!({"from": e.from.as_str(), "to": e.to.as_str(), "label": e.label})).collect::>(),
+ });
+ let json_str = serde_json::to_string(&json).unwrap_or_else(|_| "{}".into());
+ match env.new_string(&json_str) {
+ Ok(s) => s.into_raw(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
}
}
- _ => std::ptr::null_mut(),
- }
-}
-
-#[unsafe(no_mangle)]
-pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentPut(
- mut env: JNIEnv,
- _obj: JObject,
- handle: jlong,
- collection: JString,
- json_body: JString,
-) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let json_str: String = match env.get_string(&json_body) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
-
- let mut doc: nodedb_types::Document = match sonic_rs::from_str(&json_str) {
- Ok(d) => d,
- Err(_) => return NODEDB_ERR_FAILED,
- };
-
- if doc.id.is_empty() {
- doc.id = nodedb_types::id_gen::uuid_v7();
- }
-
- use nodedb_client::NodeDb;
- match h.rt.block_on(h.db.document_put(&collection, doc)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
-}
-
-#[unsafe(no_mangle)]
-pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentDelete(
- mut env: JNIEnv,
- _obj: JObject,
- handle: jlong,
- collection: JString,
- id: JString,
-) -> jint {
- let Some(h) = get_handle(handle) else {
- return NODEDB_ERR_FAILED;
- };
- let collection: String = match env.get_string(&collection) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- let id: String = match env.get_string(&id) {
- Ok(s) => s.into(),
- Err(_) => return NODEDB_ERR_FAILED,
- };
- use nodedb_client::NodeDb;
- match h.rt.block_on(h.db.document_delete(&collection, &id)) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
-}
-
-/// Generate a UUIDv7 (time-sortable, recommended for primary keys).
-#[unsafe(no_mangle)]
-pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_00024Companion_nativeGenerateId(
- env: JNIEnv,
- _class: JClass,
-) -> jstring {
- let id = nodedb_types::id_gen::uuid_v7();
- match env.new_string(&id) {
- Ok(s) => s.into_raw(),
- Err(_) => std::ptr::null_mut(),
- }
-}
-
-/// Generate an ID of the specified type.
-///
-/// Supported types: "uuidv7", "uuidv4", "ulid", "cuid2", "nanoid".
-#[unsafe(no_mangle)]
-pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_00024Companion_nativeGenerateIdTyped(
- mut env: JNIEnv,
- _class: JClass,
- id_type: JString,
-) -> jstring {
- let id_type_str: String = match env.get_string(&id_type) {
- Ok(s) => s.into(),
- Err(_) => return std::ptr::null_mut(),
- };
- let id = match nodedb_types::id_gen::generate_by_type(&id_type_str) {
- Some(id) => id,
- None => return std::ptr::null_mut(),
- };
- match env.new_string(&id) {
- Ok(s) => s.into_raw(),
- Err(_) => std::ptr::null_mut(),
- }
+ })
}
diff --git a/nodedb-lite-ffi/src/jni_bridge/document.rs b/nodedb-lite-ffi/src/jni_bridge/document.rs
new file mode 100644
index 0000000..90787f9
--- /dev/null
+++ b/nodedb-lite-ffi/src/jni_bridge/document.rs
@@ -0,0 +1,187 @@
+//! JNI entry points for the document engine and ID generation.
+//!
+//! ## JNI local-reference ownership
+//! `JString::into_raw()` returns a JNI local reference as the native method's
+//! return value. Returning that raw handle directly from the `extern "system"`
+//! fn is the correct, safe pattern — the JVM takes ownership on return. These
+//! raw handles must NOT be stored for use after the method returns; promoting
+//! them to global refs would leak.
+
+use jni::JNIEnv;
+use jni::objects::{JClass, JObject, JString};
+use jni::sys::{jint, jlong, jstring};
+
+use super::super::{NODEDB_ERR_FAILED, NODEDB_OK, ffi_guard};
+use super::core::get_handle;
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentGet(
+ mut env: JNIEnv,
+ _obj: JObject,
+ handle: jlong,
+ collection: JString,
+ id: JString,
+) -> jstring {
+ ffi_guard(std::ptr::null_mut(), || {
+ let h = match get_handle(handle) {
+ Some(h) => h,
+ None => return std::ptr::null_mut(),
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let id: String = match env.get_string(&id) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+
+ use nodedb_client::NodeDb;
+ match h.rt.block_on(h.db.document_get(&collection, &id)) {
+ Ok(Some(doc)) => {
+ let json_str = sonic_rs::to_string(&doc).unwrap_or_else(|_| "{}".into());
+ match env.new_string(&json_str) {
+ Ok(s) => s.into_raw(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
+ }
+ }
+ }
+ _ => std::ptr::null_mut(),
+ }
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentPut(
+ mut env: JNIEnv,
+ _obj: JObject,
+ handle: jlong,
+ collection: JString,
+ json_body: JString,
+) -> jint {
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let json_str: String = match env.get_string(&json_body) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+
+ let mut doc: nodedb_types::Document = match sonic_rs::from_str(&json_str) {
+ Ok(d) => d,
+ Err(_) => return NODEDB_ERR_FAILED,
+ };
+
+ if doc.id.is_empty() {
+ doc.id = nodedb_types::id_gen::uuid_v7();
+ }
+
+ use nodedb_client::NodeDb;
+ match h.rt.block_on(h.db.document_put(&collection, doc)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_nativeDocumentDelete(
+ mut env: JNIEnv,
+ _obj: JObject,
+ handle: jlong,
+ collection: JString,
+ id: JString,
+) -> jint {
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = get_handle(handle) else {
+ return NODEDB_ERR_FAILED;
+ };
+ let collection: String = match env.get_string(&collection) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ let id: String = match env.get_string(&id) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return NODEDB_ERR_FAILED;
+ }
+ };
+ use nodedb_client::NodeDb;
+ match h.rt.block_on(h.db.document_delete(&collection, &id)) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
+}
+
+/// Generate a UUIDv7 (time-sortable, recommended for primary keys).
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_00024Companion_nativeGenerateId(
+ env: JNIEnv,
+ _class: JClass,
+) -> jstring {
+ ffi_guard(std::ptr::null_mut(), || {
+ let id = nodedb_types::id_gen::uuid_v7();
+ match env.new_string(&id) {
+ Ok(s) => s.into_raw(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
+ }
+ }
+ })
+}
+
+/// Generate an ID of the specified type.
+///
+/// Supported types: "uuidv7", "uuidv4", "ulid", "cuid2", "nanoid".
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_com_nodedb_lite_NodeDbLite_00024Companion_nativeGenerateIdTyped(
+ mut env: JNIEnv,
+ _class: JClass,
+ id_type: JString,
+) -> jstring {
+ ffi_guard(std::ptr::null_mut(), || {
+ let id_type_str: String = match env.get_string(&id_type) {
+ Ok(s) => s.into(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ return std::ptr::null_mut();
+ }
+ };
+ let id = match nodedb_types::id_gen::generate_by_type(&id_type_str) {
+ Some(id) => id,
+ None => return std::ptr::null_mut(),
+ };
+ match env.new_string(&id) {
+ Ok(s) => s.into_raw(),
+ Err(_) => {
+ let _ = env.exception_clear();
+ std::ptr::null_mut()
+ }
+ }
+ })
+}
diff --git a/nodedb-lite-ffi/src/jni_bridge/mod.rs b/nodedb-lite-ffi/src/jni_bridge/mod.rs
index c30e0ec..75b5898 100644
--- a/nodedb-lite-ffi/src/jni_bridge/mod.rs
+++ b/nodedb-lite-ffi/src/jni_bridge/mod.rs
@@ -2,3 +2,4 @@
pub mod array;
pub mod core;
+pub mod document;
diff --git a/nodedb-lite-ffi/src/lib.rs b/nodedb-lite-ffi/src/lib.rs
index 961460c..6b1858f 100644
--- a/nodedb-lite-ffi/src/lib.rs
+++ b/nodedb-lite-ffi/src/lib.rs
@@ -14,8 +14,18 @@ pub mod ffi_array;
pub mod ffi_document;
pub mod ffi_graph;
pub mod ffi_vector;
+pub(crate) mod handle_registry;
pub mod jni_bridge;
+/// Run `f`, catching any panic so it never unwinds across the FFI boundary
+/// (which is UB). On panic, returns `default`.
+pub(crate) fn ffi_guard(default: T, f: impl FnOnce() -> T) -> T {
+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
+ Ok(v) => v,
+ Err(_) => default,
+ }
+}
+
pub use ffi_array::*;
pub use ffi_document::*;
pub use ffi_graph::*;
@@ -25,7 +35,7 @@ use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::sync::Arc;
-use nodedb_lite::{LiteConfig, NodeDbLite, RedbStorage};
+use nodedb_lite::{Encryption, LiteConfig, NodeDbLite, PagedbStorageDefault};
/// Error codes returned by FFI functions.
pub const NODEDB_OK: i32 = 0;
@@ -34,12 +44,44 @@ pub const NODEDB_ERR_UTF8: i32 = -2;
pub const NODEDB_ERR_FAILED: i32 = -3;
pub const NODEDB_ERR_NOT_FOUND: i32 = -4;
+/// Minimal RAII temp-directory wrapper used for the `:memory:` path.
+///
+/// Deleted on drop. No external crate dependency required.
+struct OwnedTempDir(std::path::PathBuf);
+
+impl Drop for OwnedTempDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
+impl OwnedTempDir {
+ /// Create a unique temporary directory under `std::env::temp_dir()`.
+ fn new() -> Option {
+ let mut path = std::env::temp_dir();
+ // Use process-id + a monotonic counter for uniqueness.
+ let pid = std::process::id();
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ path.push(format!("nodedb-lite-ffi-{pid}-{n}"));
+ if std::fs::create_dir_all(&path).is_ok() {
+ Some(Self(path))
+ } else {
+ None
+ }
+ }
+}
+
/// Opaque handle to a NodeDB-Lite database.
///
/// Created by `nodedb_open`, freed by `nodedb_close`.
+///
+/// `_tmpdir` is `Some` when the database was opened with the `:memory:` path.
+/// The directory is deleted when the handle is dropped.
pub struct NodeDbHandle {
- pub(crate) db: Arc>,
+ pub(crate) db: Arc>,
pub(crate) rt: tokio::runtime::Runtime,
+ _tmpdir: Option,
}
/// Open or create a NodeDB-Lite database at the given path.
@@ -48,105 +90,170 @@ pub struct NodeDbHandle {
/// The caller must call `nodedb_close` to free the handle.
///
/// # Safety
-/// `path` must be a valid null-terminated UTF-8 string.
+/// - `path` must be a valid null-terminated UTF-8 string.
+/// - `passphrase` must be NULL or a valid null-terminated UTF-8 string.
+///
+/// Encryption convention:
+/// - `passphrase` is NULL and `path` is `":memory:"` → `Encryption::Plaintext` (volatile data, safe).
+/// - `passphrase` is NULL and `path` is a real path → returns NULL (silent plaintext persistent
+/// storage is refused; pass an empty string to opt out explicitly).
+/// - `passphrase` is `""` (empty string) → `Encryption::Plaintext` (explicit conscious opt-out).
+/// - `passphrase` is a non-empty string → `Encryption::passphrase(passphrase)`.
+/// - `passphrase` is non-NULL but invalid UTF-8 → returns NULL.
#[unsafe(no_mangle)]
-pub unsafe extern "C" fn nodedb_open(path: *const c_char, peer_id: u64) -> *mut NodeDbHandle {
- let path = match ptr_to_str(path) {
- Some(s) => s,
- None => return std::ptr::null_mut(),
- };
-
- let rt = match tokio::runtime::Builder::new_multi_thread()
- .worker_threads(1)
- .enable_all()
- .build()
- {
- Ok(rt) => rt,
- Err(_) => return std::ptr::null_mut(),
- };
-
- let storage = if path == ":memory:" {
- match RedbStorage::open_in_memory() {
- Ok(s) => s,
+pub unsafe extern "C" fn nodedb_open(
+ path: *const c_char,
+ peer_id: u64,
+ passphrase: *const c_char,
+) -> *mut NodeDbHandle {
+ ffi_guard(std::ptr::null_mut(), || {
+ let path = match ptr_to_str(path) {
+ Some(s) => s,
+ None => return std::ptr::null_mut(),
+ };
+
+ let is_memory = path == ":memory:";
+ let enc = match resolve_encryption(passphrase, is_memory) {
+ Some(e) => e,
+ None => return std::ptr::null_mut(),
+ };
+
+ let rt = match tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(1)
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
Err(_) => return std::ptr::null_mut(),
- }
- } else {
- match RedbStorage::open(path) {
- Ok(s) => s,
+ };
+
+ let (storage, tmpdir) = if is_memory {
+ let tmp = match OwnedTempDir::new() {
+ Some(t) => t,
+ None => return std::ptr::null_mut(),
+ };
+ let s = match rt.block_on(PagedbStorageDefault::open(&tmp.0, enc)) {
+ Ok(s) => s,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ (s, Some(tmp))
+ } else {
+ let s = match rt.block_on(PagedbStorageDefault::open(path, enc)) {
+ Ok(s) => s,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ (s, None)
+ };
+
+ let db = match rt.block_on(NodeDbLite::open(storage, peer_id)) {
+ Ok(db) => Arc::new(db),
Err(_) => return std::ptr::null_mut(),
- }
- };
-
- let db = match rt.block_on(NodeDbLite::open(storage, peer_id)) {
- Ok(db) => Arc::new(db),
- Err(_) => return std::ptr::null_mut(),
- };
-
- Box::into_raw(Box::new(NodeDbHandle { db, rt }))
+ };
+
+ let auto_flush_ms = LiteConfig::default().auto_flush_ms;
+ let _guard = rt.enter();
+ db.start_auto_flush(auto_flush_ms);
+
+ handle_registry::insert(NodeDbHandle {
+ db,
+ rt,
+ _tmpdir: tmpdir,
+ }) as *mut NodeDbHandle
+ })
}
/// Open or create a NodeDB-Lite database with an explicit memory budget.
///
/// # Safety
-/// `path` must be a valid null-terminated UTF-8 string.
+/// - `path` must be a valid null-terminated UTF-8 string.
+/// - `passphrase` must be NULL or a valid null-terminated UTF-8 string.
+///
+/// See `nodedb_open` for the passphrase/encryption convention.
+/// `memory_mb` of 0 uses the default memory budget.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_open_with_config(
path: *const c_char,
peer_id: u64,
memory_mb: u64,
+ passphrase: *const c_char,
) -> *mut NodeDbHandle {
- let path = match ptr_to_str(path) {
- Some(s) => s,
- None => return std::ptr::null_mut(),
- };
-
- let rt = match tokio::runtime::Builder::new_multi_thread()
- .worker_threads(1)
- .enable_all()
- .build()
- {
- Ok(rt) => rt,
- Err(_) => return std::ptr::null_mut(),
- };
-
- let storage = if path == ":memory:" {
- match RedbStorage::open_in_memory() {
- Ok(s) => s,
+ ffi_guard(std::ptr::null_mut(), || {
+ let path = match ptr_to_str(path) {
+ Some(s) => s,
+ None => return std::ptr::null_mut(),
+ };
+
+ let is_memory = path == ":memory:";
+ let enc = match resolve_encryption(passphrase, is_memory) {
+ Some(e) => e,
+ None => return std::ptr::null_mut(),
+ };
+
+ let rt = match tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(1)
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
Err(_) => return std::ptr::null_mut(),
- }
- } else {
- match RedbStorage::open(path) {
- Ok(s) => s,
+ };
+
+ let (storage, tmpdir) = if is_memory {
+ let tmp = match OwnedTempDir::new() {
+ Some(t) => t,
+ None => return std::ptr::null_mut(),
+ };
+ let s = match rt.block_on(PagedbStorageDefault::open(&tmp.0, enc)) {
+ Ok(s) => s,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ (s, Some(tmp))
+ } else {
+ let s = match rt.block_on(PagedbStorageDefault::open(path, enc)) {
+ Ok(s) => s,
+ Err(_) => return std::ptr::null_mut(),
+ };
+ (s, None)
+ };
+
+ let config = if memory_mb == 0 {
+ LiteConfig::default()
+ } else {
+ LiteConfig {
+ memory_budget: (memory_mb as usize).saturating_mul(1024 * 1024),
+ ..LiteConfig::default()
+ }
+ };
+
+ let auto_flush_ms = config.auto_flush_ms;
+ let db = match rt.block_on(NodeDbLite::open_with_config(storage, peer_id, config)) {
+ Ok(db) => Arc::new(db),
Err(_) => return std::ptr::null_mut(),
- }
- };
+ };
- let config = if memory_mb == 0 {
- LiteConfig::default()
- } else {
- LiteConfig {
- memory_budget: (memory_mb as usize).saturating_mul(1024 * 1024),
- ..LiteConfig::default()
- }
- };
-
- let db = match rt.block_on(NodeDbLite::open_with_config(storage, peer_id, config)) {
- Ok(db) => Arc::new(db),
- Err(_) => return std::ptr::null_mut(),
- };
+ let _guard = rt.enter();
+ db.start_auto_flush(auto_flush_ms);
- Box::into_raw(Box::new(NodeDbHandle { db, rt }))
+ handle_registry::insert(NodeDbHandle {
+ db,
+ rt,
+ _tmpdir: tmpdir,
+ }) as *mut NodeDbHandle
+ })
}
/// Close a NodeDB-Lite database and free the handle.
///
/// # Safety
-/// `handle` must be a valid pointer returned by `nodedb_open`, or NULL (no-op).
+/// `handle` must be a token returned by `nodedb_open`, or NULL/0 (no-op).
+/// The token is a `u64` id packed into a pointer-width integer; it is never
+/// dereferenced as a raw pointer.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_close(handle: *mut NodeDbHandle) {
- if !handle.is_null() {
- drop(unsafe { Box::from_raw(handle) });
- }
+ ffi_guard((), || {
+ // handle is an opaque id token, not a real pointer — never dereference it.
+ handle_registry::remove(handle as u64);
+ })
}
/// Flush all in-memory state to disk.
@@ -155,13 +262,15 @@ pub unsafe extern "C" fn nodedb_close(handle: *mut NodeDbHandle) {
/// `handle` must be a valid pointer returned by `nodedb_open`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_flush(handle: *mut NodeDbHandle) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- match h.rt.block_on(h.db.flush()) {
- Ok(()) => NODEDB_OK,
- Err(_) => NODEDB_ERR_FAILED,
- }
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ match h.rt.block_on(h.db.flush()) {
+ Ok(()) => NODEDB_OK,
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
// ─── CRDT Sync ─────────────────────────────────────────────────────
@@ -182,33 +291,35 @@ pub unsafe extern "C" fn nodedb_start_sync(
url: *const c_char,
jwt_token: *const c_char,
) -> i32 {
- let Some(h) = handle_ref(handle) else {
- return NODEDB_ERR_NULL;
- };
- let Some(url_str) = ptr_to_str(url) else {
- return NODEDB_ERR_UTF8;
- };
- let Some(jwt_str) = ptr_to_str(jwt_token) else {
- return NODEDB_ERR_UTF8;
- };
-
- let config = nodedb_lite::sync::SyncConfig {
- url: url_str.to_string(),
- jwt_token: jwt_str.to_string(),
- client_version: format!("nodedb-lite-ffi/{}", env!("CARGO_PKG_VERSION")),
- min_backoff: std::time::Duration::from_secs(1),
- max_backoff: std::time::Duration::from_secs(60),
- ping_interval: std::time::Duration::from_secs(30),
- max_batch_size: 100,
- token_provider: None,
- token_lifetime_secs: 0,
- };
-
- // start_sync requires a tokio runtime context for spawning the background task.
- let _guard = h.rt.enter();
- let _sync_client = h.db.start_sync(config);
-
- NODEDB_OK
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ let Some(h) = handle_ref(handle) else {
+ return NODEDB_ERR_NULL;
+ };
+ let Some(url_str) = ptr_to_str(url) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let Some(jwt_str) = ptr_to_str(jwt_token) else {
+ return NODEDB_ERR_UTF8;
+ };
+
+ let config = nodedb_lite::sync::SyncConfig {
+ url: url_str.to_string(),
+ jwt_token: jwt_str.to_string(),
+ client_version: format!("nodedb-lite-ffi/{}", env!("CARGO_PKG_VERSION")),
+ min_backoff: std::time::Duration::from_secs(1),
+ max_backoff: std::time::Duration::from_secs(60),
+ ping_interval: std::time::Duration::from_secs(30),
+ max_batch_size: 100,
+ token_provider: None,
+ token_lifetime_secs: 0,
+ };
+
+ // start_sync requires a tokio runtime context for spawning the background task.
+ let _guard = h.rt.enter();
+ let _sync_client = h.db.start_sync(config);
+
+ NODEDB_OK
+ })
}
// ─── ID Generation ──────────────────────────────────────────────────
@@ -219,17 +330,19 @@ pub unsafe extern "C" fn nodedb_start_sync(
/// `out` must be a valid pointer to a `*mut c_char`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_generate_id(out: *mut *mut c_char) -> i32 {
- if out.is_null() {
- return NODEDB_ERR_NULL;
- }
- let id = nodedb_types::id_gen::uuid_v7();
- match CString::new(id) {
- Ok(cs) => {
- unsafe { *out = cs.into_raw() };
- NODEDB_OK
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ if out.is_null() {
+ return NODEDB_ERR_NULL;
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ let id = nodedb_types::id_gen::uuid_v7();
+ match CString::new(id) {
+ Ok(cs) => {
+ unsafe { *out = cs.into_raw() };
+ NODEDB_OK
+ }
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
/// Generate an ID of the specified type.
@@ -243,23 +356,25 @@ pub unsafe extern "C" fn nodedb_generate_id_typed(
id_type: *const c_char,
out: *mut *mut c_char,
) -> i32 {
- if out.is_null() {
- return NODEDB_ERR_NULL;
- }
- let Some(id_type_str) = ptr_to_str(id_type) else {
- return NODEDB_ERR_UTF8;
- };
- let id = match nodedb_types::id_gen::generate_by_type(id_type_str) {
- Some(id) => id,
- None => return NODEDB_ERR_FAILED,
- };
- match CString::new(id) {
- Ok(cs) => {
- unsafe { *out = cs.into_raw() };
- NODEDB_OK
+ ffi_guard(NODEDB_ERR_FAILED, || {
+ if out.is_null() {
+ return NODEDB_ERR_NULL;
}
- Err(_) => NODEDB_ERR_FAILED,
- }
+ let Some(id_type_str) = ptr_to_str(id_type) else {
+ return NODEDB_ERR_UTF8;
+ };
+ let id = match nodedb_types::id_gen::generate_by_type(id_type_str) {
+ Some(id) => id,
+ None => return NODEDB_ERR_FAILED,
+ };
+ match CString::new(id) {
+ Ok(cs) => {
+ unsafe { *out = cs.into_raw() };
+ NODEDB_OK
+ }
+ Err(_) => NODEDB_ERR_FAILED,
+ }
+ })
}
// ─── Memory Management ──────────────────────────────────────────────
@@ -270,9 +385,11 @@ pub unsafe extern "C" fn nodedb_generate_id_typed(
/// `ptr` must be a string previously returned by a nodedb function, or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_free_string(ptr: *mut c_char) {
- if !ptr.is_null() {
- drop(unsafe { CString::from_raw(ptr) });
- }
+ ffi_guard((), || {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+ })
}
/// Free a byte buffer returned by nodedb_* functions (e.g. `ndb_array_slice`).
@@ -283,13 +400,45 @@ pub unsafe extern "C" fn nodedb_free_string(ptr: *mut c_char) {
/// `ptr` must be a buffer previously returned by a nodedb function, or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nodedb_free_buf(ptr: *mut u8, len: usize) {
- if !ptr.is_null() && len > 0 {
- drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
- }
+ ffi_guard((), || {
+ if !ptr.is_null() && len > 0 {
+ drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
+ }
+ })
}
// ─── Internal Helpers ────────────────────────────────────────────────
+/// Resolve a `passphrase` C string pointer into an [`Encryption`] variant.
+///
+/// Returns `None` to signal that the caller should return a null handle (refused open).
+///
+/// Convention:
+/// - `passphrase` NULL + `is_memory` true → `Encryption::Plaintext` (volatile, always allowed).
+/// - `passphrase` NULL + `is_memory` false → `None` (persistent plaintext refused; use `""` to
+/// opt out explicitly).
+/// - `passphrase` `""` (empty string) → `Encryption::Plaintext` (explicit conscious opt-out).
+/// - `passphrase` non-empty string → `Encryption::passphrase(s)`.
+/// - `passphrase` non-NULL + invalid UTF-8 → `None`.
+///
+/// # Safety
+/// `passphrase` must be NULL or a valid null-terminated C string.
+pub(crate) fn resolve_encryption(passphrase: *const c_char, is_memory: bool) -> Option {
+ if passphrase.is_null() {
+ if is_memory {
+ return Some(Encryption::Plaintext);
+ } else {
+ return None;
+ }
+ }
+ let s = ptr_to_str(passphrase)?;
+ if s.is_empty() {
+ Some(Encryption::Plaintext)
+ } else {
+ Some(Encryption::passphrase(s))
+ }
+}
+
/// # Safety
/// `ptr` must be a valid null-terminated C string, or null.
pub(crate) fn ptr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
@@ -299,14 +448,17 @@ pub(crate) fn ptr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
unsafe { CStr::from_ptr(ptr) }.to_str().ok()
}
-/// # Safety
-/// `handle` must be a valid `NodeDbHandle` pointer, or null.
-pub(crate) fn handle_ref<'a>(handle: *mut NodeDbHandle) -> Option<&'a NodeDbHandle> {
- if handle.is_null() {
- None
- } else {
- Some(unsafe { &*handle })
- }
+/// Look up the handle for an opaque token returned by `nodedb_open`.
+///
+/// Returns a cloned `Arc` so the handle stays alive for the duration of the
+/// call even if another thread concurrently calls `nodedb_close`. Token 0
+/// (NULL) and unknown ids both return `None`.
+///
+/// Note: the token is a `u64` id packed into the pointer-width type used by
+/// the C ABI. On all supported 64-bit targets (arm64, x86_64) no bits are
+/// truncated. The pointer is never dereferenced.
+pub(crate) fn handle_ref(handle: *mut NodeDbHandle) -> Option> {
+ handle_registry::get(handle as u64)
}
/// Marshal a JSON string into a C output pointer.
@@ -317,6 +469,9 @@ pub(crate) fn handle_ref<'a>(handle: *mut NodeDbHandle) -> Option<&'a NodeDbHand
/// # Safety
/// `out` must be a valid, non-null `*mut *mut c_char`.
pub(crate) unsafe fn write_c_string(out: *mut *mut c_char, s: String) -> i32 {
+ if out.is_null() {
+ return NODEDB_ERR_NULL;
+ }
match CString::new(s) {
Ok(cs) => {
unsafe { *out = cs.into_raw() };
@@ -325,3 +480,26 @@ pub(crate) unsafe fn write_c_string(out: *mut *mut c_char, s: String) -> i32 {
Err(_) => NODEDB_ERR_FAILED,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::ffi_guard;
+
+ #[test]
+ fn ffi_guard_returns_value_on_success() {
+ let result = ffi_guard(42i32, || 7i32);
+ assert_eq!(result, 7);
+ }
+
+ #[test]
+ fn ffi_guard_returns_default_on_panic() {
+ let result = ffi_guard(-3i32, || -> i32 { panic!("intentional panic in test") });
+ assert_eq!(result, -3);
+ }
+
+ #[test]
+ fn ffi_guard_unit_does_not_propagate_panic() {
+ // Must not unwind out of the test — the panic is caught.
+ ffi_guard((), || panic!("intentional panic in unit test"));
+ }
+}
diff --git a/nodedb-lite-ffi/tests/array_smoke.rs b/nodedb-lite-ffi/tests/array_smoke.rs
index 94aac45..af30114 100644
--- a/nodedb-lite-ffi/tests/array_smoke.rs
+++ b/nodedb-lite-ffi/tests/array_smoke.rs
@@ -40,7 +40,7 @@ fn encode(value: &T) -> Vec {
fn array_create_put_slice_roundtrip() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 42);
+ let handle = nodedb_open(path.as_ptr(), 42, std::ptr::null());
assert!(!handle.is_null());
let name = CString::new("grid").unwrap();
@@ -118,7 +118,7 @@ fn array_create_put_slice_roundtrip() {
fn array_read_coord_returns_cell() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 43);
+ let handle = nodedb_open(path.as_ptr(), 43, std::ptr::null());
assert!(!handle.is_null());
let name = CString::new("rc").unwrap();
@@ -173,7 +173,7 @@ fn array_read_coord_returns_cell() {
fn array_delete_cell_tombstones_coord() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 44);
+ let handle = nodedb_open(path.as_ptr(), 44, std::ptr::null());
assert!(!handle.is_null());
let name = CString::new("del").unwrap();
@@ -228,7 +228,7 @@ fn array_delete_cell_tombstones_coord() {
fn array_gdpr_erase_cell_removes_content() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 45);
+ let handle = nodedb_open(path.as_ptr(), 45, std::ptr::null());
assert!(!handle.is_null());
let name = CString::new("gdpr").unwrap();
diff --git a/nodedb-lite-ffi/tests/ffi.rs b/nodedb-lite-ffi/tests/ffi.rs
index f15c2f4..06d824b 100644
--- a/nodedb-lite-ffi/tests/ffi.rs
+++ b/nodedb-lite-ffi/tests/ffi.rs
@@ -7,7 +7,7 @@ use nodedb_lite_ffi::*;
fn open_close_in_memory() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 1);
+ let handle = nodedb_open(path.as_ptr(), 1, std::ptr::null());
assert!(!handle.is_null());
nodedb_close(handle);
}
@@ -31,7 +31,7 @@ fn close_null_is_noop() {
fn vector_insert_and_search() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 1);
+ let handle = nodedb_open(path.as_ptr(), 1, std::ptr::null());
assert!(!handle.is_null());
let coll = CString::new("vecs").unwrap();
@@ -59,17 +59,24 @@ fn vector_insert_and_search() {
fn graph_insert_and_traverse() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 1);
+ let handle = nodedb_open(path.as_ptr(), 1, std::ptr::null());
+ let collection = CString::new("social").unwrap();
let from = CString::new("alice").unwrap();
let to = CString::new("bob").unwrap();
let label = CString::new("KNOWS").unwrap();
- let rc = nodedb_graph_insert_edge(handle, from.as_ptr(), to.as_ptr(), label.as_ptr());
+ let rc = nodedb_graph_insert_edge(
+ handle,
+ collection.as_ptr(),
+ from.as_ptr(),
+ to.as_ptr(),
+ label.as_ptr(),
+ );
assert_eq!(rc, NODEDB_OK);
let mut out: *mut c_char = std::ptr::null_mut();
- let rc = nodedb_graph_traverse(handle, from.as_ptr(), 2, &mut out);
+ let rc = nodedb_graph_traverse(handle, collection.as_ptr(), from.as_ptr(), 2, &mut out);
assert_eq!(rc, NODEDB_OK);
assert!(!out.is_null());
@@ -86,7 +93,7 @@ fn graph_insert_and_traverse() {
fn document_crud_via_ffi() {
let path = CString::new(":memory:").unwrap();
unsafe {
- let handle = nodedb_open(path.as_ptr(), 1);
+ let handle = nodedb_open(path.as_ptr(), 1, std::ptr::null());
let coll = CString::new("notes").unwrap();
let body = CString::new(r#"{"id":"n1","fields":{"title":{"String":"Hello"}}}"#).unwrap();
@@ -114,6 +121,28 @@ fn document_crud_via_ffi() {
}
}
+#[test]
+fn sql_execute_returns_json() {
+ let path = CString::new(":memory:").unwrap();
+ unsafe {
+ let handle = nodedb_open(path.as_ptr(), 1, std::ptr::null());
+ assert!(!handle.is_null());
+
+ // A constant-expression query is always supported.
+ let sql = CString::new("SELECT 1 + 1 AS result").unwrap();
+ let mut out: *mut c_char = std::ptr::null_mut();
+ let rc = nodedb_lite_ffi::nodedb_execute_sql(handle, sql.as_ptr(), &mut out);
+ assert_eq!(rc, NODEDB_OK);
+ assert!(!out.is_null());
+
+ let json = CStr::from_ptr(out).to_str().unwrap();
+ assert!(json.contains("columns") || json.contains("rows"));
+ nodedb_free_string(out);
+
+ nodedb_close(handle);
+ }
+}
+
#[test]
fn free_null_string_is_noop() {
unsafe {
diff --git a/nodedb-lite-wasm/Cargo.toml b/nodedb-lite-wasm/Cargo.toml
index 0008644..abf628e 100644
--- a/nodedb-lite-wasm/Cargo.toml
+++ b/nodedb-lite-wasm/Cargo.toml
@@ -12,6 +12,13 @@ homepage.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
+[features]
+# Enable the pagedb OPFS VFS and the persistent-storage JS API.
+# This is the default for browser builds — disable only when testing in Node.js
+# environments that do not provide the Web Worker API.
+default = ["opfs"]
+opfs = ["pagedb/opfs"]
+
[dependencies]
nodedb-lite = { workspace = true }
nodedb-types = { workspace = true }
@@ -23,18 +30,13 @@ serde = { workspace = true }
serde_json = { workspace = true }
sonic-rs = { workspace = true }
wasm-bindgen-futures = "0.4"
+# pagedb is a direct dep here so we can export `run_opfs_worker` which calls
+# into pagedb's OpfsWorker registrar. The `opfs` feature pulls in gloo-worker,
+# wasm-bindgen bindings, and the OpfsWorker type itself.
+pagedb = { workspace = true, features = ["opfs"] }
web-sys = { version = "0.3", features = [
"console",
- "FileSystemSyncAccessHandle",
- "FileSystemReadWriteOptions",
- "FileSystemDirectoryHandle",
- "FileSystemFileHandle",
- "FileSystemGetFileOptions",
- "WorkerGlobalScope",
- "WorkerNavigator",
- "StorageManager",
] }
-redb = { workspace = true }
nodedb-array = { workspace = true }
zerompk = { workspace = true }
diff --git a/nodedb-lite-wasm/README.md b/nodedb-lite-wasm/README.md
index ad64d64..9919d13 100644
--- a/nodedb-lite-wasm/README.md
+++ b/nodedb-lite-wasm/README.md
@@ -2,20 +2,18 @@
WebAssembly bindings for **NodeDB-Lite**, the embedded variant of NodeDB. Runs in browsers and Node.js. Exposes all eight Lite engines (Vector, Graph, Document schemaless, Document strict, Columnar/Timeseries/Spatial, KV, FTS, Array) through a single `NodeDb` API.
-> **Lite only.** This crate is *not* a WASM build of the Origin server. The distributed Origin engine (Tokio Control Plane, io_uring Data Plane, QUIC cluster transport) does not target WebAssembly. To talk to an Origin cluster from the browser, run Lite-WASM locally and sync via WebSocket. See the [WASM deployment guide](../../nodedb/docs/wasm.md) for the full picture.
+> **Lite only.** This crate is _not_ a WASM build of the Origin server. The distributed Origin engine (Tokio Control Plane, io_uring Data Plane, QUIC cluster transport) does not target WebAssembly. To talk to an Origin cluster from the browser, run Lite-WASM locally and sync via WebSocket. See the [WASM deployment guide](https://nodedb.dev/docs/wasm) for the full picture.
## Status
-**Experimental / preview.** Build and basic engine usage work end-to-end. A WASM CI lane runs `cargo check --workspace --target wasm32-unknown-unknown`, a release build via `wasm-pack build`, and the `wasm-pack test --node` suite on every PR.
-
-Outstanding items before this is considered stable:
-
-- Published `npm` package
-
-Until that lands, treat the WASM target as preview. File issues for anything that breaks.
+Ships as part of NodeDB Lite `0.1.0`. The WASM CI lane runs `cargo check --workspace --target wasm32-unknown-unknown`, `wasm-pack build`, `wasm-pack test --node`, and `wasm-pack test --headless --chrome` on every PR. The npm package is published as `@nodedb/lite` on tag release (see `release.yml`).
## Install
+```bash
+npm install @nodedb/lite
+```
+
Local development build:
```bash
@@ -35,35 +33,70 @@ A published npm package will be available once the project reaches stable status
## Quick Start
```javascript
-import init, { NodeDbLite } from "nodedb-lite-wasm";
+import init, { NodeDbLiteWasm } from "nodedb-lite-wasm";
await init();
-const db = new NodeDbLite();
-
-await db.sql("CREATE COLLECTION users");
-await db.sql("INSERT INTO users { name: 'Alice', age: 30 }");
-const rows = await db.sql("SELECT * FROM users WHERE age > 25");
-console.log(rows);
+// In-memory (no persistence across page reloads):
+const db = await NodeDbLiteWasm.open(1n);
+
+// Insert a document (fields as a JSON string of nodedb_types::Value):
+await db.documentPut(
+ "notes",
+ "n1",
+ JSON.stringify({ title: { String: "Hello" } }),
+);
+
+// Retrieve it:
+const doc = await db.documentGet("notes", "n1");
+console.log(doc);
+
+// Vector search:
+await db.vectorInsert("kb", "v1", new Float32Array([1.0, 0.0, 0.0]));
+const results = await db.vectorSearch(
+ "kb",
+ new Float32Array([1.0, 0.0, 0.0]),
+ 5,
+);
+console.log(results); // [{ id: "v1", distance: 0.0 }, ...]
+
+// Graph:
+const edgeId = await db.graphInsertEdge("social", "alice", "bob", "KNOWS");
+const subgraph = await db.graphTraverse("social", "alice", 2);
+
+// Full-text search (field name is required):
+const hits = await db.textSearch("posts", "body", "hello world", 10);
+
+// SQL escape hatch:
+const result = await db.executeSql("SELECT 1");
+console.log(result); // { columns: [...], rows: [...], rows_affected: 0 }
```
## Engines
-All eight engines work in WASM with the same SQL surface as native Lite:
-
-| Engine | DDL example |
-| ------------------ | -------------------------------------------------------------------- |
-| Document | `CREATE COLLECTION docs` |
-| Key-Value | `CREATE KV cache` |
-| Vector | `CREATE VECTOR INDEX idx ON docs METRIC cosine DIM 384` |
-| Full-text | `CREATE FTS INDEX idx ON docs FIELD body` |
-| Graph | `CREATE COLLECTION edges` + `GRAPH INSERT EDGE ...` |
-| Columnar | `CREATE COLLECTION events WITH (storage = 'columnar')` |
-| Timeseries | `CREATE COLLECTION metrics WITH (profile = 'timeseries', ...)` |
-| Spatial | `CREATE COLLECTION places WITH (profile = 'spatial', ...)` |
-| Array (NDArray) | `CREATE ARRAY grid DIMS (...) ATTRS (...) TILE_EXTENTS (...)` |
-
-See the [query language reference](../../nodedb/docs/query-language.md).
+The WASM build exposes the same engines as native Lite via the typed
+`NodeDb` methods (`document_put`, `vector_insert`, `vector_search`,
+`graph_insert_edge`, `graph_traverse`, `text_search`, `kv_put`, etc.).
+SQL DDL coverage is bounded; see
+[`docs/lite-support-matrix.md`](../docs/lite-support-matrix.md) for the
+exact list of executed plan variants. Examples below show the syntax
+each engine accepts when the corresponding DDL/DML variant is in scope —
+several are valid against Origin and return `LiteError::Unsupported`
+when executed against Lite (e.g. `CREATE ARRAY`).
+
+| Engine | DDL example |
+| --------------- | -------------------------------------------------------------- |
+| Document | `CREATE COLLECTION docs` |
+| Key-Value | `CREATE KV cache` |
+| Vector | `CREATE VECTOR INDEX idx ON docs METRIC cosine DIM 384` |
+| Full-text | `CREATE FTS INDEX idx ON docs FIELD body` |
+| Graph | `CREATE COLLECTION edges` + `GRAPH INSERT EDGE ...` |
+| Columnar | `CREATE COLLECTION events WITH (storage = 'columnar')` |
+| Timeseries | `CREATE COLLECTION metrics WITH (profile = 'timeseries', ...)` |
+| Spatial | `CREATE COLLECTION places WITH (profile = 'spatial', ...)` |
+| Array (NDArray) | `CREATE ARRAY grid DIMS (...) ATTRS (...) TILE_EXTENTS (...)` |
+
+See the [query language reference](https://nodedb.dev/docs/query-language).
## CRDT Sync to Origin
@@ -78,7 +111,7 @@ await db.sync_config({
});
```
-Origin validates constraints and returns compensation hints on conflict. See [offline sync patterns](../../nodedb/docs/offline-sync-patterns.md).
+Origin validates constraints and returns compensation hints on conflict. See [offline sync patterns](https://nodedb.dev/docs/offline-sync-patterns).
## Build Targets
@@ -136,6 +169,6 @@ Apache-2.0. See the workspace root `LICENSE` file.
## See Also
-- [WASM deployment guide](../../nodedb/docs/wasm.md)
+- [WASM deployment guide](https://nodedb.dev/docs/wasm)
- [NodeDB-Lite](../nodedb-lite/) — native embedded crate
-- [NodeDB-Lite FFI](../nodedb-lite-ffi/) — C/iOS/Android bindings
+- [NodeDB-Lite FFI](../nodedb-lite-ffi/) — C / Android bindings (iOS lands before 1.0)
diff --git a/nodedb-lite-wasm/src/array.rs b/nodedb-lite-wasm/src/array.rs
index 14585fd..48e2eaa 100644
--- a/nodedb-lite-wasm/src/array.rs
+++ b/nodedb-lite-wasm/src/array.rs
@@ -19,13 +19,13 @@
use js_sys::Uint8Array;
use wasm_bindgen::prelude::*;
+use crate::NodeDbLiteWasm;
+use crate::dispatch;
use nodedb_array::query::slice::DimRange;
use nodedb_array::schema::ArraySchema;
use nodedb_array::tile::cell_payload::CellPayload;
use nodedb_array::types::coord::value::CoordValue;
-use crate::NodeDbLiteWasm;
-
/// Decode msgpack bytes from a JS `Uint8Array` into `T`.
fn decode_msgpack zerompk::FromMessagePack<'a>>(
bytes: &Uint8Array,
@@ -57,9 +57,11 @@ impl NodeDbLiteWasm {
#[wasm_bindgen(js_name = "arrayCreate")]
pub async fn array_create(&self, name: &str, schema: &Uint8Array) -> Result<(), JsError> {
let array_schema: ArraySchema = decode_msgpack(schema, "schema")?;
- self.db
- .create_array(name, array_schema)
- .map_err(|e| JsError::new(&e.to_string()))
+ dispatch!(self, db, {
+ db.create_array(name, array_schema)
+ .await
+ .map_err(|e| JsError::new(&e.to_string()))
+ })
}
/// Write a cell into array `name` at `coord`.
@@ -85,8 +87,8 @@ impl NodeDbLiteWasm {
let system_from_ms = js_sys::Date::now() as i64;
- self.db
- .array_put_cell(
+ dispatch!(self, db, {
+ db.array_put_cell(
name,
coord_vec,
attrs,
@@ -94,7 +96,9 @@ impl NodeDbLiteWasm {
valid_from_ms,
valid_until_ms,
)
+ .await
.map_err(|e| JsError::new(&e.to_string()))
+ })
}
/// Slice query: return all live cells whose coordinates fall within `ranges`.
@@ -114,10 +118,11 @@ impl NodeDbLiteWasm {
) -> Result {
let ranges_vec: Vec