Skip to content

Commit 99af821

Browse files
committed
feat: add encryption support and new getArray methods to SQLite plugin
1 parent e09322c commit 99af821

8 files changed

Lines changed: 274 additions & 55 deletions

File tree

packages/nativescript-sqlite/README.md

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,28 @@ A high-performance SQLite plugin for NativeScript. All database operations run o
88

99
- Fully asynchronous — all queries dispatch to native background threads
1010
- Connection pool with WAL mode — concurrent reads, serialized writes
11+
- Transaction queue — concurrent `transaction()` calls are safe, they wait their turn
1112
- Write and read transactions with savepoint (nested transaction) support
1213
- Prepared statements
1314
- Two result formats: objects (`select`) or columnar arrays (`selectArray`)
1415
- Synchronous API available for simple use cases (migrations, setup)
1516
- Custom SQLite builds supported via CocoaPods
17+
- Drizzle ORM driver included
1618

1719
## Installation
1820

1921
```bash
2022
npm install @edusperoni/nativescript-sqlite
2123
```
2224

25+
The plugin does not bundle SQLite — you must link one. For most apps, add to `App_Resources/iOS/build.xcconfig`:
26+
27+
```
28+
OTHER_LDFLAGS = $(inherited) -lsqlite3
29+
```
30+
31+
For other options (custom builds, SQLCipher encryption), see [SQLite Linking](#sqlite-linking).
32+
2333
## Quick Start
2434

2535
```typescript
@@ -55,7 +65,7 @@ const user = await db.get('SELECT * FROM users WHERE id = ?', [1]);
5565
// => { id: 1, name: "Alice", ... } or undefined
5666

5767
// Clean up
58-
db.close();
68+
await db.close();
5969
```
6070

6171
## API Reference
@@ -98,8 +108,13 @@ const result = await db.selectArray(sql, params?);
98108
// result.columns => ['id', 'name', 'age']
99109
// result.rows => [[1, 'Alice', 30], [2, 'Bob', 25]]
100110

101-
// Query a single row
111+
// Query a single row as an object
102112
const row = await db.get<MyType>(sql, params?);
113+
114+
// Query a single row as a columnar array
115+
const rowArr = await db.getArray(sql, params?);
116+
// rowArr.columns => ['id', 'name', 'age']
117+
// rowArr.rows => [[1, 'Alice', 30]] (or [] if no match)
103118
```
104119
105120
#### Sync Methods
@@ -111,15 +126,18 @@ db.executeSync(sql, params?);
111126
const rows = db.selectSync<MyType>(sql, params?);
112127
const result = db.selectArraySync(sql, params?);
113128
const row = db.getSync<MyType>(sql, params?);
129+
const rowArr = db.getArraySync(sql, params?);
114130
```
115131
116132
#### Lifecycle
117133
118134
```typescript
119-
db.isOpen; // boolean
120-
db.close(); // closes all connections, finalizes all prepared statements
135+
db.isOpen; // boolean
136+
await db.close(); // waits for in-flight operations to finish, then closes all connections
121137
```
122138
139+
`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.
140+
123141
### Parameters
124142
125143
Both positional and named parameters are supported.
@@ -155,7 +173,7 @@ The prefix (`:`, `$`, `@`) is added automatically if omitted — you can pass `{
155173
156174
#### Write Transactions
157175
158-
Write transactions use the writer connection with `BEGIN IMMEDIATE`, ensuring an exclusive write lock from the start. They are serialized — concurrent calls to `transaction()` will queue automatically.
176+
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.
159177
160178
```typescript
161179
const userId = await db.transaction(async (tx) => {
@@ -168,10 +186,10 @@ const userId = await db.transaction(async (tx) => {
168186
169187
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.
170188
171-
**Concurrent transactions** are safe — the second transaction's `BEGIN IMMEDIATE` queues behind the first's `COMMIT` on the writer's serial dispatch queue:
189+
**Concurrent transactions** are safe — the second transaction waits for the first to finish before starting:
172190
173191
```typescript
174-
// Both run, but writes are serialized
192+
// Both run, but writes are serialized via the transaction queue
175193
const [r1, r2] = await Promise.all([
176194
db.transaction(async (tx) => { /* ... */ }),
177195
db.transaction(async (tx) => { /* ... */ }),
@@ -230,9 +248,9 @@ await stmt.finalize(); // release native resources
230248
231249
Prepared statements are automatically finalized when the database is closed, but it is good practice to finalize them explicitly when no longer needed.
232250
233-
### `selectArray` — Columnar Result Format
251+
### `selectArray` / `getArray` — Columnar Result Format
234252
235-
`selectArray` returns column names once and rows as arrays of values. This is more efficient for large result sets since column names are not repeated per row.
253+
`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.
236254
237255
```typescript
238256
const result = await db.selectArray<[number, string, number]>(
@@ -243,9 +261,14 @@ console.log(result.columns); // ['id', 'name', 'age']
243261
for (const [id, name, age] of result.rows) {
244262
console.log(id, name, age);
245263
}
264+
265+
// Single row variant
266+
const single = await db.getArray('SELECT id, name FROM users WHERE id = ?', [1]);
267+
// single.columns => ['id', 'name']
268+
// single.rows => [[1, 'Alice']] (or [] if no match)
246269
```
247270
248-
Available on all contexts: `db.selectArray()`, `db.selectArraySync()`, `tx.selectArray()`, `stmt.selectArray()`.
271+
Available on all contexts: `db.selectArray()`, `db.getArray()`, `db.selectArraySync()`, `db.getArraySync()`, `tx.selectArray()`, `tx.getArray()`, `stmt.selectArray()`, `stmt.getArray()`.
249272
250273
### Error Handling
251274
@@ -265,15 +288,58 @@ try {
265288
}
266289
```
267290

291+
### Low-Level Transaction Control
292+
293+
For driver integrations (e.g., drizzle), the database exposes `txId`-based methods that allow external transaction management:
294+
295+
```typescript
296+
const txId = await db.beginTransaction('deferred'); // 'deferred' | 'immediate' | 'exclusive'
297+
await db.executeInTransaction(txId, 'INSERT INTO users (name) VALUES (?)', ['Alice']);
298+
const rows = await db.selectInTransaction(txId, 'SELECT * FROM users');
299+
await db.commitTransaction(txId);
300+
// or: await db.rollbackTransaction(txId);
301+
```
302+
303+
These are used by the drizzle driver to scope each drizzle transaction to its own `txId`, enabling safe concurrent transactions through `Promise.all`.
304+
305+
## Drizzle ORM Integration
306+
307+
A custom drizzle driver is included. It creates a dedicated session per transaction, so concurrent transactions are fully isolated.
308+
309+
```typescript
310+
import { drizzle } from '@edusperoni/nativescript-sqlite/drizzle-driver';
311+
import { openDatabase } from '@edusperoni/nativescript-sqlite';
312+
import * as schema from './schema';
313+
314+
const sqlite = openDatabase({ path: '...' });
315+
const db = drizzle(sqlite, { schema });
316+
317+
// Standard drizzle usage
318+
const users = await db.select().from(schema.users);
319+
320+
// Transactions — concurrent calls are safe
321+
await Promise.all([
322+
db.transaction(async (tx) => {
323+
await tx.insert(schema.users).values({ name: 'Alice' });
324+
}),
325+
db.transaction(async (tx) => {
326+
await tx.insert(schema.users).values({ name: 'Bob' });
327+
}),
328+
]);
329+
```
330+
331+
Requires `drizzle-orm` as a peer dependency (`>=0.45.0`).
332+
268333
## Architecture
269334

270335
### Connection Pool
271336

272337
The plugin opens multiple SQLite connections to the same database file:
273338

274339
- **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.
275-
- **N reader connections** — opened with `SQLITE_OPEN_READONLY`. 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.
340+
- **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.
276341
- **1 sync connection** — opened lazily on the first sync call. Used exclusively by `executeSync`/`selectSync`/`getSync` on the main thread.
342+
- **Transaction queue** — concurrent `transaction()` calls are queued. The next transaction's `BEGIN` only dispatches after the previous one commits or rolls back.
277343

278344
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.
279345

@@ -282,24 +348,50 @@ WAL (Write-Ahead Logging) mode is enabled automatically on the writer. WAL allow
282348
- All SQLite work (prepare, bind, step, column extraction) happens on background GCD threads.
283349
- 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.
284350
- Blob columns are returned as separate `NSData` objects and converted to `ArrayBuffer` via `interop.bufferFromData` (no extra copy).
285-
- The `selectArray` format avoids repeating column names per row, reducing both serialization cost and memory usage for large result sets.
351+
- The `selectArray` / `getArray` format avoids repeating column names per row, reducing both serialization cost and memory usage for large result sets.
352+
353+
## SQLite Linking
354+
355+
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:
356+
357+
### Option A: System SQLite (simplest)
286358

287-
## Custom SQLite Builds
359+
Link the SQLite that ships with iOS. Add to `App_Resources/iOS/build.xcconfig`:
288360

289-
By default, the plugin links against the system `libsqlite3` shipped with iOS. This SQLite version may lack features like the recovery extension, FTS5 tokenizer customization, or other compile-time options.
361+
```
362+
OTHER_LDFLAGS = $(inherited) -lsqlite3
363+
```
364+
365+
This is the simplest setup. The system SQLite does not support encryption or some newer extensions (e.g., recovery).
290366

291-
To use a custom SQLite build:
367+
### Option B: Custom SQLite via CocoaPods
292368

293-
1. Add a SQLite pod to your app's `App_Resources/iOS/Podfile`:
369+
Use a custom SQLite build with specific compile-time options (FTS5, recovery, etc.). Add to `App_Resources/iOS/Podfile`:
294370

295371
```ruby
296372
pod 'sqlite3', '~> 3.46.0'
297-
# or a custom podspec with your own SQLite build
298373
```
299374

300-
2. The pod's symbols will override the system SQLite at link time. No plugin code changes are needed — it calls the same C API functions regardless of the underlying implementation.
375+
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.
376+
377+
### Option C: SQLCipher (encryption)
378+
379+
Use SQLCipher for transparent AES-256 database encryption. Add to `App_Resources/iOS/Podfile`:
380+
381+
```ruby
382+
pod 'SQLCipher', '~> 4.6'
383+
```
384+
385+
Then pass an encryption key when opening the database:
386+
387+
```typescript
388+
const db = openDatabase({
389+
path: knownFolders.documents().path + '/encrypted.sqlite',
390+
encryptionKey: 'my-secret-key',
391+
});
392+
```
301393

302-
This is how you enable features like `sqlite3_recover_*` for database recovery.
394+
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`.
303395

304396
## Type Definitions
305397

@@ -318,6 +410,7 @@ interface DatabaseOptions {
318410
readOnly?: boolean;
319411
poolSize?: number;
320412
busyTimeout?: number;
413+
encryptionKey?: string;
321414
}
322415
```
323416

packages/nativescript-sqlite/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface DatabaseOptions {
6161
readOnly?: boolean;
6262
poolSize?: number;
6363
busyTimeout?: number;
64+
encryptionKey?: string;
6465
}
6566

6667
export class SQLiteError extends Error {

packages/nativescript-sqlite/index.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface ReadTransaction {
77
select<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): Promise<T[]>;
88
selectArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
99
get<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): Promise<T | undefined>;
10+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
1011
}
1112

1213
export interface Transaction extends ReadTransaction {
@@ -19,6 +20,7 @@ export interface PreparedStatement {
1920
select<T extends SQLiteRow = SQLiteRow>(params?: SQLiteParams): Promise<T[]>;
2021
selectArray<T extends SQLiteValue[] = SQLiteValue[]>(params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
2122
get<T extends SQLiteRow = SQLiteRow>(params?: SQLiteParams): Promise<T | undefined>;
23+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
2224
finalize(): Promise<void>;
2325
}
2426

@@ -27,6 +29,7 @@ export interface SQLiteDatabase {
2729
select<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): Promise<T[]>;
2830
selectArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
2931
get<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): Promise<T | undefined>;
32+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>>;
3033

3134
transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T>;
3235
readTransaction<T>(fn: (tx: ReadTransaction) => Promise<T>): Promise<T>;
@@ -37,6 +40,7 @@ export interface SQLiteDatabase {
3740
selectSync<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): T[];
3841
selectArraySync<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): SQLiteArrayResult<T>;
3942
getSync<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): T | undefined;
43+
getArraySync<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): SQLiteArrayResult<T>;
4044

4145
// Low-level transaction control (for driver integrations like drizzle)
4246
beginTransaction(behavior?: 'deferred' | 'immediate' | 'exclusive'): Promise<number>;
@@ -46,7 +50,7 @@ export interface SQLiteDatabase {
4650
commitTransaction(txId: number): Promise<void>;
4751
rollbackTransaction(txId: number): Promise<void>;
4852

49-
close(): void;
53+
close(): Promise<void>;
5054
readonly isOpen: boolean;
5155
}
5256

packages/nativescript-sqlite/index.ios.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type { PreparedStatement, ReadTransaction, SQLiteDatabase, Transaction };
66
export * from './common';
77

88
declare class NSSQLiteDatabase extends NSObject {
9-
static openWithPathPoolSizeReadOnlyBusyTimeout(path: string, poolSize: number, readOnly: boolean, busyTimeout: number): NSSQLiteDatabase;
9+
static openWithPathPoolSizeReadOnlyBusyTimeoutEncryptionKey(path: string, poolSize: number, readOnly: boolean, busyTimeout: number, encryptionKey: string | null): NSSQLiteDatabase;
1010

1111
executeParamsCompletion(sql: string, params: NSArray<any>, completion: (error: NSError) => void): void;
1212
selectParamsCompletion(sql: string, params: NSArray<any>, completion: (json: string, blobs: NSArray<NSData>, error: NSError) => void): void;
@@ -34,6 +34,7 @@ declare class NSSQLiteDatabase extends NSObject {
3434
selectSyncParamsError(sql: string, params: NSArray<any>): string;
3535
selectArraySyncParamsError(sql: string, params: NSArray<any>): string;
3636

37+
closeWithCompletion(completion: () => void): void;
3738
close(): void;
3839
isOpen: boolean;
3940
}
@@ -160,6 +161,10 @@ class WriteTxImpl implements Transaction {
160161
return this.select<T>(sql, params).then((rows) => rows[0]);
161162
}
162163

164+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>> {
165+
return this.selectArray<T>(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
166+
}
167+
163168
async savepoint<T>(fn: (tx: Transaction) => Promise<T>): Promise<T> {
164169
const name = `sp_${this._savepointCounter.value++}`;
165170
await this.execute(`SAVEPOINT ${name}`);
@@ -207,6 +212,10 @@ class ReadTxImpl implements ReadTransaction {
207212
get<T extends SQLiteRow = SQLiteRow>(sql: string, params?: SQLiteParams): Promise<T | undefined> {
208213
return this.select<T>(sql, params).then((rows) => rows[0]);
209214
}
215+
216+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>> {
217+
return this.selectArray<T>(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
218+
}
210219
}
211220

212221
class PreparedStatementImpl implements PreparedStatement {
@@ -255,6 +264,10 @@ class PreparedStatementImpl implements PreparedStatement {
255264
return this.select<T>(params).then((rows) => rows[0]);
256265
}
257266

267+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(params?: SQLiteParams): Promise<SQLiteArrayResult<T>> {
268+
return this.selectArray<T>(params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
269+
}
270+
258271
finalize(): Promise<void> {
259272
return new Promise((resolve, reject) => {
260273
this.native.finalizePreparedCompletion(this.stmtId, (error) => {
@@ -315,6 +328,10 @@ class SQLiteDatabaseImpl implements SQLiteDatabase {
315328
return this.select<T>(sql, params).then((rows) => rows[0]);
316329
}
317330

331+
getArray<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): Promise<SQLiteArrayResult<T>> {
332+
return this.selectArray<T>(sql, params).then((r) => ({ columns: r.columns, rows: r.rows.slice(0, 1) }));
333+
}
334+
318335
async transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T> {
319336
const txId = await new Promise<number>((resolve, reject) => {
320337
this.native.beginTransactionCompletion('deferred', (id, error) => {
@@ -412,6 +429,10 @@ class SQLiteDatabaseImpl implements SQLiteDatabase {
412429
return this.selectSync<T>(sql, params)[0];
413430
}
414431

432+
getArraySync<T extends SQLiteValue[] = SQLiteValue[]>(sql: string, params?: SQLiteParams): SQLiteArrayResult<T> {
433+
return this.selectArraySync<T>(sql, params);
434+
}
435+
415436
// Low-level transaction control for driver integrations
416437

417438
beginTransaction(behavior?: string): Promise<number> {
@@ -480,13 +501,17 @@ class SQLiteDatabaseImpl implements SQLiteDatabase {
480501
});
481502
}
482503

483-
close(): void {
484-
this.native.close();
504+
close(): Promise<void> {
505+
return new Promise((resolve) => {
506+
this.native.closeWithCompletion(() => {
507+
resolve();
508+
});
509+
});
485510
}
486511
}
487512

488513
export function openDatabase(options: DatabaseOptions): SQLiteDatabase {
489-
const native = NSSQLiteDatabase.openWithPathPoolSizeReadOnlyBusyTimeout(options.path, options.poolSize ?? 4, options.readOnly ?? false, options.busyTimeout ?? 5000);
514+
const native = NSSQLiteDatabase.openWithPathPoolSizeReadOnlyBusyTimeoutEncryptionKey(options.path, options.poolSize ?? 4, options.readOnly ?? false, options.busyTimeout ?? 5000, options.encryptionKey ?? null);
490515
if (!native) {
491516
throw new SQLiteError(`Failed to open database: ${options.path}`, -1);
492517
}

0 commit comments

Comments
 (0)