Skip to content

Commit 67515c4

Browse files
committed
feat: support interruptible transactions
1 parent 8593a41 commit 67515c4

12 files changed

Lines changed: 806 additions & 19 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
@@ -24,6 +24,7 @@ tokio = { version = "1.48.0", features = ["rt", "sync", "time"] }
2424
indexmap = { version = "2.12.1", features = ["serde"] }
2525
base64 = "0.22.1"
2626
tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] }
27+
uuid = { version = "1.11.0", features = ["v4"] }
2728

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

README.md

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ const user = await db.fetchOne<User>(
238238

239239
### Transactions
240240

241-
Execute multiple statements atomically:
241+
For most cases, use `executeTransaction()` to run multiple statements atomically:
242242

243243
```typescript
244244
const results = await db.executeTransaction([
@@ -250,6 +250,49 @@ const results = await db.executeTransaction([
250250

251251
Transactions use `BEGIN IMMEDIATE`, commit on success, and rollback on any failure.
252252

253+
#### Interruptible Transactions
254+
255+
**Use interruptible transactions when you need to read data mid-transaction to
256+
decide how to proceed.** For example, inserting a record, reading back its
257+
generated ID or other computed values, then using that data in subsequent writes.
258+
259+
```typescript
260+
// Begin transaction with initial insert
261+
const tx = await db.executeInterruptibleTransaction([
262+
['INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, 0]]
263+
])
264+
265+
// Read the uncommitted data to get the generated order ID
266+
const orders = await tx.read<Array<{ id: number }>>(
267+
'SELECT id FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 1',
268+
[userId]
269+
)
270+
const orderId = orders[0].id
271+
272+
// Continue transaction with the order ID
273+
const tx2 = await tx.continue([
274+
['INSERT INTO order_items (order_id, product_id) VALUES ($1, $2)', [orderId, productId]],
275+
['UPDATE orders SET total = $1 WHERE id = $2', [itemTotal, orderId]]
276+
])
277+
278+
// Commit the transaction
279+
await tx2.commit()
280+
```
281+
282+
**Important:**
283+
284+
* Only one interruptible transaction can be active per database at a time
285+
* The write lock is held for the entire duration - keep transactions short
286+
* Uncommitted writes are visible only within the transaction's `read()` method
287+
* Always commit or rollback - abandoned transactions will rollback automatically
288+
on app exit
289+
290+
To rollback instead of committing:
291+
292+
```typescript
293+
await tx.rollback()
294+
```
295+
253296
### Error Handling
254297

255298
```typescript
@@ -296,12 +339,22 @@ await db.remove() // Close and DELETE database file(s) - irreversible!
296339
| Method | Description |
297340
| ------ | ----------- |
298341
| `execute(query, values?)` | Execute write query, returns `{ rowsAffected, lastInsertId }` |
299-
| `executeTransaction(statements)` | Execute statements atomically |
342+
| `executeTransaction(statements)` | Execute statements atomically (use for batch writes) |
343+
| `executeInterruptibleTransaction(statements)` | Begin interruptible transaction, returns `InterruptibleTransaction` |
300344
| `fetchAll<T>(query, values?)` | Execute SELECT, return all rows |
301345
| `fetchOne<T>(query, values?)` | Execute SELECT, return single row or `undefined` |
302346
| `close()` | Close connection, returns `true` if was loaded |
303347
| `remove()` | Close and delete database file(s), returns `true` if was loaded |
304348

349+
### InterruptibleTransaction Methods
350+
351+
| Method | Description |
352+
| ------ | ----------- |
353+
| `read<T>(query, values?)` | Read uncommitted data within this transaction |
354+
| `continue(statements)` | Execute additional statements, returns new `InterruptibleTransaction` |
355+
| `commit()` | Commit transaction and release write lock |
356+
| `rollback()` | Rollback transaction and release write lock |
357+
305358
### Types
306359

307360
```typescript

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.

guest-js/index.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,23 @@ beforeEach(() => {
1515
if (cmd === 'plugin:sqlite|load') return (args as { db: string }).db
1616
if (cmd === 'plugin:sqlite|execute') return [1, 1]
1717
if (cmd === 'plugin:sqlite|execute_transaction') return []
18+
if (cmd === 'plugin:sqlite|execute_interruptible_transaction') {
19+
return { dbPath: (args as { db: string }).db, transactionId: 'test-tx-id' }
20+
}
21+
if (cmd === 'plugin:sqlite|transaction_continue') {
22+
const action = (args as { action: { type: string } }).action
23+
if (action.type === 'Continue') {
24+
return { dbPath: 'test.db', transactionId: 'test-tx-id' }
25+
}
26+
return undefined
27+
}
28+
if (cmd === 'plugin:sqlite|transaction_read') return []
1829
if (cmd === 'plugin:sqlite|fetch_all') return []
1930
if (cmd === 'plugin:sqlite|fetch_one') return null
2031
if (cmd === 'plugin:sqlite|close') return true
2132
if (cmd === 'plugin:sqlite|close_all') return undefined
2233
if (cmd === 'plugin:sqlite|remove') return true
34+
if (cmd === 'plugin:sqlite|get_migration_events') return []
2335
return undefined
2436
})
2537
})
@@ -92,6 +104,69 @@ describe('Database commands', () => {
92104
expect(events).toEqual(mockEvents)
93105
})
94106

107+
it('getMigrationEvents - empty array', async () => {
108+
const events = await Database.get('test.db').getMigrationEvents()
109+
expect(lastCmd).toBe('plugin:sqlite|get_migration_events')
110+
expect(lastArgs.db).toBe('test.db')
111+
expect(events).toEqual([])
112+
})
113+
114+
it('executeInterruptibleTransaction', async () => {
115+
const tx = await Database.get('t.db').executeInterruptibleTransaction([
116+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
117+
])
118+
expect(lastCmd).toBe('plugin:sqlite|execute_interruptible_transaction')
119+
expect(lastArgs.db).toBe('t.db')
120+
expect(lastArgs.initialStatements).toEqual([
121+
{ query: 'INSERT INTO users (name) VALUES ($1)', values: ['Alice'] }
122+
])
123+
expect(tx).toBeInstanceOf(Object)
124+
})
125+
126+
it('InterruptibleTransaction.continue()', async () => {
127+
const tx = await Database.get('test.db').executeInterruptibleTransaction([
128+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
129+
])
130+
const tx2 = await tx.continue([
131+
['INSERT INTO users (name) VALUES ($1)', ['Bob']]
132+
])
133+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
134+
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
135+
expect((lastArgs.action as { type: string }).type).toBe('Continue')
136+
expect(tx2).toBeInstanceOf(Object)
137+
})
138+
139+
it('InterruptibleTransaction.commit()', async () => {
140+
const tx = await Database.get('test.db').executeInterruptibleTransaction([
141+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
142+
])
143+
await tx.commit()
144+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
145+
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
146+
expect((lastArgs.action as { type: string }).type).toBe('Commit')
147+
})
148+
149+
it('InterruptibleTransaction.rollback()', async () => {
150+
const tx = await Database.get('test.db').executeInterruptibleTransaction([
151+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
152+
])
153+
await tx.rollback()
154+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
155+
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
156+
expect((lastArgs.action as { type: string }).type).toBe('Rollback')
157+
})
158+
159+
it('InterruptibleTransaction.read()', async () => {
160+
const tx = await Database.get('test.db').executeInterruptibleTransaction([
161+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
162+
])
163+
await tx.read('SELECT * FROM users WHERE name = $1', ['Alice'])
164+
expect(lastCmd).toBe('plugin:sqlite|transaction_read')
165+
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
166+
expect(lastArgs.query).toBe('SELECT * FROM users WHERE name = $1')
167+
expect(lastArgs.values).toEqual(['Alice'])
168+
})
169+
95170
it('handles errors from backend', async () => {
96171
mockIPC(() => {
97172
throw new Error('Database error')

guest-js/index.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,122 @@ export interface SqliteError {
3838
message: string
3939
}
4040

41+
/**
42+
* **InterruptibleTransaction**
43+
*
44+
* Represents an active interruptible transaction that can be continued, committed, or rolled back.
45+
* Provides methods to read uncommitted data and execute additional statements.
46+
*/
47+
export class InterruptibleTransaction {
48+
constructor(
49+
private readonly dbPath: string,
50+
private readonly transactionId: string
51+
) {}
52+
53+
/**
54+
* **read**
55+
*
56+
* Read data from the database within this transaction context.
57+
* This allows you to see uncommitted writes from the current transaction.
58+
*
59+
* The query executes on the same connection as the transaction, so you can
60+
* read data that hasn't been committed yet.
61+
*
62+
* @param query - SELECT query to execute
63+
* @param bindValues - Optional parameter values
64+
* @returns Promise that resolves with query results
65+
*
66+
* @example
67+
* ```ts
68+
* const tx = await db.executeInterruptibleTransaction([
69+
* ['INSERT INTO users (name) VALUES ($1)', ['Alice']]
70+
* ]);
71+
*
72+
* const users = await tx.read<User[]>(
73+
* 'SELECT * FROM users WHERE name = $1',
74+
* ['Alice']
75+
* );
76+
* ```
77+
*/
78+
async read<T>(query: string, bindValues?: SqlValue[]): Promise<T> {
79+
return await invoke<T>('plugin:sqlite|transaction_read', {
80+
token: { dbPath: this.dbPath, transactionId: this.transactionId },
81+
query,
82+
values: bindValues ?? []
83+
})
84+
}
85+
86+
/**
87+
* **continue**
88+
*
89+
* Execute additional statements within this transaction and return a new transaction handle.
90+
*
91+
* @param statements - Array of [query, values?] tuples to execute
92+
* @returns Promise that resolves with a new transaction handle
93+
*
94+
* @example
95+
* ```ts
96+
* const tx = await db.executeInterruptibleTransaction([...]);
97+
* const tx2 = await tx.continue([
98+
* ['INSERT INTO users (name) VALUES ($1)', ['Bob']]
99+
* ]);
100+
* await tx2.commit();
101+
* ```
102+
*/
103+
async continue(statements: Array<[string, SqlValue[]?]>): Promise<InterruptibleTransaction> {
104+
const token = await invoke<{ dbPath: string; transactionId: string }>(
105+
'plugin:sqlite|transaction_continue',
106+
{
107+
token: { dbPath: this.dbPath, transactionId: this.transactionId },
108+
action: {
109+
type: 'Continue',
110+
statements: statements.map(([query, values]) => ({
111+
query,
112+
values: values ?? []
113+
}))
114+
}
115+
}
116+
)
117+
return new InterruptibleTransaction(token.dbPath, token.transactionId)
118+
}
119+
120+
/**
121+
* **commit**
122+
*
123+
* Commit this transaction and release the write lock.
124+
*
125+
* @example
126+
* ```ts
127+
* const tx = await db.executeInterruptibleTransaction([...]);
128+
* await tx.commit();
129+
* ```
130+
*/
131+
async commit(): Promise<void> {
132+
await invoke<void>('plugin:sqlite|transaction_continue', {
133+
token: { dbPath: this.dbPath, transactionId: this.transactionId },
134+
action: { type: 'Commit' }
135+
})
136+
}
137+
138+
/**
139+
* **rollback**
140+
*
141+
* Rollback this transaction and release the write lock.
142+
*
143+
* @example
144+
* ```ts
145+
* const tx = await db.executeInterruptibleTransaction([...]);
146+
* await tx.rollback();
147+
* ```
148+
*/
149+
async rollback(): Promise<void> {
150+
await invoke<void>('plugin:sqlite|transaction_continue', {
151+
token: { dbPath: this.dbPath, transactionId: this.transactionId },
152+
action: { type: 'Rollback' }
153+
})
154+
}
155+
}
156+
41157
/**
42158
* Custom configuration for SQLite database connection
43159
*/
@@ -196,6 +312,10 @@ export default class Database {
196312
* Executes multiple write statements atomically within a transaction.
197313
* All statements either succeed together or fail together.
198314
*
315+
* **Use this method** when you have a batch of writes to execute and don't need to
316+
* read data mid-transaction. For transactions that require reading uncommitted data
317+
* to decide how to proceed, use `executeInterruptibleTransaction()` instead.
318+
*
199319
* The function automatically:
200320
* - Begins a transaction (BEGIN)
201321
* - Executes all statements in order
@@ -365,6 +485,69 @@ export default class Database {
365485
return success
366486
}
367487

488+
/**
489+
* **executeInterruptibleTransaction**
490+
*
491+
* Begins an interruptible transaction for cases where you need to **read data mid-transaction
492+
* to decide how to proceed**. For example, inserting a record and then reading its
493+
* generated ID or computed values before continuing with related writes.
494+
*
495+
* The transaction remains open, holding a write lock on the database, until you
496+
* call `commit()` or `rollback()` on the returned transaction handle.
497+
*
498+
* **Use this method when:**
499+
* - You need to read back generated IDs (e.g., AUTOINCREMENT columns)
500+
* - You need to see computed values (e.g., triggers, default values)
501+
* - Your next writes depend on data from earlier writes in the same transaction
502+
*
503+
* **Use `executeTransaction()` instead when:**
504+
* - You just need to execute a batch of writes atomically
505+
* - You know all the data upfront and don't need to read mid-transaction
506+
*
507+
* **Important:** Only one transaction can be active per database at a time. The
508+
* writer connection is held for the entire duration - keep transactions short.
509+
*
510+
* @param initialStatements - Array of [query, values?] tuples to execute initially
511+
* @returns Promise that resolves with an InterruptibleTransaction handle
512+
*
513+
* @example
514+
* ```ts
515+
* // Insert an order and read back its ID
516+
* const tx = await db.executeInterruptibleTransaction([
517+
* ['INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, 0]]
518+
* ]);
519+
*
520+
* // Read the generated order ID
521+
* const orders = await tx.read<Array<{ id: number }>>(
522+
* 'SELECT id FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 1',
523+
* [userId]
524+
* );
525+
* const orderId = orders[0].id;
526+
*
527+
* // Use the ID in subsequent writes
528+
* const tx2 = await tx.continue([
529+
* ['INSERT INTO order_items (order_id, product_id) VALUES ($1, $2)', [orderId, productId]]
530+
* ]);
531+
*
532+
* await tx2.commit();
533+
* ```
534+
*/
535+
async executeInterruptibleTransaction(
536+
initialStatements: Array<[string, SqlValue[]?]>
537+
): Promise<InterruptibleTransaction> {
538+
const token = await invoke<{ dbPath: string; transactionId: string }>(
539+
'plugin:sqlite|execute_interruptible_transaction',
540+
{
541+
db: this.path,
542+
initialStatements: initialStatements.map(([query, values]) => ({
543+
query,
544+
values: values ?? []
545+
}))
546+
}
547+
)
548+
return new InterruptibleTransaction(token.dbPath, token.transactionId)
549+
}
550+
368551
/**
369552
* **getMigrationEvents**
370553
*

0 commit comments

Comments
 (0)