SQLite
@symbiote-native/sqlite wraps
expo-sqlite for every
SymbioteNative adapter — Database, Statement, Session (transactions and changesets), a
Bun-style SQL tagged-template helper, and a SQLite-backed key-value store. Unlike
audio’s JSI SharedObject subclasses, expo-sqlite’s native classes are
plain constructors exposed on the native module — this package’s wrapper classes hold a native
instance and forward to it.
Every adapter ships its own idiomatic <SQLiteProvider>/context equivalent, reachable from its own
subpath. Each subpath also re-exports the full core barrel, so importing from
@symbiote-native/sqlite/react (etc.) is a strict superset of importing from the package root.
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”npm install @symbiote-native/sqliteScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --sqlite (or
add --sqlite in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-sqlite and expo-modules-core come along as regular dependencies, pinned to exact
versions — never install either yourself, and never add the expo meta-package to your project.
await-lock is a real runtime dependency of the key-value store, not a native-only placeholder.
import { openDatabaseAsync } from '@symbiote-native/sqlite';
const db = await openDatabaseAsync('app.db');await db.execAsync('CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT)');await db.runAsync('INSERT INTO todos (title) VALUES (?)', 'Buy milk');const todos = await db.getAllAsync<{ id: number; title: string }>('SELECT * FROM todos');The SQL tagged-template helper, Bun-style, automatically parameterized:
const users = await db.sql<User>`SELECT * FROM users WHERE age > ${21}`;const rows = await db.sql`SELECT name, age FROM users`.values(); // [["Alice", 30], ...]const user = await db.sql<User>`SELECT * FROM users WHERE id = ${id}`.first();for await (const user of db.sql<User>`SELECT * FROM users`.each()) { /* ... */}Transactions:
await db.withTransactionAsync(async () => { await db.runAsync('UPDATE accounts SET balance = balance - ? WHERE id = ?', 100, from); await db.runAsync('UPDATE accounts SET balance = balance + ? WHERE id = ?', 100, to);}); // rolls back and re-throws if the task throwsThe key-value store, from its own subpath (kept separate since it pulls in await-lock and opens
its own database lazily — an app that only wants Database/Statement/sql shouldn’t pay for
that transitively):
import { AsyncStorage } from '@symbiote-native/sqlite/kv-store';
await AsyncStorage.setItem('token', 'secret');const token = await AsyncStorage.getItem('token'); // string | nullonInit
Section titled “onInit”Exposed directly on openDatabaseAsync/openDatabaseSync, called once right after the native
handle opens — a “run this once right after open” hook that works even where there’s no view-tree
context to hang a Provider off:
const db = await openDatabaseAsync('app.db', { onInit: async db => { await db.execAsync('CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT)'); },});Every Provider below forwards its own onInit prop straight into openDatabaseAsync rather than
calling it a second time. openDatabaseSync’s onInit must be synchronous — it rejects (throws)
if given one that returns a Promise, rather than silently racing it.
Provider / context
Section titled “Provider / context”Each adapter’s Provider opens the database once (closing and reopening if its config props
change), renders nothing until it resolves, and hands the open SQLiteDatabase to descendants. On
failure it calls onError if given, else the failure propagates through whatever error channel is
idiomatic to that framework.
import { SQLiteProvider, useSQLiteContext } from '@symbiote-native/sqlite/react';
<SQLiteProvider databaseName="app.db" onInit={migrate}> <Main /></SQLiteProvider>;
function Main() { const db = useSQLiteContext(); // ...}useSuspense is also supported — <Suspense> shows its fallback instead of the Provider
rendering null.
import { SQLiteProvider, useSQLiteContext } from '@symbiote-native/sqlite/vue';// <SQLiteProvider database-name="app.db"><Main /></SQLiteProvider> in a template// useSQLiteContext() inside a descendant's setup()import { SqliteService, provideSqliteDatabase } from '@symbiote-native/sqlite/angular';
// app config / module providers:provideSqliteDatabase({ databaseName: 'app.db', onInit: migrate });
// any component/service:constructor(private readonly sqlite: SqliteService) {}// this.sqlite.database() — an Angular resource()-backed signalDI, not a template Provider — this project’s established Angular idiom for context-like state.
<script> import { SQLiteProvider, useSQLiteContext } from '@symbiote-native/sqlite/svelte';</script>
<SQLiteProvider databaseName="app.db"> <Main /></SQLiteProvider>import { SQLiteProvider, useSQLiteContext } from '@symbiote-native/sqlite/solid';
<SQLiteProvider databaseName="app.db"> <Main /></SQLiteProvider>;None of the five ports upstream’s assetSource option (see “Deliberately out of scope” below).
React’s useSuspense has no equivalent on the other four adapters — a deliberate framework-idiom
divergence, not a gap.
openDatabaseAsync(name, options?): Promise<SQLiteDatabase>openDatabaseSync(name, options?): SQLiteDatabasedeleteDatabaseAsync(name) / deleteDatabaseSync(name): void
class SQLiteDatabase { execAsync(source) / execSync(source): void runAsync(source, ...params) / runSync(source, ...params): { lastInsertRowId, changes } getFirstAsync / getFirstSync<T>(source, ...params): T | null getAllAsync / getAllSync<T>(source, ...params): T[] getEachAsync<T>(source, ...params): AsyncIterableIterator<T> prepareAsync / prepareSync(source): SQLiteStatement withTransactionAsync(task): Promise<void> withExclusiveTransactionAsync(task): Promise<void> createSessionAsync(dbName?): Promise<SQLiteSession> sql<T>(strings, ...params): SQLiteTaggedQuery<T> // .values() / .first() / .each() serializeAsync / deserializeAsync closeAsync() / closeSync(): void}
class SQLiteStatement { executeAsync / executeSync; finalizeAsync / finalizeSync }class SQLiteSession { createChangesetAsync / applyChangesetAsync / invertChangesetAsync; finalizeAsync }Plus the full I-prefixed native type surface (native-database.ts/native-statement.ts/
native-session.ts, ported type-only) and SQLiteStorage/AsyncStorage/Storage at
/kv-store.
Error handling
Section titled “Error handling”Native errors surface as a plain thrown Error/rejected Promise — no custom error-class
hierarchy.
| Message contains | Triggered by |
|---|---|
Could not open database / invalid path |
sqlite3_open failed for the resolved path |
Database '<name>' not found |
Deleting a database that was never opened / doesn’t exist on disk |
Unable to delete database '<name>' that is currently open |
deleteDatabaseAsync/Sync on a still-open connection — close it first |
Unable to delete the database file for '<name>' database |
The file delete itself failed after the open-connection check passed |
Access to closed resource |
Any call on a database/statement/session after close*/finalize* |
Invalid bind parameter |
A bind value’s type the C binding layer can’t marshal |
Invalid arguments: ... |
A malformed call into the native layer |
ERR_INTERNAL_SQLITE_ERROR (code) / raw SQLite message |
SQLite itself rejected the statement — malformed SQL, constraint violation, etc. |
Unsupported operations |
An operation the current build (e.g. a non-libSQL build calling syncLibSQL()) doesn’t support |
Common questions
Section titled “Common questions”- “Error code 5: database is locked”. Two connections to the same file competed for a write lock. Open the database once (share the handle through the provider) and do not open it again per request. Reported with several parallel opens racing before the first resolved.
- Concurrent reads return wrong rows. Reported upstream: one prepared statement shared by concurrent reads can corrupt each other’s result sets. Prepare a statement per call or serialize.
- How do I ship a pre-populated database? Use
assetSourceonopenDatabaseAsync; it is async only,openDatabaseSyncthrows when given one. - Can I turn on WAL? Yes: run
PRAGMA journal_mode = WALright after opening, as the upstream CRUD examples do. - SQLCipher or extensions. Native build settings that this project does not wire; see below.
Sources: Expo docs: SQLite, expo/expo#49786, expo/expo#33754, expo/expo#10881, Drizzle: database is locked with ExpoSQLite.
Deliberately out of scope
Section titled “Deliberately out of scope”- The
assetSourcebundled-database-file option — needsexpo-asset, which this project doesn’t depend on. A file-copy-based alternative through@symbiote-native/file-system’sFile/DirectoryAPI is possible but not wired here. - The
expo-sqlite/pluginconfig plugin (libSQL/sqlite-vecextension bundling) — manual per-app native configuration, same convention as notifications’s push setup.bundledExtensions/loadExtensionAsync/loadExtensionSyncare still exported and work against whatever extensions your app’s own native build actually bundles. importAssetDatabaseAsync— layers on theassetSourceexclusion above.- Web-only surfaces — this project targets iOS + Android only.
- DevTools browser-extension wiring — no equivalent in this project.