@fireflydb/op-sqlite-driver
v0.0.11
Published
Bare React Native (TurboModule) drivers for FireflyDB. Bundles libfirefly and wires it through @op-engineering/op-sqlite, a pluggable secure-storage driver, and React Native WebSocket. No expo-modules.
Downloads
1,203
Maintainers
Readme
@fireflydb/op-sqlite
Bare React Native (TurboModule) drivers for FireflyDB. Bundles the Rust
libfirefly SQLite extension and wires it into
@op-engineering/op-sqlite, a
pluggable secure-storage driver, and the React Native global WebSocket. Exposes
a change-listener bridge so JS gets notified of CRDT-relevant local writes.
This is the non-Expo counterpart to @fireflydb/expo: same Rust
core and listener contract, but the native module is a codegen TurboModule
(no expo-modules-core) and SQLite is op-sqlite instead of expo-sqlite.
New Architecture required. The change-listener uses the TurboModule event emitter, which only exists on the New Architecture (
newArchEnabled=true, the default in React Native 0.76+).
This document focuses on how the native core is built and shipped. For SDK
usage, see packages/core.
Install
npm install @fireflydb/op-sqlite @op-engineering/op-sqlite react-native-get-random-values
cd ios && pod install@op-engineering/op-sqlite and react-native-get-random-values are peer
dependencies. op-sqlite must be built with extension loading enabled — do not
set iosSqlite: true in its config (the embedded iOS system SQLite forbids
sqlite3_load_extension; op-sqlite's bundled SQLite allows it, which is the
default).
Web
The package ships a web entry (src/index.web.ts, resolved automatically by
Metro's platform extensions) targeting op-sqlite's web backend (≥15.2.11).
Web cannot load extensions at runtime, so firefly is statically linked into
the SQLite wasm instead: alias op-sqlite's optional @sqlite.org/sqlite-wasm
peer to our drop-in superset, and install the browser drivers:
npm install @fireflydb/web # browser WebSocket driver + IndexedDB storage"dependencies": {
"@sqlite.org/sqlite-wasm": "npm:@fireflydb/[email protected]"
}App code is the same createFireflyClient({ sqliteOptions: { db }, … }) —
pass the connection from openAsync({ name }). Live updates work the same
as native: the wasm registers libfirefly's change listener inside its worker
(firefly_web_subscribe()) and pushes each local write out over a
BroadcastChannel the driver listens on — this needs the aliased wasm to be
≥353.2.8 (older builds lack the bridge; the client then logs a warning and
live push is disabled until the next sync round). Other differences on web:
the page must be cross-origin isolated (COOP/COEP headers — op-sqlite's web
backend uses the SharedArrayBuffer OPFS VFS); the driver verifies at
open() that the loaded wasm actually has firefly and fails with a pointer
to the alias line if not. Non-Metro bundlers need to
alias the package to the web entries themselves — the working Vite recipe and
how @fireflydb/sqlite-wasm is produced are both in
sdk/typescript/packages/web/sqlite-wasm/README.md.
Usage
The app opens the op-sqlite connection and hands it to the client. libfirefly's
CRDT triggers and per-connection device_id live on that ONE connection, so
the app's own reads/writes (drizzle, raw queries) must run on the SAME handle
to be captured and synced — a second connection to the file would lack the
extension, and the trigger-invoked firefly_* functions wouldn't exist there.
import { open } from '@op-engineering/op-sqlite';
import { createFireflyClient } from '@fireflydb/op-sqlite';
import { drizzle } from 'drizzle-orm/op-sqlite'; // optional — any consumer of the handle
const handle = open({ name: 'app.db' }); // the app owns this connection
const db = drizzle(handle, { schema }); // same connection -> app writes sync
const client = createFireflyClient({
relayUrl,
databaseID,
dbName,
sqliteOptions: { db: handle }, // the handle names the database file
token, // your TokenProvider (OIDC flow)
secureStorage: myKeychainDriver, // bring your own; see below
});
await client.init(); // loads libfirefly onto the shared connection
await client.open();
// Live local writes (encoded RowState batches), e.g. to ship upstream.
const sub = client.subscribeToChanges((event) => {
// event.source: 'local' | 'remote'; event.blob: Uint8Array
});
sub?.remove();Ownership follows who opened: the driver enforces its setup invariants on the
handle (WAL + foreign_keys pragmas, loading libfirefly — don't load it
yourself), but client.close() detaches without closing it, so the app's
database keeps working across sign-out or a client swap. Closing the handle is
the app's job.
dbPath is optional with this driver — the handle already names the file.
Supplying one turns it into a cross-check: the client throws at init() if it
doesn't match the file the handle is open on.
Secure storage is pluggable
Unlike the Expo package (which bundles expo-secure-store), this package does
not pull a secure-storage native dependency — you supply any
SecureStorageDriver (get/set/delete of Uint8Array). Common choices:
react-native-keychain, an encrypted react-native-mmkv instance, or even
expo-secure-store. For development and tests only, InMemorySecureStorage is
exported (non-persistent, no at-rest protection — never use it for a real device
key).
Layout
packages/op-sqlite/
├── cpp/ # Cross-platform C++ shared by iOS + Android
│ ├── firefly_abi.h # Rust C-ABI declarations (firefly_add_change_listener, …)
│ ├── firefly_listener.h # Listener registry public surface
│ └── firefly_listener.cpp # Process-wide handle→dispatch registry
│
├── src/
│ ├── NativeFireflyClient.ts # TurboModule codegen spec (the cross-platform contract)
│ ├── FireflyClientModule.ts # JS wrapper: subscribeToChanges + base64 decode
│ ├── index.ts # createFireflyClient + re-exports
│ └── drivers/ # op-sqlite / websocket / secureStorage
│
├── FireflyClient.podspec # at the package ROOT — RN CLI autolinking only globs *.podspec there
│
├── ios/
│ ├── Firefly.xcframework/ # ← produced by scripts/build-libfirefly.sh
│ ├── FireflyClient.mm # ObjC++ TurboModule implementing NativeFireflyClientSpec
│ └── _shared/ # file symlinks into ../cpp (CocoaPods quirk; see ios/.gitignore)
│
├── android/
│ ├── build.gradle # com.facebook.react codegen + CMake + jniLibs
│ └── src/main/
│ ├── cpp/CMakeLists.txt # Imports prebuilt libfirefly.so, builds libfirefly_jni.so
│ ├── cpp/firefly_jni.cpp # JNI shim
│ ├── jniLibs/<abi>/libfirefly.so # ← produced by scripts/build-libfirefly.sh
│ └── java/com/fireflydb/opsqlite/ # FireflyClientModule / Package / JniBridge
│
└── scripts/
└── build-libfirefly.sh # Cross-compiles core/ for iOS + AndroidThe single source of truth for the native core is the core/ Rust crate at the
repo root. Everything under ios/Firefly.xcframework/ and
android/src/main/jniLibs/ is build output (gitignored) — never hand-edit it.
How the change-listener works
op-sqlite connection ──loadExtension(getLibraryPath())──▶ libfirefly (Firefly.framework / libfirefly.so)
│ local CRDT write
▼
firefly_add_change_listener ──fires on SQLite writer thread──▶ cpp/firefly_listener.cpp (shared registry)
│ dispatch(handle, blob)
┌───────────────────────────────────────┴───────────────────────────────┐
▼ iOS ▼ Android
FireflyClient.mm fireflyDispatch firefly_jni.cpp → FireflyJniBridge.dispatch
[module emitOnFireflyChange:@{id, blobBase64}] emitOnFireflyChange({id, blobBase64})
└───────────────────────────────────────┬───────────────────────────────┘
▼
onFireflyChange event (marshalled to the JS thread by the codegen emitter)
▼
FireflyClientModule.subscribeToChanges → base64-decode → handler(Uint8Array)Both platforms link the same Firefly binary that op-sqlite dlopens as an
extension, so the C-ABI listener observes writes made through op-sqlite's
connection. The blob travels as base64 because codegen event payloads can't
carry binary; per-write RowState batches are small.
The listener registry (cpp/firefly_listener.cpp) is byte-identical to the Expo
package's — it implements libfirefly's lifetime contract (a fire may briefly
outlive firefly_remove_change_listener) once, via opaque integer handles.
Rebuilding the core
pnpm build:libfirefly # both platforms
bash scripts/build-libfirefly.sh ios # iOS only
bash scripts/build-libfirefly.sh android # Android onlyThe script cross-compiles core/ with --no-default-features --features
loadable_extension so the resulting binary is a SQLite loadable extension (no
bundled SQLite — op-sqlite ships its own). The .xcframework and .so files
are gitignored build output: the prepack hook rebuilds them on pnpm pack /
pnpm publish, so the published package ships binaries and consumers never
need a Rust toolchain. In a fresh clone, run pnpm build:libfirefly once
before building the example app. See the
Expo package README for the full
prerequisites table and the iOS/Android pipeline diagrams — the build is shared.
The shared C++ in cpp/ is not built by this script — it's compiled by the
platform build systems (CocoaPods on iOS, Gradle/CMake on Android) when the host
app builds.
