Skip to content

Commit 173e7cf

Browse files
committed
feat: refactor tx API + add backing functions
- Consolidated transaction API to address a design flaw - Calling BEGIN and then ceding the write conn could leave an open transaction dangling - Added backing functions to wrapper.rs - Added decoder and tests - Added tests for error types - Remove the idea that an error will throw when attempting a RO query with the writer. It's obvious from the return type you'll get no query results - NOTE: Integration tests and migrations coming soon
1 parent 0536341 commit 173e7cf

10 files changed

Lines changed: 701 additions & 144 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ futures-core = "0.3.31"
2323
time = "0.3.44"
2424
tokio = { version = "1.48.0", features = ["sync"] }
2525
indexmap = { version = "2.12.1", features = ["serde"] }
26+
base64 = "0.22.1"
2627

2728
# SQLx for types and queries (time feature enables datetime type decoding)
2829
sqlx = { version = "0.8.6", features = ["sqlite", "json", "time", "runtime-tokio"] }

README.md

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,18 @@ type SqlValue = string | number | boolean | null | Uint8Array
185185
Supported SQLite types:
186186
187187
* **TEXT** - `string` values (also used for DATE, TIME, DATETIME)
188-
* **INTEGER** - `number` values (integers)
188+
* **INTEGER** - `number` values (integers, preserved up to i64 range)
189189
* **REAL** - `number` values (floating point)
190190
* **BOOLEAN** - `boolean` values
191191
* **NULL** - `null` value
192192
* **BLOB** - `Uint8Array` for binary data
193193
194+
> **Note:** JavaScript's `number` type can safely represent integers up to
195+
> ±2^53 - 1 (±9,007,199,254,740,991). The plugin preserves integer precision by
196+
> binding integers as SQLite's INTEGER type (i64). For values within the i64
197+
> range (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807), full precision
198+
> is maintained. Values outside this range may lose precision.
199+
194200
```typescript
195201
// Example with different types
196202
await db.execute(
@@ -265,7 +271,6 @@ Common error codes include:
265271
* `INVALID_PATH` - Invalid database path
266272
* `IO_ERROR` - File system error
267273
* `MIGRATION_ERROR` - Migration failed
268-
* `READ_ONLY_QUERY_IN_EXECUTE` - Attempted to use execute() for a read-only query
269274
* `MULTIPLE_ROWS_RETURNED` - `fetchOne()` query returned multiple rows
270275

271276
### Executing SELECT Queries
@@ -304,45 +309,35 @@ if (user) {
304309
305310
### Using Transactions
306311

307-
Transactions ensure that multiple operations either all succeed or all fail together,
308-
maintaining data consistency:
312+
Execute multiple database operations atomically using `executeTransaction()`. All
313+
statements either succeed together or fail together, maintaining data consistency:
309314

310315
```typescript
311-
// Begin a transaction
312-
await db.beginTransaction();
313-
314-
try {
315-
// Execute multiple operations atomically
316-
await db.execute(
317-
'INSERT INTO users (name, email) VALUES ($1, $2)',
318-
['Alice', 'alice@example.com']
319-
);
320-
321-
await db.execute(
322-
'INSERT INTO audit_log (action, user) VALUES ($1, $2)',
323-
['user_created', 'Alice']
324-
);
325-
326-
// Commit if all operations succeed
327-
await db.commitTransaction();
328-
console.log('Transaction completed successfully');
329-
330-
} catch (error) {
331-
// Rollback if any operation fails
332-
await db.rollbackTransaction();
333-
console.error('Transaction failed, rolled back:', error);
334-
throw error;
335-
}
336-
```
337-
338-
**Important Notes:**
339-
340-
* All operations between `beginTransaction()` and
341-
`commitTransaction()`/`rollbackTransaction()` are executed as a single atomic unit
342-
* If an error occurs, call `rollbackTransaction()` to discard all changes
343-
* Nested transactions are not supported
344-
* Always ensure transactions are either committed or rolled back to avoid locking
345-
issues
316+
// Execute multiple inserts atomically
317+
const results = await db.executeTransaction([
318+
['INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com']],
319+
['INSERT INTO audit_log (action, user) VALUES ($1, $2)', ['user_created', 'Alice']]
320+
]);
321+
console.log(`User ID: ${results[0].lastInsertId}`);
322+
console.log(`Log rows affected: ${results[1].rowsAffected}`);
323+
324+
// Bank transfer example - all operations succeed or all fail
325+
const results = await db.executeTransaction([
326+
['UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]],
327+
['UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]],
328+
['INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)', [1, 2, 100]]
329+
]);
330+
console.log(`Transfer ID: ${results[2].lastInsertId}`);
331+
```
332+
333+
**How it works:**
334+
335+
* Automatically executes `BEGIN` before running statements
336+
* Executes all statements in order
337+
* Commits with `COMMIT` if all statements succeed
338+
* Rolls back with `ROLLBACK` if any statement fails
339+
* The write connection is held for the entire transaction, ensuring atomicity
340+
* Errors are thrown after rollback, preserving the original error message
346341

