diff --git a/README.md b/README.md
index ebe45e8..f5a0d4a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
- [@edusperoni/nativescript-mqtt](packages/nativescript-mqtt/README.md)
- [nativescript-ripple](packages/nativescript-ripple/README.md)
- [@edusperoni/nativescript-sms-inbox](packages/nativescript-sms-inbox/README.md)
+- [@edusperoni/nativescript-sqlite](packages/nativescript-sqlite/README.md)
- [@edusperoni/nativescript-supabase](packages/nativescript-supabase/README.md)
# How to use?
diff --git a/apps/demo-angular/package.json b/apps/demo-angular/package.json
index 5eed397..8cf398e 100644
--- a/apps/demo-angular/package.json
+++ b/apps/demo-angular/package.json
@@ -6,7 +6,8 @@
"@edusperoni/nativescript-mqtt": "file:../../packages/nativescript-mqtt",
"nativescript-ripple": "file:../../packages/nativescript-ripple",
"@valor/nativescript-websockets": "^1.0.3",
- "@edusperoni/nativescript-supabase": "file:../../dist/packages/nativescript-supabase"
+ "@edusperoni/nativescript-supabase": "file:../../dist/packages/nativescript-supabase",
+ "@edusperoni/nativescript-sqlite": "file:../../dist/packages/nativescript-sqlite"
},
"devDependencies": {
"@nativescript/android": "~8.9.0",
diff --git a/apps/demo-angular/src/app-routing.module.ts b/apps/demo-angular/src/app-routing.module.ts
index a06c8a8..c7ea5f4 100644
--- a/apps/demo-angular/src/app-routing.module.ts
+++ b/apps/demo-angular/src/app-routing.module.ts
@@ -10,6 +10,7 @@ const routes: Routes = [
{ path: 'nativescript-mqtt', loadChildren: () => import('./plugin-demos/nativescript-mqtt.module').then((m) => m.NativescriptMqttModule) },
{ path: 'nativescript-ripple', loadChildren: () => import('./plugin-demos/nativescript-ripple.module').then((m) => m.NativescriptRippleModule) },
{ path: 'nativescript-sms-inbox', loadChildren: () => import('./plugin-demos/nativescript-sms-inbox.module').then((m) => m.NativescriptSmsInboxModule) },
+ { path: 'nativescript-sqlite', loadChildren: () => import('./plugin-demos/nativescript-sqlite.module').then((m) => m.NativescriptSqliteModule) },
{ path: 'nativescript-supabase', loadChildren: () => import('./plugin-demos/nativescript-supabase.module').then((m) => m.NativescriptSupabaseModule) },
];
diff --git a/apps/demo-angular/src/home.component.ts b/apps/demo-angular/src/home.component.ts
index 8b09a5b..a658039 100644
--- a/apps/demo-angular/src/home.component.ts
+++ b/apps/demo-angular/src/home.component.ts
@@ -16,6 +16,9 @@ export class HomeComponent {
{
name: 'nativescript-sms-inbox',
},
+ {
+ name: 'nativescript-sqlite',
+ },
{
name: 'nativescript-supabase',
},
diff --git a/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.html b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.html
new file mode 100644
index 0000000..6f4fb4d
--- /dev/null
+++ b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.ts b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.ts
new file mode 100644
index 0000000..39a3414
--- /dev/null
+++ b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.component.ts
@@ -0,0 +1,17 @@
+import { Component, NgZone } from '@angular/core';
+import { DemoSharedNativescriptSqlite } from '@demo/shared';
+import {} from '@edusperoni/nativescript-sqlite';
+
+@Component({
+ selector: 'demo-nativescript-sqlite',
+ templateUrl: 'nativescript-sqlite.component.html',
+})
+export class NativescriptSqliteComponent {
+ demoShared: DemoSharedNativescriptSqlite;
+
+ constructor(private _ngZone: NgZone) {}
+
+ ngOnInit() {
+ this.demoShared = new DemoSharedNativescriptSqlite();
+ }
+}
diff --git a/apps/demo-angular/src/plugin-demos/nativescript-sqlite.module.ts b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.module.ts
new file mode 100644
index 0000000..1200866
--- /dev/null
+++ b/apps/demo-angular/src/plugin-demos/nativescript-sqlite.module.ts
@@ -0,0 +1,10 @@
+import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
+import { NativeScriptCommonModule, NativeScriptRouterModule } from '@nativescript/angular';
+import { NativescriptSqliteComponent } from './nativescript-sqlite.component';
+
+@NgModule({
+ imports: [NativeScriptCommonModule, NativeScriptRouterModule.forChild([{ path: '', component: NativescriptSqliteComponent }])],
+ declarations: [NativescriptSqliteComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+})
+export class NativescriptSqliteModule {}
diff --git a/apps/demo-angular/tsconfig.json b/apps/demo-angular/tsconfig.json
index 609bdc3..77a33c8 100644
--- a/apps/demo-angular/tsconfig.json
+++ b/apps/demo-angular/tsconfig.json
@@ -10,7 +10,8 @@
"nativescript-ripple/angular": ["packages/nativescript-ripple/angular/index.ts"],
"@edusperoni/nativescript-sms-inbox": ["packages/nativescript-sms-inbox/index.d.ts"],
"~/*": ["apps/demo-angular/src/*"],
- "@edusperoni/nativescript-supabase": ["../../packages/nativescript-supabase/index.ts"]
+ "@edusperoni/nativescript-supabase": ["../../packages/nativescript-supabase/index.ts"],
+ "@edusperoni/nativescript-sqlite": ["../../packages/nativescript-sqlite/index.d.ts"]
}
},
"files": ["./references.d.ts", "./src/main.ts", "./src/polyfills.ts"],
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 981edc6..9fc3dd3 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -10,7 +10,8 @@
"@nativescript/core": "file:../../node_modules/@nativescript/core",
"@nativescript/unit-test-runner": "^3.0.2",
"@valor/nativescript-websockets": "^1.0.3",
- "@edusperoni/nativescript-supabase": "file:../../packages/nativescript-supabase"
+ "@edusperoni/nativescript-supabase": "file:../../packages/nativescript-supabase",
+ "@edusperoni/nativescript-sqlite": "file:../../packages/nativescript-sqlite"
},
"devDependencies": {
"@nativescript/android": "~8.9.0",
diff --git a/apps/demo/src/main-page.xml b/apps/demo/src/main-page.xml
index f6de5b3..015886c 100644
--- a/apps/demo/src/main-page.xml
+++ b/apps/demo/src/main-page.xml
@@ -10,6 +10,7 @@
+
diff --git a/apps/demo/src/plugin-demos/nativescript-sqlite.ts b/apps/demo/src/plugin-demos/nativescript-sqlite.ts
new file mode 100644
index 0000000..4ed134c
--- /dev/null
+++ b/apps/demo/src/plugin-demos/nativescript-sqlite.ts
@@ -0,0 +1,10 @@
+import { Observable, EventData, Page } from '@nativescript/core';
+import { DemoSharedNativescriptSqlite } from '@demo/shared';
+import {} from '@edusperoni/nativescript-sqlite';
+
+export function navigatingTo(args: EventData) {
+ const page = args.object;
+ page.bindingContext = new DemoModel();
+}
+
+export class DemoModel extends DemoSharedNativescriptSqlite {}
diff --git a/apps/demo/src/plugin-demos/nativescript-sqlite.xml b/apps/demo/src/plugin-demos/nativescript-sqlite.xml
new file mode 100644
index 0000000..4787612
--- /dev/null
+++ b/apps/demo/src/plugin-demos/nativescript-sqlite.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json
index 511f8b7..33e2378 100644
--- a/apps/demo/tsconfig.json
+++ b/apps/demo/tsconfig.json
@@ -10,7 +10,8 @@
"@edusperoni/nativescript-sms-inbox": ["../../packages/nativescript-sms-inbox/index.d.ts"],
"@demo/shared": ["../../tools/demo/index.ts"],
"nativescript-ripple/angular": ["../../packages/nativescript-ripple/angular/index.ts"],
- "@edusperoni/nativescript-supabase": ["../../packages/nativescript-supabase/index.ts"]
+ "@edusperoni/nativescript-supabase": ["../../packages/nativescript-supabase/index.ts"],
+ "@edusperoni/nativescript-sqlite": ["../../packages/nativescript-sqlite/index.d.ts"]
}
},
"exclude": ["**/*.spec.ts"]
diff --git a/package.json b/package.json
index 32ec2a7..0d3a4f3 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,8 @@
"@ngtools/webpack": "^19.0.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
- "eslint": "8.2.0",
+ "drizzle-orm": "^0.45.2",
+ "eslint": "^8.57.0",
"eslint-config-prettier": "^8.3.0",
"husky": "~9.0.0",
"karma": "6.3.11",
diff --git a/packages/nativescript-sqlite/.eslintrc.json b/packages/nativescript-sqlite/.eslintrc.json
new file mode 100644
index 0000000..be41074
--- /dev/null
+++ b/packages/nativescript-sqlite/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*", "node_modules/**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/packages/nativescript-sqlite/README.md b/packages/nativescript-sqlite/README.md
new file mode 100644
index 0000000..bf00daa
--- /dev/null
+++ b/packages/nativescript-sqlite/README.md
@@ -0,0 +1,419 @@
+# @edusperoni/nativescript-sqlite
+
+A high-performance SQLite plugin for NativeScript. All database operations run on background threads via GCD — the JavaScript thread is never blocked.
+
+**Platform support:** iOS (Android planned)
+
+## Features
+
+- Fully asynchronous — all queries dispatch to native background threads
+- Connection pool with WAL mode — concurrent reads, serialized writes
+- Transaction queue — concurrent `transaction()` calls are safe, they wait their turn
+- Write and read transactions with savepoint (nested transaction) support
+- Prepared statements
+- Two result formats: objects (`select`) or columnar arrays (`selectArray`)
+- Synchronous API available for simple use cases (migrations, setup)
+- Custom SQLite builds supported via CocoaPods
+- Drizzle ORM driver included
+
+## Installation
+
+```bash
+npm install @edusperoni/nativescript-sqlite
+```
+
+The plugin does not bundle SQLite — you must link one. For most apps, add to `App_Resources/iOS/build.xcconfig`:
+
+```
+OTHER_LDFLAGS = $(inherited) -lsqlite3
+```
+
+For other options (custom builds, SQLCipher encryption), see [SQLite Linking](#sqlite-linking).
+
+## Quick Start
+
+```typescript
+import { openDatabase } from '@edusperoni/nativescript-sqlite';
+import { knownFolders } from '@nativescript/core';
+
+const db = openDatabase({
+ path: knownFolders.documents().path + '/mydb.sqlite',
+});
+
+// Create a table
+await db.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ email TEXT,
+ age INTEGER
+ )
+`);
+
+// Insert data
+await db.execute(
+ 'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
+ ['Alice', 'alice@example.com', 30]
+);
+
+// Query rows (returns array of objects)
+const users = await db.select('SELECT * FROM users WHERE age > ?', [25]);
+// => [{ id: 1, name: "Alice", email: "alice@example.com", age: 30 }]
+
+// Get a single row
+const user = await db.get('SELECT * FROM users WHERE id = ?', [1]);
+// => { id: 1, name: "Alice", ... } or undefined
+
+// Clean up
+await db.close();
+```
+
+## API Reference
+
+### `openDatabase(options): SQLiteDatabase`
+
+Opens a database and returns a `SQLiteDatabase` instance. The connection pool is created immediately.
+
+```typescript
+const db = openDatabase({
+ path: '/path/to/database.sqlite',
+ readOnly: false, // default: false
+ poolSize: 4, // number of reader connections, default: 4
+ busyTimeout: 5000, // milliseconds, default: 5000
+});
+```
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `path` | `string` | *required* | Full path to the database file, or `":memory:"` |
+| `readOnly` | `boolean` | `false` | Open in read-only mode |
+| `poolSize` | `number` | `4` | Number of reader connections in the pool |
+| `busyTimeout` | `number` | `5000` | Busy timeout in milliseconds |
+
+### SQLiteDatabase
+
+#### Async Methods
+
+All async methods dispatch work to background threads and return Promises. Reads use reader connections from the pool; writes use the dedicated writer connection.
+
+```typescript
+// Execute a write statement (INSERT, UPDATE, DELETE, CREATE, etc.)
+await db.execute(sql, params?);
+
+// Query multiple rows as objects
+const rows = await db.select(sql, params?);
+
+// Query multiple rows as columnar arrays (more efficient for large results)
+const result = await db.selectArray(sql, params?);
+// result.columns => ['id', 'name', 'age']
+// result.rows => [[1, 'Alice', 30], [2, 'Bob', 25]]
+
+// Query a single row as an object
+const row = await db.get(sql, params?);
+
+// Query a single row as a columnar array
+const rowArr = await db.getArray(sql, params?);
+// rowArr.columns => ['id', 'name', 'age']
+// rowArr.rows => [[1, 'Alice', 30]] (or [] if no match)
+```
+
+#### Sync Methods
+
+Synchronous methods block the JavaScript thread. They use a dedicated connection separate from the async pool. Use these for migrations, app setup, or when you need the result immediately and the query is fast.
+
+```typescript
+db.executeSync(sql, params?);
+const rows = db.selectSync(sql, params?);
+const result = db.selectArraySync(sql, params?);
+const row = db.getSync(sql, params?);
+const rowArr = db.getArraySync(sql, params?);
+```
+
+#### Lifecycle
+
+```typescript
+db.isOpen; // boolean
+await db.close(); // waits for in-flight operations to finish, then closes all connections
+```
+
+`close()` is async — it waits for all queued operations on the writer and reader queues to drain, rolls back any active write transaction, finalizes prepared statements, and rejects any pending queued transactions. The returned Promise resolves when everything is fully shut down.
+
+### Parameters
+
+Both positional and named parameters are supported.
+
+**Positional parameters** use `?` placeholders:
+
+```typescript
+await db.execute('INSERT INTO users (name, age) VALUES (?, ?)', ['Alice', 30]);
+```
+
+**Named parameters** use `:name`, `$name`, or `@name` placeholders and are passed as an object:
+
+```typescript
+await db.execute(
+ 'INSERT INTO users (name, age) VALUES (:name, :age)',
+ { name: 'Alice', age: 30 }
+);
+```
+
+The prefix (`:`, `$`, `@`) is added automatically if omitted — you can pass `{ name: 'Alice' }` instead of `{ ':name': 'Alice' }`.
+
+**Supported value types:**
+
+| JS Type | SQLite Type |
+|---------|-------------|
+| `string` | TEXT |
+| `number` | INTEGER or REAL (auto-detected) |
+| `boolean` | INTEGER (0 or 1) |
+| `null` | NULL |
+| `ArrayBuffer` | BLOB |
+
+### Transactions
+
+#### Write Transactions
+
+Write transactions use `BEGIN DEFERRED` by default. They are serialized through a transaction queue — concurrent calls to `transaction()` are safe and will wait their turn automatically.
+
+```typescript
+const userId = await db.transaction(async (tx) => {
+ await tx.execute('INSERT INTO users (name) VALUES (?)', ['Alice']);
+ const user = await tx.get('SELECT last_insert_rowid() as id');
+ await tx.execute('INSERT INTO profiles (user_id, bio) VALUES (?, ?)', [user.id, 'Hello!']);
+ return user.id;
+});
+```
+
+If the callback throws, the transaction is rolled back. If it completes normally, it is committed. The return value of the callback is forwarded to the caller.
+
+**Concurrent transactions** are safe — the second transaction waits for the first to finish before starting:
+
+```typescript
+// Both run, but writes are serialized via the transaction queue
+const [r1, r2] = await Promise.all([
+ db.transaction(async (tx) => { /* ... */ }),
+ db.transaction(async (tx) => { /* ... */ }),
+]);
+```
+
+#### Read Transactions
+
+Read transactions claim a dedicated reader connection for the duration of the transaction, providing a consistent snapshot.
+
+```typescript
+await db.readTransaction(async (tx) => {
+ const users = await tx.select('SELECT * FROM users');
+ const count = await tx.get('SELECT count(*) as n FROM orders');
+ // Both queries see the same snapshot
+});
+```
+
+Read transactions do not block the writer or other readers.
+
+#### Nested Transactions (Savepoints)
+
+Use `savepoint()` inside a write transaction:
+
+```typescript
+await db.transaction(async (tx) => {
+ await tx.execute('INSERT INTO users (name) VALUES (?)', ['Alice']);
+
+ try {
+ await tx.savepoint(async (sp) => {
+ await sp.execute('INSERT INTO users (name) VALUES (?)', ['Bob']);
+ throw new Error('changed my mind');
+ });
+ } catch {
+ // Bob's insert is rolled back, Alice's is still pending
+ }
+
+ // Transaction commits with only Alice
+});
+```
+
+### Prepared Statements
+
+Prepared statements are compiled once and can be executed multiple times with different parameters. They are created on the writer connection.
+
+```typescript
+const stmt = await db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
+
+await stmt.execute(['Alice', 30]);
+await stmt.execute(['Bob', 25]);
+
+const rows = await stmt.select(['Alice', 30]); // if it were a SELECT
+
+await stmt.finalize(); // release native resources
+```
+
+Prepared statements are automatically finalized when the database is closed, but it is good practice to finalize them explicitly when no longer needed.
+
+### `selectArray` / `getArray` — Columnar Result Format
+
+`selectArray` and `getArray` return column names once and rows as arrays of values. This is more efficient than `select`/`get` for large result sets since column names are not repeated per row.
+
+```typescript
+const result = await db.selectArray<[number, string, number]>(
+ 'SELECT id, name, age FROM users'
+);
+
+console.log(result.columns); // ['id', 'name', 'age']
+for (const [id, name, age] of result.rows) {
+ console.log(id, name, age);
+}
+
+// Single row variant
+const single = await db.getArray('SELECT id, name FROM users WHERE id = ?', [1]);
+// single.columns => ['id', 'name']
+// single.rows => [[1, 'Alice']] (or [] if no match)
+```
+
+Available on all contexts: `db.selectArray()`, `db.getArray()`, `db.selectArraySync()`, `db.getArraySync()`, `tx.selectArray()`, `tx.getArray()`, `stmt.selectArray()`, `stmt.getArray()`.
+
+### Error Handling
+
+All errors are instances of `SQLiteError`, which extends `Error`:
+
+```typescript
+import { SQLiteError, SQLITE_CONSTRAINT } from '@edusperoni/nativescript-sqlite';
+
+try {
+ await db.execute('INSERT INTO users (id) VALUES (?)', [1]); // duplicate
+} catch (e) {
+ if (e instanceof SQLiteError) {
+ console.log(e.message); // human-readable error from sqlite3_errmsg
+ console.log(e.code); // sqlite3 result code (e.g. 19 for CONSTRAINT)
+ console.log(e.extendedCode); // extended result code for more detail
+ }
+}
+```
+
+### Low-Level Transaction Control
+
+For driver integrations (e.g., drizzle), the database exposes `txId`-based methods that allow external transaction management:
+
+```typescript
+const txId = await db.beginTransaction('deferred'); // 'deferred' | 'immediate' | 'exclusive'
+await db.executeInTransaction(txId, 'INSERT INTO users (name) VALUES (?)', ['Alice']);
+const rows = await db.selectInTransaction(txId, 'SELECT * FROM users');
+await db.commitTransaction(txId);
+// or: await db.rollbackTransaction(txId);
+```
+
+These are used by the drizzle driver to scope each drizzle transaction to its own `txId`, enabling safe concurrent transactions through `Promise.all`.
+
+## Drizzle ORM Integration
+
+A custom drizzle driver is included. It creates a dedicated session per transaction, so concurrent transactions are fully isolated.
+
+```typescript
+import { drizzle } from '@edusperoni/nativescript-sqlite/drizzle-driver';
+import { openDatabase } from '@edusperoni/nativescript-sqlite';
+import * as schema from './schema';
+
+const sqlite = openDatabase({ path: '...' });
+const db = drizzle(sqlite, { schema });
+
+// Standard drizzle usage
+const users = await db.select().from(schema.users);
+
+// Transactions — concurrent calls are safe
+await Promise.all([
+ db.transaction(async (tx) => {
+ await tx.insert(schema.users).values({ name: 'Alice' });
+ }),
+ db.transaction(async (tx) => {
+ await tx.insert(schema.users).values({ name: 'Bob' });
+ }),
+]);
+```
+
+Requires `drizzle-orm` as a peer dependency (`>=0.45.0`).
+
+## Architecture
+
+### Connection Pool
+
+The plugin opens multiple SQLite connections to the same database file:
+
+- **1 writer connection** — opened with `SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE`. All writes (`execute`, write transactions) dispatch to a serial GCD queue, ensuring only one write happens at a time.
+- **N reader connections** — opened with `SQLITE_OPEN_READWRITE` + `PRAGMA query_only=ON`. Reads (`select`, `get`) are distributed across readers via round-robin, each with its own serial GCD queue. Multiple reads can run concurrently on different readers. Readers open as READWRITE so they can initialize WAL shared memory, but `query_only` prevents accidental writes.
+- **1 sync connection** — opened lazily on the first sync call. Used exclusively by `executeSync`/`selectSync`/`getSync` on the main thread.
+- **Transaction queue** — concurrent `transaction()` calls are queued. The next transaction's `BEGIN` only dispatches after the previous one commits or rolls back.
+
+WAL (Write-Ahead Logging) mode is enabled automatically on the writer. WAL allows readers to proceed without blocking writes, and writes to proceed without blocking readers.
+
+### Performance
+
+- All SQLite work (prepare, bind, step, column extraction) happens on background GCD threads.
+- Results are serialized to a JSON string on the background thread. Only one value (the string) crosses the native-to-JS bridge. `JSON.parse` in V8 is highly optimized native C++ code.
+- Blob columns are returned as separate `NSData` objects and converted to `ArrayBuffer` via `interop.bufferFromData` (no extra copy).
+- The `selectArray` / `getArray` format avoids repeating column names per row, reducing both serialization cost and memory usage for large result sets.
+
+## SQLite Linking
+
+The plugin does **not** bundle or link a SQLite library — you must provide one. This gives you full control over the SQLite version and features available. Add **one** of the following to your app:
+
+### Option A: System SQLite (simplest)
+
+Link the SQLite that ships with iOS. Add to `App_Resources/iOS/build.xcconfig`:
+
+```
+OTHER_LDFLAGS = $(inherited) -lsqlite3
+```
+
+This is the simplest setup. The system SQLite does not support encryption or some newer extensions (e.g., recovery).
+
+### Option B: Custom SQLite via CocoaPods
+
+Use a custom SQLite build with specific compile-time options (FTS5, recovery, etc.). Add to `App_Resources/iOS/Podfile`:
+
+```ruby
+pod 'sqlite3', '~> 3.46.0'
+```
+
+Or use your own podspec pointing to a custom SQLite build. The pod's sqlite3 symbols replace the system ones at link time. No plugin code changes needed.
+
+### Option C: SQLCipher (encryption)
+
+Use SQLCipher for transparent AES-256 database encryption. Add to `App_Resources/iOS/Podfile`:
+
+```ruby
+pod 'SQLCipher', '~> 4.6'
+```
+
+Then pass an encryption key when opening the database:
+
+```typescript
+const db = openDatabase({
+ path: knownFolders.documents().path + '/encrypted.sqlite',
+ encryptionKey: 'my-secret-key',
+});
+```
+
+Every connection in the pool (writer, readers, sync) automatically receives the key via `PRAGMA key` after opening. If the key is wrong or missing for an encrypted database, operations will fail with `SQLITE_NOTADB`.
+
+## Type Definitions
+
+```typescript
+type SQLiteValue = string | number | boolean | null | ArrayBuffer;
+type SQLiteParams = SQLiteValue[] | Record;
+type SQLiteRow = Record;
+
+interface SQLiteArrayResult {
+ columns: string[];
+ rows: T[];
+}
+
+interface DatabaseOptions {
+ path: string;
+ readOnly?: boolean;
+ poolSize?: number;
+ busyTimeout?: number;
+ encryptionKey?: string;
+}
+```
+
+## License
+
+Apache License Version 2.0
diff --git a/packages/nativescript-sqlite/common.ts b/packages/nativescript-sqlite/common.ts
new file mode 100644
index 0000000..419250b
--- /dev/null
+++ b/packages/nativescript-sqlite/common.ts
@@ -0,0 +1,76 @@
+export const SQLITE_OK = 0;
+export const SQLITE_ERROR = 1;
+export const SQLITE_INTERNAL = 2;
+export const SQLITE_PERM = 3;
+export const SQLITE_ABORT = 4;
+export const SQLITE_BUSY = 5;
+export const SQLITE_LOCKED = 6;
+export const SQLITE_NOMEM = 7;
+export const SQLITE_READONLY = 8;
+export const SQLITE_INTERRUPT = 9;
+export const SQLITE_IOERR = 10;
+export const SQLITE_CORRUPT = 11;
+export const SQLITE_NOTFOUND = 12;
+export const SQLITE_FULL = 13;
+export const SQLITE_CANTOPEN = 14;
+export const SQLITE_PROTOCOL = 15;
+export const SQLITE_EMPTY = 16;
+export const SQLITE_SCHEMA = 17;
+export const SQLITE_TOOBIG = 18;
+export const SQLITE_CONSTRAINT = 19;
+export const SQLITE_MISMATCH = 20;
+export const SQLITE_MISUSE = 21;
+export const SQLITE_NOLFS = 22;
+export const SQLITE_AUTH = 23;
+export const SQLITE_FORMAT = 24;
+export const SQLITE_RANGE = 25;
+export const SQLITE_NOTADB = 26;
+export const SQLITE_NOTICE = 27;
+export const SQLITE_WARNING = 28;
+export const SQLITE_ROW = 100;
+export const SQLITE_DONE = 101;
+
+export const SQLITE_OPEN_READONLY = 0x00000001;
+export const SQLITE_OPEN_READWRITE = 0x00000002;
+export const SQLITE_OPEN_CREATE = 0x00000004;
+export const SQLITE_OPEN_URI = 0x00000040;
+export const SQLITE_OPEN_MEMORY = 0x00000080;
+export const SQLITE_OPEN_NOMUTEX = 0x00008000;
+export const SQLITE_OPEN_FULLMUTEX = 0x00010000;
+export const SQLITE_OPEN_SHAREDCACHE = 0x00020000;
+export const SQLITE_OPEN_PRIVATECACHE = 0x00040000;
+export const SQLITE_OPEN_NOFOLLOW = 0x01000000;
+
+export const SQLITE_INTEGER = 1;
+export const SQLITE_FLOAT = 2;
+export const SQLITE_TEXT = 3;
+export const SQLITE_BLOB = 4;
+export const SQLITE_NULL = 5;
+
+export type SQLiteValue = string | number | boolean | null | ArrayBuffer;
+export type SQLiteParams = SQLiteValue[] | Record;
+export type SQLiteRow = Record;
+
+export interface SQLiteArrayResult {
+ columns: string[];
+ rows: T[];
+}
+
+export interface DatabaseOptions {
+ path: string;
+ readOnly?: boolean;
+ poolSize?: number;
+ busyTimeout?: number;
+ encryptionKey?: string;
+}
+
+export class SQLiteError extends Error {
+ constructor(
+ message: string,
+ public readonly code: number,
+ public readonly extendedCode?: number,
+ ) {
+ super(message);
+ this.name = 'SQLiteError';
+ }
+}
diff --git a/packages/nativescript-sqlite/drizzle-driver.ts b/packages/nativescript-sqlite/drizzle-driver.ts
new file mode 100644
index 0000000..42c0ede
--- /dev/null
+++ b/packages/nativescript-sqlite/drizzle-driver.ts
@@ -0,0 +1,344 @@
+/**
+ * Drizzle ORM driver for @edusperoni/nativescript-sqlite
+ *
+ * Usage:
+ * import { drizzle } from '@edusperoni/nativescript-sqlite/drizzle-driver';
+ * import { openDatabase } from '@edusperoni/nativescript-sqlite';
+ *
+ * const sqlite = openDatabase({ path: '...' });
+ * const db = drizzle(sqlite, { schema });
+ *
+ * // Concurrent transactions are safe — each gets its own native connection context
+ * await Promise.all([
+ * db.transaction(async (tx) => { ... }),
+ * db.transaction(async (tx) => { ... }),
+ * ]);
+ */
+
+import { entityKind } from 'drizzle-orm/entity';
+import { DefaultLogger, type Logger } from 'drizzle-orm/logger';
+import { NoopLogger } from 'drizzle-orm/logger';
+import { fillPlaceholders, type Query, sql } from 'drizzle-orm/sql/sql';
+import { type RelationalSchemaConfig, type TablesRelationalConfig, createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm/relations';
+import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core/db';
+import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core/dialect';
+import { SQLiteTransaction } from 'drizzle-orm/sqlite-core';
+import { type PreparedQueryConfig, type SQLiteExecuteMethod, type SQLiteTransactionConfig, SQLitePreparedQuery, SQLiteSession } from 'drizzle-orm/sqlite-core/session';
+import type { SelectedFieldsOrdered } from 'drizzle-orm/sqlite-core/query-builders/select.types';
+import type { DrizzleConfig } from 'drizzle-orm/utils';
+import type { Cache } from 'drizzle-orm/cache/core/cache';
+import type { WithCacheConfig } from 'drizzle-orm/cache/core/types';
+import { NoopCache } from 'drizzle-orm/cache/core';
+
+// @ts-expect-error — mapResultRow is not exported in drizzle-orm's .d.ts but exists at runtime (used by all built-in drivers)
+import { mapResultRow } from 'drizzle-orm/utils';
+
+export interface NSSQLiteDriverDatabase {
+ execute(sql: string, params?: any[]): Promise;
+ select(sql: string, params?: any[]): Promise[]>;
+ selectArray(sql: string, params?: any[]): Promise<{ columns: string[]; rows: any[][] }>;
+
+ beginTransaction(behavior?: string): Promise;
+ executeInTransaction(txId: number, sql: string, params?: any[]): Promise;
+ selectInTransaction(txId: number, sql: string, params?: any[]): Promise[]>;
+ selectArrayInTransaction(txId: number, sql: string, params?: any[]): Promise<{ columns: string[]; rows: any[][] }>;
+ commitTransaction(txId: number): Promise;
+ rollbackTransaction(txId: number): Promise;
+}
+
+export interface NSSQLiteResult {
+ rows?: T[];
+}
+
+// MARK: - Prepared Query
+
+class NSSQLitePreparedQuery extends SQLitePreparedQuery<{
+ type: 'async';
+ run: NSSQLiteResult;
+ all: T['all'];
+ get: T['get'];
+ values: T['values'];
+ execute: T['execute'];
+}> {
+ static override readonly [entityKind] = 'NSSQLitePreparedQuery';
+ private method: SQLiteExecuteMethod;
+
+ constructor(
+ private client: NSSQLiteDriverDatabase,
+ private txId: number | null,
+ query: Query,
+ private logger: Logger,
+ cache: Cache,
+ queryMetadata: { type: 'select' | 'update' | 'delete' | 'insert'; tables: string[] } | undefined,
+ cacheConfig: WithCacheConfig | undefined,
+ private fields: SelectedFieldsOrdered | undefined,
+ executeMethod: SQLiteExecuteMethod,
+ private _isResponseInArrayMode: boolean,
+ private customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
+ ) {
+ super('async', executeMethod, query, cache, queryMetadata, cacheConfig);
+ this.method = executeMethod;
+ }
+
+ private get _queryWithCache(): (sql: string, params: unknown[], fn: () => Promise) => Promise {
+ return (this as any).queryWithCache.bind(this);
+ }
+
+ private get _joinsNotNullableMap(): any {
+ return (this as any).joinsNotNullableMap;
+ }
+
+ override getQuery(): Query & { method: SQLiteExecuteMethod } {
+ return { ...this.query, method: this.method };
+ }
+
+ async run(placeholderValues?: Record): Promise {
+ const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
+ this.logger.logQuery(this.query.sql, params);
+ return await this._queryWithCache(this.query.sql, params, async () => {
+ if (this.txId !== null) {
+ await this.client.executeInTransaction(this.txId, this.query.sql, params as any[]);
+ } else {
+ await this.client.execute(this.query.sql, params as any[]);
+ }
+ return {} as NSSQLiteResult;
+ });
+ }
+
+ override mapAllResult(rows: unknown, isFromBatch?: boolean): unknown {
+ if (isFromBatch) {
+ rows = (rows as any).rows;
+ }
+ if (!this.fields && !this.customResultMapper) {
+ return rows;
+ }
+ if (this.customResultMapper) {
+ return this.customResultMapper(rows as unknown[][]);
+ }
+ return (rows as unknown[]).map((row) => {
+ return mapResultRow(this.fields!, row, this._joinsNotNullableMap);
+ });
+ }
+
+ async all(placeholderValues?: Record): Promise {
+ const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
+ this.logger.logQuery(this.query.sql, params);
+ const { rows } = await this._queryWithCache(this.query.sql, params, async () => {
+ let resultRows: any[];
+ if (this._isResponseInArrayMode) {
+ const result = this.txId !== null ? await this.client.selectArrayInTransaction(this.txId, this.query.sql, params as any[]) : await this.client.selectArray(this.query.sql, params as any[]);
+ resultRows = result.rows;
+ } else {
+ resultRows = this.txId !== null ? await this.client.selectInTransaction(this.txId, this.query.sql, params as any[]) : await this.client.select(this.query.sql, params as any[]);
+ }
+ return { rows: resultRows };
+ });
+ return this.mapAllResult(rows);
+ }
+
+ async get(placeholderValues?: Record): Promise {
+ const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
+ this.logger.logQuery(this.query.sql, params);
+ const clientResult = await this._queryWithCache(this.query.sql, params, async () => {
+ let resultRows: any[];
+ if (this._isResponseInArrayMode) {
+ const result = this.txId !== null ? await this.client.selectArrayInTransaction(this.txId, this.query.sql, params as any[]) : await this.client.selectArray(this.query.sql, params as any[]);
+ resultRows = result.rows;
+ } else {
+ resultRows = this.txId !== null ? await this.client.selectInTransaction(this.txId, this.query.sql, params as any[]) : await this.client.select(this.query.sql, params as any[]);
+ }
+ return { rows: resultRows[0] };
+ });
+ return this.mapGetResult(clientResult.rows);
+ }
+
+ override mapGetResult(rows: unknown, isFromBatch?: boolean): unknown {
+ if (isFromBatch) {
+ rows = (rows as any).rows;
+ }
+ const row = rows;
+ if (!this.fields && !this.customResultMapper) {
+ return row;
+ }
+ if (!row) {
+ return undefined;
+ }
+ if (this.customResultMapper) {
+ return this.customResultMapper([rows] as unknown[][]);
+ }
+ return mapResultRow(this.fields!, row, this._joinsNotNullableMap);
+ }
+
+ async values(placeholderValues?: Record): Promise {
+ const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
+ this.logger.logQuery(this.query.sql, params);
+ const clientResult = await this._queryWithCache(this.query.sql, params, async () => {
+ const result = this.txId !== null ? await this.client.selectArrayInTransaction(this.txId, this.query.sql, params as any[]) : await this.client.selectArray(this.query.sql, params as any[]);
+ return { rows: result.rows };
+ });
+ return clientResult.rows as V[];
+ }
+
+ /** @internal */
+ isResponseInArrayMode(): boolean {
+ return this._isResponseInArrayMode;
+ }
+}
+
+// MARK: - Session (pool-routed, no transaction)
+
+class NSSQLiteSession, TSchema extends TablesRelationalConfig> extends SQLiteSession<'async', NSSQLiteResult, TFullSchema, TSchema> {
+ static override readonly [entityKind] = 'NSSQLiteSession';
+ private logger: Logger;
+ private cache: Cache;
+
+ constructor(
+ protected client: NSSQLiteDriverDatabase,
+ dialect: SQLiteAsyncDialect,
+ protected schema: RelationalSchemaConfig | undefined,
+ options: { logger?: Logger; cache?: Cache } = {},
+ ) {
+ super(dialect);
+ this.logger = options.logger ?? new NoopLogger();
+ this.cache = options.cache ?? new NoopCache();
+ }
+
+ private get _dialect(): SQLiteAsyncDialect {
+ return (this as any).dialect;
+ }
+
+ prepareQuery(query: Query, fields: SelectedFieldsOrdered | undefined, executeMethod: SQLiteExecuteMethod, isResponseInArrayMode: boolean, customResultMapper?: (rows: unknown[][]) => unknown, queryMetadata?: { type: 'select' | 'update' | 'delete' | 'insert'; tables: string[] }, cacheConfig?: WithCacheConfig): NSSQLitePreparedQuery {
+ return new NSSQLitePreparedQuery(this.client, null, query, this.logger, this.cache, queryMetadata, cacheConfig, fields, executeMethod, isResponseInArrayMode, customResultMapper);
+ }
+
+ async transaction(transaction: (tx: SQLiteTransaction<'async', NSSQLiteResult, TFullSchema, TSchema>) => Promise, config?: SQLiteTransactionConfig): Promise {
+ const txId = await this.client.beginTransaction(config?.behavior);
+ const txSession = new NSSQLiteTxSession(this.client, txId, this._dialect, this.schema, {
+ logger: this.logger,
+ cache: this.cache,
+ });
+ const tx = new NSSQLiteTransaction('async', this._dialect, txSession, this.schema);
+ try {
+ const result = await transaction(tx);
+ await this.client.commitTransaction(txId);
+ return result;
+ } catch (err) {
+ await this.client.rollbackTransaction(txId);
+ throw err;
+ }
+ }
+
+ extractRawAllValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows;
+ }
+
+ extractRawGetValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows?.[0];
+ }
+
+ extractRawValuesValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows;
+ }
+}
+
+// MARK: - Transaction Session (scoped to a txId)
+
+class NSSQLiteTxSession, TSchema extends TablesRelationalConfig> extends SQLiteSession<'async', NSSQLiteResult, TFullSchema, TSchema> {
+ static override readonly [entityKind] = 'NSSQLiteTxSession';
+ private logger: Logger;
+ private cache: Cache;
+
+ constructor(
+ private client: NSSQLiteDriverDatabase,
+ private txId: number,
+ dialect: SQLiteAsyncDialect,
+ private schema: RelationalSchemaConfig | undefined,
+ options: { logger?: Logger; cache?: Cache } = {},
+ ) {
+ super(dialect);
+ this.logger = options.logger ?? new NoopLogger();
+ this.cache = options.cache ?? new NoopCache();
+ }
+
+ prepareQuery(query: Query, fields: SelectedFieldsOrdered | undefined, executeMethod: SQLiteExecuteMethod, isResponseInArrayMode: boolean, customResultMapper?: (rows: unknown[][]) => unknown, queryMetadata?: { type: 'select' | 'update' | 'delete' | 'insert'; tables: string[] }, cacheConfig?: WithCacheConfig): NSSQLitePreparedQuery {
+ return new NSSQLitePreparedQuery(this.client, this.txId, query, this.logger, this.cache, queryMetadata, cacheConfig, fields, executeMethod, isResponseInArrayMode, customResultMapper);
+ }
+
+ async transaction(_transaction: (tx: SQLiteTransaction<'async', NSSQLiteResult, TFullSchema, TSchema>) => Promise, _config?: SQLiteTransactionConfig): Promise {
+ throw new Error('Nested transactions must use savepoints via tx.transaction()');
+ }
+
+ extractRawAllValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows;
+ }
+
+ extractRawGetValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows?.[0];
+ }
+
+ extractRawValuesValueFromBatchResult(result: unknown): unknown {
+ return (result as any).rows;
+ }
+}
+
+// MARK: - Transaction
+
+class NSSQLiteTransaction, TSchema extends TablesRelationalConfig> extends SQLiteTransaction<'async', NSSQLiteResult, TFullSchema, TSchema> {
+ static override readonly [entityKind] = 'NSSQLiteTransaction';
+
+ private get _dialect(): SQLiteAsyncDialect {
+ return (this as any).dialect;
+ }
+
+ private get _session(): SQLiteSession<'async', NSSQLiteResult, TFullSchema, TSchema> {
+ return (this as any).session;
+ }
+
+ async transaction(transaction: (tx: SQLiteTransaction<'async', NSSQLiteResult, TFullSchema, TSchema>) => Promise): Promise {
+ const savepointName = `sp${this.nestedIndex}`;
+ const tx = new NSSQLiteTransaction('async', this._dialect, this._session, this.schema, this.nestedIndex + 1);
+ await this._session.run(sql.raw(`savepoint ${savepointName}`));
+ try {
+ const result = await transaction(tx);
+ await this._session.run(sql.raw(`release savepoint ${savepointName}`));
+ return result;
+ } catch (err) {
+ await this._session.run(sql.raw(`rollback to savepoint ${savepointName}`));
+ throw err;
+ }
+ }
+}
+
+// MARK: - Database
+
+export class NSSQLiteDrizzleDatabase = Record> extends BaseSQLiteDatabase<'async', NSSQLiteResult, TSchema> {
+ static override readonly [entityKind] = 'NSSQLiteDrizzleDatabase';
+}
+
+// MARK: - drizzle() factory
+
+export function drizzle = Record>(client: NSSQLiteDriverDatabase, config?: DrizzleConfig): NSSQLiteDrizzleDatabase {
+ const dialect = new SQLiteAsyncDialect({ casing: config?.casing });
+ let logger: Logger | undefined;
+ const cache: Cache | undefined = config?.cache;
+
+ if (config?.logger === true) {
+ logger = new DefaultLogger();
+ } else if (config?.logger !== false) {
+ logger = config?.logger;
+ }
+
+ let schema: RelationalSchemaConfig | undefined;
+ if (config?.schema) {
+ const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers);
+ schema = {
+ fullSchema: config.schema,
+ schema: tablesConfig.tables,
+ tableNamesMap: tablesConfig.tableNamesMap,
+ };
+ }
+
+ const session = new NSSQLiteSession(client, dialect, schema as any, { logger, cache });
+ const db = new NSSQLiteDrizzleDatabase('async', dialect, session, schema as any);
+ return db as NSSQLiteDrizzleDatabase;
+}
diff --git a/packages/nativescript-sqlite/index.android.ts b/packages/nativescript-sqlite/index.android.ts
new file mode 100644
index 0000000..29fd78a
--- /dev/null
+++ b/packages/nativescript-sqlite/index.android.ts
@@ -0,0 +1,5 @@
+export * from './common';
+
+export function openDatabase(): never {
+ throw new Error('nativescript-sqlite: Android is not yet implemented');
+}
diff --git a/packages/nativescript-sqlite/index.d.ts b/packages/nativescript-sqlite/index.d.ts
new file mode 100644
index 0000000..f97e7dc
--- /dev/null
+++ b/packages/nativescript-sqlite/index.d.ts
@@ -0,0 +1,57 @@
+import { DatabaseOptions, SQLiteArrayResult, SQLiteError, SQLiteParams, SQLiteRow, SQLiteValue } from './common';
+
+export { DatabaseOptions, SQLiteArrayResult, SQLiteError, SQLiteParams, SQLiteRow, SQLiteValue };
+export { SQLITE_OK, SQLITE_ERROR, SQLITE_BUSY, SQLITE_CONSTRAINT, SQLITE_MISMATCH, SQLITE_MISUSE, SQLITE_RANGE, SQLITE_ROW, SQLITE_DONE, SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, SQLITE_OPEN_CREATE, SQLITE_OPEN_MEMORY, SQLITE_OPEN_URI, SQLITE_OPEN_NOMUTEX, SQLITE_OPEN_FULLMUTEX, SQLITE_INTEGER, SQLITE_FLOAT, SQLITE_TEXT, SQLITE_BLOB, SQLITE_NULL } from './common';
+
+export interface ReadTransaction {
+ select(sql: string, params?: SQLiteParams): Promise;
+ selectArray(sql: string, params?: SQLiteParams): Promise>;
+ get(sql: string, params?: SQLiteParams): Promise;
+ getArray(sql: string, params?: SQLiteParams): Promise>;
+}
+
+export interface Transaction extends ReadTransaction {
+ execute(sql: string, params?: SQLiteParams): Promise;
+ savepoint(fn: (tx: Transaction) => Promise): Promise;
+}
+
+export interface PreparedStatement {
+ execute(params?: SQLiteParams): Promise;
+ select(params?: SQLiteParams): Promise;
+ selectArray(params?: SQLiteParams): Promise>;
+ get(params?: SQLiteParams): Promise;
+ getArray(params?: SQLiteParams): Promise>;
+ finalize(): Promise;
+}
+
+export interface SQLiteDatabase {
+ execute(sql: string, params?: SQLiteParams): Promise;
+ select(sql: string, params?: SQLiteParams): Promise;
+ selectArray(sql: string, params?: SQLiteParams): Promise>;
+ get(sql: string, params?: SQLiteParams): Promise;
+ getArray(sql: string, params?: SQLiteParams): Promise>;
+
+ transaction(fn: (tx: Transaction) => Promise): Promise;
+ readTransaction(fn: (tx: ReadTransaction) => Promise): Promise;
+
+ prepare(sql: string): Promise;
+
+ executeSync(sql: string, params?: SQLiteParams): void;
+ selectSync(sql: string, params?: SQLiteParams): T[];
+ selectArraySync(sql: string, params?: SQLiteParams): SQLiteArrayResult;
+ getSync(sql: string, params?: SQLiteParams): T | undefined;
+ getArraySync(sql: string, params?: SQLiteParams): SQLiteArrayResult;
+
+ // Low-level transaction control (for driver integrations like drizzle)
+ beginTransaction(behavior?: 'deferred' | 'immediate' | 'exclusive'): Promise;
+ executeInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise;
+ selectInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise;
+ selectArrayInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise;
+ commitTransaction(txId: number): Promise;
+ rollbackTransaction(txId: number): Promise;
+
+ close(): Promise;
+ readonly isOpen: boolean;
+}
+
+export function openDatabase(options: DatabaseOptions): SQLiteDatabase;
diff --git a/packages/nativescript-sqlite/index.ios.ts b/packages/nativescript-sqlite/index.ios.ts
new file mode 100644
index 0000000..0d15ab9
--- /dev/null
+++ b/packages/nativescript-sqlite/index.ios.ts
@@ -0,0 +1,519 @@
+import { DatabaseOptions, SQLiteArrayResult, SQLiteError, SQLiteParams, SQLiteRow, SQLiteValue } from './common';
+import type { PreparedStatement, ReadTransaction, SQLiteDatabase, Transaction } from '.';
+
+export { DatabaseOptions, SQLiteArrayResult, SQLiteError, SQLiteParams, SQLiteRow, SQLiteValue };
+export type { PreparedStatement, ReadTransaction, SQLiteDatabase, Transaction };
+export * from './common';
+
+declare class NSSQLiteDatabase extends NSObject {
+ static openWithPathPoolSizeReadOnlyBusyTimeoutEncryptionKey(path: string, poolSize: number, readOnly: boolean, busyTimeout: number, encryptionKey: string | null): NSSQLiteDatabase;
+
+ executeParamsCompletion(sql: string, params: NSArray, completion: (error: NSError) => void): void;
+ selectParamsCompletion(sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ selectArrayParamsCompletion(sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+
+ beginTransactionCompletion(behavior: string, completion: (txId: number, error: NSError) => void): void;
+ executeInTransactionSqlParamsCompletion(txId: number, sql: string, params: NSArray, completion: (error: NSError) => void): void;
+ selectInTransactionSqlParamsCompletion(txId: number, sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ selectArrayInTransactionSqlParamsCompletion(txId: number, sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ commitTransactionCompletion(txId: number, completion: (error: NSError) => void): void;
+ rollbackTransactionCompletion(txId: number, completion: (error: NSError) => void): void;
+
+ beginReadTransaction(completion: (txId: number, error: NSError) => void): void;
+ selectInReadTransactionSqlParamsCompletion(txId: number, sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ selectArrayInReadTransactionSqlParamsCompletion(txId: number, sql: string, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ endReadTransactionCompletion(txId: number, completion: (error: NSError) => void): void;
+
+ prepareCompletion(sql: string, completion: (stmtId: number, error: NSError) => void): void;
+ executePreparedParamsCompletion(stmtId: number, params: NSArray, completion: (error: NSError) => void): void;
+ selectPreparedParamsCompletion(stmtId: number, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ selectArrayPreparedParamsCompletion(stmtId: number, params: NSArray, completion: (json: string, blobs: NSArray, error: NSError) => void): void;
+ finalizePreparedCompletion(stmtId: number, completion: (error: NSError) => void): void;
+
+ executeSyncParamsError(sql: string, params: NSArray): boolean;
+ selectSyncParamsError(sql: string, params: NSArray): string;
+ selectArraySyncParamsError(sql: string, params: NSArray): string;
+
+ closeWithCompletion(completion: () => void): void;
+ close(): void;
+ isOpen: boolean;
+}
+
+function toNSError(error: NSError): SQLiteError {
+ const extCode = error.userInfo?.objectForKey?.('extendedCode') as number | undefined;
+ return new SQLiteError(error.localizedDescription, error.code, extCode ?? error.code);
+}
+
+function marshalParams(params?: SQLiteParams): NSArray {
+ if (!params) return NSArray.new();
+ if (Array.isArray(params)) {
+ return NSArray.arrayWithArray(
+ params.map((v) => {
+ if (v === null || v === undefined) return NSNull.null();
+ if (typeof v === 'boolean') return NSNumber.numberWithBool(v);
+ if (typeof v === 'number') return NSNumber.numberWithDouble(v);
+ if (typeof v === 'string') return v as any;
+ if (v instanceof ArrayBuffer) return NSData.dataWithData(v as any);
+ return NSNull.null();
+ }),
+ );
+ }
+ const dict = NSMutableDictionary.new();
+ for (const key of Object.keys(params)) {
+ const v = (params as Record)[key];
+ if (v === null || v === undefined) {
+ dict.setObjectForKey(NSNull.null(), key);
+ } else if (typeof v === 'boolean') {
+ dict.setObjectForKey(NSNumber.numberWithBool(v), key);
+ } else if (typeof v === 'number') {
+ dict.setObjectForKey(NSNumber.numberWithDouble(v), key);
+ } else if (typeof v === 'string') {
+ dict.setObjectForKey(v as any, key);
+ } else if (v instanceof ArrayBuffer) {
+ dict.setObjectForKey(NSData.dataWithData(v as any), key);
+ } else {
+ dict.setObjectForKey(NSNull.null(), key);
+ }
+ }
+ return NSArray.arrayWithObject(dict);
+}
+
+function parseSelectResult(json: string, blobs: NSArray | null): T {
+ const rows = JSON.parse(json);
+ if (blobs && blobs.count > 0) {
+ hydrateBlobs(rows, blobs);
+ }
+ return rows;
+}
+
+function hydrateBlobs(rows: any[], blobs: NSArray): void {
+ for (const row of rows) {
+ for (const key of Object.keys(row)) {
+ const val = row[key];
+ if (val && typeof val === 'object' && '__blob__' in val) {
+ const nsData = blobs.objectAtIndex(val.__blob__);
+ row[key] = interop.bufferFromData(nsData);
+ }
+ }
+ }
+}
+
+function parseArrayResult(json: string, blobs: NSArray | null): SQLiteArrayResult {
+ const result = JSON.parse(json) as SQLiteArrayResult;
+ if (blobs && blobs.count > 0) {
+ for (const row of result.rows) {
+ for (let i = 0; i < row.length; i++) {
+ const val = row[i] as any;
+ if (val && typeof val === 'object' && '__blob__' in val) {
+ const nsData = blobs.objectAtIndex(val.__blob__);
+ (row as any)[i] = interop.bufferFromData(nsData);
+ }
+ }
+ }
+ }
+ return result;
+}
+
+class WriteTxImpl implements Transaction {
+ constructor(
+ private native: NSSQLiteDatabase,
+ private txId: number,
+ private _savepointCounter: { value: number },
+ ) {}
+
+ execute(sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.executeInTransactionSqlParamsCompletion(this.txId, sql, marshalParams(params), (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ select(sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectInTransactionSqlParamsCompletion(this.txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseSelectResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ selectArray(sql: string, params?: SQLiteParams): Promise> {
+ return new Promise((resolve, reject) => {
+ this.native.selectArrayInTransactionSqlParamsCompletion(this.txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseArrayResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ get(sql: string, params?: SQLiteParams): Promise {
+ return this.select(sql, params).then((rows) => rows[0]);
+ }
+
+ getArray(sql: string, params?: SQLiteParams): Promise> {
+ return this.selectArray(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
+ }
+
+ async savepoint(fn: (tx: Transaction) => Promise): Promise {
+ const name = `sp_${this._savepointCounter.value++}`;
+ await this.execute(`SAVEPOINT ${name}`);
+ try {
+ const result = await fn(this);
+ await this.execute(`RELEASE ${name}`);
+ return result;
+ } catch (e) {
+ await this.execute(`ROLLBACK TO ${name}`);
+ throw e;
+ }
+ }
+}
+
+class ReadTxImpl implements ReadTransaction {
+ constructor(
+ private native: NSSQLiteDatabase,
+ private txId: number,
+ ) {}
+
+ select(sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectInReadTransactionSqlParamsCompletion(this.txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseSelectResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ selectArray(sql: string, params?: SQLiteParams): Promise> {
+ return new Promise((resolve, reject) => {
+ this.native.selectArrayInReadTransactionSqlParamsCompletion(this.txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseArrayResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ get(sql: string, params?: SQLiteParams): Promise {
+ return this.select(sql, params).then((rows) => rows[0]);
+ }
+
+ getArray(sql: string, params?: SQLiteParams): Promise> {
+ return this.selectArray(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
+ }
+}
+
+class PreparedStatementImpl implements PreparedStatement {
+ constructor(
+ private native: NSSQLiteDatabase,
+ private stmtId: number,
+ ) {}
+
+ execute(params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.executePreparedParamsCompletion(this.stmtId, marshalParams(params), (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ select(params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectPreparedParamsCompletion(this.stmtId, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseSelectResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ selectArray(params?: SQLiteParams): Promise> {
+ return new Promise((resolve, reject) => {
+ this.native.selectArrayPreparedParamsCompletion(this.stmtId, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseArrayResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ get(params?: SQLiteParams): Promise {
+ return this.select(params).then((rows) => rows[0]);
+ }
+
+ getArray(params?: SQLiteParams): Promise> {
+ return this.selectArray(params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
+ }
+
+ finalize(): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.finalizePreparedCompletion(this.stmtId, (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+}
+
+class SQLiteDatabaseImpl implements SQLiteDatabase {
+ constructor(private native: NSSQLiteDatabase) {}
+
+ get isOpen(): boolean {
+ return this.native.isOpen;
+ }
+
+ execute(sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.executeParamsCompletion(sql, marshalParams(params), (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ select(sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectParamsCompletion(sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseSelectResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ selectArray(sql: string, params?: SQLiteParams): Promise> {
+ return new Promise((resolve, reject) => {
+ this.native.selectArrayParamsCompletion(sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseArrayResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ get(sql: string, params?: SQLiteParams): Promise {
+ return this.select(sql, params).then((rows) => rows[0]);
+ }
+
+ getArray(sql: string, params?: SQLiteParams): Promise> {
+ return this.selectArray(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
+ }
+
+ async transaction(fn: (tx: Transaction) => Promise): Promise {
+ const txId = await new Promise((resolve, reject) => {
+ this.native.beginTransactionCompletion('deferred', (id, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(id);
+ }
+ });
+ });
+
+ const tx = new WriteTxImpl(this.native, txId, { value: 0 });
+ try {
+ const result = await fn(tx);
+ await new Promise((resolve, reject) => {
+ this.native.commitTransactionCompletion(txId, (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ return result;
+ } catch (e) {
+ await new Promise((resolve) => {
+ this.native.rollbackTransactionCompletion(txId, () => resolve());
+ });
+ throw e;
+ }
+ }
+
+ async readTransaction(fn: (tx: ReadTransaction) => Promise): Promise {
+ const txId = await new Promise((resolve, reject) => {
+ this.native.beginReadTransaction((id, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(id);
+ }
+ });
+ });
+
+ const tx = new ReadTxImpl(this.native, txId);
+ try {
+ const result = await fn(tx);
+ await new Promise((resolve) => {
+ this.native.endReadTransactionCompletion(txId, () => resolve());
+ });
+ return result;
+ } catch (e) {
+ await new Promise((resolve) => {
+ this.native.endReadTransactionCompletion(txId, () => resolve());
+ });
+ throw e;
+ }
+ }
+
+ prepare(sql: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.prepareCompletion(sql, (stmtId, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(new PreparedStatementImpl(this.native, stmtId));
+ }
+ });
+ });
+ }
+
+ executeSync(sql: string, params?: SQLiteParams): void {
+ const ok = this.native.executeSyncParamsError(sql, marshalParams(params));
+ if (!ok) {
+ throw new SQLiteError('executeSync failed', -1);
+ }
+ }
+
+ selectSync(sql: string, params?: SQLiteParams): T[] {
+ const json = this.native.selectSyncParamsError(sql, marshalParams(params));
+ if (!json) {
+ throw new SQLiteError('selectSync failed', -1);
+ }
+ return JSON.parse(json);
+ }
+
+ selectArraySync(sql: string, params?: SQLiteParams): SQLiteArrayResult {
+ const json = this.native.selectArraySyncParamsError(sql, marshalParams(params));
+ if (!json) {
+ throw new SQLiteError('selectArraySync failed', -1);
+ }
+ return JSON.parse(json);
+ }
+
+ getSync(sql: string, params?: SQLiteParams): T | undefined {
+ return this.selectSync(sql, params)[0];
+ }
+
+ getArraySync(sql: string, params?: SQLiteParams): SQLiteArrayResult {
+ return this.selectArraySync(sql, params);
+ }
+
+ // Low-level transaction control for driver integrations
+
+ beginTransaction(behavior?: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.beginTransactionCompletion(behavior ?? 'deferred', (id, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(id);
+ }
+ });
+ });
+ }
+
+ executeInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.executeInTransactionSqlParamsCompletion(txId, sql, marshalParams(params), (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ selectInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectInTransactionSqlParamsCompletion(txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseSelectResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ selectArrayInTransaction(txId: number, sql: string, params?: SQLiteParams): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.selectArrayInTransactionSqlParamsCompletion(txId, sql, marshalParams(params), (json, blobs, error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve(parseArrayResult(json, blobs));
+ }
+ });
+ });
+ }
+
+ commitTransaction(txId: number): Promise {
+ return new Promise((resolve, reject) => {
+ this.native.commitTransactionCompletion(txId, (error) => {
+ if (error) {
+ reject(toNSError(error));
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ rollbackTransaction(txId: number): Promise {
+ return new Promise((resolve) => {
+ this.native.rollbackTransactionCompletion(txId, () => resolve());
+ });
+ }
+
+ close(): Promise {
+ return new Promise((resolve) => {
+ this.native.closeWithCompletion(() => {
+ resolve();
+ });
+ });
+ }
+}
+
+export function openDatabase(options: DatabaseOptions): SQLiteDatabase {
+ const native = NSSQLiteDatabase.openWithPathPoolSizeReadOnlyBusyTimeoutEncryptionKey(options.path, options.poolSize ?? 4, options.readOnly ?? false, options.busyTimeout ?? 5000, options.encryptionKey ?? null);
+ if (!native) {
+ throw new SQLiteError(`Failed to open database: ${options.path}`, -1);
+ }
+ return new SQLiteDatabaseImpl(native);
+}
diff --git a/packages/nativescript-sqlite/package.json b/packages/nativescript-sqlite/package.json
new file mode 100644
index 0000000..b870064
--- /dev/null
+++ b/packages/nativescript-sqlite/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@edusperoni/nativescript-sqlite",
+ "version": "0.0.3",
+ "description": "Add a plugin description",
+ "main": "index",
+ "types": "index.d.ts",
+ "nativescript": {
+ "platforms": {
+ "ios": "6.0.0",
+ "android": "6.0.0"
+ }
+ },
+ "repository": {
+ "type": "git",
+ "url": "http://github.com/edusperoni/nativescript-plugins.git"
+ },
+ "keywords": [
+ "NativeScript",
+ "JavaScript",
+ "TypeScript",
+ "iOS",
+ "Android"
+ ],
+ "author": {
+ "name": "Eduardo Speroni",
+ "email": "edusperoni@gmail.com"
+ },
+ "bugs": {
+ "url": "http://github.com/edusperoni/nativescript-plugins/issues"
+ },
+ "peerDependencies": {
+ "drizzle-orm": ">=0.45.0"
+ },
+ "peerDependenciesMeta": {
+ "drizzle-orm": {
+ "optional": true
+ }
+ },
+ "license": "Apache-2.0",
+ "homepage": "http://github.com/edusperoni/nativescript-plugins",
+ "readmeFilename": "README.md",
+ "bootstrapper": "@nativescript/plugin-seed"
+}
diff --git a/packages/nativescript-sqlite/platforms/ios/build.xcconfig b/packages/nativescript-sqlite/platforms/ios/build.xcconfig
new file mode 100644
index 0000000..f3c501f
--- /dev/null
+++ b/packages/nativescript-sqlite/platforms/ios/build.xcconfig
@@ -0,0 +1,2 @@
+CLANG_CXX_LANGUAGE_STANDARD = c++17
+CLANG_CXX_LIBRARY = libc++
diff --git a/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.h b/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.h
new file mode 100644
index 0000000..e7ff9b9
--- /dev/null
+++ b/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.h
@@ -0,0 +1,111 @@
+#import
+
+@interface NSSQLiteDatabase : NSObject
+
++ (instancetype)openWithPath:(NSString *)path
+ poolSize:(int)poolSize
+ readOnly:(BOOL)readOnly
+ busyTimeout:(int)busyTimeoutMs
+ encryptionKey:(NSString *)encryptionKey;
+
+// --- Async operations (dispatch to GCD, callback on main queue) ---
+
+- (void)execute:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSError *))completion;
+
+// select returns JSON: [{col:val,...}, ...]
+- (void)select:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+// selectArray returns JSON: {"columns":[...],"rows":[[...], ...]}
+- (void)selectArray:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+// --- Write transactions ---
+
+- (void)beginTransaction:(NSString *)behavior
+ completion:(void (^)(int, NSError *))completion;
+
+- (void)executeInTransaction:(int)txId
+ sql:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSError *))completion;
+
+- (void)selectInTransaction:(int)txId
+ sql:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)selectArrayInTransaction:(int)txId
+ sql:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)commitTransaction:(int)txId
+ completion:(void (^)(NSError *))completion;
+
+- (void)rollbackTransaction:(int)txId
+ completion:(void (^)(NSError *))completion;
+
+// --- Read transactions ---
+
+- (void)beginReadTransaction:(void (^)(int, NSError *))completion;
+
+- (void)selectInReadTransaction:(int)txId
+ sql:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)selectArrayInReadTransaction:(int)txId
+ sql:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)endReadTransaction:(int)txId
+ completion:(void (^)(NSError *))completion;
+
+// --- Prepared statements ---
+
+- (void)prepare:(NSString *)sql
+ completion:(void (^)(int, NSError *))completion;
+
+- (void)executePrepared:(int)stmtId
+ params:(NSArray *)params
+ completion:(void (^)(NSError *))completion;
+
+- (void)selectPrepared:(int)stmtId
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)selectArrayPrepared:(int)stmtId
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion;
+
+- (void)finalizePrepared:(int)stmtId
+ completion:(void (^)(NSError *))completion;
+
+// --- Sync operations (blocks calling thread) ---
+
+- (NSString *)selectSync:(NSString *)sql
+ params:(NSArray *)params
+ error:(NSError **)error;
+
+- (NSString *)selectArraySync:(NSString *)sql
+ params:(NSArray *)params
+ error:(NSError **)error;
+
+- (BOOL)executeSync:(NSString *)sql
+ params:(NSArray *)params
+ error:(NSError **)error;
+
+// --- Lifecycle ---
+
+- (void)closeWithCompletion:(void (^)(void))completion;
+- (void)close;
+
+@property (nonatomic, readonly) BOOL isOpen;
+
+@end
diff --git a/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.mm b/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.mm
new file mode 100644
index 0000000..988442f
--- /dev/null
+++ b/packages/nativescript-sqlite/platforms/ios/src/NSSQLiteDatabase.mm
@@ -0,0 +1,1311 @@
+#import "NSSQLiteDatabase.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// MARK: - JSON String Builder
+
+class JSONBuilder {
+ std::string buf_;
+public:
+ JSONBuilder() { buf_.reserve(4096); }
+
+ void reset() { buf_.clear(); }
+ const std::string &str() const { return buf_; }
+
+ void appendRaw(const char *s, size_t len) { buf_.append(s, len); }
+ void appendRaw(char c) { buf_.push_back(c); }
+
+ void appendNull() { buf_.append("null", 4); }
+ void appendBool(bool v) { v ? buf_.append("true", 4) : buf_.append("false", 5); }
+
+ void appendInt(int64_t v) {
+ char tmp[32];
+ int n = snprintf(tmp, sizeof(tmp), "%lld", (long long)v);
+ buf_.append(tmp, n);
+ }
+
+ void appendDouble(double v) {
+ char tmp[64];
+ int n = snprintf(tmp, sizeof(tmp), "%.17g", v);
+ buf_.append(tmp, n);
+ }
+
+ void appendString(const char *s, int len) {
+ buf_.push_back('"');
+ const char *end = s + len;
+ while (s < end) {
+ unsigned char c = *s;
+ switch (c) {
+ case '"': buf_.append("\\\"", 2); break;
+ case '\\': buf_.append("\\\\", 2); break;
+ case '\b': buf_.append("\\b", 2); break;
+ case '\f': buf_.append("\\f", 2); break;
+ case '\n': buf_.append("\\n", 2); break;
+ case '\r': buf_.append("\\r", 2); break;
+ case '\t': buf_.append("\\t", 2); break;
+ default:
+ if (c < 0x20) {
+ char esc[8];
+ snprintf(esc, sizeof(esc), "\\u%04x", c);
+ buf_.append(esc, 6);
+ } else {
+ buf_.push_back(c);
+ }
+ break;
+ }
+ s++;
+ }
+ buf_.push_back('"');
+ }
+
+ void appendBlobPlaceholder(int index) {
+ buf_.append("{\"__blob__\":", 11);
+ appendInt(index);
+ buf_.push_back('}');
+ }
+};
+
+// MARK: - SQLite Connection
+
+class SQLiteConnection {
+ sqlite3 *db_ = nullptr;
+ std::string path_;
+
+public:
+ SQLiteConnection() = default;
+ ~SQLiteConnection() { close(); }
+
+ SQLiteConnection(const SQLiteConnection &) = delete;
+ SQLiteConnection &operator=(const SQLiteConnection &) = delete;
+
+ sqlite3 *handle() const { return db_; }
+
+ bool open(const std::string &path, int flags, int busyTimeoutMs, const std::string &encryptionKey, std::string &outError) {
+ int rc = sqlite3_open_v2(path.c_str(), &db_, flags, nullptr);
+ if (rc != SQLITE_OK) {
+ outError = db_ ? sqlite3_errmsg(db_) : "Failed to allocate memory for database";
+ if (db_) { sqlite3_close(db_); db_ = nullptr; }
+ return false;
+ }
+ path_ = path;
+ sqlite3_extended_result_codes(db_, 1);
+ sqlite3_busy_timeout(db_, busyTimeoutMs);
+
+ if (!encryptionKey.empty()) {
+ std::string pragmaSQL = "PRAGMA key = '" + encryptionKey + "'";
+ char *errMsg = nullptr;
+ rc = sqlite3_exec(db_, pragmaSQL.c_str(), nullptr, nullptr, &errMsg);
+ if (rc != SQLITE_OK) {
+ outError = errMsg ? errMsg : "Failed to set encryption key";
+ if (errMsg) sqlite3_free(errMsg);
+ sqlite3_close(db_);
+ db_ = nullptr;
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ bool configureWAL(std::string &outError) {
+ char *errMsg = nullptr;
+ int rc = sqlite3_exec(db_, "PRAGMA journal_mode=WAL", nullptr, nullptr, &errMsg);
+ if (rc != SQLITE_OK) {
+ outError = errMsg ? errMsg : "Failed to set WAL mode";
+ if (errMsg) sqlite3_free(errMsg);
+ return false;
+ }
+ return true;
+ }
+
+ void close() {
+ if (db_) {
+ sqlite3_close_v2(db_);
+ db_ = nullptr;
+ }
+ }
+
+ int lastErrorCode() const { return db_ ? sqlite3_errcode(db_) : SQLITE_ERROR; }
+ int lastExtendedErrorCode() const { return db_ ? sqlite3_extended_errcode(db_) : SQLITE_ERROR; }
+ const char *lastErrorMsg() const { return db_ ? sqlite3_errmsg(db_) : "Database not open"; }
+
+ bool execute(const char *sql, std::string &outError) {
+ char *errMsg = nullptr;
+ int rc = sqlite3_exec(db_, sql, nullptr, nullptr, &errMsg);
+ if (rc != SQLITE_OK) {
+ outError = errMsg ? errMsg : sqlite3_errmsg(db_);
+ if (errMsg) sqlite3_free(errMsg);
+ return false;
+ }
+ return true;
+ }
+};
+
+// MARK: - Parameter Binding
+
+static bool bindParams(sqlite3_stmt *stmt, NSArray *params, std::string &outError) {
+ if (!params || params.count == 0) return true;
+
+ id first = params[0];
+ bool isDict = [first isKindOfClass:[NSString class]] && params.count == 2 && [params[1] isKindOfClass:[NSDictionary class]];
+
+ if ([first isKindOfClass:[NSDictionary class]]) {
+ NSDictionary *dict = (NSDictionary *)first;
+ for (NSString *key in dict) {
+ NSString *paramName = [key hasPrefix:@":"] || [key hasPrefix:@"$"] || [key hasPrefix:@"@"]
+ ? key
+ : [@":" stringByAppendingString:key];
+ int idx = sqlite3_bind_parameter_index(stmt, [paramName UTF8String]);
+ if (idx == 0) {
+ outError = std::string("Unknown parameter: ") + [paramName UTF8String];
+ return false;
+ }
+ id value = dict[key];
+ if ([value isKindOfClass:[NSNull class]] || value == nil) {
+ sqlite3_bind_null(stmt, idx);
+ } else if ([value isKindOfClass:[NSNumber class]]) {
+ NSNumber *num = (NSNumber *)value;
+ const char *type = [num objCType];
+ if (strcmp(type, @encode(BOOL)) == 0 || strcmp(type, @encode(char)) == 0) {
+ sqlite3_bind_int(stmt, idx, [num intValue]);
+ } else if (strcmp(type, @encode(int)) == 0 || strcmp(type, @encode(long)) == 0 ||
+ strcmp(type, @encode(long long)) == 0 || strcmp(type, @encode(short)) == 0) {
+ sqlite3_bind_int64(stmt, idx, [num longLongValue]);
+ } else {
+ sqlite3_bind_double(stmt, idx, [num doubleValue]);
+ }
+ } else if ([value isKindOfClass:[NSString class]]) {
+ const char *utf8 = [(NSString *)value UTF8String];
+ sqlite3_bind_text(stmt, idx, utf8, -1, SQLITE_TRANSIENT);
+ } else if ([value isKindOfClass:[NSData class]]) {
+ NSData *data = (NSData *)value;
+ sqlite3_bind_blob(stmt, idx, data.bytes, (int)data.length, SQLITE_TRANSIENT);
+ }
+ }
+ return true;
+ }
+
+ for (NSUInteger i = 0; i < params.count; i++) {
+ int idx = (int)(i + 1);
+ id value = params[i];
+ if ([value isKindOfClass:[NSNull class]] || value == nil) {
+ sqlite3_bind_null(stmt, idx);
+ } else if ([value isKindOfClass:[NSNumber class]]) {
+ NSNumber *num = (NSNumber *)value;
+ const char *type = [num objCType];
+ if (strcmp(type, @encode(BOOL)) == 0 || strcmp(type, @encode(char)) == 0) {
+ sqlite3_bind_int(stmt, idx, [num intValue]);
+ } else if (strcmp(type, @encode(int)) == 0 || strcmp(type, @encode(long)) == 0 ||
+ strcmp(type, @encode(long long)) == 0 || strcmp(type, @encode(short)) == 0) {
+ sqlite3_bind_int64(stmt, idx, [num longLongValue]);
+ } else {
+ sqlite3_bind_double(stmt, idx, [num doubleValue]);
+ }
+ } else if ([value isKindOfClass:[NSString class]]) {
+ const char *utf8 = [(NSString *)value UTF8String];
+ sqlite3_bind_text(stmt, idx, utf8, -1, SQLITE_TRANSIENT);
+ } else if ([value isKindOfClass:[NSData class]]) {
+ NSData *data = (NSData *)value;
+ sqlite3_bind_blob(stmt, idx, data.bytes, (int)data.length, SQLITE_TRANSIENT);
+ }
+ }
+ return true;
+}
+
+// MARK: - Statement Execution
+
+struct ExecuteResult {
+ bool success;
+ std::string error;
+ int errorCode;
+ int extendedErrorCode;
+};
+
+static ExecuteResult executeSQL(SQLiteConnection &conn, const char *sql, NSArray *params) {
+ ExecuteResult result = {true, "", SQLITE_OK, SQLITE_OK};
+ sqlite3_stmt *stmt = nullptr;
+
+ int rc = sqlite3_prepare_v2(conn.handle(), sql, -1, &stmt, nullptr);
+ if (rc != SQLITE_OK) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ return result;
+ }
+
+ if (!bindParams(stmt, params, result.error)) {
+ result.success = false;
+ result.errorCode = SQLITE_ERROR;
+ result.extendedErrorCode = SQLITE_ERROR;
+ sqlite3_finalize(stmt);
+ return result;
+ }
+
+ rc = sqlite3_step(stmt);
+ if (rc != SQLITE_DONE && rc != SQLITE_ROW) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ }
+
+ sqlite3_finalize(stmt);
+ return result;
+}
+
+static void appendColumnValue(sqlite3_stmt *stmt, int i, JSONBuilder &json, std::vector &blobs) {
+ int colType = sqlite3_column_type(stmt, i);
+ switch (colType) {
+ case SQLITE_INTEGER:
+ json.appendInt(sqlite3_column_int64(stmt, i));
+ break;
+ case SQLITE_FLOAT:
+ json.appendDouble(sqlite3_column_double(stmt, i));
+ break;
+ case SQLITE_TEXT: {
+ const char *text = (const char *)sqlite3_column_text(stmt, i);
+ int bytes = sqlite3_column_bytes(stmt, i);
+ json.appendString(text, bytes);
+ break;
+ }
+ case SQLITE_BLOB: {
+ const void *data = sqlite3_column_blob(stmt, i);
+ int bytes = sqlite3_column_bytes(stmt, i);
+ NSData *blobData = [NSData dataWithBytes:data length:bytes];
+ int blobIdx = (int)blobs.size();
+ blobs.push_back(blobData);
+ json.appendBlobPlaceholder(blobIdx);
+ break;
+ }
+ case SQLITE_NULL:
+ default:
+ json.appendNull();
+ break;
+ }
+}
+
+struct SelectResult {
+ bool success;
+ std::string json;
+ std::vector blobs;
+ std::string error;
+ int errorCode;
+ int extendedErrorCode;
+};
+
+static SelectResult selectSQL(SQLiteConnection &conn, const char *sql, NSArray *params) {
+ SelectResult result = {true, "", {}, "", SQLITE_OK, SQLITE_OK};
+ sqlite3_stmt *stmt = nullptr;
+
+ int rc = sqlite3_prepare_v2(conn.handle(), sql, -1, &stmt, nullptr);
+ if (rc != SQLITE_OK) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ return result;
+ }
+
+ if (!bindParams(stmt, params, result.error)) {
+ result.success = false;
+ result.errorCode = SQLITE_ERROR;
+ result.extendedErrorCode = SQLITE_ERROR;
+ sqlite3_finalize(stmt);
+ return result;
+ }
+
+ int colCount = sqlite3_column_count(stmt);
+ std::vector colNames;
+ colNames.reserve(colCount);
+ bool namesCollected = false;
+
+ JSONBuilder json;
+ json.appendRaw('[');
+ bool firstRow = true;
+
+ while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+ if (!namesCollected) {
+ for (int i = 0; i < colCount; i++) {
+ const char *name = sqlite3_column_name(stmt, i);
+ colNames.emplace_back(name ? name : "");
+ }
+ namesCollected = true;
+ }
+
+ if (!firstRow) json.appendRaw(',');
+ firstRow = false;
+ json.appendRaw('{');
+
+ for (int i = 0; i < colCount; i++) {
+ if (i > 0) json.appendRaw(',');
+ json.appendString(colNames[i].c_str(), (int)colNames[i].length());
+ json.appendRaw(':');
+ appendColumnValue(stmt, i, json, result.blobs);
+ }
+ json.appendRaw('}');
+ }
+
+ json.appendRaw(']');
+
+ if (rc != SQLITE_DONE) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ } else {
+ result.json = json.str();
+ }
+
+ sqlite3_finalize(stmt);
+ return result;
+}
+
+static SelectResult selectArraySQL(SQLiteConnection &conn, const char *sql, NSArray *params) {
+ SelectResult result = {true, "", {}, "", SQLITE_OK, SQLITE_OK};
+ sqlite3_stmt *stmt = nullptr;
+
+ int rc = sqlite3_prepare_v2(conn.handle(), sql, -1, &stmt, nullptr);
+ if (rc != SQLITE_OK) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ return result;
+ }
+
+ if (!bindParams(stmt, params, result.error)) {
+ result.success = false;
+ result.errorCode = SQLITE_ERROR;
+ result.extendedErrorCode = SQLITE_ERROR;
+ sqlite3_finalize(stmt);
+ return result;
+ }
+
+ int colCount = sqlite3_column_count(stmt);
+
+ JSONBuilder json;
+ // {"columns":[...],"rows":[[...], ...]}
+ json.appendRaw("{\"columns\":[", 12);
+ for (int i = 0; i < colCount; i++) {
+ if (i > 0) json.appendRaw(',');
+ const char *name = sqlite3_column_name(stmt, i);
+ json.appendString(name ? name : "", name ? (int)strlen(name) : 0);
+ }
+ json.appendRaw("],\"rows\":[", 10);
+
+ bool firstRow = true;
+ while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+ if (!firstRow) json.appendRaw(',');
+ firstRow = false;
+ json.appendRaw('[');
+ for (int i = 0; i < colCount; i++) {
+ if (i > 0) json.appendRaw(',');
+ appendColumnValue(stmt, i, json, result.blobs);
+ }
+ json.appendRaw(']');
+ }
+
+ json.appendRaw("]}", 2);
+
+ if (rc != SQLITE_DONE) {
+ result.success = false;
+ result.error = conn.lastErrorMsg();
+ result.errorCode = conn.lastErrorCode();
+ result.extendedErrorCode = conn.lastExtendedErrorCode();
+ } else {
+ result.json = json.str();
+ }
+
+ sqlite3_finalize(stmt);
+ return result;
+}
+
+// MARK: - Prepared Statement Handle
+
+struct PreparedStmtHandle {
+ sqlite3_stmt *stmt;
+ SQLiteConnection *conn;
+ dispatch_queue_t queue;
+};
+
+// MARK: - Read Transaction Handle
+
+struct ReadTxHandle {
+ SQLiteConnection *conn;
+ dispatch_queue_t queue;
+ int readerIndex;
+};
+
+// MARK: - NSSQLiteDatabase Implementation
+
+@implementation NSSQLiteDatabase {
+ SQLiteConnection _writerConn;
+ std::vector _readerConns;
+ SQLiteConnection _syncConn;
+ bool _syncConnOpened;
+
+ dispatch_queue_t _writerQueue;
+ std::vector _readerQueues;
+ std::atomic _readerIndex;
+
+ std::string _path;
+ std::string _encryptionKey;
+ int _busyTimeoutMs;
+ BOOL _readOnly;
+ BOOL _isOpen;
+
+ std::atomic _nextTxId;
+ std::atomic _nextStmtId;
+
+ std::mutex _txMutex;
+ bool _hasActiveWriteTx;
+ std::vector> _pendingTxStarts;
+
+ std::mutex _stmtMutex;
+ std::unordered_map _preparedStmts;
+
+ std::mutex _readTxMutex;
+ std::unordered_map _readTxHandles;
+ std::vector _readerAvailable;
+}
+
++ (instancetype)openWithPath:(NSString *)path
+ poolSize:(int)poolSize
+ readOnly:(BOOL)readOnly
+ busyTimeout:(int)busyTimeoutMs
+ encryptionKey:(NSString *)encryptionKey {
+ NSSQLiteDatabase *db = [[NSSQLiteDatabase alloc] init];
+ if (![db _openWithPath:path poolSize:poolSize readOnly:readOnly busyTimeout:busyTimeoutMs encryptionKey:encryptionKey]) {
+ return nil;
+ }
+ return db;
+}
+
+- (BOOL)_openWithPath:(NSString *)path
+ poolSize:(int)poolSize
+ readOnly:(BOOL)readOnly
+ busyTimeout:(int)busyTimeoutMs
+ encryptionKey:(NSString *)encryptionKey {
+ _path = [path UTF8String];
+ _busyTimeoutMs = busyTimeoutMs;
+ _readOnly = readOnly;
+ _encryptionKey = encryptionKey ? [encryptionKey UTF8String] : "";
+ _syncConnOpened = false;
+ _readerIndex = 0;
+ _nextTxId = 1;
+ _nextStmtId = 1;
+ _hasActiveWriteTx = false;
+
+ std::string error;
+
+ int writerFlags = readOnly
+ ? (SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX)
+ : (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX);
+
+ if (!_writerConn.open(_path, writerFlags, busyTimeoutMs, _encryptionKey, error)) {
+ NSLog(@"[NSSQLiteDatabase] Failed to open writer: %s", error.c_str());
+ return NO;
+ }
+
+ if (!readOnly) {
+ if (!_writerConn.configureWAL(error)) {
+ NSLog(@"[NSSQLiteDatabase] Failed to enable WAL: %s", error.c_str());
+ _writerConn.close();
+ return NO;
+ }
+ }
+
+ NSString *writerLabel = [NSString stringWithFormat:@"com.nssqlite.writer.%@", [path lastPathComponent]];
+ _writerQueue = dispatch_queue_create([writerLabel UTF8String], DISPATCH_QUEUE_SERIAL);
+
+ if (poolSize < 1) poolSize = 1;
+
+ // Readers open as READWRITE so they can initialize WAL shared memory (SHM).
+ // READONLY connections cannot create/map the SHM file, which causes "unable to
+ // open database file" errors when the DB is already in WAL mode.
+ // PRAGMA query_only=ON prevents accidental writes through these connections.
+ for (int i = 0; i < poolSize; i++) {
+ auto *reader = new SQLiteConnection();
+ int readerFlags = (readOnly ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE) | SQLITE_OPEN_NOMUTEX;
+ if (!reader->open(_path, readerFlags, busyTimeoutMs, _encryptionKey, error)) {
+ NSLog(@"[NSSQLiteDatabase] Failed to open reader %d: %s", i, error.c_str());
+ delete reader;
+ continue;
+ }
+ if (!readOnly) {
+ reader->execute("PRAGMA query_only=ON", error);
+ }
+ _readerConns.push_back(reader);
+ _readerAvailable.push_back(true);
+
+ NSString *label = [NSString stringWithFormat:@"com.nssqlite.reader.%d.%@", i, [path lastPathComponent]];
+ _readerQueues.push_back(dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_SERIAL));
+ }
+
+ _isOpen = YES;
+ return YES;
+}
+
+- (BOOL)isOpen {
+ return _isOpen;
+}
+
+// MARK: - Async Execute
+
+- (void)execute:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSError *))completion {
+ const char *sqlUTF8 = strdup([sql UTF8String]);
+ NSArray *paramsCopy = params ? [params copy] : nil;
+
+ dispatch_async(_writerQueue, ^{
+ auto result = executeSQL(self->_writerConn, sqlUTF8, paramsCopy);
+ free((void *)sqlUTF8);
+
+ if (completion) {
+ NSError *error = result.success ? nil : [self _errorWithMessage:result.error
+ code:result.errorCode
+ extendedCode:result.extendedErrorCode];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion(error);
+ });
+ }
+ });
+}
+
+// MARK: - Select Result Dispatch Helper
+
+- (void)_dispatchSelectResult:(const SelectResult &)result
+ completion:(void (^)(NSString *, NSArray *, NSError *))completion {
+ if (!completion) return;
+ if (result.success) {
+ NSString *jsonStr = [[NSString alloc] initWithUTF8String:result.json.c_str()];
+ NSMutableArray *blobs = nil;
+ if (!result.blobs.empty()) {
+ blobs = [NSMutableArray arrayWithCapacity:result.blobs.size()];
+ for (auto &b : result.blobs) [blobs addObject:b];
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion(jsonStr, blobs, nil);
+ });
+ } else {
+ NSError *error = [self _errorWithMessage:result.error
+ code:result.errorCode
+ extendedCode:result.extendedErrorCode];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion(nil, nil, error);
+ });
+ }
+}
+
+// MARK: - Async Select
+
+- (void)select:(NSString *)sql
+ params:(NSArray *)params
+ completion:(void (^)(NSString *, NSArray