347342
### Closing Connections
348343

@@ -465,8 +460,8 @@ const filtered = await db.fetchAll<User[]>(
465460
)
466461
```
467462

468-
> **Important:** Do NOT use `execute()` for read-only queries. It will return
469-
> an error. Always use `fetchAll()` or `fetchOne()` for reads.
463+
> **Note:** Use `execute()` and `executeTransaction()` for write operations.
464+
> For SELECT queries, use `fetchAll()` or `fetchOne()`.
470465
471466
## Configuration
472467

@@ -541,7 +536,7 @@ await Database.closeAll()
541536

542537
#### Instance Methods
543538

544-
##### `execute(query: string, bindValues?: unknown[]): Promise<QueryResult>`
539+
##### `execute(query: string, bindValues?: unknown[]): Promise<WriteQueryResult>`
545540

546541
Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.).
547542

@@ -602,9 +597,9 @@ await db.remove()
602597
### TypeScript Interfaces
603598

604599
```typescript
605-
interface QueryResult {
600+
interface WriteQueryResult {
606601
rowsAffected: number // Number of rows modified
607-
lastInsertId: number // ROWID of last inserted row
602+
lastInsertId: number // ROWID of last inserted row (not set for WITHOUT ROWID tables, returns 0)
608603
}
609604

610605
interface CustomConfig {

api-iife.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
fn main() {
2+
// TODO: Add commands to the plugin
23
tauri_plugin::Builder::new(&["hello"]).build();
34
}

guest-js/index.ts

Lines changed: 57 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ import { invoke } from '@tauri-apps/api/core'
1212
*/
1313
export type SqlValue = string | number | boolean | null | Uint8Array
1414

15-
export interface QueryResult {
16-
/** The number of rows affected by the query. */
15+
/**
16+
* Result returned from write operations (INSERT, UPDATE, DELETE, etc.).
17+
*/
18+
export interface WriteQueryResult {
19+
/** The number of rows affected by the write operation. */
1720
rowsAffected: number
18-
/** The last inserted row ID (SQLite ROWID). */
21+
/**
22+
* The last inserted row ID (SQLite ROWID).
23+
* Only set for INSERT operations on tables with a ROWID.
24+
* Tables created with WITHOUT ROWID will not set this value (returns 0).
25+
*/
1926
lastInsertId: number
2027
}
2128

@@ -107,11 +114,9 @@ export default class Database {
107114
* **execute**
108115
*
109116
* Executes a write query against the database (INSERT, UPDATE, DELETE, etc.).
110-
* This method is specifically for mutations that modify data.
117+
* This method is for mutations that modify data.
111118
*
112-
* **Important:** Do NOT use this for SELECT queries. Use `fetchX()` instead.
113-
* Using `execute()` for read queries will trigger an error to prevent unnecessary
114-
* write mode initialization.
119+
* For SELECT queries, use `fetchAll()` or `fetchOne()` instead.
115120
*
116121
* SQLite uses `$1`, `$2`, etc. for parameter binding.
117122
*
@@ -132,7 +137,7 @@ export default class Database {
132137
* );
133138
* ```
134139
*/
135-
async execute(query: string, bindValues?: SqlValue[]): Promise<QueryResult> {
140+
async execute(query: string, bindValues?: SqlValue[]): Promise<WriteQueryResult> {
136141
const [rowsAffected, lastInsertId] = await invoke<[number, number]>(
137142
'plugin:sqlite|execute',
138143
{
@@ -147,6 +152,50 @@ export default class Database {
147152
}
148153
}
149154

155+
/**
156+
* **executeTransaction**
157+
*
158+
* Executes multiple write statements atomically within a transaction.
159+
* All statements either succeed together or fail together.
160+
*
161+
* The function automatically:
162+
* - Begins a transaction (BEGIN)
163+
* - Executes all statements in order
164+
* - Commits on success (COMMIT)
165+
* - Rolls back on any error (ROLLBACK)
166+
*
167+
* @param statements - Array of [query, values?] tuples to execute
168+
* @returns Promise that resolves with results for each statement when all complete successfully
169+
* @throws SqliteError if any statement fails (after rollback)
170+
*
171+
* @example
172+
* ```ts
173+
* // Execute multiple inserts atomically
174+
* const results = await db.executeTransaction([
175+
* ['INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com']],
176+
* ['INSERT INTO audit_log (action, user) VALUES ($1, $2)', ['user_created', 'Alice']]
177+
* ]);
178+
* console.log(`User ID: ${results[0].lastInsertId}`);
179+
* console.log(`Log rows affected: ${results[1].rowsAffected}`);
180+
*
181+
* // Mixed operations
182+
* const results = await db.executeTransaction([
183+
* ['UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]],
184+
* ['UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]],
185+
* ['INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)', [1, 2, 100]]
186+
* ]);
187+
* ```
188+
*/
189+
async executeTransaction(statements: Array<[string, SqlValue[]?]>): Promise<WriteQueryResult[]> {
190+
return await invoke<WriteQueryResult[]>('plugin:sqlite|execute_transaction', {
191+
db: this.path,
192+
statements: statements.map(([query, values]) => ({
193+
query,
194+
values: values ?? []
195+
}))
196+
})
197+
}
198+
150199
/**
151200
* **fetchAll**
152201
*
@@ -211,78 +260,6 @@ export default class Database {
211260
return result
212261
}
213262

214-
/**
215-
* **beginTransaction**
216-
*
217-
* Begins a new database transaction. All subsequent operations will be
218-
* part of this transaction until `commitTransaction()` or `rollbackTransaction()`
219-
* is called.
220-
*
221-
* Transactions provide atomicity - either all operations succeed or all are rolled back.
222-
*
223-
* @example
224-
* ```ts
225-
* await db.beginTransaction();
226-
* try {
227-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
228-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
229-
* await db.commitTransaction();
230-
* } catch (error) {
231-
* await db.rollbackTransaction();
232-
* throw error;
233-
* }
234-
* ```
235-
*/
236-
async beginTransaction(): Promise<void> {
237-
await invoke('plugin:sqlite|begin_transaction', {
238-
db: this.path
239-
})
240-
}
241-
242-
/**
243-
* **commitTransaction**
244-
*
245-
* Commits the current transaction, making all changes permanent.
246-
*
247-
* @example
248-
* ```ts
249-
* await db.beginTransaction();
250-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
251-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
252-
* await db.commitTransaction();
253-
* ```
254-
*/
255-
async commitTransaction(): Promise<void> {
256-
await invoke('plugin:sqlite|commit_transaction', {
257-
db: this.path
258-
})
259-
}
260-
261-
/**
262-
* **rollbackTransaction**
263-
*
264-
* Rolls back the current transaction, discarding all changes made since
265-
* `beginTransaction()` was called.
266-
*
267-
* @example
268-
* ```ts
269-
* await db.beginTransaction();
270-
* try {
271-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
272-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
273-
* await db.commitTransaction();
274-
* } catch (error) {
275-
* await db.rollbackTransaction();
276-
* throw error;
277-
* }
278-
* ```
279-
*/
280-
async rollbackTransaction(): Promise<void> {
281-
await invoke('plugin:sqlite|rollback_transaction', {
282-
db: this.path
283-
})
284-
}
285-
286263
/**
287264
* **close**
288265
*

0 commit comments

Comments
 (0)